Pi Durable
Run Pi Durable, Earendil's durable agent harness, inside 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.
@rivet-dev/pi runs Pi Durable, Earendil’s durable agent harness, inside a Rivet Actor. You define the agent once, and every key you call it with gets its own Actor, conversations, and files. Pi Durable 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.
- The agent loop runs in the Actor. Model calls, retries, and the conversation run in your backend, next to your application code, where you control credentials, permissions, and logging.
- Files without a sandbox. By default, the agent’s file tools work on files in the Actor’s own SQLite database, so there is nothing else to set up.
- A sandbox when it needs a shell. Add a sandbox such as E2B or Daytona, and file and shell tools run there. Nothing the agent runs executes on your worker, and your provider keys never enter the sandbox.
- Agents are durable. Each agent lives in its Actor’s SQLite database, not in memory, so it can sleep, crash, or upgrade mid-run and pick up where it left off.
Quickstart
Install
npm add @rivet-dev/pi @earendil-works/pi-durable rivetkit
Define the agent
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();
Set ANTHROPIC_API_KEY. For another model provider, change model and set that provider’s key, such as OPENAI_API_KEY. See Model access.
The agent needs no sandbox. Its read, write, and edit tools work on files stored in the Actor’s own database.
Send a prompt
TypeScript
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 hello-world Rivet Actor to counter.ts, then read the file back to me.",
{ requestId: crypto.randomUUID() },
);
console.log(result.status === "done" ? result.text : `Unanswered: ${result.reason}`);
curl
URL="http://localhost:6420/gateway/agent/action"
AGENT="rvt-namespace=default&rvt-method=getOrCreate&rvt-key=user-123&rvt-pool=default"
curl -X POST "$URL/prompt?$AGENT" \
-H "Content-Type: application/json" \
-d '{"args": ["Write a hello-world Rivet Actor to counter.ts, then read the file back to me.", {"requestId": "counter-1"}]}'
Each action is a POST to the gateway with its arguments in args. See Call Action.
The prompt action waits for the run to finish and returns { status, text }. A model error doesn’t reject it: status is unanswered, with Pi’s reason. Sending the same requestId again returns the first answer instead of running twice.
Add a sandbox (optional)
To let the agent run commands, give it a sandbox and Pi’s CodingTools, which add bash:
npm add @rivet-dev/sandbox-adapter
import { createRegistry } from "@earendil-works/pi-durable";
import { CodingTools } from "@earendil-works/pi-durable/tools";
import { pi } from "@rivet-dev/pi";
import { agentOSProvider } from "@rivet-dev/sandbox-adapter/agentos";
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: agentOSProvider(),
});
export const registry = setup({ use: { agent } });
registry.start();
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();
import { createRegistry } from "@earendil-works/pi-durable";
import { CodingTools } from "@earendil-works/pi-durable/tools";
import { pi } from "@rivet-dev/pi";
import { daytonaProvider } from "@rivet-dev/sandbox-adapter/daytona";
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: daytonaProvider(),
});
export const registry = setup({ use: { agent } });
registry.start();
import { createRegistry } from "@earendil-works/pi-durable";
import { CodingTools } from "@earendil-works/pi-durable/tools";
import { pi } from "@rivet-dev/pi";
import { freestyleProvider } from "@rivet-dev/sandbox-adapter/freestyle";
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: freestyleProvider({
create: {
snapshotId: "freestyle/ubuntu-sm",
// A VM has no network access until a rule allows it. This allows DNS and HTTPS out.
firewall: {
rules: [
{ action: "allow", source: {}, destination: { public: true, port: 53, protocol: "udp" } },
{ action: "allow", source: {}, destination: { public: true, port: 443, protocol: "tcp" } },
],
},
},
}),
});
export const registry = setup({ use: { agent } });
registry.start();
agentOS needs no provider account. For E2B, install e2b and set E2B_API_KEY. For Daytona, install @daytonaio/sdk and set DAYTONA_API_KEY. For Freestyle, install freestyle and set FREESTYLE_API_KEY. See Sandboxes.
Deploy
In development, Rivet runs a local control plane for you and keeps Actor data on your machine.
To scale Rivet in production, pick how much of it you want to run yourself:
Fully managed
Bring your own compute
Full self-hosting
If you are running your own nodes, follow the guide for your hosting provider:
Why run Pi on Rivet
Stateful agents on any infrastructure
- A live process per agent. Each agent is an Actor with its own memory and SQLite database. It keeps its conversations loaded between prompts, so two servers never race on the same agent. Nothing is rebuilt from a database or replayed from an event log on each request.
- No cloud lock-in. Rivet is open source under Apache 2.0. Use Rivet Cloud, bring your own compute, or self-host everything. Workers run on Kubernetes, AWS ECS, Railway, Render, Vercel, Google Cloud Run, AWS Lambda, or any VM.
Any harness, any model
- Bring your harness. Rivet runs existing agent harnesses, starting with Pi, instead of asking you to rewrite your agent for a new framework.
- Bring your model and keys. Use any provider the harness supports, your own API keys, or your users’ own subscriptions.
Built for scale and realtime
- Lightweight. A running Actor adds about 72 KB of memory on top of the agent’s conversations, and a sleeping agent uses none. Give every user, task, or thread its own agent.
- Wakes in milliseconds. A sleeping agent wakes on its next prompt in about 12 ms, instead of the seconds it takes to boot a container.
- Realtime and multiplayer. Clients connect over WebSockets and stream events as the agent works. Many users can watch and steer the same conversation.
- One primitive for your backend. Agents are Actors, like the rest of your Rivet app, so they call other agents and Actors directly and use the same schedules, queues, and workflows.
- Swappable sandboxes. A sandbox crash does not lose the conversation, and a tool call adds about 10 ms in the same datacenter.
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. The pi-schema package builds tool parameters. Install them next to Pi Durable:
npm add @earendil-works/pi-ai @earendil-works/pi-schema
| To | Use |
|---|---|
| Define a tool’s parameters | obj, str, and the other builders from pi-schema, which build the JSON 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 { obj, oneOf, opt, str } from "@earendil-works/pi-schema";
import { defineTool } from "@earendil-works/pi-durable";
// pi-schema builds the JSON 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: obj({
pokemon: str({ description: "Pokémon name in lowercase, such as sudowoodo." }),
version: opt(oneOf(["heartgold", "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 { output: [{ 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 { num, obj, str } from "@earendil-works/pi-schema";
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: obj({ repo: str({ description: "owner/name" }), pr: num() }),
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 { output: [{ 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
A conversation is one thread of messages inside an agent. Each agent has a root conversation, which prompt uses by default, and can hold more conversations. A conversation keeps the full history of messages and tool calls, handles one run at a time, calling the model and tools until the model is done, and streams its changes to every client that watches it. It survives sleep, crashes, and upgrades. See Architecture.
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 }. A model error doesn’t reject it: a failed model call is retried automatically, and if it keeps failing, status is unanswered with Pi’s reason, and the conversation is kept. 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 | submit(text, { requestId }) |
| Steer a run | prompt(text, { whenBusy: "steer" }) |
| Cancel a run | conversation.abort(id) |
| Switch models | conversation.configure(id, { model: { provider, modelId } }) |
| Summarize older messages | conversation.compact(id) |
| 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. Only abort stops a run. If prompt times out or its client disconnects, prompt rejects, and the run keeps going until it finishes. Send the prompt again with the same requestId to get its result instead of running the agent twice. submit takes the same options as prompt and returns { submissionId } at once. Pair it with onAnswer to deliver the answer. See the source for every action.
Steer, follow up, or cancel
On an idle conversation, a prompt runs right away. While a run is in progress, prompt(text, { whenBusy }) decides what happens to the new prompt:
whenBusy | What it does |
|---|---|
"followUp" | The default. The agent reads the message once it has no tool calls left, and the run continues. |
"steer" | The agent reads the message after its current tool calls finish, before its next model call. |
"reject" | The prompt fails with a ConversationBusy error, and nothing is queued. |
To cancel a run, call conversation.abort(id). It stops any command still running in the sandbox. Daytona can’t cancel a command, so it runs until it exits or times out.
Compaction
When the context fills up, the agent summarizes older messages and keeps going. Call conversation.compact(id) to do it yourself. The conversation keeps working while the summary is written.
Deliver answers
onAnswer runs in the agent Actor for every final answer, with the Actor’s context. Use it to send answers somewhere, such as a chat app or a text message, without a client waiting on prompt:
// Texts every answer to the user whose agent this is.
onAnswer: async (c, answer) => {
const [phone] = c.key;
await sendSms(phone, answer.text ?? "Sorry, I couldn't answer that.");
},
- It gets
{ conversationId, text, message }. Responses that only call tools aren’t final answers, so they don’t call it. - It runs inside the run, so an answer that finishes after a crash or restart is still delivered. If the Actor stops while
onAnsweris running, it can run again for the same answer. - A throw goes to
onReportand leaves the answer in the conversation. - It runs in conversations that use the default extensions. A conversation that selects its extensions by name doesn’t get it.
See Personal Agent over SMS for a complete example.
Stream a conversation
import { applyImmutable, type Op } from "@earendil-works/chord/delta";
import { contentText } from "@earendil-works/pi-ai";
import type { ConversationView, LiveState } from "@earendil-works/pi-durable";
import type { PiViewFrame } from "@rivet-dev/pi";
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
const conn = client.agent.getOrCreate(["user-123"]).connect();
let view: ConversationView | undefined;
// Operations that arrive before the watch call returns.
const early: Op[] = [];
// The text of the current answer printed so far, and the tool calls already shown.
let printed = "";
const shownTools = new Set<string>();
conn.on("pi.view", (frame) => {
// RivetKit types event payloads as unknown, so name the frame's type.
const { value, ops = [] } = frame as PiViewFrame;
if (value) view = value;
else if (view) view = applyImmutable(view, ops);
else early.push(...ops);
if (view) show(view);
});
function show(current: ConversationView) {
const live = current.docs["pi.live"] as LiveState;
for (const tool of live.tools ?? []) {
if (shownTools.has(tool.callId)) continue;
shownTools.add(tool.callId);
console.log(`\n> ${tool.name}`);
}
// The answer in progress, or the newest entry once it is finished.
const last = current.entries.at(-1)?.model?.[0];
const answer = live.generation?.message ?? (last?.role === "assistant" ? last : undefined);
const text = answer ? contentText(answer.content, "") : "";
if (!text.startsWith(printed)) printed = "";
process.stdout.write(text.slice(printed.length));
printed = text;
}
const root = await conn.harness.root();
const { value } = await conn.conversation.watch(root.id);
view = applyImmutable(value, early);
show(view);
await conn.prompt("Write an isPalindrome function in TypeScript, add tests for it, and run them.");
await conn.dispose();
The conversation.watch(id) action returns { value }, the conversation’s view, including a partial answer, so a client that connects late catches up. Then the agent sends pi.view frames to that connection only. Each frame has conversationId, seq, and either value or ops.
- A frame with
seq0 has avalue, which replaces everything the client had. - Other frames have Chord operations in
ops. Apply them to the view withapplyImmutablefrom@earendil-works/chord/delta, which you install withnpm add @earendil-works/chord. - The
seqnumber goes up by one for each frame. A gap inseqmeans callconversation.watchagain. - To stop listening, call
conversation.unwatch(id).
| In the view | What it shows |
|---|---|
entries | The newest entries of the transcript, starting with the newest 50. |
before | The id of the entry just before entries. It is absent when entries reaches the start of the transcript. |
docs["pi.live"].run | Present while a run is in progress. |
docs["pi.live"].generation | The partial answer in message, and the retry backoff in retry. |
docs["pi.live"].tools | The tools of the current round, each with a name, a status, and its running output. |
docs["pi.live"].compactions | Older messages are being summarized to free up context. |
docs["pi.live"].runFailure | Why the newest run ended without an answer. |
docs["pi.inbox"] | Messages waiting for the current run. |
docs["pi.usage"] | The conversation’s token and cost totals. |
To read older entries, pass before to conversation.entries(id, { maxEntryId: before }, limit). To read the messages the model sees, call conversation.context(id), which returns { messages }.
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: [] }),
// Store the whole document every 50 changes, so loading it replays at most 50.
checkpointWhen: (_value, _ops, { deltasSinceBase }) => deltasSinceBase >= 50,
});
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.
Architecture
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. Each key gets its own agent, with its own conversations, files, and sandbox.
States
A conversation is idle until a prompt starts a run, and goes back to idle when the run ends or is cancelled. When the context fills up during a run, the agent compacts it and keeps going.
Sleep
An idle agent sleeps and frees its memory. The next action on its key wakes it: the agent opens Pi from SQLite, resumes interrupted work, and reconnects to the same sandbox the first time a tool needs it, so clients don’t do anything different. While the agent sleeps, its sandbox is suspended.
During a retry or poll wait, the Actor also sleeps, and a scheduled wake resumes the work when the wait ends.
What survives
| Conversations | Sandbox | |
|---|---|---|
| Sleep | Every step so far. | Reconnected on first use. |
| Crash | Every step saved before the crash. A run in progress resumes on wake from the step it reached. | Reconnected on first use. |
| Upgrade | Every step so far. A run in progress gets time to finish first. See Upgrades. | Reconnected on first use. |
| Destroy | Deleted. | Deleted. |
A model or instructions set with conversation.configure are part of the conversation, so they are kept too. Without a sandbox, files live in the Actor’s SQLite database and survive the same way as conversations. If the sandbox no longer exists when the agent wakes, a new one is created and the old files are lost.
Actions that don’t run a tool, such as conversation.context or conversation.configure, don’t connect to the sandbox. They keep working while the sandbox provider is down.
Resuming a run
After a crash or a cut-off upgrade, the agent reads the saved steps on wake and carries on from the step it reached:
| Stopped while | On wake |
|---|---|
| The model was answering | The call counts as a failed attempt, and the partial answer is saved as a stopped message. The model call is sent again after the retry backoff. With no retries left, the run ends unanswered. |
A tool marked replay: "safe" ran | The tool runs again. |
| Any other tool ran | The tool isn’t run again. Its result tells the model the call stopped partway. |
| A task phase ran | The phase runs again, and earlier phases are kept. |
The run was cancelled with abort | Nothing resumes. |
Streamed output is saved in batches, about once a second. A crash can lose about the last second of a partial answer or of a tool’s running output. Finished messages and tool results are saved before the run continues.
Nothing runs while a crashed Actor is down. The run resumes when the Actor starts again. A client that is still connected gets a fresh view of the conversation.
Upgrades
When you ship new code, running work gets up to sleepGracePeriod (15 minutes by default) to finish before the Actor restarts on the new version. See Versions. Work still running when the grace period ends resumes on wake, after your onWake runs.
A client waiting on prompt gets an error if the Actor stops first. It can send the prompt again with the same requestId to get the first prompt’s result instead of a second run.
Destroying the Actor stops running work at once, without a grace period.
Roll forward only. An older version of Pi Durable can’t open data that a newer version wrote, so don’t roll back after an upgrade has run.
Security
The agent loop runs in an Actor on your backend, and the commands the model chooses run in a sandbox. Everything that needs a secret stays in your backend.
What reaches the sandbox
- Only calls of the built-in tools: commands to run, and files to read or write. Each agent has its own sandbox. Your custom tools run on your worker.
- Not your worker’s environment. Commands run with the sandbox’s own environment.
- File tools reject paths outside the working directory. Shell commands can reach anything inside the sandbox, so the sandbox itself is the boundary.
- Without a sandbox, files live in the Actor’s own SQLite database and there is no shell, so
bashreturns an error. Commands never fall back to running on your worker.
Where secrets live
- Model keys, sandbox provider keys, and the secrets your custom tools use stay on your worker. The model and the sandbox never see them.
- User subscription logins stay in your
credentialsActor. The agent never receives a refresh token. - A conversation stores every message and tool result, and clients can read them with
conversation.contextor watch them as events, so keep secrets out of tool results and error messages. - Traces never record prompt text, tool arguments, or tool results.
Who can connect
- Anyone who can connect to an agent can prompt it and read every one of its conversations. Add authentication and check that the caller may use the agent’s key. Keys aren’t secret.
- Protect the
credentialsActor the same way, because its actions return provider tokens. - Give a browser a short-lived token for one agent instead of your Rivet credentials. See Client SDK.
Known limitations
- No automatic
AGENTS.mdor skill discovery. Load them with prompt sections. See System Prompt and Skills. - 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. - No
dboption. Keep your data instate, documents, or another Actor. - No built-in approval flow. See Human in the Loop.
- Roll forward only. See Upgrades.
- Node.js 22.19 or later.
Model access
An agent calls the model from your worker, so its keys stay in your backend and never enter the sandbox. It looks for a key in this order:
- The
apiKeysoption passed topi(). - The
credentialsActor, when your users bring their own subscriptions. - The worker’s environment.
The agent never reads Pi’s local ~/.pi/agent/auth.json or models.json.
Environment variables
Set the key for your model provider in the environment of the process that runs your Actors:
ANTHROPIC_API_KEY=sk-ant-...
Pi reads the provider-named variable, such as ANTHROPIC_API_KEY, OPENAI_API_KEY, XAI_API_KEY, DEEPSEEK_API_KEY, GROQ_API_KEY, or OPENROUTER_API_KEY. See Pi’s provider list for the rest.
Keys in code
Pass apiKeys to set keys by provider id in code, for example when they come from a secret manager or differ per deployment. The keys stay in the Actor’s memory and win over every other source.
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";
const anthropicKey = process.env.MY_ANTHROPIC_KEY;
if (!anthropicKey) throw new Error("MY_ANTHROPIC_KEY is not set.");
const extensions = createRegistry();
extensions.install(CodingTools);
const agent = pi({
model: "anthropic/claude-opus-5-5",
registry: extensions,
sandbox: e2bProvider(),
apiKeys: { anthropic: anthropicKey },
});
export const registry = setup({ use: { agent } });
registry.start();
Custom providers
To use a provider Pi does not know, register it with providers, in the shape of Pi’s models.json, and give it a key the same way.
Configuration
The pi() function accepts every actor() option except db, plus:
| Option | Description |
|---|---|
registry | Required. Your tools and tasks, from createRegistry(). See Bundle Tools and Hooks into Extensions. |
model | Starting model of new conversations, as provider/modelId. |
scopedModels | Models a client may switch to. |
apiKeys | API keys by provider. See Keys in code. |
providers | Providers Pi does not know. See Custom providers. |
credentials | Logins your app stores. See Run Agents on Your Users’ Subscriptions. |
sandbox | Where CodingTools run. Without one, files live in the Actor’s database. See Providers for each provider’s options. |
documents | Documents clients may read. |
settings | Pi Durable settings, such as retry and compaction. |
onAnswer | Runs for every final answer. See Deliver answers. |
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 so does pi_schema_too_new when the actor’s data has a newer Pi Durable schema than the code. Other Pi Durable errors reach clients as internal errors.
Guides
Agent Design Patterns
Keys, subagents, agent-to-agent messages, workflows, schedules, and shared credentials.
Give an Agent a Sandbox
Run an agent’s file and shell tools in agentOS, E2B, Daytona, Freestyle, or your own provider.
Give an Agent Your Own Tools
Give an agent tools that run in your backend, with your APIs and secrets.