A Durable Agent is a Durable Object with the Agents SDK Lifecycle installed. The Lifecycle starts the capabilities you add to the object. The most important one is the harness: the agent loop that calls the model, runs tools, and decides when the agent is done.
In this guide, you build an agent that:
- runs the Pi harness in a Durable Object,
- works on files in a Cloudflare Computer workspace, stored in the same object,
- talks to clients through the
WebSocketscapability.
You do not need an API key. The agent uses Workers AI through the AI binding.
- A Cloudflare account ↗︎
- Node.js 22.19 or later
-
Create a Worker project:
npm create cloudflare@latest -- my-agent --type=hello-world --lang=tsyarn create cloudflare my-agent --type=hello-world --lang=tspnpm create cloudflare@latest my-agent --type=hello-world --lang=ts -
Go to the project directory and install the Agents SDK, the Pi packages, and Cloudflare Computer:
cd my-agentnpm i agents @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/pi-schema @earendil-works/chord @cloudflare/computeryarn add agents @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/pi-schema @earendil-works/chord @cloudflare/computerpnpm add agents @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/pi-schema @earendil-works/chord @cloudflare/computerbun add agents @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/pi-schema @earendil-works/chord @cloudflare/computerThe Pi packages are optional peer dependencies of
agents, at~1.5.0. Pi Durable is experimental and its API can change in minor releases, so eachagentsrelease supports one Pi minor version. Install the versions your package manager resolves foragents.
Add the AI binding and a SQLite-backed Durable Object to your Wrangler configuration file:
{
"name": "my-agent",
"main": "src/index.ts",
"upload_source_maps": true,
// Set this to today's date
"compatibility_date": "2026-11-06",
"compatibility_flags": ["nodejs_compat"],
"ai": {
"binding": "AI",
},
"durable_objects": {
"bindings": [{ "name": "Assistant", "class_name": "Assistant" }],
},
"exports": {
"Assistant": { "type": "durable-object", "storage": "sqlite" },
},
}name = "my-agent"
main = "src/index.ts"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-11-06"
compatibility_flags = [ "nodejs_compat" ]
[ai]
binding = "AI"
[[durable_objects.bindings]]
name = "Assistant"
class_name = "Assistant"
[exports.Assistant]
type = "durable-object"
storage = "sqlite"aiadds the Workers AI binding asenv.AI.exportsdeclares the class with"storage": "sqlite". The Lifecycle and its capabilities store their data in each object's SQLite database. Refer to Durable Object class exports.- The
agentspackage requires thenodejs_compatflag.
Replace src/index.ts with an Assistant Durable Object that runs the Pi harness:
import { DurableObject } from "cloudflare:workers";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, Harness } from "@earendil-works/pi-durable";
import { PiHarness } from "agents/harness/pi";
import { Lifecycle } from "agents/lifecycle";
import { createAI } from "agents/models/pi-ai";
import { routeAgentRequest } from "agents/routing";
export class Assistant extends DurableObject {
// Workers AI models, through the AI binding
ai = createAI({ binding: this.env.AI });
// Pi's tools and prompt sections. Create one per object.
registry = createRegistry();
// The harness: the Pi agent loop, stored in this object's database
harness = new PiHarness({
harness: ({ storage, context, onWake }) => {
const models = createModels();
models.setProvider(this.ai.provider);
return Harness.open(
storage,
{ models, registry: this.registry, onWake },
context,
);
},
defaults: { model: this.ai("@cf/zai-org/glm-5.3-flash") },
});
// The Lifecycle starts the harness inside the Durable Object
lifecycle = Lifecycle.install(this).use(this.harness);
async onRequest(request) {
if (request.method !== "POST") {
return Response.json(await this.harness.messages());
}
const { status, text } = await this.harness.prompt(await request.text());
return Response.json({ status, text });
}
}
export default {
async fetch(request, env) {
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
};import { DurableObject } from "cloudflare:workers";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, Harness } from "@earendil-works/pi-durable";
import { PiHarness } from "agents/harness/pi";
import { Lifecycle } from "agents/lifecycle";
import { createAI } from "agents/models/pi-ai";
import { routeAgentRequest } from "agents/routing";
export class Assistant extends DurableObject<Env> {
// Workers AI models, through the AI binding
readonly ai = createAI({ binding: this.env.AI });
// Pi's tools and prompt sections. Create one per object.
readonly registry = createRegistry();
// The harness: the Pi agent loop, stored in this object's database
readonly harness = new PiHarness({
harness: ({ storage, context, onWake }) => {
const models = createModels();
models.setProvider(this.ai.provider);
return Harness.open(
storage,
{ models, registry: this.registry, onWake },
context,
);
},
defaults: { model: this.ai("@cf/zai-org/glm-5.3-flash") },
});
// The Lifecycle starts the harness inside the Durable Object
readonly lifecycle = Lifecycle.install(this).use(this.harness);
async onRequest(request: Request): Promise<Response> {
if (request.method !== "POST") {
return Response.json(await this.harness.messages());
}
const { status, text } = await this.harness.prompt(await request.text());
return Response.json({ status, text });
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>;Here is what each part does:
Assistantextends the platformDurableObject. Each named instance is one agent, with its own SQLite database.PiHarnessis the harness. It runs the Pi ↗︎ agent loop, stores the transcript in the object's database, and keeps a run going after the object is evicted from memory.createAI()fromagents/models/pi-aiconnects Pi to Workers AI.defaults.modelis the model a new session starts with.Lifecycle.install(this)takes over the object'sfetchandalarmhandlers, and.use(this.harness)adds the harness. Do not definefetch()oralarm()on the class yourself.onRequest()handles HTTP requests that no capability handled. APOSTsends a prompt and waits for the answer. AGETreturns the transcript.routeAgentRequest()sends requests for/agents/assistant/:nameto theAssistantinstance called:name. For more information, refer to Routing.
Generate types for the Env interface:
npx wrangler types-
Start the development server:
npx wrangler devThe
AIbinding always runs remotely, so Wrangler asks you to log in the first time. -
In another terminal, send the agent called
demoa prompt:curl -X POST http://localhost:8787/agents/assistant/demo \ -d "What is 47 times 19? Reply with the number only."{ "status": "done", "text": "893" }statusisdonewhen the model answered, andunansweredwhen the run failed or was stopped. -
Read the transcript:
curl http://localhost:8787/agents/assistant/demo
The transcript lives in the demo instance's database. A request to /agents/assistant/another-name reaches a different agent, with an empty transcript.
A Cloudflare Computer Workspace is a durable filesystem. It stores its files in the same Durable Object's SQLite database, next to Pi's transcript, so the files survive eviction too.
createPiTools() turns the workspace into tools the model can call: read, ls, find, grep, write, edit, and delete. Install them in Pi's registry when the harness opens:
// ...
import { Workspace } from "@cloudflare/computer";
import { createPiTools } from "@cloudflare/computer/tools/pi-ai";
export class Assistant extends DurableObject {
ai = createAI({ binding: this.env.AI });
registry = createRegistry();
// A durable filesystem in the object's SQLite database
workspace = new Workspace({ storage: this.ctx.storage });
harness = new PiHarness({
harness: ({ storage, context, onWake }) => {
const { tools, execute } = createPiTools({ workspace: this.workspace });
this.registry.install({
name: "workspace",
tools: tools.map((tool) => ({
...tool,
async execute(args, api, ctx) {
const { content, isError } = await execute(
{ id: api.callId, name: tool.name, arguments: args },
{ abortSignal: ctx.abortSignal },
);
return { output: content, isError };
},
})),
});
const models = createModels();
models.setProvider(this.ai.provider);
return Harness.open(
storage,
{ models, registry: this.registry, onWake },
context,
);
},
defaults: { model: this.ai("@cf/zai-org/glm-5.3-flash") },
});
// lifecycle and onRequest() are unchanged
}// ...
import { Workspace } from "@cloudflare/computer";
import { createPiTools } from "@cloudflare/computer/tools/pi-ai";
export class Assistant extends DurableObject<Env> {
readonly ai = createAI({ binding: this.env.AI });
readonly registry = createRegistry();
// A durable filesystem in the object's SQLite database
readonly workspace = new Workspace({ storage: this.ctx.storage });
readonly harness = new PiHarness({
harness: ({ storage, context, onWake }) => {
const { tools, execute } = createPiTools({ workspace: this.workspace });
this.registry.install({
name: "workspace",
tools: tools.map((tool) => ({
...tool,
async execute(args, api, ctx) {
const { content, isError } = await execute(
{ id: api.callId, name: tool.name, arguments: args },
{ abortSignal: ctx.abortSignal },
);
return { output: content, isError };
},
})),
});
const models = createModels();
models.setProvider(this.ai.provider);
return Harness.open(
storage,
{ models, registry: this.registry, onWake },
context,
);
},
defaults: { model: this.ai("@cf/zai-org/glm-5.3-flash") },
});
// lifecycle and onRequest() are unchanged
}Ask the agent to use it:
curl -X POST http://localhost:8787/agents/assistant/demo \
-d "Write a haiku about Durable Objects to haiku.md, then list the files."Pi calls write, then ls, and answers with what it found. The file stays in the demo agent's workspace for the next prompt. For the other workspace options, such as running JavaScript, refer to Filesystem tools.
So far you have talked to the agent over HTTP, one request at a time. The WebSockets capability keeps a connection open, so a browser or another client can send prompts and get replies as they happen. Idle connections hibernate, so an open connection does not keep the object in memory.
Add a WebSockets field and install it after the harness:
// ...
import { WebSockets } from "agents/websockets";
export class Assistant extends DurableObject {
// ai, registry, workspace, and harness are unchanged
// Clients send prompts and get replies over WebSockets
webSockets = new WebSockets({
handlers: {
onMessage: async (connection, message) => {
const { text } = await this.harness.prompt(String(message));
connection.send(text ?? "");
},
},
});
lifecycle = Lifecycle.install(this).use(this.harness).use(this.webSockets);
// onRequest() is unchanged
}// ...
import { WebSockets } from "agents/websockets";
export class Assistant extends DurableObject<Env> {
// ai, registry, workspace, and harness are unchanged
// Clients send prompts and get replies over WebSockets
readonly webSockets = new WebSockets({
handlers: {
onMessage: async (connection, message) => {
const { text } = await this.harness.prompt(String(message));
connection.send(text ?? "");
},
},
});
readonly lifecycle = Lifecycle.install(this)
.use(this.harness)
.use(this.webSockets);
// onRequest() is unchanged
}WebSockets accepts every WebSocket upgrade. HTTP requests still reach onRequest(), so the curl commands keep working.
Connect with wscat ↗︎ and type a prompt:
npx wscat -c ws://localhost:8787/agents/assistant/demoConnected (press CTRL+C to quit)
< {"type":"cf_agent_identity","name":"demo","agent":"assistant"}
> What is in haiku.md?
< ...The first frame is the agent's identity, which clients such as useAgent and AgentClient read for you. For a React client, refer to the client SDK.
Here is the complete agent:
import { DurableObject } from "cloudflare:workers";
import { Workspace } from "@cloudflare/computer";
import { createPiTools } from "@cloudflare/computer/tools/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, Harness } from "@earendil-works/pi-durable";
import { PiHarness } from "agents/harness/pi";
import { Lifecycle } from "agents/lifecycle";
import { createAI } from "agents/models/pi-ai";
import { routeAgentRequest } from "agents/routing";
import { WebSockets } from "agents/websockets";
export class Assistant extends DurableObject {
ai = createAI({ binding: this.env.AI });
registry = createRegistry();
// Tools: a durable filesystem in the object's SQLite database
workspace = new Workspace({ storage: this.ctx.storage });
// Harness: the Pi agent loop, with the workspace as its tools
harness = new PiHarness({
harness: ({ storage, context, onWake }) => {
const { tools, execute } = createPiTools({ workspace: this.workspace });
this.registry.install({
name: "workspace",
tools: tools.map((tool) => ({
...tool,
async execute(args, api, ctx) {
const { content, isError } = await execute(
{ id: api.callId, name: tool.name, arguments: args },
{ abortSignal: ctx.abortSignal },
);
return { output: content, isError };
},
})),
});
const models = createModels();
models.setProvider(this.ai.provider);
return Harness.open(
storage,
{ models, registry: this.registry, onWake },
context,
);
},
defaults: { model: this.ai("@cf/zai-org/glm-5.3-flash") },
});
// Capability: clients send prompts and get replies over WebSockets
webSockets = new WebSockets({
handlers: {
onMessage: async (connection, message) => {
const { text } = await this.harness.prompt(String(message));
connection.send(text ?? "");
},
},
});
// Runtime: the Lifecycle starts the harness and the capabilities, and
// handles the object's fetch, alarm, and WebSocket events
lifecycle = Lifecycle.install(this).use(this.harness).use(this.webSockets);
async onRequest(request) {
if (request.method !== "POST") {
return Response.json(await this.harness.messages());
}
const { status, text } = await this.harness.prompt(await request.text());
return Response.json({ status, text });
}
}
export default {
async fetch(request, env) {
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
};import { DurableObject } from "cloudflare:workers";
import { Workspace } from "@cloudflare/computer";
import { createPiTools } from "@cloudflare/computer/tools/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, Harness } from "@earendil-works/pi-durable";
import { PiHarness } from "agents/harness/pi";
import { Lifecycle } from "agents/lifecycle";
import { createAI } from "agents/models/pi-ai";
import { routeAgentRequest } from "agents/routing";
import { WebSockets } from "agents/websockets";
export class Assistant extends DurableObject<Env> {
readonly ai = createAI({ binding: this.env.AI });
readonly registry = createRegistry();
// Tools: a durable filesystem in the object's SQLite database
readonly workspace = new Workspace({ storage: this.ctx.storage });
// Harness: the Pi agent loop, with the workspace as its tools
readonly harness = new PiHarness({
harness: ({ storage, context, onWake }) => {
const { tools, execute } = createPiTools({ workspace: this.workspace });
this.registry.install({
name: "workspace",
tools: tools.map((tool) => ({
...tool,
async execute(args, api, ctx) {
const { content, isError } = await execute(
{ id: api.callId, name: tool.name, arguments: args },
{ abortSignal: ctx.abortSignal },
);
return { output: content, isError };
},
})),
});
const models = createModels();
models.setProvider(this.ai.provider);
return Harness.open(
storage,
{ models, registry: this.registry, onWake },
context,
);
},
defaults: { model: this.ai("@cf/zai-org/glm-5.3-flash") },
});
// Capability: clients send prompts and get replies over WebSockets
readonly webSockets = new WebSockets({
handlers: {
onMessage: async (connection, message) => {
const { text } = await this.harness.prompt(String(message));
connection.send(text ?? "");
},
},
});
// Runtime: the Lifecycle starts the harness and the capabilities, and
// handles the object's fetch, alarm, and WebSocket events
readonly lifecycle = Lifecycle.install(this)
.use(this.harness)
.use(this.webSockets);
async onRequest(request: Request): Promise<Response> {
if (request.method !== "POST") {
return Response.json(await this.harness.messages());
}
const { status, text } = await this.harness.prompt(await request.text());
return Response.json({ status, text });
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>;npx wrangler deployYour agent is now running on Cloudflare's global network at https://my-agent.<YOUR_SUBDOMAIN>.workers.dev/agents/assistant/demo.
Anyone who knows that URL can talk to your agent. Before you share it, check who is calling in your Worker. For more information, refer to Authentication.
- Pi harness: stream events to clients, work with several sessions, and recover after eviction.
- Pi extensions: give the model more tools and system prompt sections.
Scheduler: run the agent on a schedule, such as a plan every morning.- Capabilities: everything else you can add to the Durable Object.