Skip to content
ColliePWA

09/Documentation

The durable SDK (experimental)

The npm package @colliepwa/durable-sdk, part by part: its entries and runtimes, openCollie, the pane methods, the four kinds of question and requireApproval, hooks, history, caps, the helpers, the Pi glue and the versions

Experimental. The package, its API and the wire it speaks may change, or the feature may be removed, in a later minor release. Versions says what that means for your durable agent.

@colliepwa/durable-sdk is the npm package a durable agent uses to show itself on your phone through Collie. This page lists every part of it. Durable agents has the quickstart and the setup on the Collie side, and The durable wire explains what travels between the two.

Install and entries

One install gives five entries: the root import for most durable agents, and four for a narrower need.

npm install @colliepwa/durable-sdk@experimental
# or
bun add @colliepwa/durable-sdk@experimental
importwhat it holds
@colliepwa/durable-sdkopenCollie and the whole API. On Bun it serves the socket with the Bun server, elsewhere with the node:http server
@colliepwa/durable-sdk/bunThe same API, always with the Bun server
@colliepwa/durable-sdk/nodeThe same API, always with the node:http server, plus NO_DESCENDANT_CHECK, the line it writes at start
@colliepwa/durable-sdk/coreThe layout and the wire as one function, handle(request), for a durable agent that brings its own transport. No runtime import, so it runs wherever a web Request does
@colliepwa/durable-sdk/piThe glue for a durable agent built on Pi Durable (The Pi helpers)

The package needs Node 22.19 or later, or Bun 1.4 or later, and has no runtime dependency. ./pi needs @earendil-works/pi-durable, @earendil-works/pi-ai and @earendil-works/chord, 1.1 or later and below 2. The entry is tested with Pi Durable 1.1.0 and 1.2.0. The three are optional peer dependencies: a durable agent that never imports ./pi installs none.

The runtime decides whether the durable agent can tell who answers. The descendant check refuses an act from the durable agent itself or from a process it started, with 403 caller-refused. It covers answer, send, stop, action, rename, close, reopen and starter. Reads stay open.

runtimethe descendant checkwhat you set
Bun on LinuxOn by default. Bun reads the caller's process id off the socketNothing. callerCheck: "off" turns it off
Bun on macOS, or any other system but LinuxCannot run. The start fails with an error that names callerCheckcallerCheck: "off"
Node, any systemCannot run: Node cannot read the caller of a socket. The durable agent writes one line at start that says soNothing. callerCheck is not read

Without the check, any process of your user can answer a card, a tool the agent runs included. Run the agent's tools in a sandbox that cannot reach the socket. The socket needs a system with file owners, so on Windows openCollie stops at start. A durable agent there serves ./core behind TLS with a token instead (Two ways to reach a durable agent).

Open a connection

openCollie makes the private socket, and the handle it returns lays out what the phone shows.

import { openCollie } from "@colliepwa/durable-sdk";

const collie = await openCollie({ hostName: "desk" });
const space = collie.workspace("support", { title: "Support" });
const tab = space.tab("main", { title: "Tickets" });
const pane = tab.pane("ticket-1", { title: "Maria Lopez", harness: "desk" });
collie.ready();

With no socket option, the path comes from COLLIE_DURABLE_SOCKET, the variable Collie reads too.

optionwhat it doesdefault
socketThe socket path: absolute, at most 103 bytes. Its folder is made with mode 0700, or must already be yours with mode 0700The COLLIE_DURABLE_SOCKET variable. Neither set: the start fails
hostNameThe durable agent's own name in its hello, cut to 64 bytes. RequiredNone
tailLimitHow many raw events each pane keeps for Collie's reads500, at most 1000
capsWhat the durable agent says it can do (Caps)Everything the SDK can do
callerCheckdescendant or off. Read on Bun onlydescendant
hooksOne named place for each act the phone sends (Hooks and history)None
warnWhere the SDK writes its one-line notes, for example about a Collie that speaks another wire versionconsole.warn
onRenameDeprecated. The old name of hooks.onRename, kept for one release, without the request idNone

Two more options exist on createCore only, not on openCollie: receipts and allowAlways (Questions).

