{"slug": "pi-durable", "title": "Pi Durable", "summary": "Earendil and the Pi community shipped Pi 1.0 alongside Pi Durable, an experimental package for building long-running, durable agents that run anywhere a JavaScript runtime exists. Pi Durable ships memory, SQLite, and JSONL storage backends plus a conformance suite and benchmarks, with SQLite and JSONL code using no Node APIs so it can run on Bun or inside a Cloudflare Durable Object. The full source, excluding tests, is about 15,000 lines — roughly 150,000 tokens with GPT and 250,000 with Claude — and the storage backends alone account for 3,000 lines.", "body_md": "# Pi Durable\n\nToday Earendil and the Pi community [shipped Pi 1.0](https://earendil.com/posts/pi-1-0/). This\nreflects our belief that after countless hours of hardening, maintenance, and\nactive development, Pi is now a solid foundation on which to build. Pi also\ncontinues to evolve. Together with Pi 1.0, we are shipping an experimental new\npackage called Pi Durable. Pi Durable was built specifically for long-running,\ndurable, and malleable agents that can run anywhere. We would like you to join\nin the fun and help us make it the best durable harness there is.\n\n## Why Pi Durable?\n\nPi the coding agent is built to run on your (remote) machine, inside a terminal, driven by one person. If the process dies, you look at what happened and tell it to continue. That is what Pi 1.0 focuses on and excels at, and that is not changing.\n\nAt Earendil, we want to bring this technology to everyone, in whatever form fits their needs best. For that, we need a harness that runs anywhere, can be reached from different surfaces, supports infinitely long conversations, survives catastrophic internal and external failures, and lets multiple humans steer the same agents.\n\nPi Durable is that harness. It does not replace the Pi coding agent. It is a framework for building any agentic application, coding agents included. It shares not only code with the Pi coding agent, like pi-ai, but also its principles: minimalism and malleability.\n\nIt also lets us explore designs in this space without disrupting Pi the coding agent. Lessons we learn building agentic applications on Pi Durable will flow back into Pi the coding agent as they prove themselves valuable.\n\n## What is a harness?\n\nEverybody has their own definition of a harness. We [wrote about this\npreviously](https://earendil.com/posts/what-is-a-harness/), but let us reintroduce the concept of\nthe harness for Pi Durable.\n\nA harness is storage plus the machinery needed to run one or more conversations with large language models in parallel. It provides the tools those models call, and the execution environments the tools run in.\n\nA conversation is an interaction between you and an agent, recorded as a transcript. The agent is the large language model together with its settings, like the thinking level, and the tools it can call.\n\nTools do their work through an execution environment, which can be your laptop, a remote VM, or an in-memory sandbox. Which tools and which execution environment an agent gets is up to each conversation.\n\nEverything the harness runs, from calling the model to executing a tool, is a task.\n\nLike everything in Pi, Pi Durable is built so your agent can understand it. The entire source code, without tests, is about 15,000 lines, which comes out to about 150,000 tokens with GPT and about 250,000 with Claude. That's the worst case. To build on Pi Durable, your agent rarely needs all of it; the storage backends alone are 3,000 lines it can usually skip.\n\nNow let us give you a little tour of Pi Durable, to illustrate what we built and why we built it.\n\n## Long runs anywhere\n\nWe want agents to run for a long time and to be able to run anywhere, where anywhere currently means anywhere there is a JavaScript runtime.\n\nIn Pi Durable, a harness opens over a storage backend. Pi Durable ships memory, SQLite, and JSONL storage, plus a conformance suite and benchmarks for your own backend. The SQLite and JSONL storage code uses no Node APIs, so with a small adapter it runs on Bun or inside a Cloudflare Durable Object. The storage interface is small and easy to implement on top of whatever you have, like a key-value store or Postgres. One process owns a storage at a time, and other clients attach to that process.\n\nOn SQLite, the harness only keeps the working set in memory: the active transcripts, live tasks, and pending submissions. Everything else stays on disk until it is needed. Active transcripts are naturally bounded by the model's context window, because compaction summarizes older messages before they overflow it. So even a conversation with tens of thousands of messages fits snugly into memory.\n\nTools that need files or a shell get them from an execution environment. Pi\nDurable ships a Node execution environment, which gives tools access to your\nlocal files. Like storage, the execution environment interface is small and easy\nto implement, so you can also expose remote execution environments to your\ntools. That allows the harness to run on one machine while its tools run on\nanother. Your `env` function builds the environment for every tool call, from\nthe conversation's working directory, so each conversation can run in a\ndifferent place.\n\n``` js\nimport { BACKGROUND_CONTEXT } from \"@earendil-works/chord/context\";\nimport { createModels } from \"@earendil-works/pi-ai/models\";\nimport { openaiProvider } from \"@earendil-works/pi-ai/providers/openai\";\nimport { createRegistry, Harness } from \"@earendil-works/pi-durable\";\nimport { NodeExecutionEnv } from \"@earendil-works/pi-durable/env/node\";\nimport {\n    openNodeSqliteStorage,\n} from \"@earendil-works/pi-durable/storage/sqlite/node\";\nimport { CodingTools } from \"@earendil-works/pi-durable/tools\";\n\nconst context = BACKGROUND_CONTEXT; // every call takes a context for cancellation\nconst models = createModels();\nmodels.setProvider(openaiProvider());\n\nconst registry = createRegistry();\nregistry.install(CodingTools); // read, write, edit, bash\n\nconst env = ({ cwd }: { cwd?: string }) =>\n    new NodeExecutionEnv({ cwd: cwd ?? process.cwd() });\nconst harness = await Harness.open(\n    await openNodeSqliteStorage(\"./agent.sqlite\"),\n    { models, registry, env },\n    context,\n);\n// The root conversation: created on first use, and the same one after every\n// restart.\nconst root = await harness.root(context, {\n    agent: {\n        model: { provider: \"openai\", modelId: \"gpt-6.1-sol\" },\n        cwd: \"/work/repo\",\n    },\n});\n```\n\n## Survives crashes\n\nWe want an agent to survive its process dying, whether the laptop sleeps, the container is redeployed, or the machine runs out of memory, and to pick up where it left off.\n\nIn Pi Durable, every step of a run is a task that stores a checkpoint before it\nmoves on. If the process dies, a new process opens the same storage, finds the\nunfinished tasks, and continues each one from its last checkpoint. A model\nrequest that was cut off is sent again; the partial answer stays in the\ntranscript, marked as aborted. A tool call that was cut off reruns if it is safe\nto; otherwise the model is told it was interrupted. Pi Durable has no built-in\nsubagents, but they take a few lines of code to build, as the triage tool below\nshows. A subagent runs in a conversation of its own, so it continues from where\nit left off too, and a subagent tool that is safe to rerun finds its subagent\nagain and waits for its answer. Queued messages are still queued. A `requestId`\nmakes a submission exactly-once, so a client that retries after a crash gets the\noriginal submission back instead of asking twice.\n\n``` js\nconst job = {\n    type: \"input\",\n    content: \"Fix the flaky login test\",\n    requestId: \"job-42\",\n} as const;\nawait root.submit(job, context);\n// The process dies here, in the middle of a tool call.\n\n// A new process opens the same storage.\nconst harness = await Harness.open(\n    await openNodeSqliteStorage(\"./agent.sqlite\"),\n    { models, registry, env },\n    context,\n);\nharness.resume(); // continue the interrupted run\nconst root = await harness.root(context);\n// the same submission, answered\nconst settled = await (await root.submit(job, context)).wait(context);\n```\n\n## Many conversations at once\n\nWe want one harness to run many conversations at the same time, without one blocking another.\n\nIn Pi Durable, one harness runs as many conversations as you need, concurrently, all with the same guarantees. A conversation starts fresh or forks another one at any point in its transcript, and sees the parent's history up to that point without copying it.\n\nThink of a Slack channel where your agent answers mentions from anyone. Then somebody opens a thread. The channel can be one conversation, and the thread a fork of it at the message the thread replies to. Both run at the same time and neither blocks the other.\n\n``` js\nconst channel = await harness.root(context);\nconst question = await channel.submit(\n    { type: \"input\", content: \"@agent why did the deploy fail?\" },\n    context,\n);\nconst answered = await question.wait(context);\n\n// Someone replies to the agent's answer in a thread. Every conversation names\n// its owner, which decides what an abort reaches (more on that under Tasks).\n// The thread has none.\nconst thread = await channel.fork(\n    answered.answer!,\n    { ownership: { kind: \"ownerless\" } },\n    context,\n);\n\n// Both conversations work at the same time.\nconst inThread = await thread.submit(\n    { type: \"input\", content: \"@agent can we roll it back?\" },\n    context,\n);\nconst inChannel = await channel.submit(\n    { type: \"input\", content: \"@agent who is on call today?\" },\n    context,\n);\nawait Promise.all([inThread.wait(context), inChannel.wait(context)]);\n```\n\nEach conversation also stores its own agent: the model, the thinking level, the selected extensions and which of their tools are active, extra instructions, and the working directory in its execution environment. A reviewer next to the main agent can use a cheaper model, read-only tools, and its own checkout.\n\n## Extensions\n\nWe want everything an agent can do to be pluggable, and every plugged-in piece to take part in durability.\n\nIn Pi Durable, an extension is a named bundle of system prompt sections, tools, hooks, and tasks. The application installs extensions in a registry. Each conversation selects which extensions and tools it uses, and stores only their names.\n\n### System prompt sections\n\nThe system prompt is rebuilt from the sections of the conversation's extensions before every request, so a changed section is picked up by the next request. Pi Durable records what changed in the transcript, at the position where it changed, so a restart or a fork sees exactly what the model saw. On models that support system prompt and tool changes in the middle of a conversation, only the change is sent, so the prompt cache stays valid.\n\n``` js\nimport { defineExtension, section } from \"@earendil-works/pi-durable\";\n\nconst ProjectContext = defineExtension({\n    name: \"project-context\",\n    sections: [\n        // Read from the conversation's execution environment. The files can be\n        // loaded and watched in the background; every request renders the\n        // latest state.\n        section(\"agents_md\", (input) => agentsMd.latest(input.env)),\n        section(\"skills\", (input) => skills.latest(input.env)),\n    ],\n});\n```\n\n### Tools\n\nEvery tool call runs as its own durable task, and its intent is stored before it runs. After a crash, a tool reruns only if it says that is safe. Otherwise the model is told the call was interrupted, with the output stored so far, and decides what to do. Each conversation can also get its own set of tools, like the Slack thread from earlier, which may search but not deploy.\n\n``` js\nimport { Type } from \"@earendil-works/pi-ai\";\nimport { defineTool } from \"@earendil-works/pi-durable\";\n\nconst searchIssues = defineTool({\n    name: \"search_issues\",\n    description: \"Search the issue tracker\",\n    parameters: Type.Object({ query: Type.String() }),\n    replay: \"safe\", // only reads, so a rerun after a crash is fine\n    execute: async (args, api) => {\n        // streamed to every client watching\n        api.output(`searching for ${args.query}\\n`);\n        return {\n            content: [{ type: \"text\", text: await tracker.search(args.query) }],\n        };\n    },\n});\n\nconst deploy = defineTool({\n    name: \"deploy\",\n    description: \"Deploy a version to production\",\n    parameters: Type.Object({ version: Type.String() }),\n    // No replay: a deploy interrupted by a crash is reported to the model,\n    // never repeated.\n    execute: async (args) => ({\n        content: [{ type: \"text\", text: await ci.deploy(args.version) }],\n    }),\n});\n\nregistry.install(defineExtension({ name: \"ops\", tools: [searchIssues, deploy] }));\n\n// The thread may search, but not deploy.\nawait thread.configure({ tools: { remove: [deploy] } }, context);\n```\n\nA tool gets the harness API for its call: it can commit entries and documents, start tasks and conversations, and talk to other conversations. That makes a subagent a few lines of code. A tool creates a conversation it owns, gives it a smaller model and its own instructions, and waits for its answer. The subagent is a conversation like any other, so it survives a crash, counts its own cost, and a UI can show it under the call.\n\n``` python\nimport type { AssistantMessage } from \"@earendil-works/pi-ai\";\nimport { AssistantEntry, configure } from \"@earendil-works/pi-durable\";\n\nconst triage = defineTool({\n    name: \"triage\",\n    description: \"Label an incoming issue as bug, feature, or question\",\n    parameters: Type.Object({ issue: Type.String() }),\n    // a rerun after a crash finds the same subagent and the same submission\n    replay: \"safe\",\n    execute: async (args, api, context) => {\n        const child = await api.commit(async (tx) => {\n            const existing = (\n                await tx.scanConversations({ ownerTaskId: api.taskId }, 1)\n            ).items[0];\n            if (existing !== undefined) return existing.id;\n            // Owned by this call, so aborting the call aborts the subagent.\n            const created = await tx.createConversation({\n                ownership: { kind: \"task\", taskId: api.taskId },\n            });\n            // It starts as a copy of this conversation's agent. Make it a small\n            // model without tools.\n            await configure(tx, created.id, {\n                model: { provider: \"openai\", modelId: \"gpt-6-luna\" },\n                tools: [],\n                instructions: \"Answer with one word: bug, feature, or question.\",\n            });\n            return created.id;\n        }, context);\n        // lets a UI show the subagent under the call\n        await api.details({ conversationId: child }, context);\n        const subagent = await api.conversation(child, context);\n        const request = {\n            type: \"input\",\n            content: args.issue,\n            requestId: `triage:${api.taskId}`,\n        } as const;\n        const settled = await (\n            await subagent!.submit(request, context)\n        ).wait(context);\n        // The answer is an entry in the subagent's transcript. Read it and take\n        // its text.\n        const entry = await api.commit(\n            (tx) => tx.entry(AssistantEntry, settled.answer!),\n            context,\n        );\n        const message = entry?.model?.[0] as AssistantMessage;\n        const text = message.content\n            .flatMap((content) => (content.type === \"text\" ? [content.text] : []))\n            .join(\"\");\n        return { content: [{ type: \"text\", text }] };\n    },\n});\n```\n\nExtensions can also change other extensions' tools. A tool with the same name in a later extension replaces the earlier one, for example a bash that runs inside a Python virtualenv. A wrap decorates whichever tool won, wherever the wrapping extension is selected.\n\n``` js\nimport { wrapTool } from \"@earendil-works/pi-durable\";\nimport { createBashTool } from \"@earendil-works/pi-durable/tools\";\n\n// Times every bash call, whichever bash the conversation ends up with.\nconst Timing = defineExtension({\n    name: \"timing\",\n    wraps: [\n        wrapTool(createBashTool(), (bash) => ({\n            ...bash,\n            execute: async (args, api, context) => {\n                const start = Date.now();\n                try {\n                    return await bash.execute(args, api, context);\n                } finally {\n                    metrics.record(\"bash\", Date.now() - start);\n                }\n            },\n        })),\n    ],\n});\n```\n\n### Hooks\n\nHooks let extensions step into tasks, including the built-in tasks for generating a model response, invoking a tool, or performing compaction. They can rewrite a request before it goes to the model, block or rewrite a tool call, replace a result, keep a run going, or write a summary themselves. A hook can run again after a crash, so a hook that makes a decision stores it in a memo: a small value stored with the task, where the first write wins.\n\n``` js\nimport { hook, ToolTask } from \"@earendil-works/pi-durable\";\n\nconst Approval = defineExtension({\n    name: \"approval\",\n    hooks: [\n        hook(ToolTask, {\n            beforeTool: async (call, api, context) => {\n                if (call.name !== \"deploy\") return undefined;\n                // After a restart, the hook finds the stored answer instead of\n                // asking again.\n                let approved = await api.memo<boolean>(\n                    \"approval:deploy\",\n                    context,\n                );\n                approved ??= await api.memo(\n                    \"approval:deploy\",\n                    await askInSlack(call),\n                    context,\n                );\n                return approved\n                    ? undefined\n                    : { block: \"Nobody approved the deploy.\" };\n            },\n        }),\n    ],\n});\n```\n\nSeveral extensions can hook the same thing. Their hooks run as a chain, in the\norder the conversation selects the extensions, and each hook defines how its\nchain runs. `beforeTool` passes rewritten arguments down the chain, and the\nfirst block stops it. `afterTool` passes the result down the chain. `onYield`\nstops at the first hook that keeps the run going. Observers like `afterResponse`\nalways run them all. A hook that throws is reported, and the chain continues,\nexcept in `beforeTool`, where a throw blocks the call.\n\n### Tasks\n\nThe harness runs conversations with built-in tasks: one for each model request, one for each tool call, and one for compaction. Extensions bring their own tasks and get the same machinery: a checkpoint after every step, timers that survive restarts, and waiting on other tasks.\n\nA checkout that splits the bill across several cards charges every card at once. If one card is declined, the other payments are aborted and refund themselves:\n\n``` js\nimport { defineTask, type TaskId } from \"@earendil-works/pi-durable\";\n\nconst Payment = defineTask<{ card: string }, { phase: \"charge\" }, string>({\n    name: \"shop.payment\",\n    version: 1,\n    initial: () => ({ phase: \"charge\" }),\n    phases: {\n        charge: async (task, runtime, context) => {\n            // The key makes the charge idempotent: if a crash reruns this\n            // phase, the card is only charged once.\n            const charge = await bank.charge(\n                task.input.card,\n                `payment-${task.id}`,\n            );\n            await runtime.commit(\n                () => ({\n                    status: \"terminal\",\n                    outcome: charge.ok\n                        ? { status: \"completed\", result: charge.receipt }\n                        : { status: \"failed\", error: { message: charge.error } },\n                }),\n                context,\n            );\n        },\n    },\n    // Another payment failed, or the checkout was cancelled: undo this one.\n    abort: async (task, runtime, context) => {\n        await bank.refund(`payment-${task.id}`);\n        await runtime.commit(\n            () => ({ status: \"terminal\", outcome: { status: \"aborted\" } }),\n            context,\n        );\n    },\n});\n\ntype CheckoutState =\n    | { phase: \"pay\" }\n    | { phase: \"decide\"; payments: TaskId<string>[] };\nconst Checkout = defineTask<{ cards: string[] }, CheckoutState, string>({\n    name: \"shop.checkout\",\n    version: 1,\n    initial: () => ({ phase: \"pay\" }),\n    phases: {\n        pay: async (task, runtime, context) => {\n            await runtime.commit(async (tx) => {\n                const payments: TaskId<string>[] = [];\n                for (const card of task.input.cards) {\n                    payments.push(\n                        await tx.createTask(Payment, { card }, {\n                            ownership: { kind: \"task\", taskId: task.id },\n                        }),\n                    );\n                }\n                // Run no code until every payment is done. The first failed\n                // payment aborts the others.\n                return {\n                    status: \"waiting\",\n                    checkpoint: { phase: \"decide\", payments },\n                    on: payments,\n                    policy: \"failFast\",\n                };\n            }, context);\n        },\n        decide: async (task, runtime, context) => {\n            const outcomes = await runtime.outcomes(\n                task.state.checkpoint.payments,\n                context,\n            );\n            const paid = outcomes.every(\n                (outcome) => outcome.status === \"completed\",\n            );\n            await runtime.commit(\n                () => ({\n                    status: \"terminal\",\n                    outcome: paid\n                        ? { status: \"completed\", result: \"Order placed.\" }\n                        : {\n                            status: \"failed\",\n                            error: { message: \"A payment failed.\" },\n                        },\n                }),\n                context,\n            );\n        },\n    },\n    abort: (_task, runtime, context) =>\n        runtime.commit(\n            () => ({ status: \"terminal\", outcome: { status: \"aborted\" } }),\n            context,\n        ),\n});\n\n// The agent starts a checkout with a tool.\nconst checkout = defineTool({\n    name: \"checkout\",\n    description: \"Pay for the cart, split across several cards\",\n    parameters: Type.Object({ cards: Type.Array(Type.String()) }),\n    execute: async (args, api, context) => {\n        // Owned by this call: aborting the call aborts the checkout and refunds\n        // its payments.\n        const owner = {\n            ownership: { kind: \"task\", taskId: api.taskId },\n        } as const;\n        const id = await api.createTask(\n            Checkout,\n            { cards: args.cards },\n            owner,\n            context,\n        );\n        const { outcome } = (await api.waitForTask(id, context)).state;\n        const text =\n            outcome.status === \"completed\" ? outcome.result : outcome.status;\n        return { content: [{ type: \"text\", text }] };\n    },\n});\n\nregistry.install(defineExtension({\n    name: \"shop\",\n    tools: [checkout],\n    tasks: [Payment, Checkout],\n}));\n```\n\nTasks and conversations form one ownership tree. Aborting a task aborts what it owns, bottom-up, so every task cleans up its own effects first, and a task only finishes once the work it owns has finished. A subagent is the same pattern: a conversation owned by the tool call that started it.\n\nTasks are foreground by default: they are part of the conversation's current\nwork. The conversation is idle only once they are done, and aborting the\nconversation, for example when the user presses Esc, aborts them and everything\nthey own. A background task belongs to the conversation, but not to its current\nwork. The conversation goes idle while it runs, and an ordinary abort leaves it\nand everything it owns alone. That fits a subagent that should outlive the turn\nthat started it, or a reminder that fires tomorrow. Aborting the task itself, or\nthe conversation with `{ background: true }`, still stops it.\n\n```\n// Part of the current work: Esc aborts it, and the conversation waits for it.\nawait api.createTask(\n    Checkout,\n    input,\n    { ownership: { kind: \"task\", taskId: api.taskId } },\n    context,\n);\n\n// Side work: the conversation goes idle while it runs, and Esc leaves it alone.\nawait api.createTask(\n    Reminder,\n    input,\n    { ownership: { kind: \"conversation\" }, background: true },\n    context,\n);\n```\n\n## Compaction\n\nWe want long conversations to keep going without the agent stopping to summarize.\n\nIn Pi Durable, compaction is a task like any other, and it runs while the conversation keeps going. When the context gets close to the model's limit, a background compaction summarizes the older messages, and the summary is placed at the next turn boundary. The conversation only waits for a summary when the next request would not fit otherwise. If the provider still rejects a request as too long, the harness compacts and retries once. You can also compact manually at any time, with your own instructions. The older messages always stay in storage.\n\n``` js\nconst harness = await Harness.open(storage, {\n    models,\n    registry,\n    settings: {\n        compaction: {\n            // past contextWindow - reserveTokens, the next request waits for a\n            // summary\n            reserveTokens: 16384,\n            // this far before that, a summary starts in the background\n            backgroundTokens: 32768,\n        },\n    },\n}, context);\n\n// Manual, also while the agent is working.\nawait root.compact(\"Keep the names of the failing tests\", context);\n```\n\n`reset()` goes further: it starts a new context, optionally from a handoff note,\nand a tool can ask for the same by returning `control: { handoff }`. Because\nnothing is deleted, a second tool can still search everything before the\nhandoff. That is all it takes to build an agent that hands off to itself and\nlooks things up later.\n\n``` js\nconst handoff = defineTool({\n    name: \"handoff\",\n    description:\n        \"Start over from a handoff note. \" +\n        \"Older messages stay searchable with search_history.\",\n    parameters: Type.Object({ note: Type.String() }),\n    execute: async (args, api, context) => {\n        // Queued behind the handoff, so it starts the next run in the new\n        // context.\n        const self = await api.conversation(api.conversationId, context);\n        await self!.submit(\n            {\n                type: \"input\",\n                content: \"Continue.\",\n                requestId: `handoff:${api.taskId}`,\n            },\n            context,\n        );\n        // Ends this run and starts a new context from the note, like\n        // reset(note).\n        return {\n            content: [{ type: \"text\", text: \"Handing off.\" }],\n            control: { handoff: args.note },\n        };\n    },\n});\n\nconst searchHistory = defineTool({\n    name: \"search_history\",\n    description: \"Search older messages, including those before a handoff\",\n    parameters: Type.Object({ text: Type.String() }),\n    replay: \"safe\",\n    execute: async (args, api, context) => {\n        // Tools read records through a transaction too. One that writes\n        // nothing stores nothing.\n        const page = await api.commit(\n            (tx) => tx.scanEntries({ conversationId: api.conversationId }, 200),\n            context,\n        );\n        const hits = page.items.filter((entry) =>\n            JSON.stringify(entry.model ?? []).includes(args.text),\n        );\n        const text = hits.map((entry) => JSON.stringify(entry.model)).join(\"\\n\");\n        return { content: [{ type: \"text\", text }] };\n    },\n});\n```\n\n## Durable application state\n\nWe want the state of the application built on the agent to be as durable as the conversation itself.\n\nIn Pi Durable, application state, like a todo list, a plan, a ticket, or the sandbox a conversation runs in, lives in documents. Documents are typed JSON stored next to the transcript and changed in the same atomic commits, so the state never disagrees with the transcript that produced it. Each document says what a fork starts with: the parent's value at the fork point, its current value, or a fresh one.\n\n``` js\nimport { defineDoc } from \"@earendil-works/pi-durable\";\n\nconst Todos = defineDoc<{ items: string[] }>({\n    kind: \"app.todos\",\n    version: 1,\n    scope: \"conversation\",\n    history: \"rewindable\",\n    fork: \"asOf\", // a fork starts with the todos its parent had at the fork entry\n    initial: () => ({ items: [] }),\n});\n\nconst Todo = defineExtension({\n    name: \"todo\",\n    tools: [\n        defineTool({\n            name: \"todo\",\n            description: \"Add an item to your todo list\",\n            parameters: Type.Object({ item: Type.String() }),\n            execute: async (args, api, context) => {\n                await api.commit(async (tx) => {\n                    const todos = await tx.doc(Todos, api.conversationId);\n                    todos.items.push(args.item);\n                }, context);\n                const text = `Added ${args.item}`;\n                return { content: [{ type: \"text\", text }] };\n            },\n        }),\n    ],\n    // The model sees the list before every request.\n    sections: [\n        section(\"todos\", async (input, context) => {\n            const todos = await input.read.snapshot(\n                Todos,\n                input.conversationId,\n                context,\n            );\n            return todos?.items.join(\"\\n\") || undefined;\n        }),\n    ],\n});\n\n// A UI subscribes to the committed value.\nconst todos = await harness.documentState(Todos, channel.id, context);\ntodos?.subscribe((value) => renderTodos(value?.items ?? []));\n```\n\n## Malleable\n\nWe want to change the code of a running agent without stopping it.\n\nIn Pi Durable, the registry can change while conversations run. Installing an extension under a name that is already installed replaces it in one step. A tool call that is already running finishes on the code it started with; the next call uses the new code. Conversations store extension and tool names, never code, so after a restart they pick up whatever the new process installs.\n\n```\n// The extension's file changed on disk.\n// same name \"ops\": replaces the installed one\nregistry.install(await loadExtension(\"./ops.ts\"));\n```\n\n## Multiplayer\n\nWe want many people and clients to work with the same conversations at once: watch them, join late, and steer them.\n\nIn Pi Durable, everything a UI needs is committed state, so any number of clients can attach to any conversation in the harness. A client gets the current view first: the transcript, the answer being streamed, running tools and their output, queued messages, the agent, and usage. After that it only gets what changes. A client that joins late or reconnects starts from the current view. Any client can steer a running conversation or queue a follow-up.\n\n``` js\n// A second client joins the thread while the agent is working.\nconst view = await thread.viewState(context);\nrender(view.value);\nview.subscribe((value) => render(value));\n\n// And steers it. The message joins the running work after the current tool\n// calls.\nawait thread.submit(\n    { type: \"input\", content: \"Check the staging logs first\", whenBusy: \"steer\" },\n    context,\n);\n```\n\nFor a remote client, `thread.watch()` delivers the exact operations of every\ncommit, small enough to send over a socket. If you prefer the coding agent's\nfamiliar events, `watchEvents()` turns the commits into those, at the cost of\nmore bytes on the wire.\n\n## Try it\n\nYou can try Pi Durable today. Pi Durable is experimental, and the API might\nstill change. Point your agent at `packages/durable` in a Pi checkout, have it\nread the\n[README](https://github.com/earendil-works/pi/blob/main/packages/durable/README.md),\nthe over thirty\n[examples](https://github.com/earendil-works/pi/tree/main/packages/durable/test/examples),\nthe [small coding agent on Pi\nDurable](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/src/experimental/durable),\nor this beautiful [vacation planning\nagent](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/src/experimental/vacation),\nand get building.\n\nThe vacation planner is about 1,300 lines of TypeScript, most of them the TUI. If it looks like a coding agent, that's only because it borrows its TUI components from the Pi coding agent.\n\nTo run both demos from a Pi checkout:\n\n```\nnpm install && npm run build\nnode packages/coding-agent/src/experimental/durable/main.ts\nnode packages/coding-agent/src/experimental/vacation/main.ts\n```\n\nTo build on Pi Durable in your own project:\n\n```\nnpm install @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord\n```\n\nIn the coming weeks, we will talk more about Pi Durable and show you the small agentic tools we build with it to help us work, like a Slack bot or a GitHub triage bot. We don't want to spill the beans yet. There is more coming as we use Pi Durable ourselves, just like we use Pi.\n\n## FAQ\n\n### Why TypeScript again?\n\nBecause it is the easiest way to bootstrap this. But as everybody knows by now, it's very easy to port everything to Rust or assembler. We're not ruling this out in the future, but at the moment we are focusing on TypeScript.", "url": "https://wpnews.pro/news/pi-durable", "canonical_source": "https://earendil.com/posts/pi-durable/", "published_at": "2026-10-01 19:24:08+00:00", "updated_at": "2026-10-01 21:33:14.082140+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-infrastructure"], "entities": ["Earendil", "Pi", "Pi Durable", "Pi 1.0", "pi-ai", "SQLite", "JSONL", "Cloudflare Durable Object"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/pi-durable", "markdown": "https://wpnews.pro/news/pi-durable.md", "text": "https://wpnews.pro/news/pi-durable.txt", "jsonld": "https://wpnews.pro/news/pi-durable.jsonld"}}