---
title: "Host a Pi Durable agent"
description: "Run an agent built on Pi Durable on an exe.dev VM: a private web chat, SQLite storage, models without API keys, and work that survives a crash."
url: https://exe-staging.dev/deploy/pi-durable
category: "AI agents"
updated: 2026-10-16
---

# Host a Pi Durable agent

An agent that picks up where it left off, even after kill -9.

- **Software:** Pi Durable (https://earendil.com/posts/pi-durable/)
- **Source:** https://github.com/earendil-works/pi
- **License:** MIT
- **Time:** 10 minutes
- **Example VM name:** `my-pi-durable` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe-staging.dev/deploy/pi-durable/manifest.json

## 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/extensions` that load on save, starting with an email tool.
- Nightly backups, and one checked to hold the conversation.

Running on the VM:

- **Your agent**: Pi Durable 1.3 on Node.js 24, managed by systemd, port 8000, data in `~/pi-durable/data/agent.sqlite`
- **Extensions**: Your tools, loaded the moment you save them, data in `~/pi-durable/extensions`
- **Workspace**: Where the agent reads, writes, and runs commands, data in `~/pi-durable/workspace`
- **GPT-6.1 Sol**: OpenAI, through exe.dev's LLM integration
- **Nightly backups**: Database, code, and workspace; 14 kept, data in `~/pi-durable/backups`

How it is reached:

- https://my-pi-durable.exe.xyz/ (private): The chat, for you and anyone you share the VM with

## Deploy with Shelley

Open https://exe-staging.dev/new?prompt=Set%20up%20Pi%20Durable%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe-staging.dev%2Fdeploy%2Fpi-durable.md%20from%20top%20to%20bottom.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-pi-durable.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-pi-durable.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20A%20Pi%20Durable%20agent%20with%20Pi%27s%20coding%20tools%2C%20its%20conversation%20in%20SQLite%2C%20running%20under%20systemd.%0A-%20A%20private%20web%20chat%20at%20%60https%3A%2F%2F%3Cvm%3E.exe.xyz%2F%60%20that%20streams%20replies%20and%20tool%20output.%0A-%20OpenAI%27s%20GPT-6.1%20Sol%20through%20exe.dev%27s%20LLM%20integration.%20No%20API%20key%20on%20the%20VM.%0A-%20A%20task%20that%20still%20finished%20after%20the%20agent%20was%20killed%20halfway%20through.%0A-%20Extensions%20in%20%60~%2Fpi-durable%2Fextensions%60%20that%20load%20on%20save%2C%20starting%20with%20an%20email%20tool.%0A-%20Nightly%20backups%2C%20and%20one%20checked%20to%20hold%20the%20conversation.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details.%20Follow%20%22Install%20by%0Ahand%22%20first%2C%20skipping%20VM%20creation%3B%20the%20walkthrough%20is%20for%20the%20owner%0Aafterwards.%20Do%20not%20run%20the%20later%20Restore%20or%20Update%20instructions.%0AKeep%20the%20VM%20private%3A%20never%20run%20share%20set-public%2C%20because%20the%20agent%20runs%0Acommands%20with%20sudo.%20On%20an%20existing%20VM%2C%20ask%20before%20replacing%20~%2Fpi-durable%0Aor%20anything%20that%20listens%20on%20port%208000.%0AVerify%20the%20pong%20reply%2C%20the%20crash%20test%20%28the%20same%20id%20and%20%22status%22%3A%22done%22%0Aafter%20the%20kill%29%2C%20that%20pi-durable.service%20and%20pi-durable-backup.timer%20are%0Aenabled%2C%20and%20a%20backup%20with%20entries%20in%20it.%20Do%20not%20send%20a%20test%20email.%0AFinish%20with%20https%3A%2F%2F%3Cvm%3E.exe.xyz%2F%2C%20using%20this%20VM%27s%20name%2C%20and%20suggest%0Athe%20owner%20try%20%22Pull%20the%20plug%22.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open.%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe-staging.dev/deploy/pi-durable/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Or follow the steps below by hand.

[Pi Durable](https://earendil.com/posts/pi-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.

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

```bash
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:

```bash
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:

```bash
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.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Install by hand</summary>

### Create a VM

On your computer (pick another name if `my-pi-durable` is taken):

```bash
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:

```bash
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:

```bash
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](/docs/integrations-llm), and a web server on
port 8000. It only answers requests that come through exe.dev's
[HTTPS proxy](/docs/proxy), which names the signed-in person in
`X-ExeDev-Email`, or from the VM itself:

```bash
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](/docs/send-email):

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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](#say-hello).

</details>

<details id="back-up-and-restore" class="dp-details">
<summary>Back up and restore</summary>

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:

```bash
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](#create-the-vm) 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:

```bash
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`.

</details>

<details id="update" class="dp-details">
<summary>Update Pi Durable</summary>

Read the
[changelog](https://github.com/earendil-works/pi/blob/main/packages/durable/CHANGELOG.md)
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:

```bash
~/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](#back-up-and-restore) you just took. It holds the
old `package.json`, so run `npm ci --ignore-scripts` in `~/pi-durable`
before starting the agent again.

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

On the VM:

```bash
systemctl status pi-durable --no-pager
journalctl -u pi-durable -n 100 --no-pager
```

- **The page says *Model error*:** check `PI_MODEL` in
  `~/pi-durable/agent.env`; it's `provider/model`, such as
  `openai/gpt-6.1-sol`. `curl https://llm.int.exe.xyz/v1/models`
  lists what the [LLM integration](/docs/integrations-llm) offers this VM.
- **`EADDRINUSE` in 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 through
  `https://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.

</details>

## Frequently asked questions

### Is Pi Durable ready for production?

Not yet, by its makers' account: it's experimental, and the API changes between releases (1.2.0 renamed a tool result's `content` to `output`, for one). This recipe pins 1.3.0. Read the [changelog](https://github.com/earendil-works/pi/blob/main/packages/durable/CHANGELOG.md) and take a backup before you [update](#update).

### 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](https://exe.dev/pricing) plus model usage, billed to your exe.dev account through the [LLM integration](/docs/integrations-llm). 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](#share-it): 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.


## Related

- [Host OpenClaw](https://exe-staging.dev/deploy/openclaw.md): An AI sidekick with its own computer. Your laptop can take a nap.
- [Run Claude Code on a remote server](https://exe-staging.dev/deploy/claude-code.md): A computer for Claude. A breather for your laptop.
- [A Durable Object with celld](https://exe-staging.dev/deploy/celld.md): Durable Objects on a VM you own. Same code, your bucket.
- [Self-host n8n](https://exe-staging.dev/deploy/n8n.md): Connect your apps. Let the boring bits run themselves.
- [VS Code in your browser, with code-server](https://exe-staging.dev/deploy/code-server.md): Your editor in a tab. Your projects right where you left them.
- [Self-host Uptime Kuma](https://exe-staging.dev/deploy/uptime-kuma.md): A little lookout for your websites. Green bars encouraged.
