Quickstart: Pi Durable
Last updated 29 October 2026
Run a Pi Durable Harness on a Tonbo database, so another machine can resume its conversation. Pi Durable is the durable agent harness of Earendil’s open-source Pi toolkit: it commits an agent’s conversation, model turns and tool calls to its storage, so a process that dies picks the work up where it stopped.
Run it on your laptop, in a sandbox or on any machine with Node.js 22.19 or later and internet access, with nothing to allowlist, on Linux (x64 or arm64) or macOS on Apple silicon; on any other platform, such as an Intel Mac, build the package’s SQLite extension from source first (see Requirements).
Sign in
Sign in at tonbo.dev with GitHub or Google. A new account asks you to choose a username: it names your personal account, which holds your databases, streams and keys, and appears in the console’s addresses. You can change it once every 90 days.
Create a database
Create a database named harness-1. It goes to the default region, us-east-1, unless you choose another (see Regions and URLs): pick the one nearest where your program runs. With the CLI, name it with --region, as in tonbo database create harness-1 --region ap-southeast-1.
- Open Databases in the console.
- Choose Create database and name it
harness-1.
npm i -g @tonbo/cli
tonbo login
tonbo database create harness-1tonbo login signs you in through a browser on the same machine. On a machine without one, such as a remote server, set TONBO_API_KEY to a key that acts as you and is not narrowed, created in the console under API keys, and the CLI uses it instead.
With a key that acts as you and is not narrowed, in TONBO_API_KEY: create one in the console under API keys without narrowing it. The next step’s key is narrowed, and a narrowed key never creates databases.
curl -sS https://api.tonbo.dev/v1/databases \
-H "Authorization: Bearer $TONBO_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"name":"harness-1"}'It answers 201 once the database is active, or 202 while it is still being created.
The database is creating for a moment, then active. Node.js code opens it by name, and Python code by its URL, which tonbo database show harness-1 and the console show (see Regions and URLs).
Create an API key
Give the program its own key, narrowed to harness-1: it reads and writes only that database, and may name one that does not exist yet. A key that acts as you and is not narrowed reaches every database and stream you can in its account (see What a key can reach).
- In the console, choose API keys in the sidebar.
- Choose New API key and narrow it to the database
harness-1. - Copy the key. It starts with
tbo_and is shown once.
tonbo api-key create harness-1-app --database harness-1It prints the key once, as TONBO_API_KEY=..., ready for the program’s environment. Put it before the command that runs the program, as the next step does, rather than exporting it in your shell: the CLI reads TONBO_API_KEY too, and would then act with the narrowed key.
On this machine, after tonbo login, a Node.js program can also open its databases with no key at all: open() falls back to your login (see Connect). Python does not, and the narrowed key is what any program uses anywhere else.
Install the packages
npm install --save-exact @tonbo/sqlite-ursula@0.11.2 \
@earendil-works/pi-durable@1.1.0 @earendil-works/pi-ai@1.1.0 \
@earendil-works/chord@1.1.0Pi Durable’s own API is experimental and changes without notice between its releases, so pin the Pi versions you test with. Tonbo’s Streams acceptance test runs @tonbo/sqlite-ursula 0.11.2 with the Pi packages at 1.1.0.
Check that npm added the prebuilt extension for your platform:
npm ls --all | grep @tonbo/sqlite-ursula-It prints the extension’s package, such as @tonbo/sqlite-ursula-darwin-arm64 on a Mac with Apple silicon. If it prints nothing, npm skipped that optional package: install again without --omit=optional, or, on a platform with no prebuilt extension, build it from source (see Requirements).
Open the storage and run a turn
Take Pi Durable’s own quick start and swap its storage: the Harness opens on a Tonbo database instead of memory, then resumes whatever the last process left unfinished.
- const storage = new MemoryStorage();
+ const storage = await openUrsulaPiStorage("harness-1");
const harness = await Harness.open(storage, { models, registry }, context);
+ harness.resume();The whole file, saved as agent.mjs:
import { createInterface } from "node:readline/promises";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { createRegistry, Harness } from "@earendil-works/pi-durable";
import { UrsulaResetError } from "@tonbo/sqlite-ursula";
import { openUrsulaPiStorage } from "@tonbo/sqlite-ursula/tonbo/pi";
// node agent.mjs <request id> [stop | hold]
const [requestId, mode] = process.argv.slice(2);
const context = BACKGROUND_CONTEXT;
const models = createModels();
models.setProvider(openaiProvider()); // reads OPENAI_API_KEY
const registry = createRegistry();
// CACHE_DIR plays another machine: each keeps its own local copy of the database.
const storage = await openUrsulaPiStorage("harness-1", { dir: process.env.CACHE_DIR });
const harness = await Harness.open(storage, { models, registry }, context);
const root = await harness.root(context, {
agent: { model: { provider: "openai", modelId: "gpt-6-sol" } },
});
const { submissions } = await harness.inspect(context);
console.log("unfinished:", submissions.length);
harness.resume(); // continue any run the last process left unfinished
// The same request id finds the same submission instead of asking twice.
const input = { type: "input", requestId, content: "What is the capital of France?" };
const submission = await root.submit(input, context);
console.log("submission:", submission.id);
if (mode === "stop") process.exit(); // stored; stop before the answer, as a crash would
const settled = await submission.wait(context);
console.log(settled.status, await lastAnswer()); // "done", and the answer is durable
if (mode === "hold") {
const prompt = createInterface({ input: process.stdin, output: process.stdout });
await prompt.question("press Enter to ask again ");
prompt.close();
try {
const next = await root.submit({ type: "input", content: "And of Italy?" }, context);
console.log((await next.wait(context)).status, await lastAnswer());
} catch (error) {
const reset = [error, error?.cause].find((cause) => cause instanceof UrsulaResetError);
if (!reset) throw error;
console.log("UrsulaResetError", reset.kind, reset.action);
process.exit(1);
}
}
await harness.close(context); // closes the storage too
async function lastAnswer() {
const { messages } = await root.context(context);
const parts = messages.at(-1).content.filter((part) => part.type === "text");
return parts.map((part) => part.text).join("");
}In every shell you use below, export the key from the API key step and your OpenAI key:
export TONBO_API_KEY=tbo_... OPENAI_API_KEY=...Run it with the request id q1. It prints how many submissions the last process left unfinished, the submission’s id and, once the model answered, done with the answer:
$ node agent.mjs q1
unfinished: 0
submission: 9
done The capital of France is Paris.The ids and the model’s words differ for you; the shape of the output does not.
Finish the run from another machine
Stop a run midway and let a second process finish it. In shell 1, ask under a new request id with stop: the program exits as soon as the question is stored, before the model answers, as a crash would.
$ node agent.mjs q2 stop
unfinished: 0
submission: 17In shell 2, run the same request with its own CACHE_DIR, which stands in for another machine: it has no local copy, so the database rebuilds from Tonbo. On another machine with the keys and agent.mjs, leave CACHE_DIR out.
$ CACHE_DIR=./machine-b node agent.mjs q2
unfinished: 1
submission: 17
done The capital of France is Paris.Shell 2 found the unfinished submission, and harness.resume() continued it to its answer. The same request id returned the same submission, so the question was asked once.
Open it from two shells at once
A database has one writer at a time, and the process that opened it last is that writer. In shell 1, ask with hold, which keeps the database open and waits for Enter:
$ node agent.mjs q3 hold
unfinished: 0
submission: 25
done The capital of France is Paris.
press Enter to ask againWhile it waits, open the database in shell 2, again with its own CACHE_DIR: one local copy serves one process at a time.
$ CACHE_DIR=./machine-b node agent.mjs q3
unfinished: 0
submission: 25
done The capital of France is Paris.Then press Enter in shell 1. Its next question is refused:
press Enter to ask again
UrsulaResetError fenced stopShell 2’s open made it the writer, so shell 1’s next commit failed with UrsulaResetError of kind fenced and action stop. Shell 1 stops rather than open the database again: opening it would take the database back from shell 2 (see one writer).
When the storage is reset
On Pi 1.1, which this installs, a long outage rejects a Harness call with Pi’s StorageRejected and the session continues; from Pi 1.2 the call waits instead (see backlog_full). A reset rejects it with UrsulaResetError. On reopen, close and open again. On stop, another process took the database over: stop and alert rather than take it back.
import { UrsulaResetError } from "@tonbo/sqlite-ursula";
async function open() {
const storage = await openUrsulaPiStorage("harness-1");
const harness = await Harness.open(storage, { models, registry }, context);
harness.resume();
return harness;
}
let harness = await open();
// Call this when a Harness call rejects.
async function onError(error) {
if (!(error instanceof UrsulaResetError)) throw error;
await harness.close(context);
if (error.action === "stop") {
// Another process owns the database. Opening it again would fence that one.
console.error("database taken over by another writer, stopping", error);
process.exit(1);
}
// "fix": the cause, such as a deleted database, must be fixed before an open.
if (error.action !== "reopen") throw error;
harness = await open();
}Run one instance per database. On Kubernetes, use the Recreate strategy or a StatefulSet: a rolling update starts the new pod before the old one stops.
Add it to a project
In a project of your own, with the program’s API key in TONBO_API_KEY where the project keeps its secrets, never in its code:
- Create the database with
tonbo database create harness-1, signed in withtonbo login, or in the console.tonbo database show harness-1printsState:activeonce it is ready. - Install, keeping the exact versions:
npm install --save-exact @tonbo/sqlite-ursula@0.11.2 \ @earendil-works/pi-durable@1.1.0 @earendil-works/pi-ai@1.1.0 \ @earendil-works/chord@1.1.0npm ls --all | grep @tonbo/sqlite-ursula-then prints the prebuilt extension for your platform. - Where the Harness opens on
new MemoryStorage(), open it onawait openUrsulaPiStorage("harness-1")from@tonbo/sqlite-ursula/tonbo/piinstead, and callharness.resume()afterHarness.opento continue any run the last process left unfinished.
Run one instance per database. One process writes a database at a time: the last one to open it (see When two instances run).
Next steps
- Connect from Node.jsresolveDatabase, the key and its errors, synced(), and what the host needs.
- Rules for databasesOne instance per database, stopping when a reset says stop, and the other rules.
- GuaranteesWhy one process writes a database at a time, and what happens to the first when a second opens it.
- Cold startsHow long a new host takes to rebuild a conversation, and how to keep restarts from rebuilding.
- LimitsTransaction, snapshot and database size, requests per second, and databases per account.
- PricingWrites, retention, the monthly allowance, and what happens when the balance runs out.