Skip to main content
Agents

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.

YOUR BACKENDSANDBOXEStool callsAgentShell + filesAgentShell + filesAgentShell + files
  • 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

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:

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. 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. The pi-schema package builds tool parameters. Install them next to Pi Durable:

npm add @earendil-works/pi-ai @earendil-works/pi-schema
ToUse
Define a tool’s parametersobj, str, and the other builders from pi-schema, which build the JSON 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 { 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.

ClientAgentcalls the modelSandboxruns toolspromptviewtool callresultrepeats until the model stops calling tools
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.

ToCall
Prompt and waitprompt(text, { requestId })
Start a run without waitingsubmit(text, { requestId })
Steer a runprompt(text, { whenBusy: "steer" })
Cancel a runconversation.abort(id)
Switch modelsconversation.configure(id, { model: { provider, modelId } })
Summarize older messagesconversation.compact(id)
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. 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:

whenBusyWhat 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 onAnswer is running, it can run again for the same answer.
  • A throw goes to onReport and 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 seq 0 has a value, which replaces everything the client had.
  • Other frames have Chord operations in ops. Apply them to the view with applyImmutable from @earendil-works/chord/delta, which you install with npm add @earendil-works/chord.
  • The seq number goes up by one for each frame. A gap in seq means call conversation.watch again.
  • To stop listening, call conversation.unwatch(id).
In the viewWhat it shows
entriesThe newest entries of the transcript, starting with the newest 50.
beforeThe id of the entry just before entries. It is absent when entries reaches the start of the transcript.
docs["pi.live"].runPresent while a run is in progress.
docs["pi.live"].generationThe partial answer in message, and the retry backoff in retry.
docs["pi.live"].toolsThe tools of the current round, each with a name, a status, and its running output.
docs["pi.live"].compactionsOlder messages are being summarized to free up context.
docs["pi.live"].runFailureWhy 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:

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.

AGENTIdleRunningCompactingSleepingSandboxpromptdone, abortcontext fulldoneidlenext actiontool calls

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.

AGENT ACTORAgent loopin memorySQLiteconversations, sandbox idSandboxsave each steprestore on waketool callsreconnect by idSleep, a crash, or an upgrade clears memory. SQLite is kept.

What survives

ConversationsSandbox
SleepEvery step so far.Reconnected on first use.
CrashEvery step saved before the crash. A run in progress resumes on wake from the step it reached.Reconnected on first use.
UpgradeEvery step so far. A run in progress gets time to finish first. See Upgrades.Reconnected on first use.
DestroyDeleted.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 whileOn wake
The model was answeringThe 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" ranThe tool runs again.
Any other tool ranThe tool isn’t run again. Its result tells the model the call stopped partway.
A task phase ranThe phase runs again, and earlier phases are kept.
The run was cancelled with abortNothing resumes.
YOUR BACKENDBefore the crashAfter restartModel call✓Tool call✓Task phaserunningModel call✓Tool call✓Task phaseresumes hereCrashSQLitekept through the crashsaves each stepreads on restart

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

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

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 bash returns 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 credentials Actor. The agent never receives a refresh token.
  • A conversation stores every message and tool result, and clients can read them with conversation.context or 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 credentials Actor 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.md or skill discovery. Load them with prompt sections. See System Prompt and Skills.
  • 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.
  • 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. 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:

  1. The apiKeys option passed to pi().
  2. The credentials Actor, when your users bring their own subscriptions.
  3. The worker’s environment.
YOUR BACKEND1apiKeys2credentials Actor3EnvironmentAgentModel providerSandboxmodel callstool calls,no keys

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:

OptionDescription
registryRequired. Your tools and tasks, from createRegistry(). See Bundle Tools and Hooks into Extensions.
modelStarting model of new conversations, as provider/modelId.
scopedModelsModels a client may switch to.
apiKeysAPI keys by provider. See Keys in code.
providersProviders Pi does not know. See Custom providers.
credentialsLogins your app stores. See Run Agents on Your Users’ Subscriptions.
sandboxWhere CodingTools run. Without one, files live in the Actor’s database. See Providers for each provider’s options.
documentsDocuments clients may read.
settingsPi Durable settings, such as retry and compaction.
onAnswerRuns 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

Edit this page Last updated November 10, 2026