Introducing Pi Durable for Rivet
Run Earendil's Pi Durable harness in a Rivet Actor. Every model request and tool call is checkpointed to SQLite, so runs finish through crashes and upgrades.
Last week we shipped Pi 1.0 for Rivet. Today, pi() runs Pi Durable in a Rivet Actor. Each Actor runs one Pi Durable harness and stores it in its own SQLite database.
Pi Durable is still in beta, so expect its API to change between releases.
Pi Durable on Rivet gives you:
- Lightweight: about 1.3 MB of RAM per session, so hundreds of agents share one process.
- Fast to start: Actors wake from hibernation in milliseconds.
- Durable: every step is saved to the Actor’s SQLite, so runs finish through crashes and upgrades.
- Sleeps when idle: idle agents hibernate and use no memory.
- Multiplayer streaming: any number of clients watch one conversation live.
- Sandbox optional: without a sandbox, Pi’s file tools work on files stored in the Actor’s SQLite.
- Vanilla Pi Durable: actions mirror Pi Durable’s API, and
c.piis the realHarness. - And more: schedules, per-agent state, swappable sandboxes, scoped access, and self-hosting.
Use cases include:
- Background agents: keep working after the user closes the tab, and wake themselves on a schedule
- Multiplayer agents: many people watch and steer the same conversation in realtime
- Email agents: one agent per inbox or thread, with the whole conversation remembered between replies
- Chat bots: answer mentions in Slack, GitHub, Linear, or Discord, with one conversation per thread
- Support agents: one agent per customer, with tools that call your own APIs
Why Pi Durable, and what do you mean by “durable”?
Pi Durable is Earendil’s harness for agents whose work has to outlive the process it started in. These agents stay on for months, serve several people at once, and get interrupted along the way by crashes, restarts, and upgrades.
“Durable” means none of that loses work:
- Every step is saved first: a model call, a tool call, or a task you define writes its progress to the Actor’s SQLite before the next step starts.
- Restarts pick up mid-run: after a crash, the Actor reads that record and continues where the run stopped.
- Nobody has to press retry: interrupted work finishes on its own, including subagents and other tasks running in parallel.
- Exactly-once input: sending the same
requestIdtwice returns the first result, so a retried webhook or client never starts duplicate work.
Show me the code
Install @rivet-dev/pi with Pi Durable:
npm add @rivet-dev/pi @earendil-works/pi-durable rivetkit
Define the agent. Set an API key for any supported provider, such as ANTHROPIC_API_KEY. Pi’s read, write, and edit tools work without a sandbox, on files stored in the Actor’s SQLite. Add a sandbox to give the agent a shell with bash:
E2B
npm add @rivet-dev/sandbox-adapter e2b
Set E2B_API_KEY.
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 tools = createRegistry();
tools.install(CodingTools);
const agent = pi({
model: "anthropic/claude-opus-5-5",
registry: tools,
sandbox: e2bProvider(),
});
export const registry = setup({ use: { agent } });
registry.start();
Daytona
npm add @rivet-dev/sandbox-adapter @daytonaio/sdk
Set DAYTONA_API_KEY.
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 tools = createRegistry();
tools.install(CodingTools);
const agent = pi({
model: "anthropic/claude-opus-5-5",
registry: tools,
sandbox: daytonaProvider(),
});
export const registry = setup({ use: { agent } });
registry.start();
No sandbox
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 SQLite
const tools = createRegistry();
tools.install(defineExtension({ name: "files", tools: [createReadTool(), createWriteTool(), createEditTool()] }));
const agent = pi({
model: "anthropic/claude-opus-5-5",
registry: tools,
});
export const registry = setup({ use: { agent } });
registry.start();
Send a prompt. Pass the webhook’s delivery id as the requestId, so a redelivery never runs the job twice:
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
const agent = client.agent.getOrCreate(["acme/api", "issue-1234"]);
const result = await agent.prompt("Find the failing test and fix it.", {
requestId: "delivery-8f2c",
});
// A model error resolves with status "unanswered" and Pi's reason
console.log(result.status === "done" ? result.text : result.reason);
See the Pi Durable docs for streaming, forks, and documents.
Add tools, with a twist
Every tool call is a task, and tools can start tasks of their own. Each step saves its intent, runs, then saves its result, so a restart picks up after the last saved beat.
Tools define their parameters with Type from pi-ai, Pi’s model library:
npm add @earendil-works/pi-ai
Here refund_order runs a two-phase refund as a task:
// Checkpoints in Pi Durable are explicit: each phase names the state it saves,
// so you, or an agent editing this file, can see exactly where a restart resumes.
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";
type RefundState = { phase: "refund" } | { phase: "notify"; refundId: string };
// Two phases. Each one saves its progress before the next one starts
const Refund = defineTask<{ orderId: string }, RefundState, string>({
name: "orders.refund",
version: 1,
initial: () => ({ phase: "refund" }),
phases: {
refund: async (task, runtime, context) => {
// Keyed on the order, so the payment API refunds each order at most once
const res = await fetch(`https://api.example.com/orders/${task.input.orderId}/refund`, {
method: "POST",
headers: { "idempotency-key": `refund-${task.input.orderId}` },
});
const { refundId } = (await res.json()) as { refundId: string };
await runtime.commit(() => ({ status: "running", checkpoint: { phase: "notify", refundId } }), context);
},
notify: async (task, runtime, context) => {
const { refundId } = task.state.checkpoint;
await fetch(`https://api.example.com/orders/${task.input.orderId}/notify`, { method: "POST" });
await runtime.commit(() => ({ status: "terminal", outcome: { status: "completed", result: refundId } }), context);
},
},
abort: async (_task, runtime, context) => {
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context);
},
});
// The tool owns the refund task, so cancelling the call cancels the refund
const refundOrder = defineTool({
name: "refund_order",
description: "Refund an order and notify the customer",
parameters: Type.Object({ orderId: Type.String() }),
execute: async ({ orderId }, api, context) => {
const id = await api.createTask(Refund, { orderId }, { ownership: { kind: "task", taskId: api.taskId } }, context);
const { outcome } = (await api.waitForTask(id, context)).state;
const text = outcome.status === "completed" ? `Refunded: ${outcome.result}` : outcome.status;
return { content: [{ type: "text", text }] };
},
});
const tools = createRegistry();
tools.install(defineExtension({ name: "orders", tools: [refundOrder], tasks: [Refund] }));
// One support agent per customer. Refund tasks are stored in its SQLite next to the conversation
const support = pi({
model: "anthropic/claude-opus-5-5",
registry: tools,
});
export const registry = setup({ use: { support } });
registry.start();
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
const support = client.support.getOrCreate(["customer-123"]);
// The ticket id doubles as the requestId, so a retried request reuses the first run
const result = await support.prompt("Order 4521 arrived broken. Please refund it.", {
requestId: "ticket-881",
});
console.log(result.status === "done" ? result.text : result.reason);
If the process crashes after the refund, the task resumes at notify. The idempotency key covers a crash mid-request. Plain tool calls rerun after a crash only if marked replay: "safe".
Run the harness outside the sandbox
An agent that runs inside its sandbox shares the sandbox’s fate. When the sandbox crashes, fills its disk, or goes to sleep, the agent goes down with it, and its credentials sit next to untrusted code.
On Rivet, the harness runs in your backend and the sandbox is just a set of tools. Keys, history, and permissions stay in the Actor, and only tool calls reach the sandbox.
Read Run Your Harness Outside the Sandbox for the full argument.
More features
Schedules
Agents wake themselves on a schedule and sleep in between. On Pi Durable, a scheduled run that crashes picks up where it stopped.
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(),
onCreate: async (c) => {
// Every morning at 9:00, prompt the agent
await c.cron.set({
name: "morning-digest",
expression: "0 9 * * *",
action: "prompt",
args: ["Summarize yesterday's support tickets."],
});
},
});
export const registry = setup({ use: { agent } });
registry.start();
See Schedules.
Per-agent state
Pi Durable documents give each agent its own typed state in the Actor’s SQLite. A tool’s write lands in the same commit as its result, so the notes and the conversation always match.
import { Type } from "@earendil-works/pi-ai";
import { createRegistry, defineDoc, defineExtension, defineTool } from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
import { setup } from "rivetkit";
// A typed document stored in the Actor's SQLite, next to the conversation
const Notes = defineDoc<{ items: string[] }>({
kind: "app.notes",
version: 1,
scope: "conversation",
history: "latest",
fork: "current",
initial: () => ({ items: [] }),
});
const saveNote = defineTool({
name: "save_note",
description: "Save a note for later",
parameters: Type.Object({ text: Type.String() }),
execute: async ({ text }, api, context) => {
// Committed in the same transaction as the tool result
await api.commit(async (tx) => {
(await tx.doc(Notes, api.conversationId)).items.push(text);
}, context);
return { content: [{ type: "text", text: "Saved." }] };
},
});
const tools = createRegistry();
tools.install(defineExtension({ name: "notes", tools: [saveNote] }));
// Clients read a document by kind with harness.snapshot("app.notes", conversationId)
const agent = pi({
model: "anthropic/claude-opus-5-5",
registry: tools,
documents: [Notes],
});
export const registry = setup({ use: { agent } });
registry.start();
And more
- Swappable sandboxes: agentOS, E2B, Daytona, or your own provider
- Scoped access: share a link that reaches one Actor and expires
- Open source: Apache 2.0, on Rivet Cloud or self-hosted
Read More
- Pi Durable, Earendil’s announcement
- Monday Meditations, October 5th 2026, the Pi team on why they built Pi Durable
- Discord, tell us what you build