on the handlewhat it is
addressThe socket path it listens on
epochNew and random on every start, 128 bits
coreThe core behind the socket. A test, or a second transport, calls core.handle directly
workspace(id, { title, cwd? })A workspace, made once per id. Its tab(id, { title }) gives a tab, and the tab's pane(id, options) a pane
starters(list)What the phone can start from the New page, at most 50. Each is { id, label, run }, and run resolves to { paneId } of the pane it showed
history(provider)Serve the durable agent's own record of its conversations (Hooks and history)
ready()Call once every pane is shown, and after a restart once every open ask is raised again. Until then every route but hello answers 503 starting
close()Stop the server. Replies already on their way still go out, for at most 2 s, then the socket file goes

close() leaves open asks open on purpose: a shutdown is not an answer, and the durable agent decides what happens to the work that waits on one.

tab.pane(id, { title, harness, cwd?, sessionId? }) returns the pane that has that id when there is one, so a durable agent can show a conversation twice without a second pane. Titles are cut to 256 bytes and harness to 64. cwd is an absolute path, else it is left out.

Every id follows the id rule: 1 to 128 bytes, no control character, no whitespace, and none of /, ? or #. A bad id is a TypeError. A workspace or tab id may not hold :, because the wire names a tab <workspace>:<tab>.

Panes

A pane is one conversation, and its methods set what the phone shows for it.

methodwhat the phone showslimit
state(s)working, waiting or idle. A change to working starts a new turnWhile any ask is open the pane reads waiting
event(e)Chat's items: item, remove, settled, reset and synced eventsSee the item table below
subtitle(text)One line under the title. null clears it120 characters
badge(value){ count } or { text } next to the title. null clears itA count from 0 to 9999, a text of 12 characters
progress(value){ kind: "steps", steps } or { kind: "percent", value, label? }, drawn while the pane works or waits. null clears it12 steps, each label 60 characters, a value from 0 to 100
actions(list)Buttons in the durable agent's section of the belt: { id, label, icon?, busy?, disabled? }. null clears them4 buttons, a label of 16 characters
onAction(fn)Runs a tap on one of those buttons. Return false to refuse itThe phone sends no tap for a busy or disabled button
onSend(fn)Takes the phone's text, as fn(text, { steer, requestId })32 KiB of text
onAbort(fn)Takes Stop, as fn({ requestId })None
ask(input, { signal? })A question card (Questions)50 open asks per pane
updateAsk, decideAsk, outcomeOfChange, decide or read back an ask (Questions)None
close()Takes the pane out of the layout. Its open asks close as stoppedNone

A pane also has four read-only fields: id, sessionId, turnId (the running turn, or the last one) and openAsks. The icon of an action is one of play, pause, check, x, refresh, archive, flag, star, send, note, search and more. Another name draws no icon.

An item event carries one item with an id and an optional ts. An item with a known id replaces the earlier one in place.

kindwhat it holdslimit
userA line the person sent. requestId names the send it came from, so the phone swaps its own line for it64 KiB of text
reply, thinking, noticeThe agent's words64 KiB of text
markdownText the phone renders as markdown: headings, lists, emphasis, code spans and links32 KiB of text
codeA code block, with lang such as ts32 KiB of text. lang is 1 to 24 letters, digits or _ + # . -
linkAn href and an optional labelhref is https:// only, at most 2048 characters. label is 120 characters
toolA tool call: tool: { kind: "other", name, summary, output? } and a status of running, done, failed or deniedname 128 bytes, summary 1 KiB, output 16 KiB

A reply, thinking, notice or markdown item may carry partial: true while its text grows. Write the same id again as it grows, at most every 250 ms, then once more with the final text and no partial, before the pane leaves working.

Text over its limit is cut, and controls and invisible characters are removed. A bad lang, a bad href, a bad badge or a bad progress is a TypeError or a RangeError instead, because Collie would drop it. A durable agent may not append ask, ask-closed or state events itself: ask() and state() write those.

Questions

pane.ask shows a card and resolves with the answer, and requireApproval asks about one tool call in one line.

import { requireApproval } from "@colliepwa/durable-sdk";

