Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/container-harness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"agents": patch
---

Add experimental `agents/harness/container`, `agents/harness/container/daemon`, `agents/harness/container/runtime` and `agents/harness/store`. Everything in them may change before it stabilizes.

`ContainerHarness` runs an agent CLI such as Claude Code or Codex in a Cloudflare Container (`ctx.container`) and drives it from a Durable Object, with the same interface as `PiHarness` (`prompt`, `submit`, `wait`, `abort`, `messages`, `pending`, `session(id)`, `sessions`). Typed presets (`claudeCode()`, `codex()`, `containerAgent()`) say what runs: the harness builds the container from `cloudflare/debian-trixie` at runtime and snapshots it, so there is no image to build, and `ContainerEgress` adds credentials (`apiKey`, `baseUrl`, `headers`) outside the container, which only ever sees a placeholder key. The container stops after an idle timeout (five minutes by default) and the next prompt starts a new one in which the CLI resumes its own session; a container lost mid-run settles that run as `container_lost` (or reruns it with `onContainerLost: "retry"`), and an object evicted mid-run reattaches and replays what it missed. `agents/harness/container/runtime` is the daemon's Node runtime with `cliAdapter`, for images that bring their own CLI; `agents/harness/container/daemon` is its runtime-agnostic core; and `agents/harness/store` is a session store on the object's SQLite that any harness can use.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,6 @@ test-results/
# npm artifacts (repo uses pnpm)
package-lock.json
!examples/next/harnesses/pi/vendor/pi-dev/*.tgz

# Built by packages/agents/scripts/build-daemon.ts
packages/agents/src/harness/container/daemon-source.generated.ts
3 changes: 2 additions & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"packages/channels/live-tests/snapshots",
"site/agents/.astro",
"**/routeTree.gen.ts",
"**/think.d.ts"
"**/think.d.ts",
"**/*.generated.ts"
]
}
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@
}
]
},
"ignorePatterns": ["**/env.d.ts", "**/routeTree.gen.ts"]
"ignorePatterns": ["**/env.d.ts", "**/routeTree.gen.ts", "**/*.generated.ts"]
}
37 changes: 19 additions & 18 deletions examples/next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,25 @@ SDK pull requests. Keeping them under `examples/next` avoids presenting the new
composition patterns as part of the current stable examples before the stack
lands.

