Host a Pi Durable agent
An agent that picks up where it left off, even after kill -9.
Shelley handles the setup. About 10 minutes.
Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/pi-durable/prompt.txt | ssh exe.dev new --prompt=/dev/stdinPi Durable is a TypeScript library, from the team behind the Pi coding agent, for agents that outlive their process. It writes every model turn and tool call to storage as it goes, so when the process dies, the next one opens the same database and carries on.
It's a library, not an app. This recipe is the app: a small agent with Pi's coding tools, one SQLite file, and a web chat. Your VM supplies what the library leaves to its host: something to restart it, a disk to keep it, a machine of its own to run commands on, a private address, and models without an API key.
Note
Pi Durable is experimental, and its API changes between releases. This recipe pins version 1.3.0.
What you get#
- A Pi Durable agent with Pi's coding tools, its conversation in SQLite, running under systemd.
- A private web chat at
https://<vm>.exe.xyz/that streams replies and tool output. - OpenAI's GPT-6.1 Sol through exe.dev's LLM integration. No API key on the VM.
- A task that still finished after the agent was killed halfway through.
- Extensions in
~/pi-durable/extensionsthat load on save, starting with an email tool. - Nightly backups, and one checked to hold the conversation.
What's running, and where
https://my-pi-durable.exe.xyz/
The chat, for you and anyone you share the VM with
Deploy with Shelley#
Click Deploy and Shelley will set up Pi Durable on a new VM, then check that it works. No config-file wrangling required.
Prefer a terminal?
curl -fsSL \
https://exe-staging.dev/deploy/pi-durable/prompt.txt \
| ssh exe.dev new --prompt=/dev/stdinWhat Shelley is told
Set up Pi Durable on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/pi-durable.md from top to bottom.
The recipe's example VM name is my-pi-durable. This VM's name is the output of `hostname`: use it wherever the recipe writes my-pi-durable. Skip the step that creates the VM; you are on it.
You are done when all of these are true. Check each one yourself:
- A Pi Durable agent with Pi's coding tools, its conversation in SQLite, running under systemd.
- A private web chat at `https://<vm>.exe.xyz/` that streams replies and tool output.
- OpenAI's GPT-6.1 Sol through exe.dev's LLM integration. No API key on the VM.
- A task that still finished after the agent was killed halfway through.
- Extensions in `~/pi-durable/extensions` that load on save, starting with an email tool.
- Nightly backups, and one checked to hold the conversation.
Read the whole recipe, including the closed details. Follow "Install by
hand" first, skipping VM creation; the walkthrough is for the owner
afterwards. Do not run the later Restore or Update instructions.
Keep the VM private: never run share set-public, because the agent runs
commands with sudo. On an existing VM, ask before replacing ~/pi-durable
or anything that listens on port 8000.
Verify the pong reply, the crash test (the same id and "status":"done"
after the kill), that pi-durable.service and pi-durable-backup.timer are
enabled, and a backup with entries in it. Do not send a test email.
Finish with https://<vm>.exe.xyz/, using this VM's name, and suggest
the owner try "Pull the plug".
Finish with the URLs I should open.
Also at /deploy/pi-durable/prompt.txt. The recipe it follows is /deploy/pi-durable.md.
Say hello#
Open https://my-pi-durable.exe.xyz/ and sign in to exe.dev. Try:
Look around this VM and describe it in three lines.
Each command it runs shows up in a box, with its output as it streams. Open the page in a second tab: same conversation, live in both.
Warning
Keep this VM private. The agent runs commands with sudo, and anyone who can open the page can ask it to. Never make the VM public.
Pull the plug#
Ask:
Count to 30 with bash, one number a second, then tell me how far you got.
While it counts, kill the agent. On your computer:
ssh my-pi-durable.exe.xyz sudo systemctl kill -s KILL pi-durable
The page says reconnecting… and is back a few seconds later. systemd
restarted the agent, and it found the unfinished task in its database.
bash isn't marked safe to run twice, so instead of rerunning it, the
agent is told it was interrupted, with the numbers printed so far, and
decides what to do next.
A reboot works the same way: ssh exe.dev restart my-pi-durable.
Make it yours#
The agent lives in ~/pi-durable on the VM: agent.ts (model, tools,
web server), public/index.html (the page), and extensions/. Every
.ts file in extensions/ loads as soon as it's saved, even mid-task.
There's one to start with, notify.ts, which sends email. Try:
Count to 60 with bash, then email me that you're done.
To add a tool, ask the agent itself, "Give yourself a tool that checks
whether a website is up", or run pi in ~/pi-durable on the VM. Both
know where Pi Durable's documentation is. Mark a tool replay: "safe"
only if running it twice does no harm.
After changing agent.ts, run sudo systemctl restart pi-durable on the
VM. The conversation picks up where it was.
Share it#
On your computer:
ssh exe.dev share add my-pi-durable ann@example.com
Ann signs in at the same address. Every message shows who sent it, and one sent while the agent works joins the task in progress. Share only with people you'd trust with a terminal on this VM.
Keep it safe#
Everything is in ~/pi-durable: the conversation and unfinished work in
data/agent.sqlite, your code, and the agent's workspace/. Each night
between 03:30 and 04:00 UTC, a backup of all three goes to
~/pi-durable/backups. Copy them off the VM, too. On your computer:
rsync -av my-pi-durable.exe.xyz:pi-durable/backups/ ./pi-durable-backups/
Something odd? On the VM, journalctl -u pi-durable -n 50 shows what the
agent logged.
The nuts and bolts#
Happy clicking Deploy? You can stop here. These are the setup steps Shelley follows, plus the commands for later.
Install by hand
Create a VM#
On your computer (pick another name if my-pi-durable is taken):
ssh exe.dev new --name=my-pi-durable
ssh my-pi-durable.exe.xyz
All remaining installation commands run on the VM, as exedev.
Install Node.js 24#
Pi Durable stores its data with Node's built-in SQLite, which needs
Node.js 24.16 or newer; the VM's own Node is older. NodeSource's package
replaces it. sqlite3 is for backups:
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs sqlite3
node --version
Expect v24.16.0 or later.
Write the agent#
The project, with Pi Durable pinned to 1.3.0, its settings, and an
AGENTS.md for coding agents that work on it:
mkdir -p ~/pi-durable/data ~/pi-durable/workspace ~/pi-durable/extensions \
~/pi-durable/public ~/pi-durable/backups
cd ~/pi-durable
cat > package.json <<'EOF'
{
"name": "pi-durable-agent",
"private": true,
"type": "module",
"dependencies": {
"@earendil-works/chord": "1.3.0",
"@earendil-works/pi-ai": "1.3.0",
"@earendil-works/pi-durable": "1.3.0"
}
}
EOF
npm install --ignore-scripts --no-audit --no-fund
[ -e agent.env ] || cat > agent.env <<'EOF'
# Read at start: sudo systemctl restart pi-durable after a change.
# An OpenAI or Anthropic model of exe.dev's LLM integration, such as
# anthropic/claude-sonnet-5-5
PI_MODEL=openai/gpt-6.1-sol
EOF
cat > AGENTS.md <<'EOF'
# A Pi Durable agent
- `agent.ts`: the harness, models, and web server on port 8000. After a
change: `sudo systemctl restart pi-durable`. Logs: `journalctl -u pi-durable`.
- `extensions/*.ts`: one extension per file, `export default defineExtension(...)`,
loaded as soon as it's saved. `extensions/notify.ts` is an example.
- Pi Durable 1.3.0 (experimental): `node_modules/@earendil-works/pi-durable/README.md`.
- Mark a tool `replay: "safe"` only if running it twice does no harm.
- Only the service may open `data/agent.sqlite`.
EOF
The agent: Pi Durable's harness over data/agent.sqlite, Pi's read,
write, edit, and bash tools working in workspace/, models through
exe.dev's LLM integration, and a web server on
port 8000. It only answers requests that come through exe.dev's
HTTPS proxy, which names the signed-in person in
X-ExeDev-Email, or from the VM itself:
cat > ~/pi-durable/agent.ts <<'EOF'
// A durable agent on Pi Durable: one process, one SQLite file, a web chat.
// Docs: node_modules/@earendil-works/pi-durable/README.md
import { existsSync, watch } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { AssistantEntry, createRegistry, defineExtension, Harness, section } from "@earendil-works/pi-durable";
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
import { CodingTools } from "@earendil-works/pi-durable/tools";
const dir = import.meta.dirname;
const workspace = join(dir, "workspace");
const [provider, ...rest] = (process.env.PI_MODEL ?? "openai/gpt-6.1-sol").split("/");
const model = { provider, modelId: rest.join("/") };
const context = BACKGROUND_CONTEXT;
let ready = false;
// Port 8000 doubles as a lock: one process may own the database, and a
// second copy exits here, before it opens it.
const server = createServer((req, res) => handle(req, res).catch((error) => {
console.error(error);
if (!res.headersSent) send(res, 500, { error: String(error?.message ?? error) });
}));
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(Number(process.env.PORT ?? 8000), "0.0.0.0", () => resolve(undefined));
});
// Models come through exe.dev's LLM integration: no API key on this VM.
const viaExe = (base, path) => ({
...base,
auth: { apiKey: { name: "exe.dev LLM integration", resolve: async () => ({
auth: { apiKey: "exe-llm-integration", baseUrl: `https://llm.int.exe.xyz${path}` },
}) } },
});
const models = createModels();
models.setProvider(viaExe(openaiProvider(), "/openai/v1"));
models.setProvider(viaExe(anthropicProvider(), "/anthropic"));
const registry = createRegistry();
registry.install(CodingTools); // read, write, edit, bash
registry.install(defineExtension({
name: "agent",
sections: [
section("preamble", () => [
"You are a helpful agent with your own Linux VM on exe.dev, where you can run commands with sudo.",
"Each user message starts with the sender's email address. Several people may share this chat.",
"Keep replies short. Work in the current directory unless asked otherwise.",
`Your own code is in ${dir}. Each .ts file in ${dir}/extensions loads as soon as it is saved:`,
`see extensions/notify.ts, and ${dir}/node_modules/@earendil-works/pi-durable/README.md for the API.`,
].join("\n"), { tag: false }),
section("cwd", (input) => input.env?.cwd),
],
}));
// Every .ts file in extensions/ is an extension: loaded at start, and again
// whenever it changes, without a restart.
const extensionsDir = join(dir, "extensions");
const loaded = new Map();
async function load(file) {
const path = join(extensionsDir, file);
try {
if (!existsSync(path)) {
if (loaded.has(file)) registry.uninstall(loaded.get(file));
loaded.delete(file);
return console.log(`unloaded extensions/${file}`);
}
const { default: extension } = await import(`${pathToFileURL(path)}?v=${Date.now()}`);
registry.install(extension);
loaded.set(file, extension);
console.log(`loaded extensions/${file}`);
} catch (error) {
console.error(`extensions/${file}:`, error);
}
}
for (const file of await readdir(extensionsDir)) if (file.endsWith(".ts")) await load(file);
const pending = new Map();
watch(extensionsDir, (_, file) => {
if (!file?.endsWith(".ts")) return;
clearTimeout(pending.get(file));
pending.set(file, setTimeout(() => load(file), 300));
});
const harness = await Harness.open(
await openNodeSqliteStorage(join(dir, "data", "agent.sqlite")),
{
models,
registry,
env: ({ cwd }) => new NodeExecutionEnv({ cwd: cwd ?? workspace }),
onReport: (error) => console.error("pi-durable:", error),
},
context,
);
// The same conversation after every restart.
const root = await harness.root(context, { agent: { model, cwd: workspace } });
const view = await root.viewState(context);
const current = view.value.docs["pi.agent"]?.model;
if (current?.provider !== model.provider || current?.modelId !== model.modelId) {
await root.configure({ model }, context);
}
harness.resume(); // continue whatever the last process left unfinished
ready = true;
console.log(`pi-durable agent on port ${server.address().port}, model ${provider}/${model.modelId}`);
// The browser gets a plain summary of the conversation view.
const textOf = (content) => typeof content === "string"
? content
: (content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("");
function summarize(value) {
const items = [];
const tools = new Map();
const assistant = (message, live) => {
for (const part of message.content ?? []) {
if (part.type === "text" && part.text) items.push({ type: "assistant", text: part.text, live });
if (part.type === "toolCall") {
const tool = { type: "tool", name: part.name, args: part.arguments, status: "pending", output: "" };
tools.set(part.id, tool);
items.push(tool);
}
}
if (message.stopReason === "error" && message.errorMessage) {
items.push({ type: "note", text: `Model error: ${message.errorMessage}` });
}
};
for (const entry of value.entries) {
const message = entry.model?.[0];
if (entry.kind === "chat.input") items.push({ type: "user", ...entry.data });
else if (entry.kind === "pi.user") items.push({ type: "user", author: "", text: textOf(message?.content) });
else if (entry.kind === "pi.assistant" && message) assistant(message, false);
else if (entry.kind === "pi.tool-result" && message) {
const tool = tools.get(message.toolCallId);
if (tool) Object.assign(tool, { status: message.isError ? "error" : "done", output: textOf(message.content) });
} else if (entry.kind === "pi.compaction") items.push({ type: "note", text: "Older messages were summarized." });
else if (entry.kind === "pi.reset") items.push({ type: "note", text: "Started a fresh context." });
}
const live = value.docs["pi.live"] ?? {};
if (live.generation?.message) assistant(live.generation.message, true);
for (const running of live.tools ?? []) {
const tool = tools.get(running.callId);
if (tool && running.status !== "done") Object.assign(tool, { status: running.status, output: running.output ?? "" });
}
for (const tool of tools.values()) tool.output = tool.output.slice(-4000);
for (const queued of value.docs["pi.inbox"]?.items ?? []) {
if (queued.entry?.kind === "chat.input") items.push({ type: "user", ...queued.entry.data, queued: true });
}
const agent = value.docs["pi.agent"]?.model;
return {
busy: live.run !== undefined,
model: agent ? `${agent.provider}/${agent.modelId}` : "",
items: items.slice(-300),
};
}
// Server-sent events: every open page gets each new summary.
const pages = new Set();
let latest = JSON.stringify(summarize(view.value));
let timer;
view.subscribe(() => {
timer ??= setTimeout(() => {
timer = undefined;
const next = JSON.stringify(summarize(view.value));
if (next === latest) return;
latest = next;
for (const page of pages) page.write(`data: ${latest}\n\n`);
}, 100);
});
setInterval(() => { for (const page of pages) page.write(": ping\n\n"); }, 25_000).unref();
function send(res, status, body) {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
// exe.dev's proxy signs people in and names them in X-ExeDev-Email. Requests
// without it are only accepted from this VM itself.
function caller(req) {
const email = req.headers["x-exedev-email"];
if (typeof email === "string" && email) return email;
const local = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress ?? "");
return local && !req.headers["x-forwarded-for"] ? "local" : undefined;
}
async function handle(req, res) {
const who = caller(req);
if (!who) return send(res, 401, { error: "sign in through https://<vm>.exe.xyz/" });
if (!ready) return send(res, 503, { error: "starting" });
const url = new URL(req.url ?? "/", "http://localhost");
if (req.method === "GET" && url.pathname === "/") {
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
return res.end(await readFile(join(dir, "public", "index.html")));
}
if (req.method === "GET" && url.pathname === "/api/state") return send(res, 200, JSON.parse(latest));
if (req.method === "GET" && url.pathname === "/api/events") {
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
res.write(`data: ${latest}\n\n`);
pages.add(res);
return req.on("close", () => pages.delete(res));
}
if (req.method === "POST" && url.pathname === "/api/stop") {
await root.abort(context);
return send(res, 200, { stopped: true });
}
if (req.method === "POST" && url.pathname === "/api/messages") {
let body = "";
for await (const chunk of req) body += chunk;
const { text, requestId, wait } = JSON.parse(body || "{}");
if (typeof text !== "string" || !text.trim()) return send(res, 400, { error: "text is required" });
const submission = await root.submit({
type: "input",
content: `${who}: ${text}`,
// The same requestId twice is one message, even across a crash.
requestId: typeof requestId === "string" ? requestId : undefined,
whenBusy: "steer", // join the running work after its current tool calls
entry: { kind: "chat.input", data: { author: who.split("@")[0], text } },
}, context);
if (!wait) return send(res, 202, { id: submission.id });
const settled = await submission.wait(context);
if (settled.status !== "done" || settled.type !== "input") return send(res, 200, { id: submission.id, ...settled });
const answer = await root.commit((tx) => tx.entry(AssistantEntry, settled.answer), context);
return send(res, 200, { id: submission.id, status: "done", answer: textOf(answer?.model?.[0]?.content) });
}
send(res, 404, { error: "not found" });
}
// systemd stops with SIGTERM. Unfinished work stays in the database and
// resumes on the next start.
process.once("SIGTERM", async () => {
for (const page of pages) page.end();
server.close();
await harness.close(context);
process.exit(0);
});
EOF
An example extension, a tool that sends email through exe.dev's email gateway:
cat > ~/pi-durable/extensions/notify.ts <<'EOF'
// An example tool: email someone through exe.dev's email gateway. It reaches
// you, your team, and people who have signed in to this VM's page.
// No `replay: "safe"`: if the agent restarts mid-send, the model is told the
// call was interrupted, rather than the email going out twice.
import { Type } from "@earendil-works/pi-ai";
import { defineExtension, defineTool } from "@earendil-works/pi-durable";
export default defineExtension({
name: "notify",
tools: [
defineTool({
name: "notify",
description: "Email someone a short note, for example when a long task finishes.",
parameters: Type.Object({
to: Type.String({ description: "an email address from this chat" }),
subject: Type.String(),
body: Type.String(),
}),
execute: async (args) => {
const res = await fetch("http://169.254.169.254/gateway/email/send", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(args),
});
if (!res.ok) throw new Error(`email not sent: ${res.status} ${await res.text()}`);
return { output: [{ type: "text", text: `Emailed ${args.to}.` }] };
},
}),
],
});
EOF
The page, which follows the conversation through server-sent events:
cat > ~/pi-durable/public/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pi Durable</title>
<style>
:root { color-scheme: light dark; --line: #8884; --dim: #888; }
body { margin: 0; font: 15px/1.5 system-ui, sans-serif; display: flex; flex-direction: column; height: 100dvh; }
header { display: flex; gap: .75rem; align-items: center; padding: .6rem 1rem; border-bottom: 1px solid var(--line); }
header b { flex: 1; }
#status { color: var(--dim); font-size: .9em; }
#log { flex: 1; overflow-y: auto; padding: 1rem; }
#log > * { max-width: 46rem; margin: 0 auto .75rem; }
.user { padding: .5rem .75rem; border-radius: .5rem; background: #8882; white-space: pre-wrap; }
.user small { display: block; color: var(--dim); }
.queued { opacity: .6; }
.assistant { white-space: pre-wrap; }
.note { color: var(--dim); font-style: italic; }
details { border: 1px solid var(--line); border-radius: .5rem; padding: .25rem .75rem; }
summary { cursor: pointer; font-family: ui-monospace, monospace; font-size: .85em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
pre { overflow-x: auto; font-size: .8em; max-height: 20rem; }
form { display: flex; gap: .5rem; padding: .75rem 1rem; border-top: 1px solid var(--line); }
textarea { flex: 1; font: inherit; padding: .5rem; resize: none; }
button { font: inherit; padding: .4rem 1rem; }
</style>
</head>
<body>
<header><b>Pi Durable</b><span id="status">connecting…</span><button id="stop" hidden>Stop</button></header>
<main id="log"></main>
<form id="form">
<textarea id="text" rows="2" placeholder="Message the agent. Enter sends, Shift+Enter for a new line."></textarea>
<button>Send</button>
</form>
<script>
const log = document.getElementById("log"), status = document.getElementById("status");
const stop = document.getElementById("stop"), text = document.getElementById("text");
const icons = { pending: "…", running: "⏳", done: "✓", error: "✗" };
function el(tag, className, content) {
const node = document.createElement(tag);
if (className) node.className = className;
if (content !== undefined) node.textContent = content;
return node;
}
function render(state) {
const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
const open = new Set([...log.querySelectorAll("details[open]")].map((d) => d.dataset.key));
log.replaceChildren(...state.items.map((item, i) => {
if (item.type === "user") {
const node = el("div", item.queued ? "user queued" : "user", item.text);
if (item.author) node.prepend(el("small", "", item.queued ? `${item.author} · queued` : item.author));
return node;
}
if (item.type === "tool") {
const node = el("details");
node.dataset.key = i;
node.open = open.has(String(i)) || item.status === "running";
const args = item.args?.command ?? item.args?.path ?? JSON.stringify(item.args ?? {});
node.append(el("summary", "", `${icons[item.status] ?? ""} ${item.name} ${args}`), el("pre", "", item.output || "(no output yet)"));
return node;
}
return el("div", item.type, item.text);
}));
if (nearBottom) log.scrollTop = log.scrollHeight;
status.textContent = `${state.model} · ${state.busy ? "working" : "idle"}`;
stop.hidden = !state.busy;
}
// EventSource reconnects by itself, so a restarted agent reappears here.
const events = new EventSource("api/events");
events.onmessage = (event) => render(JSON.parse(event.data));
events.onerror = () => { status.textContent = "reconnecting…"; };
async function post(path, body) {
const res = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body ?? {}) });
if (!res.ok) alert((await res.json().catch(() => ({}))).error ?? res.statusText);
}
document.getElementById("form").onsubmit = (event) => {
event.preventDefault();
if (!text.value.trim()) return;
post("api/messages", { text: text.value, requestId: crypto.randomUUID() });
text.value = "";
};
text.onkeydown = (event) => {
if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); event.target.form.requestSubmit(); }
};
stop.onclick = () => post("api/stop");
</script>
</body>
</html>
EOF
Start it#
systemd starts the agent at boot and restarts it whenever it stops:
sudo tee /etc/systemd/system/pi-durable.service >/dev/null <<'EOF'
[Unit]
Description=Pi Durable agent
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=0
[Service]
User=exedev
WorkingDirectory=/home/exedev/pi-durable
EnvironmentFile=/home/exedev/pi-durable/agent.env
Environment=PATH=/home/exedev/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/usr/bin/node agent.ts
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now pi-durable
until curl -fs localhost:8000/api/state; do sleep 1; done; echo
systemctl is-enabled pi-durable
Expect {"busy":false,"model":"openai/gpt-6.1-sol","items":[]}
and enabled. Now ask it something:
curl -fsS localhost:8000/api/messages -H 'Content-Type: application/json' \
-d '{"text":"Reply with exactly: pong","wait":true}'; echo
Expect "status":"done" and "answer":"pong".
Try a crash#
Start a task that takes a while, and kill the agent in the middle of it:
curl -fsS localhost:8000/api/messages -H 'Content-Type: application/json' \
-d '{"text":"Run: sleep 10; echo awake. Then reply with one word: finished","requestId":"crash-test"}'; echo
sleep 5
sudo systemctl kill -s KILL pi-durable
Once systemd has restarted it, send the same message again, asking to
wait for the answer. The same requestId makes it the same message, so
this finds the original and waits for the agent to finish it:
until curl -fs localhost:8000/api/state >/dev/null; do sleep 1; done
curl -fsS localhost:8000/api/messages -H 'Content-Type: application/json' \
-d '{"text":"Run: sleep 10; echo awake. Then reply with one word: finished","requestId":"crash-test","wait":true}'; echo
Expect the same id as before and "status":"done".
Set up backups#
The backup script copies the database while the agent runs (SQLite's
VACUUM INTO reads one consistent snapshot), checks the copy, and packs
it with the code and workspace:
cat > ~/pi-durable/backup <<'EOF'
#!/bin/sh
# Back up the agent while it runs: its database, code, and workspace,
# to ~/pi-durable/backups/pi-durable-<UTC time>.tar.gz. Keeps the newest 14.
set -eu
cd "$HOME"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/pi-durable/data"
sqlite3 pi-durable/data/agent.sqlite "VACUUM INTO '$tmp/pi-durable/data/agent.sqlite'"
test "$(sqlite3 "$tmp/pi-durable/data/agent.sqlite" 'PRAGMA integrity_check')" = ok
dest="pi-durable/backups/pi-durable-$(date -u +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$dest.partial" --exclude=node_modules \
pi-durable/AGENTS.md pi-durable/agent.ts pi-durable/agent.env pi-durable/backup pi-durable/package.json \
pi-durable/package-lock.json pi-durable/extensions pi-durable/public pi-durable/workspace \
-C "$tmp" pi-durable/data
mv "$dest.partial" "$dest"
ls -1 pi-durable/backups/pi-durable-*.tar.gz | head -n -14 | xargs -r rm --
echo "$HOME/$dest"
EOF
chmod +x ~/pi-durable/backup
Take one and look inside:
backup=$(~/pi-durable/backup)
tar -xzOf "$backup" pi-durable/data/agent.sqlite > /tmp/check.sqlite
sqlite3 /tmp/check.sqlite 'SELECT count(*) FROM entries'
rm /tmp/check.sqlite
Expect a number above zero: the messages so far. Then back up every night:
sudo tee /etc/systemd/system/pi-durable-backup.service >/dev/null <<'EOF'
[Unit]
Description=Back up the Pi Durable agent
[Service]
Type=oneshot
User=exedev
ExecStart=/home/exedev/pi-durable/backup
EOF
sudo tee /etc/systemd/system/pi-durable-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Back up the Pi Durable agent every night
[Timer]
OnCalendar=*-*-* 03:30:00 UTC
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now pi-durable-backup.timer
systemctl list-timers pi-durable-backup.timer
The timer should list its next run. Installation done: open
https://my-pi-durable.exe.xyz/ and say hello.
Back up and restore
On the VM, ~/pi-durable/backup takes an extra backup and prints its
path. Take one before every update. Backups hold the whole conversation,
so keep them private.
Go back to a backup#
This replaces the conversation, code, and workspace files with the backup's, and loses every message since. On the VM:
ls ~/pi-durable/backups
sudo systemctl stop pi-durable
rm -f ~/pi-durable/data/agent.sqlite-wal ~/pi-durable/data/agent.sqlite-shm
tar -xzf ~/pi-durable/backups/pi-durable-20261016-033012.tar.gz -C ~
sudo systemctl start pi-durable
Use a file from the listing in place of the example. Files the agent
created in workspace/ since the backup stay where they are.
Move to a new VM#
On the new VM, follow Install by hand through
Install Node.js 24. Copy a backup across from your computer, for
example scp ./pi-durable-backups/pi-durable-20261016-033012.tar.gz my-new-vm.exe.xyz:, then unpack it and install its packages, on the new
VM:
tar -xzf ~/pi-durable-20261016-033012.tar.gz -C ~
mkdir -p ~/pi-durable/backups
cd ~/pi-durable && npm ci --ignore-scripts --no-audit --no-fund
Then follow Start it, skipping the pong test, and the systemd part of
Set up backups. Stop the old agent first, on the old VM:
sudo systemctl disable --now pi-durable pi-durable-backup.timer.
Update Pi Durable
Read the changelog for each version between yours and the new one: breaking changes are common, and your extensions may need edits. The three packages share a version number and move together. On the VM, with the version you picked:
~/pi-durable/backup
cd ~/pi-durable
v=1.4.0 # the version you picked
npm install --save-exact --ignore-scripts --no-audit --no-fund \
"@earendil-works/pi-durable@$v" "@earendil-works/pi-ai@$v" "@earendil-works/chord@$v"
sudo systemctl restart pi-durable
until curl -fs localhost:8000/api/state >/dev/null; do sleep 1; done
journalctl -u pi-durable -n 20 --no-pager
The log should end with pi-durable agent on port 8000. If the agent
doesn't come back, press Ctrl-C, read the log, and
go back to the backup you just took. It holds the
old package.json, so run npm ci --ignore-scripts in ~/pi-durable
before starting the agent again.
Something not working?
On the VM:
systemctl status pi-durable --no-pager
journalctl -u pi-durable -n 100 --no-pager
- The page says Model error: check
PI_MODELin~/pi-durable/agent.env; it'sprovider/model, such asopenai/gpt-6.1-sol.curl https://llm.int.exe.xyz/v1/modelslists what the LLM integration offers this VM. EADDRINUSEin the log: something else has port 8000, perhaps a second copy of the agent started by hand. Stop it: only one process may own the database, and the agent won't open it without the port.- An extension doesn't load: the log names the file and the error. Until you fix it, the agent keeps the version that last loaded.
{"error":"sign in…"}from the API: requests must come throughhttps://my-pi-durable.exe.xyz/or from the VM itself.- The page stays on reconnecting…: the agent isn't running; the status and log above say why.
Frequently asked questions#
Is Pi Durable ready for production?
How is this different from the Pi coding agent?
Pi is the terminal coding agent, and it comes preinstalled on exe.dev VMs: run pi. Pi Durable is a library from the same team for building agent apps that run for days and survive restarts. This recipe is one such app, with Pi's coding tools inside.
What exactly survives a crash?
The conversation, queued messages, and every task's last checkpoint. A model request that was cut off is sent again. A tool call reruns only if its tool says that's safe; otherwise the model hears it was interrupted, with the output so far. SQLite survives the process dying, but a power cut can lose the last fraction of a second.
What does it cost?
Your VM plan plus model usage, billed to your exe.dev account through the LLM integration. A request cut off by a crash is sent again, so it's paid for twice.
Can I use Claude instead?
Yes. On the VM, set PI_MODEL=anthropic/claude-sonnet-5-5 in ~/pi-durable/agent.env and run sudo systemctl restart pi-durable. The conversation carries on with the new model.
Can several people talk to it at once?
Yes. Share the VM: everyone sees the same live conversation, each message is signed with its sender, and a message sent while the agent works steers the task in progress.
Updated Oct 16, 2026 · Markdown · manifest.json · Something wrong? Tell us