Quickstart: Pi Durable

Last updated 2 November 2026In Getting started

View .md

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.

  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/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.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/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:

Persist and Resume, on Tonbo
- 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 unfinished

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

agent.mjs
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 too

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 the submission’s id and, once the model answered, the answer:

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

Shell 1
$ node agent.mjs q2 stop
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
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.

hold.mjs
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:

Shell 1
$ node hold.mjs
open; press Enter to write

While it waits, run agent.mjs 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
submission 25
The capital of France is Paris.

Then press Enter in shell 1. Its write is refused:

Shell 1
open; press Enter to write
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

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.

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

  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/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.0
    npm ls --all | grep @tonbo/sqlite-ursula- then prints the prebuilt extension for your platform.
  3. Where the Harness opens on new MemoryStorage(), look the database up with const { url, token } = await resolveDatabase("harness-1") from @tonbo/sdk/database and open it on await openUrsulaPiStorage(url, { token }) from @tonbo/sqlite-ursula/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