| Example | Status | Demonstrates |
| -------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| [`lifecycle`](./lifecycle) | Available | A plain `DurableObject` composed with `Lifecycle` and a reusable capability |
| [`schedules`](./schedules) | Available | `Scheduler` installed as a reusable lifecycle capability |
| [`queue`](./queue) | This PR | Durable background work: `Queue` installed as a reusable lifecycle capability |
| [`tasks`](./tasks) | This PR | Durable replayable `Tasks` installed as a reusable lifecycle capability |
| [`streams`](./streams) | This PR | Durable `Streams` composed with `Tasks`, served over SSE |
| [`sessions`](./sessions) | This PR | Durable message trees, streamed reads, and Sessions-owned attachments |
| [`websockets`](./websockets) | This PR | One plain `DurableObject`, `useAgent` over the WebSocket or Cap'n Web transport, callables on every wire, hibernating connections and tags |
| [`mcp-client`](./mcp-client) | Available | `MCPClientManager` installed as a reusable lifecycle capability |
| [`routing`](./routing) | This PR | `RoutedAgents` on a plain `DurableObject` hub: one Agent per chat, a per-user catalog with push-based metadata, forwarded requests and sockets |
| [`dynamic-agents`](./dynamic-agents) | This PR | A supervisor runs user-submitted code as facets: isolated storage, supervised abort, code upgrades over stable state |
| [`harnesses/codex`](./harnesses/codex) | This PR | A static Codex Rust/Wasm loop composed as a Lifecycle capability, using LanguageModelV4 and Shell Workspace |
| [`harnesses/pi`](./harnesses/pi) | This PR | Experimental: pi-durable sessions on `agents/harness/pi`, with Workspace tools and a JavaScript `exec` from `@cloudflare/computer` |
| [`harnesses/think`](./harnesses/think) | This PR | Experimental: Think's agent loop on a plain `DurableObject` with `agents/harness/think`, served to `useAgentChat` by `ThinkChat` |
| [`harnesses/self-modifying`](./harnesses/self-modifying) | This PR | A Lifecycle capability runs editable harness revisions in fresh Dynamic Workers with trusted System tools and auto-discovered Custom tools |
| [`models`](./models) | This PR | One `createAI` per framework (AI SDK and pi-ai): `@cf/` ids for Workers AI, vendor models routed through AI Gateway |
| [`channels`](./channels) | This PR | Agents on different harnesses served through `Channels.forHarness` and one `ChannelGateway`, to a browser client and terminal clients |
| Example | Status | Demonstrates |
| -------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`lifecycle`](./lifecycle) | Available | A plain `DurableObject` composed with `Lifecycle` and a reusable capability |
| [`schedules`](./schedules) | Available | `Scheduler` installed as a reusable lifecycle capability |
| [`queue`](./queue) | This PR | Durable background work: `Queue` installed as a reusable lifecycle capability |
| [`tasks`](./tasks) | This PR | Durable replayable `Tasks` installed as a reusable lifecycle capability |
| [`streams`](./streams) | This PR | Durable `Streams` composed with `Tasks`, served over SSE |
| [`sessions`](./sessions) | This PR | Durable message trees, streamed reads, and Sessions-owned attachments |
| [`websockets`](./websockets) | This PR | One plain `DurableObject`, `useAgent` over the WebSocket or Cap'n Web transport, callables on every wire, hibernating connections and tags |
| [`mcp-client`](./mcp-client) | Available | `MCPClientManager` installed as a reusable lifecycle capability |
| [`routing`](./routing) | This PR | `RoutedAgents` on a plain `DurableObject` hub: one Agent per chat, a per-user catalog with push-based metadata, forwarded requests and sockets |
| [`dynamic-agents`](./dynamic-agents) | This PR | A supervisor runs user-submitted code as facets: isolated storage, supervised abort, code upgrades over stable state |
| [`harnesses/codex`](./harnesses/codex) | This PR | A static Codex Rust/Wasm loop composed as a Lifecycle capability, using LanguageModelV4 and Shell Workspace |
| [`harnesses/pi`](./harnesses/pi) | This PR | Experimental: pi-durable sessions on `agents/harness/pi`, with Workspace tools and a JavaScript `exec` from `@cloudflare/computer` |
| [`harnesses/think`](./harnesses/think) | This PR | Experimental: Think's agent loop on a plain `DurableObject` with `agents/harness/think`, served to `useAgentChat` by `ThinkChat` |
| [`harnesses/container`](./harnesses/container) | This PR | Experimental: Claude Code or Codex in a Cloudflare Container, driven from a Durable Object by `agents/harness/container`, resumed across containers |
| [`harnesses/self-modifying`](./harnesses/self-modifying) | This PR | A Lifecycle capability runs editable harness revisions in fresh Dynamic Workers with trusted System tools and auto-discovered Custom tools |
| [`models`](./models) | This PR | One `createAI` per framework (AI SDK and pi-ai): `@cf/` ids for Workers AI, vendor models routed through AI Gateway |
| [`channels`](./channels) | This PR | Agents on different harnesses served through `Channels.forHarness` and one `ChannelGateway`, to a browser client and terminal clients |

Each example is an independent workspace package and should stay focused on one
capability. Once the APIs are stable, move the examples into the main examples
Expand Down
2 changes: 2 additions & 0 deletions examples/next/harnesses/container/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# AI Gateway token. With Unified Billing it is the only credential needed.
AI_GATEWAY_TOKEN=
3 changes: 3 additions & 0 deletions examples/next/harnesses/container/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
container/
.dev.vars
.env
199 changes: 199 additions & 0 deletions examples/next/harnesses/container/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# Next: an agent CLI in a Container

An early-access, server-only example of `ContainerHarness` from
`agents/harness/container`: Claude Code or Codex runs in a Cloudflare
Container, and a plain Durable Object drives it with the same interface as
`PiHarness`. There is no Dockerfile, and no credentials enter the container.

