{"slug": "pi-durable-on-cloudflare", "title": "Pi Durable on Cloudflare", "summary": "Pi Durable, a harness for long-running AI agents from Earendil, can be paired with Cloudflare Durable Objects to store each chat session in its own SQLite-backed object that hibernates when idle and wakes on requests, WebSocket messages, or alarms. The post walks through building a multiplayer chat agent where a Worker routes clients by session ID to a Durable Object running the Pi Durable harness, with conversations and tasks in embedded SQLite and a subagent searching across conversations. Pi Durable ships a runtime-neutral asynchronous database interface with Node.js and Cloudflare Durable Objects adapters, plus a conformance suite compatible with Vitest or Jest test runners.", "body_md": "Many AI agents respond to user requests or other external events, do their work and then sit idle for long periods. Keeping a machine running around the clock wastes compute and quickly gets expensive.\n\nPi Durable is a harness designed for long-running agents. Read the [announcement](https://earendil.com/posts/pi-durable/) explaining its concepts and storage model in detail. And Cloudflare Durable Objects offer a stateful objects that shut down when idle and start up again when a request, WebSocket message, or alarm arrives.\n\nIn this post we will build a chat agent that stores every session in its own Durable Object, manages multiple conversations per session, survives hibernation, wakes up for scheduled work, and uses a subagent to search across conversations.\n\nWe’ll run with 90s vibes for the interface but make it multiplayer because it’s 2026: multiple participants connected to the same chat session.\n\nA Pi Durable harness is scoped to one session. That maps naturally to one Durable Object instance addressed by the session ID. Its SQLite-backed storage persists session data, the Durable Object can hibernate while idle, and its alarm can wake it for pending work.\n\n```\nReact client\n    │ PartySocket\n    ▼\nWorker\n    │ route by session ID\n    ▼\nSQLite-backed Durable Object instance\n    ├── Hibernatable WebSockets\n    ├── embedded SQLite database\n    │       └── conversations and tasks\n    └── Pi Durable Harness ──▶ Model provider\n```\n\nThe Worker creates a session ID and returns a shareable URL that clients connect to over WebSockets. Everyone who opens the same session URL reaches the same object. Different session IDs address different objects with isolated state.\n\nPi Durable makes no assumption about where sessions are stored. You can bring your own storage backend if the built-in memory, JSONL or SQLite support is not exactly what you need. The SQLite implementation defines a runtime-neutral, asynchronous database interface and includes an adapter for Node.js, as well as an adapter for Cloudflare Durable Objects using the synchronous storage `SqlStorage` API through `ctx.storage.sql`.\n\n```\nawait openDurableObjectSqliteStorage(ctx.storage)\n```\n\nIf your requires rolling your own SQLite adapter or a different storage backend, use Pi Durable’s conformance suite to check that it behaves correctly. It works with any Vitest- or Jest-compatible test runner. For a Durable Object, use it with the [Cloudflare Workers Vitest integration](https://developers.cloudflare.com/workers/testing/vitest-integration/):\n\n```\nregisterStorageConformance(\n  { describe, expect, it },\n  \"Cloudflare Durable Object SQLite\",\n  async (use) => {\n    const stub = env.SESSIONS.get(env.SESSIONS.newUniqueId());\n    await runInDurableObject(stub, async (_instance, state) => {\n      await use(await openDurableObjectSqliteStorage(state.storage));\n    });\n  },\n);\n```\n\nThe harness gets initialized with the `Session` Durable Object. Cloudflare runs the constructor every time the object starts: when it gets first created, and again after hibernation or eviction when a request, alarm, or WebSocket message arrives. All events are held back until the initialization is complete.\n\n```\nexport class Session extends DurableObject<Env> {\n  #harness!: Harness;\n\n  constructor(ctx: DurableObjectState, env: Env) {\n    super(ctx, env);\n    void ctx.blockConcurrencyWhile(() => this.#initialize());\n  }\n\n  async #initialize(): Promise<void> {\n    const storage = await openDurableObjectSqliteStorage(this.ctx.storage);\n    const chatOptions = createChatHarnessOptions((timestamp) =>\n      this.#setAlarmNoLaterThan(timestamp),\n    );\n    const harness = await Harness.open(\n      storage,\n      {\n        ...chatOptions,\n        onReport: (error) => reportError(\"Harness background task failed\",\n error),\n      },\n      BACKGROUND_CONTEXT,\n    );\n\n    // A storage error closes the harness. Reset the object so the next event reopens it.\n    void harness.closed.then((end) => {\n      if (end.reason === \"failed\") this.ctx.abort(\"Harness failed\");\n    });\n\n    try {\n      this.#harness = harness;\n      await this.#restoreEventStreams();\n      await this.#refreshAlarm();\n    } catch (error) {\n      await harness.close(BACKGROUND_CONTEXT);\n      throw error;\n    }\n  }\n}\n```\n\nThis is the minimum integration of the harness. The full implementation also streams live task activity to clients and tells them when a subagent conversation is created.\n\nThe session is the durable boundary, but it is not one giant transcript. Chat participants can create independent conversations within the same session, or fork them from a committed user or assistant entry.\n\nTo avoid creating duplicates, for instance after dropping the WebSocket connection, the harness stores a receipt with the request ID in the same commit that creates the conversation:\n\n``` js\nconst conversationId = await this.#harness.commit(async (tx) => {\n  const doc = await tx.doc(ConversationCommandReceipts, requestId, null);\n  if (doc.receipt !== undefined) return doc.receipt.conversationId;\n\n  const conversation = await tx.createConversation({ ownership: { kind: \"ownerless\" } });\n  doc.receipt = { command: \"conversation.create\", conversationId: conversation.id };\n  return conversation.id;\n}, BACKGROUND_CONTEXT);\n```\n\nCreating a fork is similar, only that it receives the parent's history up to the selected entry:\n\n``` js\nconst fork = await tx.forkConversation(parentConversationId, entryId, {\n  ownership: { kind: \"ownerless\" },\n});\n```\n\nWhen a chat participant sends a message, the session attributes it with the display name and submits it as a follow-up prompt.\n\n``` js\nconst conversation = await this.#harness.conversation(conversationId, BACKGROUND_CONTEXT);\nawait conversation?.submit(\n  {\n    type: \"input\",\n    content: attributedPrompt(username, content),\n    requestId,\n    whenBusy: \"followUp\",\n  },\n  BACKGROUND_CONTEXT,\n);\n```\n\nIf several people send messages while the model or a tool is running, the messages enter Pi's durable inbox and are answered in order.\n\nFor each connected client, the session opens Pi's event stream for the selected conversation. The stream begins with an atomic snapshot and then emits events for committed transcript changes, streaming output, tools, compaction, resets, and inbox changes.\n\n``` js\nconst stream = await watchEvents(\n  this.#harness,\n  conversationId,\n  BACKGROUND_CONTEXT,\n);\n\nsocket.serializeAttachment({ username, conversationId });\nsocket.send(JSON.stringify({\n  type: \"conversation.events\",\n  conversationId,\n  events: [stream.snapshot],\n}));\n\nstream.start((events) => {\n  socket.send(JSON.stringify({\n    type: \"conversation.events\",\n    conversationId,\n    events,\n  }));\n});\n```\n\nThe React client updates its selected conversation state with these events and uses a separate session snapshot to populate the sidebar.\n\nPersisting messages is only one part of durability. The application must also reconstruct live views, recover interrupted Pi tasks, and wake at future deadlines.\n\nCloudflare keeps the WebSockets open while the object sleeps. Each socket stores the participant's name and selected conversation, so when the object wakes, it reopens the matching Pi event streams. If a connection drops, the client resends anything the server hasn't confirmed under the same request ID, so nothing runs twice.\n\nAfter restoring the live views, the session inspects the persisted task set. Future deadlines are projected onto the Durable Object alarm. For tasks that can run immediately, `waitForTask()` enables Pi’s scheduler and watches for settlement, after which the session recalculates its next alarm.\n\nThis agent is given a `schedule` tool for it to act later, for example to check the weather after a couple of minutes. Behind the scenes, this is turned into a durable Pi task that sleeps until the task deadline.\n\nPi can track any number of deadlines, but a Durable Object has only one alarm. A newly scheduled task brings the alarm forward if its deadline comes first. Each time the object wakes, it rebuilds the alarm from Pi's tasks. Work still in progress gets a short recovery alarm, so an evicted object comes back to finish it:\n\n```\noverride async alarm(): Promise<void> {\n  await this.#refreshAlarm();\n}\n\n#refreshAlarm(): Promise<void> {\n  return this.#serializeAlarmUpdate(async () => {\n    const { tasks } = await this.#harness.inspect(BACKGROUND_CONTEXT);\n    const now = Date.now();\n    let nextAlarm: number | undefined;\n    const tasksToResume: TaskId[] = [];\n\n    for (const task of tasks) {\n      if (task.state.kind === \"blocked\") continue;\n      const scheduled = scheduledDeadline(task);\n      const waitsForFuture =\n        scheduled !== undefined && scheduled > now && !task.record.abortRequested;\n      const deadline = waitsForFuture ? scheduled : now + RECOVERY_ALARM_DELAY_MS;\n      nextAlarm = nextAlarm === undefined ? deadline : Math.min(nextAlarm, deadline);\n      if (!waitsForFuture) tasksToResume.push(task.record.id);\n    }\n\n    await this.#replaceAlarm(nextAlarm);\n    // waitForTask starts the scheduler, so persist its recovery alarm first.\n    for (const taskId of tasksToResume) this.#refreshAlarmWhenTaskSettles(taskId);\n  });\n}\n```\n\nBecause alarms have at-least-once execution, the handler must be idempotent. `#refreshAlarm()` recalculates everything from the stored tasks, and the stable schedule request ID stops the instruction from being submitted twice.\n\nWith several conversations in one session, a participant may ask about something outside the selected conversation’s context. Rather than loading every transcript into the parent conversation, the `search_history` tool delegates retrieval to a subagent. In Pi Durable, a subagent is an ordinary conversation owned by the tool task that created it.\n\nIt searches user and assistant messages from top-level conversations, including earlier messages in the conversation that initiated the search. It cannot search its own conversation or messages created by previous searches, preventing generated search results from becoming evidence for future searches.\n\n```\nParent conversation\n    │ search_history(question)\n    ▼\nDurable tool task\n    │ owns\n    ▼\nChild conversation\n    │ search_conversations\n    ▼\nFinal answer returned to parent\n```\n\nThe tool is marked as replay-safe, so if the Durable Object is evicted mid-call, Pi reruns it on the next start instead of reporting it as interrupted:\n\n``` js\nconst childId = await api.commit(async (tx) => {\n  // Reuse the original child if this tool task is replayed.\n  const existing = (\n    await tx.scanConversations(\n      { ownerTaskId: api.taskId },\n      1,\n    )\n  ).items[0];\n\n  if (existing !== undefined) return existing.id;\n\n  const child = await tx.createConversation({\n    ownership: {\n      kind: \"task\",\n      taskId: api.taskId,\n    },\n  });\n\n  await configure(tx, child.id, {\n    extensions: [conversationSearchExtension],\n    tools: [searchConversationsTool],\n  });\n\n  return child.id;\n}, context);\n\n// Let the interface link from the parent tool call to its subagent.\nawait api.details({ conversationId: childId }, context);\n\nconst child = await api.conversation(childId, context);\nif (child === undefined) {\n  throw new Error(`Search conversation ${childId} is missing`);\n}\n\nconst submission = await child.submit(\n  {\n    type: \"input\",\n    content:\n      `Answer this question using search_conversations: ${question}\\n\\n` +\n      \"Try a few relevant terms. Reply briefly, cite conversation IDs, \" +\n      \"and say so if nothing relevant was found.\",\n    requestId: `search-history:${api.taskId}`,\n  },\n  context,\n);\n\nconst settled = await submission.wait(context);\nif (settled.status !== \"done\" || settled.type !== \"input\") {\n  throw new Error(`History search did not answer: ${settled.status}`);\n}\n\nconst answer = await api.commit(\n  (tx) => tx.entry(AssistantEntry, settled.answer),\n  context,\n);\n\nreturn {\n  output: [{\n    type: \"text\",\n    text: messageText(answer?.model?.[0]),\n  }],\n  details: { conversationId: childId },\n};\n```\n\nOwnership gives the child a durable identity. If the tool is interrupted and replayed, it finds the conversation created by the original task and continues.\n\nThe `search_conversations` tool, the only tool given to the subagent, uses a deliberately simple substring search in this sample implementation. Raw matches and intermediate tool calls remain isolated in the subagent conversation. The parent conversation receives only the final answer.\n\nYou need Node.js 22.19 or newer, pnpm 11, and an OpenAI API key. Clone the sample repository, install its dependencies, and copy the example environment file:\n\n```\ngit clone https://github.com/earendil-works/pi-durable-cloudflare.git\ncd pi-durable-cloudflare\npnpm install\ncp .dev.vars.example .dev.vars\nOPENAI_API_KEY=your-key\npnpm dev\n```\n\nCreate a session, open its URL in a second browser, and try this sequence:\n\nNote that the demo intentionally has no authentication.\n\nThis example is deliberately small, but it shows the key capabilities of Pi Durable: durable sessions, pluggable storage, long-running and scheduled work, and subagents.\n\nStart building your agents on Pi Durable. We’d love to hear what you think and how you use it.", "url": "https://wpnews.pro/news/pi-durable-on-cloudflare", "canonical_source": "https://earendil.com/posts/pi-durable-on-cloudflare/", "published_at": "2026-10-16 12:45:00+00:00", "updated_at": "2026-10-16 13:14:23.476329+00:00", "lang": "en", "topics": ["ai-agents", "ai-infrastructure", "developer-tools", "artificial-intelligence"], "entities": ["Pi Durable", "Earendil", "Cloudflare", "Cloudflare Durable Objects", "PartySocket", "SQLite", "Vitest", "Jest"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/pi-durable-on-cloudflare", "markdown": "https://wpnews.pro/news/pi-durable-on-cloudflare.md", "text": "https://wpnews.pro/news/pi-durable-on-cloudflare.txt", "jsonld": "https://wpnews.pro/news/pi-durable-on-cloudflare.jsonld"}}