const args = { order: "5521", amount: 40 };
const approval = await requireApproval("refund", args, {
  pane,
  toolCallId: "call-1",
  timeoutMs: 10 * 60_000,
});
if (!approval.approved) throw new Error(`refund not approved: ${approval.decidedBy}`);

requireApproval(toolName, args, options) builds the card from what the call is, never from the model's words about it. The title is "refund wants to run", the tool line is refund(order: "5521", amount: 40), the push says "refund needs a yes", and the buttons are Allow and Deny. It returns { approved, decidedBy, decidedAt, askId }. A deny, a timeout, a stop and an abort all return approved: false.

option of requireApprovalwhat it doesdefault
paneThe pane that asks. RequiredNone
toolCallIdThe tool call's own id, the same across a restart. Any string: one that breaks the id rule is made safeRequired
itemIdThe item the card sits underThe tool call id
timeoutMsHow long the phone has. Past it the call is denied, decided by timeoutNo limit
signalAn AbortSignal. Aborting denies the call, decided by programNone
labelsThe words of the two buttons, { allow?, deny? }Allow and Deny

callSummary(name, args) and argumentsView(args) are the two pieces it uses: the call in one line, and the arguments as key: value pairs with each value as JSON.

pane.ask(input) takes one of four kinds. The promise resolves with the answer in the shape of the kind.

kindwhat the card showsthe answerlimit
permissionOne button per option, each with an effect: allow, allow-always, deny or cancel{ optionId, note? }1 to 6 options
choiceOne button per option. With multiple: true the person picks several{ optionIds }1 to 8 options
textOne text field, with an optional placeholder{ text }maxLength from 1 to 2000, default 500. The placeholder is 60 characters
form1 to 6 fields, each of kind text, choice or toggle, some required{ values }, keyed by field idA choice field has 1 to 8 options

Every kind takes toolCallId, itemId and title (256 bytes). It may take reason (2 KiB), tool, push: { title?, line? } (60 and 120 characters, the words of the notification) and expiresAt (a Date or an ISO 8601 string with a zone).

Leave id out. The SDK derives it with askIdFor(paneId, toolCallId): SHA-256 over the two ids, the first 32 hex characters. The same tool call raised again after a restart therefore gets the same id, and an answer the phone sent across the restart lands. Raising an id that is already open is a TypeError.

on the panewhat it does
updateAsk(askId, patch)Changes an open ask in place: title, reason, tool, options, placeholder, maxLength or fields. Its revision rises by one, so an answer to the old form gets 409 stale and the phone asks for a fresh tap. False when the ask is not open
decideAsk(askId, answer?)The durable agent decided the ask itself, somewhere other than the phone. With answer, the waiting ask() resolves with it. Without, it rejects with AskClosed. A later tap on the phone gets 409 decided, and the card says "Decided in the durable agent." False when the ask is not open
outcomeOf(askId)The first decision: { decidedBy, decidedAt, optionId?, effect? }. The pane remembers its last 500. Undefined for an ask it never raised or forgot

decidedBy is phone, program or timeout. An ask passes its expiresAt and closes as decided by timeout: ask() rejects with AskClosed, whose decidedBy is timeout. The card shows the time left until then. A question that timed out or was decided in the durable agent leaves one line in Chat, such as "Timed out before an answer: refund wants to run".

An aborted signal, pane.close() and a close from the phone close the ask as decided by program, and ask() rejects with AskClosed.

Caution. An allow-always option lets every later call through after one answer. Over https the token is the only gate, so a durable agent there offers Allow and Deny only. createCore with allowAlways: false makes an allow-always option a TypeError. The phone offers no allow-always on an ask whose words were cut or cleaned, or that names no tool.

Every act from the phone carries a request id, and the core keeps its receipt: a repeat runs nothing and gets the first reply again with replayed: true. The default MemoryReceiptStore keeps the newest RECEIPTS_PER_GROUP (1000) per pane, and per group for starters, renames, closes and reopens. A repeat of an id it dropped gets 500 receipt-dropped and runs nothing. The store is gone with the process.

To keep receipts across a restart, write a ReceiptStore with get(requestId) and put(requestId, record, group), and pass it to createCore as receipts. openCollie always keeps them in memory.

Hooks and history