```ts
import { ContainerHarness, claudeCode, codex } from "agents/harness/container";

// Adds the credentials outside the container; export it from the main module.
export { ContainerEgress } from "agents/harness/container";

abstract class CodingAgent extends DurableObject<Env> {
abstract agent(): ContainerAgent;

readonly harness = new ContainerHarness({
container: this.ctx.container,
egress: this.ctx.exports.ContainerEgress,
agent: this.agent()
});

readonly lifecycle = Lifecycle.install(this).use(this.harness);
}

export class ClaudeCodeAgent extends CodingAgent {
agent() {
return claudeCode({
baseUrl: `${this.env.AI_GATEWAY_URL}/anthropic`,
apiKey: this.env.AI_GATEWAY_TOKEN
});
}
}

export class CodexAgent extends CodingAgent {
agent() {
return codex({
baseUrl: `${this.env.AI_GATEWAY_URL}/openai`,
apiKey: this.env.AI_GATEWAY_TOKEN
});
}
}

// anywhere in either object:
const { text } = await this.harness.prompt("Add a test for parse()");
```

The two agents differ only in the preset. Leave out `baseUrl` to call the
provider directly with its own key, and add `headers` for anything else a
request needs (gateway metadata, or `cf-aig-authorization` for a BYOK
gateway).

```jsonc
// wrangler.jsonc
"containers": [
{ "class_name": "ClaudeCodeAgent", "scheduling_policy": "durable_object" },
{ "class_name": "CodexAgent", "scheduling_policy": "durable_object" }
]
```

## What happens

- **First start.** The harness starts `cloudflare/debian-trixie`, uses
`exec()` to create an unprivileged `agent` user and install the CLI
(`npm install -g @anthropic-ai/claude-code`), writes its bundled daemon
into the container, and snapshots the filesystem. Later starts restore the
snapshot and are ready in a few seconds. Changing the preset (a new CLI
version, extra `setup` steps) sets up afresh.
- **Each turn** is one CLI process run as `agent`: `claude -p
--output-format stream-json` or `codex exec --json`. The daemon turns its
output into the harness's messages and events, and mirrors the CLI's
session files (`~/.claude/projects`, `~/.codex/sessions`) to the object as
the turn runs.
- **Credentials.** The CLI is pointed at a placeholder host
(`http://anthropic.harness.internal`) with a placeholder key. The harness
intercepts that host with `ctx.container.interceptOutboundHttp()`, and
`ContainerEgress`, running in your Worker, swaps in the real credentials
and forwards to `baseUrl`. `env` in the container shows only the
placeholder.
- **Recovery.** Five minutes after the last prompt (`idleTimeoutMs`) the
harness snapshots the container, workspace included, and stops it. The
next prompt starts a new one from that snapshot and the CLI resumes its
own session. If the container dies mid-run, that run settles `unanswered`
with `container_lost`, the next container starts from the last snapshot
(workspace edits since then are lost), and the session continues from
the CLI's session files, which are saved as each turn runs. If the object is evicted
mid-run, the container keeps working; the object reattaches and replays
what it missed.

## Customise the CLI

Everything goes in `setup`: steps run once, on the first start, and their
result is kept in the snapshot. A step with `user: "agent"` runs as the user
the CLI runs as, in its home, and every session's home starts as a copy of
that home. So installing your plugins and mods, skills, `CLAUDE.md` or
`~/.claude/settings.json` works as it does on your laptop:

```ts
claudeCode({
baseUrl: `${this.env.AI_GATEWAY_URL}/anthropic`,
apiKey: this.env.AI_GATEWAY_TOKEN,
setup: [
// your own Claude Code build, over the pinned release (as root)
{
name: "my fork",
command: ["npm", "install", "-g", "github:you/claude-code#mods"]
},
// your plugins and mods, from your own marketplace (as the agent)
{
name: "my mods",
user: "agent",
command: [
"sh",
"-c",
"claude plugin marketplace add you/my-mods && claude plugin install token-chart@my-mods"
]
}
]
});
```

