Quickstart: Pi Durable

Last updated 29 October 2026

View .md

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.

  1. Open Databases in the console.
  2. Choose Create database and name it harness-1.

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).

  1. In the console, choose API keys in the sidebar.
  2. Choose New API key and narrow it to the database harness-1.
  3. Copy the key. It starts with tbo_ and is shown once.

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.0

Pi 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:

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:

Shell 1
$ 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.

Shell 1
$ node agent.mjs q2 stop
unfinished: 0
submission: 17

In 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.

Shell 2
$ 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:

Shell 1
$ node agent.mjs q3 hold
unfinished: 0
submission: 25
done The capital of France is Paris.
press Enter to ask again

While it waits, open the database in shell 2, again with its own CACHE_DIR: one local copy serves one process at a time.

Shell 2
$ 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:

Shell 1
press Enter to ask again
UrsulaResetError fenced stop

Shell 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.

Closing and reopening
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:

  1. Create the database with tonbo database create harness-1, signed in with tonbo login, or in the console. tonbo database show harness-1 prints State: active once it is ready.
  2. 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.0
    npm ls --all | grep @tonbo/sqlite-ursula- then prints the prebuilt extension for your platform.
  3. Where the Harness opens on new MemoryStorage(), open it on await openUrsulaPiStorage("harness-1") from @tonbo/sqlite-ursula/tonbo/pi instead, and call harness.resume() after Harness.open to 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