A hook is the one named place for an act the phone sends, and a history provider serves your own record.

hookwhen it runshow it refuses
onSend(pane, { text, steer, turnId, requestId })Text for a pane that has no pane.onSendThrow: 500 internal, an unknown outcome for the phone
onAbort(pane, { turnId, requestId })Stop for a pane that has no pane.onAbortThrow, as above
onAction(pane, { actionId, turnId?, requestId })A belt tap on a pane that has no pane.onActionReturn false: 422 action-refused
onRename({ kind, id, title }, requestId)A new title for a workspace or a tabReturn false: 422 rename-refused
onClose({ kind, id, paneIds }, requestId)The phone closes a pane, a tab or a workspaceReturn false: 422 close-refused
onReopen({ id, paneId? }, requestId)The phone reopens a closed conversationReturn false: 422 reopen-refused

A hook runs only when its cap is on and its request id is new. A refusal stores nothing, so the same request id may try again. The per-pane handlers win over a hook. A pane with neither answers 409 not-supported to a send, a stop or a tap.

The SDK applies an accepted act itself. A rename sets the title. A close removes the target and everything inside it, and closes their open asks as stopped. A reopen without a hook puts back a pane this process closed; a conversation closed before a restart needs onReopen. The layout is made again on every start, so a durable agent that wants a title or a close to outlive the process stores it in its hook.

collie.history(provider) serves every conversation, open and closed, from the durable agent's own store. It turns the history cap on when caps is left out or names history: true.

HistoryProviderreturns
list(after, limit){ items, next? }: conversation rows, newest first, at most limit (100)
items(id, after, limit){ items, next? }: the conversation's items, oldest first, each in its latest version, at most limit (1000). undefined for an id it does not know

A row is { id, title, subtitle?, workspaceId?, tabId?, startedAt, endedAt?, state, items }, with state open or closed and items the count. A cursor (after, next) is your own string, 1 to 512 bytes of printable ASCII with no space. next absent ends the list.

Once a provider is set, each pane's sessionId is its pane id, and the history serves the pane's conversation under it. Pass sessionId to tab.pane() to name another id, or null when it cannot stay the same across a restart.

tailLimit is not history. It is the raw tail each pane keeps for Collie's event reads, 500 events by default. A reader that fell behind it gets the pane's whole current view instead, at most the newest 2000 items.

Caps

A cap says the durable agent can do one thing, and the phone draws a control only for a cap the durable agent declared.

capwhat the durable agent takes
asksRaises questions and takes answers
startersLists starters and runs one from the New page
sendTakes text from the phone
steerTakes text into a running turn. Needs send
abortStops a running turn
renameTakes a new title for a workspace or a tab
closeCloses a pane, a tab or a workspace
actionsLists belt buttons on a pane and runs one
historyServes its conversations, open and closed
reopenShows a closed conversation again

Left out, caps turns on everything the SDK can do, and history once history() has a provider. Given, it is the whole list: a cap it does not name is off, so { asks: true } declares asks alone. To turn one off and keep the rest, spread ALL_CAPS: { ...ALL_CAPS, steer: false }. A route whose cap is off answers 409 not-supported. terminal is always false.

Helpers and constants

The root import also exports the parts openCollie is made of, and the rules it applies.

