Quickstart: Pi Durable
Last updated 2 November 2026In Getting started
How to use Pi Durable is in Pi’s own README, from its Quick Start to Persist and Resume; this page moves its storage to a Tonbo database, so another machine can resume the conversation.
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): 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. Over SSH, or on a machine without a browser, it prints a short code instead: open https://tonbo.dev/cli/device in a browser on any computer where you are signed in, enter the code, check the computer name and approve (tonbo login --device asks for the code anywhere). For non-interactive use, such as CI, 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/sdk@1.0.0 @tonbo/sqlite-ursula@0.11.4 \
@earendil-works/pi-durable@1.5.0 @earendil-works/pi-ai@1.5.0 \
@earendil-works/chord@1.5.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/sdk 1.0.0 and @tonbo/sqlite-ursula 0.11.4 with the Pi packages at 1.5.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).
Swap the storage
Pi’s Persist and Resume opens the Harness on a SQLite file. On Tonbo, its imports and its storage change, and nothing else:
- import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
+ import { resolveDatabase } from "@tonbo/sdk/database";
+ import { openUrsulaPiStorage } from "@tonbo/sqlite-ursula/pi";
- const harness = await Harness.open(await openNodeSqliteStorage("./session.sqlite"), { models, registry }, context);
+ const { url, token } = await resolveDatabase("harness-1");
+ const harness = await Harness.open(await openUrsulaPiStorage(url, { token }), { models, registry }, context);
const root = await harness.root(context); // the same root as last time
harness.resume(); // continue any run the last process left unfinishedresolveDatabase looks the database up by name with the key in TONBO_API_KEY, and hands out its URL and a provider of short-lived tokens for the key. openUrsulaPiStorage opens it with them, and harness.close() closes it with the Harness.
Run a turn
A whole program, saved as agent.mjs. It reads the answer as Pi’s Quick Start does, from the entry the submission settled with:
import { BACKGROUND_CONTEXT as ctx } from "@earendil-works/chord/context";
import { contentText } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { AssistantEntry, createRegistry, Harness } from "@earendil-works/pi-durable";
import { resolveDatabase } from "@tonbo/sdk/database";
import { openUrsulaPiStorage } from "@tonbo/sqlite-ursula/pi";
// node agent.mjs <request id> [stop]
const [requestId, mode] = process.argv.slice(2);
const models = createModels();
models.setProvider(openaiProvider()); // reads OPENAI_API_KEY
// Reads TONBO_API_KEY. CACHE_DIR plays another machine, with its own local copy.
const { url, token } = await resolveDatabase("harness-1");
const storage = await openUrsulaPiStorage(url, { token, dir: process.env.CACHE_DIR });
const harness = await Harness.open(storage, { models, registry: createRegistry() }, ctx);
const root = await harness.root(ctx, { agent: { model: { provider: "openai", modelId: "gpt-6-sol" } } });
// 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, ctx);
console.log("submission", submission.id);
if (mode === "stop") process.exit(); // stored; stop before the answer, as a crash would
const settled = await submission.wait(ctx);
if (settled.status === "done") {
const answer = await root.commit((tx) => tx.entry(AssistantEntry, settled.answer), ctx);
console.log(contentText(answer.model[0].content, ""));
} else console.log(settled.status, settled.reason);
await harness.close(ctx); // closes the storage tooIn 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 the submission’s id and, once the model answered, the answer:
$ node agent.mjs q1
submission 9
The capital of France is Paris.The id and the model’s words differ for you; the shape of the output does not. agent.mjs never calls harness.resume(): submitting and waiting start Pi’s scheduler, which continues what the last process left unfinished. A process that only opens the Harness and waits calls harness.resume() itself (see Persist and Resume).
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
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
submission 17
The capital of France is Paris.Shell 2 printed the same submission: the request id q2 found the question shell 1 stored, so it was asked once, and wait() ran it to its answer.
Open it from two shells at once
One process writes a database at a time: the last one to open it (see When two instances run). Save this beside agent.mjs as hold.mjs: it opens the database, waits for Enter, then writes to it.
import { createInterface } from "node:readline/promises";
import { BACKGROUND_CONTEXT as ctx } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, Harness } from "@earendil-works/pi-durable";
import { resolveDatabase } from "@tonbo/sdk/database";
import { UrsulaResetError } from "@tonbo/sqlite-ursula";
import { openUrsulaPiStorage } from "@tonbo/sqlite-ursula/pi";
const { url, token } = await resolveDatabase("harness-1");
const storage = await openUrsulaPiStorage(url, { token });
const harness = await Harness.open(storage, { models: createModels(), registry: createRegistry() }, ctx);
const root = await harness.root(ctx);
const prompt = createInterface({ input: process.stdin, output: process.stdout });
await prompt.question("open; press Enter to write ");
prompt.close();
try {
await root.submit({ type: "input", content: "And of Italy?" }, ctx);
console.log("written");
await harness.close(ctx);
} catch (error) {
// The call that met the reset rejects with it; later calls wrap it as their cause.
const reset = error instanceof UrsulaResetError ? error : error?.cause;
if (!(reset instanceof UrsulaResetError)) throw error;
console.log(reset.name, reset.kind, reset.action);
process.exit(1);
}In shell 1, start it and leave it waiting:
$ node hold.mjs
open; press Enter to writeWhile it waits, run agent.mjs 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
submission 25
The capital of France is Paris.Then press Enter in shell 1. Its write is refused:
open; press Enter to write
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
A long outage makes a Harness call wait until the stream holds its commit again (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 { resolveDatabase } from "@tonbo/sdk/database";
import { UrsulaResetError } from "@tonbo/sqlite-ursula";
import { openUrsulaPiStorage } from "@tonbo/sqlite-ursula/pi";
async function open() {
const { url, token } = await resolveDatabase("harness-1");
const storage = await openUrsulaPiStorage(url, { token });
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/sdk@1.0.0 @tonbo/sqlite-ursula@0.11.4 \ @earendil-works/pi-durable@1.5.0 @earendil-works/pi-ai@1.5.0 \ @earendil-works/chord@1.5.0npm ls --all | grep @tonbo/sqlite-ursula-then prints the prebuilt extension for your platform. - Where the Harness opens on
new MemoryStorage(), look the database up withconst { url, token } = await resolveDatabase("harness-1")from@tonbo/sdk/databaseand open it onawait openUrsulaPiStorage(url, { token })from@tonbo/sqlite-ursula/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.