---
description: Run the Pi Durable harness on Cloudflare with the Agents SDK. PiHarness keeps your agent's work durable inside a Durable Object, even if it is interrupted mid-turn.
title: Pi
image: https://developers.cloudflare.com/agents/harnesses/pi/og.png?v=6a341e286a3bfbc9
---

[Skip to content](#main-content)

> Documentation Index  
> Fetch the complete documentation index at: https://developers.cloudflare.com/agents/llms.txt  
> Use this file to discover all available pages before exploring further.

# Pi

Last updated Nov 3, 2026|Copy as Markdown| [View as Markdown](https://developers.cloudflare.com/agents/harnesses/pi/index.md)| [Agent setup](https://developers.cloudflare.com/agent-setup/)

[Pi ↗︎](https://pi.dev/) is a minimal, extensible agent harness. The Agents SDK provides first-class support for building agents using the Pi harness, and this page shows how to run [Pi Durable ↗︎](https://earendil.com/posts/pi-durable/) in a Cloudflare Durable Object with `PiHarness` from the Agents SDK.

Pi runs the agent loop: the transcript, the inbox of follow-ups and steers, model calls, tools, retries, and crash recovery. `PiHarness` gives Pi storage in the Durable Object's SQLite database, and wakes the object when Pi has work to finish.

![](https://developers.cloudflare.com/icons/agents/claude/light.svg)![](https://developers.cloudflare.com/icons/agents/claude/dark.svg)![](https://developers.cloudflare.com/icons/agents/codex/light.svg)![](https://developers.cloudflare.com/icons/agents/codex/dark.svg)![](https://developers.cloudflare.com/icons/agents/cursor/light.svg)![](https://developers.cloudflare.com/icons/agents/cursor/dark.svg)![](https://developers.cloudflare.com/icons/agents/opencode/light.svg)![](https://developers.cloudflare.com/icons/agents/opencode/dark.svg)Copy promptPrompt copied!

To start from a complete app, deploy the [Pi harness example ↗︎](https://github.com/cloudflare/agents/tree/main/examples/next/harnesses/pi). It streams sessions to a browser UI over WebSockets, and gives Pi a durable filesystem and JavaScript execution from `@cloudflare/computer`.

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/agents/tree/main/examples/next/harnesses/pi)

## How it works

`PiHarness` is a new "Lifecycle capability" provided by the Cloudflare Agents SDK. Pi Durable provides the agent harness and the [Lifecycle](https://developers.cloudflare.com/agents/runtime/lifecycle/) is responsible for keeping the agent running in the Durable Object. The Lifecycle is a core concept in the Agents SDK ensuring that long-running work can run in a Durable Object, surviving restarts, crashes, and network issues. A Lifecycle capability is a reusable piece of a Durable Object that the Lifecycle starts and gives SQLite storage and a durable job queue. `PiHarness` uses them in two ways:

- **Storage.** Pi keeps its transcripts, inbox, and tasks in its own tables in the object's SQLite database. The table names start with `pi_`, so they do not collide with your own tables.
- **Wake-up.** Pi's scheduler runs in memory, and an evicted object has none. `PiHarness` schedules one Lifecycle job for the object, driven by Pi's `wake()`. The job keeps a heartbeat while Pi works, is due when Pi's next sleeping task wakes, and completes when Pi has nothing to do. If the object is evicted mid-run, the job's alarm restarts it, Pi reopens its storage, and the run continues.

`PiHarness` does not choose how clients reach a session. It gives you a watch of each session's state, and you send it over WebSockets, HTTP, or RPC.

## Install

Install `agents` with Pi Durable, Pi AI, Pi Schema, and Chord:

npmyarnpnpmbun

```
npm i agents @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/pi-schema @earendil-works/chord
```

```
yarn add agents @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/pi-schema @earendil-works/chord
```

```
pnpm add agents @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/pi-schema @earendil-works/chord
```

```
bun add agents @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/pi-schema @earendil-works/chord
```

The 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 each `agents` release supports one Pi minor version. Install the versions your package manager resolves for `agents`.

Agents SDK 0.33.0 and later support Pi 1.5. A new Pi minor version can store conversations in a format the previous one cannot read. After a conversation runs on Pi 1.5, rolling your Worker back to an Agents SDK version on Pi 1.4 leaves that conversation unreadable, so roll forward to fix a bad deploy instead.

The `agents/models/pi-ai` entry point supports AI Gateway and Workers AI models, so you can get started with Cloudflare models right away or use your existing Pi AI provider. The examples on this page use Workers AI through the `AI` binding and the [Pi AI model provider](https://developers.cloudflare.com/agents/models/pi-ai/). Add the binding and a SQLite-backed Durable Object to your Wrangler configuration:

```jsonc
{
	"name": "pi-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" }],
	},
	"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Assistant"] }],
}
```

```toml
name = "pi-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"

[[migrations]]
tag = "v1"
new_sqlite_classes = [ "Assistant" ]
```

## Create the agent

`PiHarness` is a Lifecycle capability, so it runs on a plain Durable Object. Open Pi's `Harness` in a factory, install a Lifecycle on the object, and add the harness to it:

*src/index.jsjs*

```js
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";

export class Assistant extends DurableObject {
	ai = createAI({ binding: this.env.AI });
	registry = createRegistry();

	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") },
	});

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

	async ask(prompt) {
		const { text } = await this.harness.prompt(prompt);
		return text;
	}
}

export default {
	async fetch(request, env) {
		const agent = env.Assistant.getByName("demo");
		return Response.json({ text: await agent.ask("What is 47 × 19?") });
	},
};
```

*src/index.tsts*

```ts
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";

export class Assistant extends DurableObject<Env> {
	ai = createAI({ binding: this.env.AI });
	registry = createRegistry();

	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") },
	});

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

	async ask(prompt: string) {
		const { text } = await this.harness.prompt(prompt);
		return text;
	}
}

export default {
	async fetch(request: Request, env: Env) {
		const agent = env.Assistant.getByName("demo");
		return Response.json({ text: await agent.ask("What is 47 × 19?") });
	},
};
```

The `harness` factory receives `storage`, Pi's storage over the object's SQLite database, `context`, a background context for opening Pi, and `onWake`, so the object can hibernate while Pi's tasks sleep. Everything else that `Harness.open()` takes is yours to build in the factory: `models`, the `registry` of tools and prompt sections, `settings`, `env`, and `onReport`.

The factory runs inside the object's startup, before any session is used, and again whenever the harness reopens Pi after a storage error (refer to [Recovery](#recovery)). Keep it safe to run more than once. If it throws, the operation that started it fails, and the next operation tries again. Every `PiHarness` operation waits for startup, so a call that arrives over RPC before startup does not open Pi on its own.

## Options

| Option | Description |
| --- | --- |
| `harness` | Required. Receives `{ storage, context, onWake }` and returns Pi's `Harness`, usually from `Harness.open()`. Pass `onWake` on to `Harness.open()`. |
| `defaults` | What a new session starts with: `model`, a Pi AI `Model` such as `ai("@cf/...")`, `thinkingLevel`, and `maxTurns`, the most tool rounds one run takes. Change one session's model later with `session.setModel()`. |

Without a model, a session's prompts end unanswered until you set one. A run that reaches `maxTurns` runs that round's tools, then ends unanswered with reason `turn_limit`. Like the other defaults, `maxTurns` applies to new sessions only. Changing it does not change sessions that already exist. Without it, a run takes as many tool rounds as the model asks for.

Settings for the whole harness go in Pi's `settings`, passed to `Harness.open()`. For example, Pi retries a failed model request 3 times by default, and `settings: { retry: { maxRetries: 2, baseDelayMs: 500 } }` changes that. `compaction` and `toolExecution` set how Pi compacts long transcripts and runs a round's tools.

## Add tools and a system prompt

Both tools and system prompt sections are provided to the Pi `Harness` via extensions. A Pi extension is a plain object with a `name`, `tools`, and `sections`. Install it on the registry you open Pi with:

```ts
import type { ToolRegistration } from "@earendil-works/pi-durable";
import { obj, str } from "@earendil-works/pi-schema";

const WordCount = obj({ text: str() });

const wordCount: ToolRegistration<typeof WordCount> = {
	name: "word_count",
	description: "Count the words in a text.",
	parameters: WordCount,
	replay: "safe",
	async execute({ text }) {
		const words = text.split(/\s+/).filter(Boolean).length;
		return { output: [{ type: "text", text: String(words) }] };
	},
};

this.registry.install({
	name: "editor",
	sections: [
		{ key: "preamble", render: () => "You are an editor.", tag: false },
	],
	tools: [wordCount],
});
```

For more information on creating and configuring extensions, including skills and replay safety, refer to [Extensions](https://developers.cloudflare.com/agents/harnesses/pi/extensions/).

## Send prompts

`harness.prompt()` submits to the root session and waits for the answer. It returns the final assistant `text`, the `status`, and the session's transcript as `messages`.

For long runs, submit and wait separately. `submit()` returns once Pi has durably stored the input, before the model runs. `wait()` returns the result when Pi finishes:

```ts
const receipt = await this.harness.submit("Summarize the latest report", {
	operationId: "report-summary-42",
});

// Later, possibly from another request:
const result = await this.harness.wait(receipt.operationId);
// { operationId, session, status: "done" | "unanswered", text?, reason? }
```

An `operationId` makes a submission idempotent. Submitting the same id again returns the same operation with `accepted: false`. Use an id that survives your own retries, such as an inbound event id.

A submission made while the session is running is queued and answered after the current run, as its own run. Set `whenBusy: "steer"`, or call `session.steer()`, to join the running work after its current tool round instead. Set `whenIdle: "reject"` so an idle session refuses the input with Pi's `ConversationIdle` instead of starting a run. With `whenBusy: "steer"` as well, the input joins the running work or is refused, without racing the end of the run.

`harness.pending()` lists submissions Pi has not finished, with their status: `queued` or `running`.

### Label input the user did not type

Pi records every submitted input as a `pi.user` entry, as if the user typed it. Your code often submits input of its own, such as a prompt from a scheduled job, a webhook payload, or the outcome of an approval. To record where that input came from, pass `entry` with a `kind` of your own and any `data` you want to keep with it:

```js
await this.harness.submit("The deploy was approved. Run the migration.", {
	entry: { kind: "app.approval", data: { approved: true, by: "ben" } },
});
```

```ts
await this.harness.submit("The deploy was approved. Run the migration.", {
	entry: { kind: "app.approval", data: { approved: true, by: "ben" } },
});
```

The model sees no difference: it still gets the text as a user message. Only the transcript changes. The input is stored with your `kind` and `data` instead of `pi.user`, and `messages()`, `watch()`, and `entries()` return both, so your UI can show it differently, for example as "Approved by ben" rather than as something the user said. Pi reserves kinds that start with `pi.`, so use a prefix of your own, such as `app.`.

The kind is a label, not a permission. It records what your code said the input was, and nothing checks it. The model reads the input as if the user wrote it, so do not put anything in it that you would not let the user send.

### Inputs queued behind a failed run

A run fails with `model_error` when the model errors and Pi does not retry it, or the error outlasts Pi's retries. It fails with `no_model` when the session has no usable model, and with `turn_limit` when it reaches `defaults.maxTurns`. The run's own submission settles `unanswered` with that reason. Once you have fixed the cause, call `session.continue()` to answer the transcript as it is, without a new message. Pi's `settings.afterFailedRun` decides what happens to the submissions queued behind it:

| Value | Result |
| --- | --- |
| `keep` (default) | `pending()` lists them as `queued`, and `wait()` keeps waiting. The next `submit()` runs them first, in order, then the new input. The next `reset()` runs them too. |
| `continue` | The next run starts with them. If the cause persists, such as a missing model, each one fails in its own run. |
| `withdraw` | `wait()` returns `{ status: "unanswered", reason: "run_failed" }`, and `pending()` no longer lists them. |

Set it in the factory:

```js
harness: ({ storage, context, onWake }) =>
	Harness.open(
		storage,
		{
			models,
			registry: this.registry,
			onWake,
			settings: { afterFailedRun: "withdraw" },
		},
		context,
	);
```

```ts
harness: ({ storage, context, onWake }) =>
	Harness.open(
		storage,
		{
			models,
			registry: this.registry,
			onWake,
			settings: { afterFailedRun: "withdraw" },
		},
		context,
	);
```

With `keep`, the kept submissions are not work for Pi, so the object can hibernate until the next `submit()` or `reset()`. Pass a `signal` to `wait()` if a caller should not wait that long. To withdraw a kept submission, call `abort(operationId)`.

## Work with sessions

A session is a Pi conversation. The root session has the id `"1"` and exists from the start. The `harness` methods take an optional `session` option, and `harness.session(id)` returns a handle for one session:

```ts
const session = await this.harness.sessions.create();
await session.submit("Draft a release note");

const fork = await this.harness.sessions.fork(session.id);
const all = await this.harness.sessions.list();
// [{ id: "1", busy: false }, { id: "2", busy: true }, { id: "3", parent: "2", busy: false }]
```

| Method | Description |
| --- | --- |
| `submit(input, options)` | Durably submit input with `operationId`, `whenBusy`, `whenIdle`, or `entry`. Returns a receipt. |
| `continue(options)` | Start a run on the transcript as it is, without a new message. Only an idle session takes one. |
| `prompt(input, options)` | Submit and wait for the answer and the updated transcript. |
| `steer(input)` | Submit with `whenBusy: "steer"`. |
| `wait(operationId, signal)` | Wait for an operation. Aborting `signal` stops the wait, not the work. |
| `abort(operationId)` | Withdraw a queued operation, or abort the run it joined. With no id, abort everything in the session. |
| `pending()` | Operations Pi has not settled yet: `queued` or `running`. |
| `reset(handoff)` | Start a new context, optionally from a handoff note. |
| `setModel(model)` | Change this session's model to a Pi AI `Model`, such as `ai("@cf/zai-org/glm-5.3-flash")`. |
| `messages()` | The active transcript, as Pi's `EntryRecord` entries since the newest reset. |
| `watch(window)` | Pi's view of the session: the newest transcript entries and the session's live state. |
| `entries(query, limit)` | A page of the session's transcript, including entries before its resets. |
| `busy()` | Whether the session is running. |

`sessions.create()` makes a new top-level session with the harness defaults. `sessions.fork(id)` makes a session that sees another's history up to its newest entry. `sessions.list()` includes sessions that Pi's subagent tools created.

Session ids are Pi's conversation ids, so you cannot choose them. To give each user or chat its own agent, use one Durable Object per conversation and the root session in each.

`session.continue()` answers the transcript as it is, for example after a run ended unanswered because the model errored or the run reached `maxTurns`. A busy session rejects it with Pi's `ConversationBusy`. A transcript that already ends in an answer has nothing to continue, so the operation settles `unanswered` with reason `nothing_to_answer`. Like `submit()`, it takes an `operationId` and returns a receipt for `wait()`.

## Watch sessions from clients

`session.watch()` returns Pi's view of the session: the newest transcript entries, and the session's live state in `pi.live`, such as the answer being streamed, running tools, and why the last run ended without an answer. Render the first value, then each new one:

```ts
import type { ConversationView, LiveState } from "@earendil-works/pi-durable";

const watch = await this.harness.session().watch();
render(watch.value);

watch.start(async (call) => {
	if (call.kind === "value") render(call.value);
});

function render(view: ConversationView) {
	// A session that has never run has no pi.live yet.
	const live = (view.docs["pi.live"] ?? {}) as LiveState;
	drawEntries(view.entries);
	if (live.generation?.message) drawStreaming(live.generation.message);
	if (live.runFailure) drawFailure(live.runFailure);
}

// When the client goes away:
await watch.stop();
```

To send a session over a socket, send `watch.value` once, then each change's `call.ops`. The client applies them to its copy of the view with `applyImmutable()` from `@earendil-works/chord/delta`.

`live.run` is set while the session is running. `live.runFailure` stays after a failed run until the next run starts, so check `live.run === undefined` to tell whether the session is idle.

The view holds the newest 50 entries and grows to 100 before its oldest entries leave. Unlike `messages()`, its entries include those before a reset or compaction: Pi adds the reset or summary entry in place instead of cutting the transcript there. To render a longer transcript, merge each value into Pi's `ConversationTranscript`, which pages older entries with `session.entries()`. After a client reconnects, pass the transcript's `resume` window to `session.watch()` to get only the entries it missed.

A client that connects mid-run gets the partial answer in its first value. A watch lives in memory. After the object restarts, open a new watch and send its first value again.

The entries in `messages()` and the view are Pi's transcript entries, not a chat UI format. Map them to what your client renders.

For anything `PiHarness` does not cover, `await harness.pi()` returns the opened Pi `Harness`.

## Recovery

| What happens | Result |
| --- | --- |
| The object is evicted, crashes, or exceeds its memory or CPU limit | The wake-up job's next alarm restarts the object, and Pi continues from its last checkpoint. |
| A deploy happens mid-run | Same as a crash. The runtime gives in-flight work 30 seconds, then the alarm restarts the object. |
| The model was streaming | Pi keeps the partial answer it stored. The request counts as a failed attempt under `settings.retry`, so Pi calls the model again after the retry backoff. With no retry left, the run ends unanswered with reason `interrupted`. |
| A tool with `replay: "safe"` was running | Pi runs the tool again. |
| Any other tool was running | Pi does not run it again. The model gets an interrupted result and decides what to do next. |

Mark a tool `replay: "safe"` when running it twice is harmless, such as a read or a whole-file write. Leave other tools unsafe, such as an edit that would fail on text it already replaced, or a call that charges a card.

While Pi has work, the job's heartbeat alarm fires every 30 seconds. A crashed object restarts on the next heartbeat.

While Pi's tasks sleep, such as during a retry backoff, the job is due when the first one wakes. Pi reports when through the `onWake` callback that the factory passes to `Harness.open()`, and sets no in-memory timer, so the object can hibernate until then.

Pi shows a streaming answer and running tool output in the view at once, but stores them in batches: with the next change that depends on them, such as the end of the answer, and at the latest after `settings.progress.persistWithinMs` (default 1,000 ms). A crash mid-stream loses up to that much of the stream. To store every update instead, at the cost of one storage write each, set `settings: { progress: { persistWithinMs: 0 } }` in `Harness.open()`.

If the factory passes `onReport` to `Harness.open()`, Pi reports failures there as one report tagged by `kind`, such as `storage_failed` when a storage error closes Pi.

### Tasks that keep crashing

A task that keeps crashing the object would restart it forever. Pi ends a task that a crash or eviction cuts off `settings.restartLimit` times in a row (default 5):

- A tool call ends with an error result that the model sees, and the run goes on.
- A model request ends its run, and the run's operations settle `unanswered` with reason `restart_limit`.

Either way, the job completes and the object can hibernate. Pi reports the task to `onReport` as `task_interrupted`. To run the work again once you have dealt with the cause, call `session.continue()` or submit it again.

## Limitations

- **Approvals.** Pi Durable has no approval or permission step for tool calls yet.
- **Abort and tools.** `abort()` waits until the session is idle. A tool that ignores its abort signal keeps the session busy until it returns.
- **Long model calls.** The wake-up job waits inside an alarm invocation, and an alarm invocation runs for up to 15 minutes. The harness hands off to a new alarm every 10 minutes, but one model request that streams for more than 15 minutes can be cut off.
- **Background tasks.** Work that Pi runs in the background is checked on each heartbeat, so the harness notices it finish up to 30 seconds late.
- **Graceful eviction.** A running session keeps work in flight, so the object does not drain while Pi runs.
- **Session deletion.** Pi Durable cannot delete a conversation yet.

## Related resources

### [Pi harness example](https://github.com/cloudflare/agents/tree/main/examples/next/harnesses/pi)

A complete Pi agent app with WebSocket sessions, a durable filesystem, and code execution.

### [Extensions](https://developers.cloudflare.com/agents/harnesses/pi/extensions/)

Add tools and system prompt sections to PiHarness.

### [Pi AI model provider](https://developers.cloudflare.com/agents/models/pi-ai/)

Workers AI and AI Gateway models for Pi AI and Pi Durable.

### [Pi Durable announcement](https://earendil.com/posts/pi-durable/)

Earendil's introduction to Pi Durable.

Was this helpful?

YesNo

## On this page

[![](https://developers.cloudflare.com/_astro/logo.te5VL_aD.svg)Docs](https://developers.cloudflare.com/)

```json
{"@context":"https://schema.org","@type":"WebPage","@id":"https://developers.cloudflare.com/agents/harnesses/pi/#page","headline":"Pi","description":"Run the Pi Durable harness on Cloudflare with the Agents SDK. PiHarness keeps your agent's work durable inside a Durable Object, even if it is interrupted mid-turn.","url":"https://developers.cloudflare.com/agents/harnesses/pi/","inLanguage":"en","image":"https://developers.cloudflare.com/agents/harnesses/pi/og.png?v=6a341e286a3bfbc9","dateModified":"2026-11-03","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"},"keywords":["AI"]}
```