exportwhat it is
createCore(options)The core without a server. It takes the openCollie options but socket and callerCheck, plus receipts and allowAlways. It returns epoch, workspace, starters, history, ready, isReady and handle(request)
handleThe wire as one function, a web Request in and a Response out. core.handle(request) is the usual way in
wireListener(core)core.handle as a node:http request listener, with drained() for a clean close
hello, versionLineThe hello reply, and the one line a durable agent writes for a version refusal
mintId()A new random id, 128 bits as 32 hex characters
askIdFor(paneId, toolCallId)The derived ask id
AskClosedThe error ask() rejects with when no answer came. It carries by and decidedBy
idOk(id), assertId(id, what), safeId(raw)The id rule: a check, a check that throws, and a safe id made from any string, the same one every time
cursorOk(cursor)The history cursor rule
cut(text, max, multiline?)Clean a string and cut it to max bytes, with … at the end
hrefOf, langOf, badgeOf, progressOfA link target, a code language, a badge and a progress, checked as the pane checks them
subtitleOf, pushTextOf, renameTitleOfA subtitle, the push words of an ask and a rename title, cleaned and cut
CAPS, LIMITSEvery cap per field in bytes, and every cap per list
ACTION_LABEL_MAX, BADGE_TEXT_MAX, BADGE_COUNT_MAX16, 12 and 9999
SUBTITLE_MAX, LINK_LABEL_MAX, RENAME_TITLE_MAX120 characters each
PROGRESS_STEPS_MAX, PROGRESS_LABEL_MAX12 steps, 60 characters
PUSH_TITLE_MAX, PUSH_LINE_MAX, PLACEHOLDER_MAX60, 120 and 60 characters
ANSWER_TEXT_MAX, ANSWER_TEXT_DEFAULT, ANSWER_TEXT_BYTES2000 and 500 characters, and 8000 bytes
ALL_CAPS, PROTOCOL_RANGE, SOCKET_ENVEvery cap on, the wire versions the SDK speaks, and the name COLLIE_DURABLE_SOCKET
MemoryReceiptStore, RECEIPTS_PER_GROUPThe default receipt store, and its 1000 per group

Every type on the wire and every hook payload is exported too, from src/core/types.ts.

The Pi helpers

./pi turns a Pi Durable conversation into a pane, a risky tool call into a card, and Pi's store into history.

exportwhat it does
showConversation(loc, harness, conversation, o)Shows a conversation as a pane, history first, then live events. Send goes to Pi's submit with the request id, and Stop to its abort. Safe to call twice
PaneRegistryWhich pane shows which conversation. After a restart, reRaised(risky) waits until each risky call without a result has asked again, so ready() comes after the asks
riskGuard({ panes, risky, words?, timeoutMs?, onTimeout? })A Pi extension whose beforeTool hook turns a call to a tool in risky into an Allow and Deny card. A denied call is blocked. With timeoutMs, the card expires, a call nobody answered is blocked, and onTimeout(tool, title) lets the durable agent log it
askTools({ panes, words, timeoutMs?, onTimeout? })The support desk's two asking tools: ask_queue, a choice, and ask_customer, a text ask. With timeoutMs, an unanswered ask expires and the tool returns "timed out"
spawnHelperTool(o)The tool spawn_helper: a child conversation shown as a new pane, which returns the helper's answer
piHistory({ harness, panes, describe })A HistoryProvider that reads every entry of every Pi conversation
firstMessageOf(conversation)The text of a conversation's first message, to name its pane
paneIdOf(id), conversationOfPaneId(paneId)A Pi conversation's pane id, conv-<id>, and back
summaryOf(args, name?)A tool call's arguments in words, at most 200 characters
MapperPi entries and agent events as items. A growing reply keeps one item id
PARTIAL_MS, RE_RAISE_MS250 ms between two writes of a growing reply, and the 5000 ms reRaised waits at most
ESCALATEDExample vocabulary: the support desk's notice for the Escalate button that showConversation adds with escalate: true
ASKING_TOOLSExample vocabulary: the names of the support desk's two asking tools
Submit, ReplyExtrasThe types of a custom submit, and of the items a durable agent adds under a finished reply

The words a card shows come in as options, so ./pi holds no text of the example but those two constants. It is written against Pi Durable 1.1.0. The support desk in examples/durable-member/ puts every part together.

Versions

The package has its own version, apart from Collie's, and names the wire version it speaks.

todaywhat it means
SDK_VERSION0.1.0The package version. Until the package leaves the dist-tag experimental, a minor release may change the API
WIRE_VERSION1The durable wire version the package speaks

Collie speaks one wire version and accepts the one before it for one minor release after a new one ships. When a Collie speaks none of your versions, the core writes one line through warn, once per version, for example collie: Collie speaks wire v2, this durable agent speaks v1. Update the agent's SDK, or Collie. The phone says the mismatch on the durable agent's home, in Collie's own words. Update the older side.

The package is published under the npm dist-tag experimental, never latest. If the durable wire goes at the exit, the package is deprecated (ADR 0105).

Edit this page on GitHub

The rest of the documentation