Skip to main content
Blog

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.

Introducing Pi Durable for Rivet

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.pi is the real Harness.
  • 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 requestId twice returns the first result, so a retried webhook or client never starts duplicate work.
YOUR BACKENDBefore the crashAfter restartModel call✓Tool call✓Task phaserunningModel call✓Tool call✓Task phaseresumes hereCrashSQLitekept through the crashsaves each stepreads on restart

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.

1 · SAVE2 · RUN3 · SAVEIntentEffectResultrefund order 4521POST /refundrefund r_1savedsavedoutside the harnessSQLitewhere a restart can pick up

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.

SANDBOXESYOUR BACKENDSANDBOXESAgentAgentAgentShell + filesShell + filesShell + filesAgentAgentAgentShell + filesShell + filesShell + filesvsAgent in the sandboxAgent outside the sandbox

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.

CLIENTSYOUR BACKEND · TRUSTEDSANDBOX · UNTRUSTEDClientsAgent ActorSQLiteShell + filesModel providerauthenticatedagent loop + keyssessionown environmenttool callsno secretsmodel calls

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

Read More