Pi
Run the Pi agent in a Rivet Actor. The agent lives in the Actor's SQLite database, so it can sleep, crash, or upgrade mid-run and pick up where it left off.
The agent runs Pi Durable, Earendil’s durable agent harness, which saves every conversation, model call, tool call, and task as it happens.
Pi Durable is still in beta, so expect its API to change between releases.
What durable means
Each agent is a Rivet Actor. A run is a series of steps, such as model calls, tool calls, and task phases, and the agent saves each step to the Actor’s SQLite database before the next one begins.
- One Actor per key. Each key gets its own agent, with its own conversations, files, and sandbox.
- Idle agents sleep. A sleeping agent uses no memory. The next action on its key wakes it with its conversations and files.
- Crashes and upgrades resume. Work that was cut off carries on from its last saved step. See Resuming a run.
- Resent prompts run once. A client can safely resend a prompt with the same
requestId. - Clients that join late catch up. They get a snapshot of the run, then live events.
To get started, see the Quickstart.
Without a sandbox
An agent needs no sandbox. Without one, Pi’s read, write, and edit tools work on files stored in the Actor’s own SQLite database:
import { createRegistry, defineExtension } from "@earendil-works/pi-durable";
import { createEditTool, createReadTool, createWriteTool } from "@earendil-works/pi-durable/tools";
import { pi } from "@rivet-dev/pi";
import { setup } from "rivetkit";
// read, write, and edit. With no sandbox, the files live in the Actor's own database.
const extensions = createRegistry();
extensions.install(defineExtension({ name: "files", tools: [createReadTool(), createWriteTool(), createEditTool()] }));
const agent = pi({
model: "anthropic/claude-opus-5-5",
registry: extensions,
});
export const registry = setup({ use: { agent } });
registry.start();
- Nothing to set up. The files are saved with the conversation and survive sleep, crashes, and upgrades.
- Large files are cheap. A read of a few lines loads only those lines from the database.
- No shell.
bashreturns an error. To run commands, add a sandbox.
Use pi-ai
The pi-ai package is Pi’s model library, and Pi Durable is built on it. Install it next to Pi Durable:
npm add @earendil-works/pi-ai
| To | Use from pi-ai |
|---|---|
| Define a tool’s parameters | Type, which builds the schema Pi checks every call against |
| Read the messages of a conversation | AssistantMessage, UserMessage, and ToolResultMessage |
Pick a thinking level for thinkingLevel | ModelThinkingLevel, from "off" to "max" |
import { Type } from "@earendil-works/pi-ai";
import { defineTool } from "@earendil-works/pi-durable";
// pi-ai's Type builds the schema Pi checks every call against.
export const findPokemon = defineTool({
name: "find_pokemon",
description: "List the areas where a wild Pokémon appears in Pokémon HeartGold or SoulSilver.",
parameters: Type.Object({
pokemon: Type.String({ description: "Pokémon name in lowercase, such as sudowoodo." }),
version: Type.Optional(Type.Union([Type.Literal("heartgold"), Type.Literal("soulsilver")])),
}),
replay: "safe",
execute: async ({ pokemon, version = "soulsilver" }, _api, context) => {
const url = `https://pokeapi.co/api/v2/pokemon/${encodeURIComponent(pokemon)}/encounters`;
const response = await fetch(url, { signal: context.abortSignal });
if (!response.ok) throw new Error(`PokéAPI returned ${response.status} for ${pokemon}.`);
const encounters = (await response.json()) as Encounter[];
const areas = encounters
.filter((encounter) => encounter.version_details.some((detail) => detail.version.name === version))
.map((encounter) => encounter.location_area.name);
const text =
areas.length > 0 ? `${pokemon} in ${version}: ${areas.join(", ")}.` : `No wild ${pokemon} in ${version}.`;
return { content: [{ type: "text", text }] };
},
});
type Encounter = {
location_area: { name: string };
version_details: { version: { name: string } }[];
};
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
const agent = client.agent.getOrCreate(["user-123"]);
const root = await agent.harness.root();
const { messages } = await agent.conversation.context(root.id);
// The messages are pi-ai messages: user, assistant, and toolResult.
const answers = messages.filter(
(message): message is AssistantMessage => message.role === "assistant",
);
for (const answer of answers) {
const text = answer.content
.flatMap((block) => (block.type === "text" ? [block.text] : []))
.join("");
const { totalTokens, cost } = answer.usage;
console.log(`${answer.model}, ${totalTokens} tokens, $${cost.total}: ${text}`);
}
Model names such as anthropic/claude-opus-5-5 come from pi-ai’s model catalog. Each assistant message records the model that wrote it and its token usage and cost.
Tools and tasks
The CodingTools extension adds read, write, edit, and bash, which run in the sandbox:
import { createRegistry } from "@earendil-works/pi-durable";
import { CodingTools } from "@earendil-works/pi-durable/tools";
import { pi } from "@rivet-dev/pi";
import { e2bProvider } from "@rivet-dev/sandbox-adapter/e2b";
import { setup } from "rivetkit";
// read, write, edit, and bash, running in the sandbox
const extensions = createRegistry();
extensions.install(CodingTools);
const agent = pi({
model: "anthropic/claude-opus-5-5",
registry: extensions,
sandbox: e2bProvider(),
});
export const registry = setup({ use: { agent } });
registry.start();
Your own tools and tasks run in your backend. For multi-step work with side effects, launch a task from the tool:
import { Type } from "@earendil-works/pi-ai";
import { createRegistry, defineExtension, defineTask, defineTool } from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
import { setup } from "rivetkit";
const PREVIEWS_API = "https://api.example.com/previews";
// A durable task. Each phase saves its checkpoint before the next one starts,
// so after a crash the task carries on from the phase it reached.
const DeployPreview = defineTask<
{ repo: string; pr: number },
{ phase: "deploy" } | { phase: "comment"; url: string },
string
>({
name: "previews.deploy",
version: 1,
initial: () => ({ phase: "deploy" }),
phases: {
deploy: async (task, runtime, context) => {
// The preview id comes from the task id, so a rerun after a crash finds the same preview.
const response = await fetch(`${PREVIEWS_API}/preview-${task.id}`, {
method: "PUT",
body: JSON.stringify(task.input),
});
const { url } = (await response.json()) as { url: string };
await runtime.commit(() => ({ status: "running", checkpoint: { phase: "comment", url } }), context);
},
comment: async (task, runtime, context) => {
const { url } = task.state.checkpoint;
await fetch(`https://api.github.com/repos/${task.input.repo}/issues/${task.input.pr}/comments`, {
method: "POST",
headers: { authorization: `Bearer ${process.env.GITHUB_TOKEN}` },
body: JSON.stringify({ body: `Preview: ${url}` }),
});
await runtime.commit(() => ({ status: "terminal", outcome: { status: "completed", result: url } }), context);
},
},
// Runs when the run is cancelled before the task finishes: delete the preview.
abort: async (task, runtime, context) => {
await fetch(`${PREVIEWS_API}/preview-${task.id}`, { method: "DELETE" });
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context);
},
});
const deployPreview = defineTool({
name: "deploy_preview",
description: "Deploy a preview of a pull request and post its URL on the pull request.",
parameters: Type.Object({ repo: Type.String({ description: "owner/name" }), pr: Type.Number() }),
execute: async (args, api, context) => {
// Owned by this tool call, so cancelling the run aborts the task too.
const id = await api.createTask(DeployPreview, args, { ownership: { kind: "task", taskId: api.taskId } }, context);
const { outcome } = (await api.waitForTask(id, context)).state;
const text = outcome.status === "completed" ? `Preview at ${outcome.result}.` : `Preview ${outcome.status}.`;
return { content: [{ type: "text", text }] };
},
});
const extensions = createRegistry();
extensions.install(defineExtension({ name: "previews", tools: [deployPreview], tasks: [DeployPreview] }));
const agent = pi({
model: "anthropic/claude-opus-5-5",
registry: extensions,
});
export const registry = setup({ use: { agent } });
registry.start();
If the Actor restarts during comment, the task resumes there and doesn’t deploy a second preview. If the run is cancelled first, abort deletes the preview. To learn what happens to a plain tool call, see Resuming a run.
Call the agent
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
const agent = client.agent.getOrCreate(["user-123"]);
const result = await agent.prompt(
"Write a Python script that rolls two dice 10,000 times, run it, and show me how often each total came up.",
{ requestId: crypto.randomUUID() },
);
console.log(result.status === "done" ? result.text : `Unanswered: ${result.reason}`);
The prompt action sends input, waits, and returns { status: "done", text } or { status: "unanswered", reason }. The other actions mirror Pi Durable’s methods under harness, conversation, and submission, with IDs in place of objects.
| To | Call |
|---|---|
| Prompt and wait | prompt(text, { requestId }) |
| Start a run without waiting | conversation.submit(id, { type: "input", content, requestId }) |
| Steer a run | prompt(text, { whenBusy: "steer" }) |
| Cancel a run | conversation.abort(id) |
| Switch models | conversation.configure(id, { model: { provider, modelId } }) |
| Get the root conversation | harness.root() |
| Start a conversation | harness.createConversation({ ownership: { kind: "ownerless" } }) |
Actions wait up to ten minutes, and a timed-out wait leaves the run going. A prompt sent during a run queues as a follow-up. See the source for every action.
Stream a conversation
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
const conn = client.agent.getOrCreate(["user-123"]).connect();
// The text of the current answer printed so far.
let printed = "";
conn.on("pi.events", ({ events }) => {
for (const event of events) {
if (event.type === "message_update") {
for (const change of event.changes) {
if (change.type === "text_delta") {
process.stdout.write(change.delta);
printed += change.delta;
}
}
}
if (event.type === "message_end") {
// A short answer can arrive whole here, with no text_delta before it.
const message = event.entry.model?.[0];
if (message?.role === "assistant") {
const text = message.content
.flatMap((block) => (block.type === "text" ? [block.text] : []))
.join("");
process.stdout.write(text.slice(printed.length));
}
printed = "";
}
if (event.type === "tool_execution_start") console.log(`\n> ${event.toolName}`);
}
});
const root = await conn.harness.root();
await conn.conversation.watchEvents(root.id);
await conn.prompt("Write an isPalindrome function in TypeScript, add tests for it, and run them.");
await conn.dispose();
The watchEvents action returns a snapshot, then sends pi.events to that connection only. A short answer can arrive whole in message_end, with no text_delta before it, so print the rest of the text there. A batch with seq 0 replaces the client’s state, and a gap in seq means call watchEvents again.
Your own actions and c.pi
Your own actions and lifecycle hooks get Pi Durable’s Harness as c.pi. In onSleep and onDestroy, it’s set only if Pi is already open. This action reports what the agent has spent on model calls:
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createRegistry } from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
import { setup } from "rivetkit";
const agent = pi({
model: "anthropic/claude-opus-5-5",
registry: createRegistry(),
actions: {
// What this agent has spent on model calls, in dollars, across every conversation.
cost: async (c) => {
const { models } = await c.pi.usage(BACKGROUND_CONTEXT);
return Object.values(models).reduce((total, usage) => total + usage.cost.total, 0);
},
},
});
export const registry = setup({ use: { agent } });
registry.start();
Keep app state in a document
A document is typed state saved with the conversation. Change it inside c.pi.commit, and read it with c.pi.snapshot:
import { defineDoc } from "@earendil-works/pi-durable";
// The Portal 2 test chambers a player has solved, kept with the conversation.
export const Chambers = defineDoc<{ solved: string[] }>({
kind: "aperture.chambers",
version: 1,
scope: "conversation",
history: "latest",
fork: "current",
initial: () => ({ solved: [] }),
});
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createRegistry } from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
import { setup } from "rivetkit";
import { Chambers } from "./chambers";
const agent = pi({
model: "anthropic/claude-opus-5-5",
registry: createRegistry(),
documents: [Chambers],
actions: {
solveChamber: async (c, chamber: string) => {
// root() creates the root conversation on first use.
const root = await c.pi.root(BACKGROUND_CONTEXT);
// The change is saved in one commit, with the conversation.
await c.pi.commit(async (tx) => {
(await tx.doc(Chambers, root.id)).solved.push(chamber);
}, BACKGROUND_CONTEXT);
},
solvedChambers: async (c) => {
const root = await c.pi.root(BACKGROUND_CONTEXT);
const chambers = await c.pi.snapshot(Chambers, root.id, BACKGROUND_CONTEXT);
return chambers?.solved ?? [];
},
},
});
export const registry = setup({ use: { agent } });
registry.start();
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
const agent = client.agent.getOrCreate(["player-1"]);
await agent.solveChamber("1-01");
await agent.solveChamber("1-02");
console.log(await agent.solvedChambers()); // ["1-01", "1-02"]
A fork with fork: "current" starts with a copy of the document. Clients can also read it by kind with the harness.snapshot and harness.watchDoc actions.
Upgrades and crashes
- When you ship new code, running work gets up to
sleepGracePeriod(15 minutes) to finish before the Actor restarts. - Anything cut off resumes from its last saved step, after your
onWakeruns. - A retry wait longer than a minute lets the Actor sleep, and a scheduled wake resumes it.
- Destroying the Actor stops it immediately.
See Architecture.
Known limitations
- Skills aren’t supported yet. Put the skill files in the workspace and add a prompt section that tells the model where they are, so it reads the right one before acting. See Instructions.
- Pi doesn’t load
AGENTS.mdfiles. Setinstructionsor add a prompt section. See Instructions. - No shell without a sandbox.
bashneeds a sandbox. - Plain tool calls aren’t rerun after a crash. Use a task or
replay: "safe". - A tool that ignores its abort signal blocks sleep. An upgrade then waits up to
sleepGracePeriodfor it to finish. - A cut-off model request is sent again from the start.
- No image reads, and no binary writes in a sandbox.
- Waits cap at ten minutes. For longer jobs, use
conversation.submitand stream. - Only
promptis traced in Observability. - No
dboption. Keep your data instate, documents, or another Actor. - No built-in approval flow. See Human in the Loop.
- Roll forward only. An older Pi Durable can’t open data a newer one wrote.
- Node.js 22.19 or later.
Configuration
The pi() function accepts every actor() option except db, plus:
| Option | Description |
|---|---|
registry | Required. Your tools and tasks, from createRegistry(). See Extensions. |
model | Starting model of new conversations, as provider/modelId. |
scopedModels | Models a client may switch to. |
apiKeys | API keys by provider. Defaults to the environment. See LLM API Keys. |
providers | Custom providers, in the shape of Pi’s models.json. |
credentials | Logins your app stores. See User Subscriptions. |
sandbox | Where CodingTools run. Without one, files live in the Actor’s database. See Sandboxes. |
documents | Documents clients may read. |
settings | Pi Durable settings, such as retry and compaction. |
Other options, such as thinkingLevel and env, pass through to Pi Durable. By default, actionTimeout is ten minutes and sleepGracePeriod is 15 minutes. A ConversationBusy error reaches clients as a UserError, and other Pi Durable errors as internal errors.
Next: Design Patterns, how to structure an app with many agents.