Skip to main content
Agents

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.

YOUR BACKENDBefore the crashAfter restartModel call✓Tool call✓Task phaserunningModel call✓Tool call✓Task phaseresumes hereCrashSQLitekept through the crashsaves each stepreads on restart

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. bash returns 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
ToUse from pi-ai
Define a tool’s parametersType, which builds the schema Pi checks every call against
Read the messages of a conversationAssistantMessage, UserMessage, and ToolResultMessage
Pick a thinking level for thinkingLevelModelThinkingLevel, from "off" to "max"

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.

ToCall
Prompt and waitprompt(text, { requestId })
Start a run without waitingconversation.submit(id, { type: "input", content, requestId })
Steer a runprompt(text, { whenBusy: "steer" })
Cancel a runconversation.abort(id)
Switch modelsconversation.configure(id, { model: { provider, modelId } })
Get the root conversationharness.root()
Start a conversationharness.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:

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 onWake runs.
  • 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.md files. Set instructions or add a prompt section. See Instructions.
  • No shell without a sandbox. bash needs 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 sleepGracePeriod for 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.submit and stream.
  • Only prompt is traced in Observability.
  • No db option. Keep your data in state, 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:

OptionDescription
registryRequired. Your tools and tasks, from createRegistry().
modelStarting model of new conversations, as provider/modelId.
scopedModelsModels a client may switch to.
apiKeysAPI keys by provider. Defaults to the environment. See LLM API Keys.
providersCustom providers, in the shape of Pi’s models.json.
credentialsLogins your app stores. See User Subscriptions.
sandboxWhere CodingTools run. Without one, files live in the Actor’s database. See Sandboxes.
documentsDocuments clients may read.
settingsPi 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.