Changing `setup` takes effect when a container next starts: one that is
running when you deploy keeps its setup until it stops (idle, or
`harness.stop()`), and the next one sets up afresh. The workspace snapshot
is tied to the old setup, so the new container starts without it. A token a
step uses (to clone a private repository, say) stays in the snapshot, where
the agent can read it: prefer public sources or short-lived URLs.

## Another CLI

Build an image whose entrypoint serves the daemon with `cliAdapter`, and use
`containerAgent({ image: ctx.container.images.agent, adapter: "my-agent" })`:

```ts
import { cliAdapter, serveFromEnv } from "agents/harness/container/runtime";

await serveFromEnv(
cliAdapter({
id: "my-agent",
stateDirs: [".my-agent/sessions"],
command: (turn) => ({
argv: [
"my-agent",
"--json",
...(turn.state.id ? ["--resume", turn.state.id] : [])
],
stdin: turn.prompt
}),
parser: () => ({
line: (text) => myAgentLine(JSON.parse(text)), // events, state, outcome
end: ({ code }) => ({ status: "unanswered", reason: `exit ${code}` })
})
})
);
```

## Run

Containers need Docker locally. Set `AI_GATEWAY_URL` in `wrangler.jsonc` and
put an AI Gateway token in `.env` (see `.env.example`):

```sh
cp .env.example .env # then fill in AI_GATEWAY_TOKEN
pnpm install
pnpm run dev
```

With [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/)
the gateway token is the only credential, and it never leaves the object.

Exercise the agent named `demo`, as Claude Code or as Codex: the routes are
the same.

```sh
B=http://localhost:8787/agents/claude-code-agent/demo # or /agents/codex-agent/demo

# Prompt and wait for the answer.
curl -X POST $B/prompt -H "content-type: application/json" \
-d '{"text": "Create hello.txt containing hi", "wait": true}'

# Or submit, watch events over SSE, and wait for one operation.
curl -X POST $B/prompt -H "content-type: application/json" -d '{"text": "ls"}'
curl -N $B/events
curl $B/operations/<operationId>

# Stop the container, then prompt again: a new container resumes the session.
curl -X POST $B/stop

curl $B/messages
curl $B/ # container, sessions, pending operations
```

Add `?session=<id>` to address a session other than the root; `POST
$B/sessions` creates one.

The routes have no authentication, to keep the example short. They drive an
agent that runs commands and spends model credit: put your own auth in front
of them before deploying anywhere public. The container can reach the
Internet (setup needs it); the credentials still never enter it.
28 changes: 28 additions & 0 deletions examples/next/harnesses/container/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types --include-runtime=false env.d.ts` (hash: 7e46a4b034a3ec92f0729f5d8ebbb00c)
interface __BaseEnv_Env {
AI_GATEWAY_URL: "https://gateway.ai.cloudflare.com/v1/<account>/<gateway>";
AI_GATEWAY_TOKEN: string;
ClaudeCodeAgent: DurableObjectNamespace<
import("./src/index").ClaudeCodeAgent
>;
CodexAgent: DurableObjectNamespace<import("./src/index").CodexAgent>;
}
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./src/index");
durableNamespaces: "ClaudeCodeAgent" | "CodexAgent";
}
interface Env extends __BaseEnv_Env {}
}
interface Env extends __BaseEnv_Env {}
type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string
? EnvType[Binding]
: string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<
Pick<Cloudflare.Env, "AI_GATEWAY_URL" | "AI_GATEWAY_TOKEN">
> {}
}
21 changes: 21 additions & 0 deletions examples/next/harnesses/container/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@cloudflare/agents-next-container-harness-example",
"description": "Experimental: Claude Code or Codex in a Cloudflare Container, driven from a Durable Object by agents/harness/container",
"private": true,
"type": "module",
"version": "0.0.0",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"types": "wrangler types env.d.ts --include-runtime false"
},
"dependencies": {
"agents": "*"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260729.1",
"typescript": "^6.0.3",
"wrangler": "^4.145.0"
}
}
Loading
Loading