wp-durable API guide (for Claude sessions and other agents)

Status: Server v2 (Change 3, 2026-10-10): a remote engine with streaming, images, async results with callbacks, resume, exactly-once, conversations and tasks, an http_fetch tool, and a sandboxed bash environment. It builds on Stage 1 and Change 2 (Claude API); every earlier route keeps its behaviour. Written from the deployed source (src/). The service code is the source of truth.

Term Meaning
Engine pi-durable 1.1.0 with pi-ai 1.1.0, the agent library. Used as is.
Server The wp-durable process. It embeds the Engine, owns storage and credentials, and serves the Client API.
Client API The bearer-protected /v1 routes in this guide.
Conversation A transcript with its own model, tools, and work directory.
Submission One input (or entry write) handed to a conversation; it settles done or unanswered.
Task Durable work of the Engine: pi.generation (a model call), pi.tool (a tool call), pi.compaction, wp.callback.

Contents

  1. What this service is
  2. Authentication
  3. Quick start
  4. Health and status
  5. Models
  6. Conversations
  7. Submitting
  8. Getting the result: poll, long poll, stream, callback
  9. Streaming
  10. Images
  11. Tools
  12. Work directory
  13. Extensions
  14. Isolation, secrets, and network
  15. Persist, resume, exactly-once
  16. Tasks and inspection
  17. Limits
  18. Cost
  19. Errors
  20. Rules for callers
  21. Route reference
  22. Known limitations

What this service is

One always-on Railway container running the Server. You hand a task to a conversation over HTTP; the agent answers, using tools when it needs them: it fetches URLs (http_fetch) and runs code in a bash environment with files that persist (bash, read, write, edit). Every step is committed to storage before it is shown, so the service can restart or be redeployed without losing conversations, submissions, files, or pending callbacks.

Authentication

Every route except GET /healthz, GET /auth, and GET / (a 302 to /auth) requires:

Authorization: Bearer <WP_DURABLE_TOKEN>

The three server-sent-event routes (…/events, …/watch, …/stream) also accept ?token=<WP_DURABLE_TOKEN> for clients that cannot set headers (EventSource). No other route accepts it.

The token is on this machine at /Users/steven/DevTemp/wpDurable/.secrets/wp-durable-token (mode 0600). Load it into a shell variable; never print it, never paste it into a message, a transcript, a commit, or a URL you share.

WP_URL=https://wp-durable-production.up.railway.app
WP_TOKEN=$(cat /Users/steven/DevTemp/wpDurable/.secrets/wp-durable-token)
AUTH="Authorization: Bearer $WP_TOKEN"

A wrong or missing token returns 401 {"error":"Missing or invalid bearer token"}.

Quick start

# 1. A conversation of your own (model optional; default openai-codex/gpt-6-sol)
CID=$(curl -s -X POST "$WP_URL/v1/conversations" -H "$AUTH" -H 'content-type: application/json' \
  -d '{"title":"my analysis","agent":{"model":"openai-codex/gpt-6-sol"}}' | jq .id)

# 2. Submit a task; 202 at once
SID=$(curl -s -X POST "$WP_URL/v1/conversations/$CID/submit" -H "$AUTH" -H 'content-type: application/json' \
  -d '{"content":"Fetch https://wp-durable-docs.pages.dev/ and give me the page title.","requestId":"me-title-20261011T0300Z"}' \
  | jq .submissionId)

# 3a. Wait for the answer (long poll, at most 25 s per call; call again while "timedOut": true)
curl -s -H "$AUTH" "$WP_URL/v1/submissions/$SID/wait?timeoutMs=25000" | jq '{status, timedOut, answer}'

# 3b. Or watch it stream
curl -sN -H "$AUTH" "$WP_URL/v1/submissions/$SID/stream"

Health and status

curl -s "$WP_URL/healthz"
# {"ok":true,"storage":"ok","credential":"present","provider":"openai-codex","model":"gpt-6-sol",
#  "credentials":{"openai-codex":"present","anthropic":"missing"},"defaultModel":"openai-codex/gpt-6-sol",
#  "rootConversationId":1,"uptimeSeconds":123,"disk":{"dataBytes":153710,"workBytes":0},"tools":7,"isolation":"ok"}

curl -s -H "$AUTH" "$WP_URL/v1/status"
# {"ok":true,"defaultModel":"…","providers":{…},"tools":[…ToolInfo…],"extensions":{"jev":{…},"apify":{…},"bridge":{…}},
#  "limits":{…},"deferred":"server","faux":false,"isolation":{"ok":true,"mode":"setpriv","uid":61337,"checks":[…]},
#  "disk":{…},"uptimeSeconds":…}

Models

GET /v1/models lists every chat model of the two providers with allowed (its credential resolves now), default, input (["text","image"] when it reads images), thinkingLevels, and price (USD per million tokens, base tier). Give a model as "provider/modelId" (preferred), { "provider", "modelId" }, or a bare id (always openai-codex/<id>). Thinking levels: off, minimal, low, medium, high, xhigh, max; pick one the model lists. The guide of Change 2 details these rules; they are unchanged.

Conversations

# Create (title and agent optional)
curl -s -X POST "$WP_URL/v1/conversations" -H "$AUTH" -H 'content-type: application/json' \
  -d '{"title":"data cleanup","agent":{"model":"anthropic/claude-haiku-5-5","thinkingLevel":"low","tools":["bash","read","write","edit"]}}'
# 201 {"id":42,"conversationId":42,"title":"data cleanup","createdAt":"…","lastActivityAt":"…","status":"idle",
#      "entries":0,"usage":{"models":{},"tools":{}},"usageSummary":{"tokens":0,"cost":0},
#      "agent":{"model":{…},"thinkingLevel":"low","tools":["bash","read","write","edit"]}}

curl -s -H "$AUTH" "$WP_URL/v1/conversations?limit=20"            # newest first; follow nextCursor
curl -s -H "$AUTH" "$WP_URL/v1/conversations/42"                  # the item plus workDir {bytes, files} when it exists
curl -s -X PATCH "$WP_URL/v1/conversations/42" -H "$AUTH" -H 'content-type: application/json' \
  -d '{"title":"renamed","agent":{"model":"anthropic/claude-sonnet-5-5","tools":null}}'

Entries (the transcript):

curl -s -H "$AUTH" "$WP_URL/v1/conversations/42/entries"                 # active context, oldest first
curl -s -H "$AUTH" "$WP_URL/v1/conversations/42/entries?all=true&order=descending&limit=50"

Each entry: id, conversationId, kind (pi.user, pi.assistant, pi.tool-result, pi.system, pi.compaction, pi.reset, app.*), model (the messages), data?, head?, byTaskId?, createdAt (from the message time; entries without a message have none). The active context starts at the newest reset or compaction; all=true shows everything, including what a fork inherited. Images in entries are summarized as {type:"image",mimeType,bytes} unless images=inline.

Other operations:

Call Effect
POST /v1/conversations/42/reset { "handoff"? } 202. Starts a new context (older entries stay stored); the handoff text is the first message of the new context. While busy it is queued like a write.
POST /v1/conversations/42/compact { "instructions"? } 202 { taskId }. Summarizes older entries in the background; the summary is placed when idle or at the next boundary.
POST /v1/conversations/42/fork { "entryId", "title"?, "agent"? } 201 with the new conversation. It sees the history up to entryId and continues on its own (new work directory).
POST /v1/conversations/42/abort 200 { status:"idle", withdrawn:[…], abortedTasks:[…] } once idle: queued inputs withdrawn, the running model call and tool calls aborted (a running bash command is killed).
GET /v1/conversations/42/usage The usage document: tokens and cost per provider/modelId and per tool.
GET /v1/conversations/42/submissions Submissions of this conversation, newest first.
GET /v1/conversations/42/tasks Live tasks of this conversation and of conversations it owns.

Submitting

curl -s -X POST "$WP_URL/v1/conversations/42/submit" -H "$AUTH" -H 'content-type: application/json' -d '{
  "type": "input",
  "content": "Summarize the attached chart.",
  "requestId": "me-chart-20261011T0300Z",
  "whenBusy": "followUp",
  "callbackUrl": "https://example.com/hooks/wp-durable"
}'
# 202 {"submissionId":43,"conversationId":42,"status":"running"}    ("queued" when the conversation is busy)
Field Rule
type "input" (default) or "write".
content Input: a non-empty string, or content blocks [{"type":"text","text":…},{"type":"image","mimeType":…,"data":<base64>}] (Images).
entry Write: { "kind": "app.<name>", "data"?: <JSON ≤ 64 KB> }; appends an entry without asking the model.
requestId Required, ≤ 200 characters, unique per task. The exactly-once key, scoped to the conversation.
whenBusy followUp (default: queued until the run answers), steer (joins the running work after the current tool round), reject (409 code:"busy", nothing written).
callbackUrl Optional: receive the result by POST when it settles (Callbacks).
limits Optional, input only: { "outputTokens": <1..1000000> }, the output cap of this run's model requests (see below).

Output cap. Every model request goes out with the provider's own max-tokens parameter (Anthropic max_tokens, OpenAI Responses max_output_tokens where the model supports it): 32000 tokens by default (WP_DURABLE_LIMIT_OUTPUT_TOKENS), or the model's maximum when that is lower, or the run's limits.outputTokens (the lowest of the run's inputs). The provider stops the answer there; the submission then settles done with stopReason: "length". Thinking: for a model with a thinking budget the budget comes on top of the cap; for a model with adaptive thinking (effort levels, for example claude-haiku-5-5) thinking counts within the cap, so a very small cap can end with thinking only and an empty answer (a 40-token cap did exactly that in the production check). The ChatGPT backend of openai-codex has no such parameter, so no cap applies to its models. The Server itself stops no run and has no spend limit.

POST /v1/submit is the Stage 1 route: the same body without type (input only), with conversationId optional (default 1). A body conversationId on the conversation route must equal the path id.

Before admission the Server checks, in this order, and writes nothing when one fails: an already known requestId (returns the original submission, 202, no checks); the credential of the conversation's provider (409 {error, provider}); images on a model without image input (400); whenBusy: "reject" on a busy conversation (409); the callback URL (rules).

Getting the result: poll, long poll, stream, callback

A settled input submission shows the answer's stopReason (stop, or length when the answer reached the output cap) and the limits it was sent with, if any.

Responses are deferred on the Server side: a submit answers 202 at once and the work continues in the Server. pi-ai's provider-side deferred mode is not used: in pi-ai 1.1.0 only the faux test provider implements fetchDeferred; openai-codex and anthropic do not. When a real provider supports it, it becomes a setting.

Poll:

curl -s -H "$AUTH" "$WP_URL/v1/submissions/43"
# {"id":43,"submissionId":43,"conversationId":42,"requestId":"…","type":"input","mode":"followUp","status":"done",
#  "entryId":44,"content":"…","answer":"…","answerEntryId":51,"model":"openai-codex/gpt-6-sol",
#  "createdAt":"…","settledAt":"…","callback":{"url":"https://example.com/…","status":"delivered","attempts":1,"lastStatus":200}}

status: queued → running → done (with answer) or unanswered (with error/reason: aborted, model_error, reset, stale, …, and a redacted detail). Poll every 3 s, or use:

Long poll: GET /v1/submissions/43/wait?timeoutMs=25000 returns the submission as soon as it settles, or after timeoutMs with "timedOut": true and the current status (a settled record has no timedOut field). The Server holds a long poll at most 25 s (WP_DURABLE_LIMIT_WAIT_MS), whatever timeoutMs asks (it accepts 0 to 60 000; default 25 000): Railway's edge resets a response that carries no bytes for 30 s, so a longer silent wait would never reach the client. Poll until the status is done or unanswered:

while :; do
  S=$(curl -s -H "$AUTH" "$WP_URL/v1/submissions/$SID/wait?timeoutMs=25000")
  case "$(echo "$S" | jq -r .status)" in done|unanswered) echo "$S" | jq '{status, answer}'; break ;; esac
done

Withdraw a queued submission: POST /v1/submissions/43/abort → 200 with the record (unanswered, aborted); 409 code:"already_placed" when it is already running (abort the conversation instead) or code:"settled".

List: GET /v1/submissions?conversationId=42&status=done&limit=20 (newest first, nextCursor).

Callbacks

With callbackUrl, the Server POSTs when the submission settles:

POST <callbackUrl>
content-type: application/json
x-wp-durable-event: submission.settled
x-wp-durable-submission: 43
x-wp-durable-attempt: 1

{"event":"submission.settled","submission":{ …the same object as GET /v1/submissions/43… }}

Streaming

The Engine commits partial text, thinking, and tool output at most every 100 ms; the streams forward each commit. All three are server-sent events with a : hb heartbeat every 15 s. A client that joins late starts from the current state; nothing is replayed.

Submission stream, the simplest:

curl -sN -H "$AUTH" "$WP_URL/v1/submissions/43/stream"
# event: status    data: {"status":"running"}
# event: thinking  data: {"delta":"…"}
# event: tool      data: {"phase":"start","callId":"…","name":"bash","arguments":{"command":"ls"}}
# event: tool      data: {"phase":"end","callId":"…","name":"bash","ok":true,"summary":"first 200 characters"}
# event: text      data: {"delta":"…"}
# event: done      data: { …the final submission… }          (then the stream closes)

text and thinking deltas cover the whole answer from attach time on: the first event of a message carries what was already written, so the concatenated text deltas equal the answer. A settled submission gets status and done at once. How finely text arrives depends on the provider; the Codex channel may deliver a short answer in one piece.

Conversation events (everything, Engine event names):

curl -sN -H "$AUTH" "$WP_URL/v1/conversations/42/events"
# event: snapshot  data: {"seq":1,"value":{"type":"snapshot","entries":[…],"run":…,"generation":…,"tools":[…],"inbox":[…],"agent":{…},"usage":{…}}}
# event: events    data: {"seq":2,"events":[{"type":"run_start",…},{"type":"message_update","changes":[{"type":"text_delta",…}]},…]}

Event types: run_start, run_end, turn_start, turn_end, message_start, message_update (text and thinking deltas), message_end, tool_execution_start, tool_execution_update, tool_execution_end, inbox_update, submission, auto_retry_start, auto_retry_end, entry_appended, agent_changed, usage_changed, task_failed, compaction_start, compaction_end. A client more than 100 frames behind gets a new snapshot with overflow:true.

Structural view as JSON patches: GET /v1/conversations/42/watch sends snapshot {seq, value} (the active entries and the built-in documents) and then patch {seq, ops} (RFC 6902 add/remove/replace).

Images

Send images as content blocks (base64), to a conversation whose model lists "image" in input:

IMG=$(base64 < chart.png | tr -d '\n')
curl -s -X POST "$WP_URL/v1/conversations/42/submit" -H "$AUTH" -H 'content-type: application/json' \
  -d "{\"content\":[{\"type\":\"text\",\"text\":\"Which word is in this picture?\"},{\"type\":\"image\",\"mimeType\":\"image/png\",\"data\":\"$IMG\"}],\"requestId\":\"me-img-1\"}"

image/png, image/jpeg, image/gif, image/webp; at most 10 per submission, each ≤ 5 MB decoded; the request body of the submit routes may be up to 32 MB. A text-only model answers 400 and nothing is written. Stored entries keep the image; responses and streams show {type:"image",mimeType,bytes} unless images=inline on the entries route.

Tools

Every conversation is offered five tools unless agent.tools restricts it:

Tool What it does Replay after a restart
http_fetch HTTP request: { url, method?, headers?, body?, timeoutMs?, maxBytes? }. GET and HEAD run again; other methods return interrupted and are not sent again.
bash Runs a command in the work directory: { command, timeout? } (seconds, default 120, at most 600). interrupted with the output so far; the run continues.
read Reads a text file of the work directory: { path, offset?, limit? }. Runs again.
write Creates or replaces a file: { path, content } (≤ 4 MB). interrupted.
edit Replaces exact text in a file: { path, edits: [{ oldText, newText }] }. interrupted.

GET /v1/tools returns each tool's description, JSON schema, replay mode, and limits.

The prompt names only the tools a request offers. The environment section (the work directory, bash, the file tools, http_fetch) mentions only those of them the conversation offers, and a conversation with agent.tools: [] gets a no-tools section instead: "This conversation has no tools. Answer from what you know and from the conversation; do not write tool-call syntax." (Change 5, after a model without tools wrote tool calls as text until its output cap.)

bash details: combined stdout and stderr; the last 64 KB are kept in the result and longer output is saved to .tmp/pi-output-*.log in the work directory (the result names the file). A nonzero exit or a timeout is an error result that still carries the output. Installed: bash, coreutils, findutils, grep, sed, gawk, tar, gzip, xz, unzip, procps, curl, jq, git, ca-certificates, python3 (with venv and pip; use a venv for pip install), node 22, npm.

http_fetch details:

Work directory

Each conversation has /data/work/<conversationId>, created on first use, on the volume: files survive restarts and redeploys. It is bash's working directory and HOME; read, write, and edit work only inside it. A subagent child works in its parent's directory (see Subagent below).

curl -s -H "$AUTH" "$WP_URL/v1/conversations/42/env/files?path=."          # {path, entries:[{name,kind,size,modifiedAt}]} (≤ 1000)
curl -s -H "$AUTH" "$WP_URL/v1/conversations/42/env/file?path=hello.py"    # {path,size,mimeType,text} or {…,base64}; ≤ 1 MB, else 413
curl -s -X DELETE -H "$AUTH" "$WP_URL/v1/conversations/42/env"             # {removedBytes}; 409 busy while it or a subagent child works

path is relative to the work directory. A path outside it, or a file the agent user does not own, answers 403; a missing file 404; a conversation without a work directory yet 404.

Extensions

Change 4 (specs in CustomExtension/) adds three extensions of the Server. A tool whose key or connection is missing is not offered; GET /v1/tools lists what is offered now, and GET /v1/status has extensions.<name> with each extension's state, configured or not. Each tool comes with a short prompt section (section in GET /v1/tools) that tells the model when to use it. agent.tools may restrict a conversation to some of them, as for the core tools.

Jev: jev_decide

Calibrated judgements over a text or JSON state from Jev, TypeSafe's decision model, through Steven's Worker (POST /v1/access/decide, access key JEV_ACCESS_KEY). It returns numbers and choices, never text it wrote. About 350 ms per call whatever the number of questions; nearly free. Offered when the key is set.

// tool arguments
{
  "state": "The sky is clear and the sun is out.",          // non-blank string, non-empty object or array
  "questions": {                                             // ≥ 1; ids 1–64 chars of A-Z a-z 0-9 _ -
    "outside": { "type": "noul", "instructions": "Is it a good day to be outside?" },
    "weather": { "type": "choice", "instructions": "What is the weather?",
                 "criteria": { "sunny": "sunny weather", "rainy": "rainy weather", "unknown": "cannot tell" } },
    "detail":  { "type": "score", "instructions": "How detailed is it?", "criteria": ["none", "some", "much"] }
  },
  "model": "jev-latest"                                      // optional; one of the Worker's GET /v1/models
}
// result content: the Worker's answer as pretty JSON
{ "model": "jev-1.13.0",
  "answers": { "outside": { "type": "noul", "noul": 0.94 },
               "weather": { "type": "choice", "choice": "sunny", "probabilities": { "sunny": 1, … }, "confidence": 1 },
               "detail":  { "type": "score", "score": 1.1, "legend": { "0": "none", … }, "probabilities": { … }, "confidence": 0.8 } },
  "usage": { "input_tokens": 350, "output_tokens": 58 } }

Apify: apify_run

Offered when the Server has APIFY_ACCESS_KEY and today's budget is not spent. It runs one of three allow-listed Apify Actors synchronously through Steven's Worker (https://x402apify.stevenevans669.workers.dev, POST /v1/access/run) and returns the run's dataset items. A run takes seconds to minutes and costs real money on Steven's Apify account.

Parameter Meaning Default
actor apify/rag-web-browser, apify/google-search-scraper, apify/website-content-crawler (the Worker's GET /v1/actors, read at start and every hour, without apify/cheerio-scraper) required
input the Actor's input object, passed to Apify verbatim; fields that carry code (pageFunction, preNavigationHooks, postNavigationHooks, customDataFunction, any …Function or …Hook(s)) are refused required
timeout run timeout in seconds, an integer 5..300 120
memory run memory in MB, a power of two 128..4096 the Actor's
maxItems charged-item cap of pay-per-result Actors, 1..1000 none
maxTotalChargeUsd spend cap of the run in USD, 0.01..1 1
limit dataset items returned, 1..1000 20

A web search that reads the top page as Markdown: { "actor": "apify/rag-web-browser", "input": { "query": "…", "maxResults": 3, "scrapingTool": "raw-http" }, "maxTotalChargeUsd": 0.05 }. raw-http is the cheapest and fastest scraper but runs no browser; JavaScript-heavy pages need a browser option of the Actor's input schema.

Budget. One global daily budget, WP_DURABLE_APIFY_DAILY_USD (default 2.00 USD); the day is the UTC date. Each run is booked at its cap when it is sent: min(maxTotalChargeUsd, 1, remaining). The Worker receives that cap and stops the run there; it does not report the real charge, so the accounting is conservative. The booking is given back when the Worker's answer proves that no run started: 400, 401, 413, 429, 422 upstream_rejected for Apify's invalid-input, 502 upstream_refused, 503 upstream_busy. Every other answer keeps it (a 422 for a run that failed was charged). Without maxTotalChargeUsd the cap is the Worker's announced maximum (1 USD). When 0.01 USD or less remains, the tool answers daily Apify budget exhausted (2.00 USD); resets at 00:00 UTC without calling the Worker, and it is not offered (not in GET /v1/tools, not in the model's request) until 00:00 UTC. The bookings survive restarts (Server document wp.spend). GET /v1/status shows extensions.apify: { state, actors, budget: { dailyUsd, spentTodayUsd, remainingUsd, dayUtc } }; state is present, missing (no key), rejected (the Worker answered 401), or exhausted.

Empty dataset. When the Actor finishes without data, the Worker answers 200 { items: [], count: 0 } (for example when rag-web-browser's search step times out). The result is not an error; its text starts apify_run: 0 items — the Actor finished without data; try another query, a browser scrapingTool, or another Actor. The booking stays.

Real charge. When the Worker reports the run's real charge as usage.totalUsd (planned on the Worker side, not sent yet), the Server books that amount instead of the cap and reports it as the tool's cost (details.chargedUsd).

Result. Items JSON up to 64 KB: the items as pretty JSON. Larger: the items are saved to downloads/apify-<taskId>.json in the working directory (for bash, jq, python3), and the result shows the first items that fit in 16 KB, the path, and the count. details: { actor, count, ms, capUsd, savedPath? }. The tool's usage carries the cap as its cost, so pi.usage.tools.apify_run.cost.total and the conversation's usageSummary show the money; an error that keeps the booking carries it too.

Errors. The Worker's error code and message come back as an error result. 429 and 503 add Retry after N s.; 504 starts with run timed out (Apify may still have charged); 401 answers Apify key rejected. There is no retry: a retry could run the Actor twice. An invalid call (unknown actor, a value out of range, an unknown field) is refused by the Server's own check before any request: apify_run: refused by the Server's own check, nothing was sent …. After a Server restart in the middle of a run the call ends interrupted and is not sent again; its cap stays booked.

No model-written code (spec 1.2). apify/cheerio-scraper is not offered, and code-carrying input fields are refused for every Actor: code that runs inside an Actor has the run's Apify token and proxy password in scope.

Subagent: subagent

Delegate a self-contained task to a child conversation with its own model, thinking level, and tools, and get the answer back. Offered to every conversation (agent.tools may leave it out); no key needed.

// tool arguments
{
  "task": "Everything the child needs: it does not see this conversation.",   // required
  "model": "anthropic/claude-haiku-5-5",   // optional: an allowed model (GET /v1/models, credential present); default: the parent's
  "thinkingLevel": "low",                  // optional; default: the parent's
  "tools": ["read", "bash"],               // optional: a subset of the parent's tools, never `subagent`; default: all of them except subagent
  "background": false,                     // optional, default false
  "title": "check the logs"                // optional, at most 80 characters; default: the start of the task
}

Mac bridge

A conversation can work on a directory of Steven's Mac instead of its work directory. The Mac program wp-bridge (bridge/ in this repo) keeps one outbound WebSocket to the Server and serves one or more roots. read, write, and edit change the real files there; bash runs in a fresh Linux container (Colima) with the root mounted at the same path, as the Mac user, without network unless the bridge says so.

Install it once (and again after every change of bridge/), then start the installed copy on the Mac (Steven, or the accepting session in a cmux terminal tab):

cd ~/DevTemp/wpDurable && npm ci && npm run bridge:install    # a self-contained copy in ~/.wp-bridge/bin
~/.wp-bridge/bin/wp-bridge --root wpDurable=/Users/steven/DevTemp/wpDurable    # the launcher: from /, clean environment
# options: --root <name>=<path> (repeatable, at most 32; the first is the default) --mode rw|ro --network off|on
#          --id <bridgeId> (default: the host name) --server <url> --image <name> --token-file <path>
#          --git-writable (let the model change .git) --allow-hidden-files (see "Hidden")

The bridge program is the boundary (spec 1.4): the bridge refuses to start when its own program directory (the package of the script it runs), the ws module it loaded, node, or its state folder ~/.wp-bridge lies in a root, or a root lies in the program directory or the state folder (compared by device and inode, so another letter case or /System/Volumes/Data/… changes nothing). What the model writes in a root therefore never becomes bridge code. So npm run bridge in this repository serves only roots outside the repository; for the repository itself use the installed copy. The installed copy holds the compiled program, bridge/Dockerfile, and ws, as regular files (a link stops the install); nothing resolves back into the repository (WP_BRIDGE_INSTALL_DIR moves it; a folder that is not an earlier install is never replaced). Its launcher wp-bridge changes to / and starts the install's node with a clean environment, so a repository's mise.toml or .envrc cannot add NODE_OPTIONS, GIT_EXEC_PATH, or DOCKER_CONFIG.

At start it prints the PATH it uses (the inherited entries outside every root, without relative entries and without node_modules/.bin, then Homebrew and the system directories) and the absolute paths of docker, colima, and git it found there (the Homebrew link, such as /opt/homebrew/bin/docker, whose real file lies outside every root); it starts these three by those paths only, and host git without any GIT_* variable. It checks Docker (it runs colima start when Colima is stopped); removes the containers of a crashed run and then clears the immutable flags that run left (see "Read-only"; in any mode); in mode rw locks each root's bridge/exclude.txt and reads it once; builds the image wp-bridge-tools from its Dockerfile when it is missing or changed (the first time takes a few minutes); checks the roots (see "Hidden" and "Read-only"); locks their .git files (mode rw); prepares <root>/.wp-bridge/; warns about uncommitted changes (that git status runs in a container, never on the Mac; for a root whose git directory lies outside it, the bridge says it cannot check); and prints one line per operation (control characters in paths, commands, and error messages are shown as escapes). Once the Server accepts it, it removes containers an earlier process with the same id left behind. Ctrl-C (or SIGTERM, SIGHUP), also during the start, disconnects, stops this process's running containers, and clears its flags; the flags go only after docker lists none of its containers, and stay for the next start when docker cannot confirm that. The token is .secrets/wp-bridge-token (mode 600; the Server's WP_BRIDGE_TOKEN); the bridge refuses a token file others can read, and sends the token only over wss:// (plain ws:// only to localhost).

The bridge stops, with the reason, instead of retrying when the Server refuses its registration, rejects its token, or another wp-bridge holds the same id (stop the other one, or pass --id). After a Server restart it reconnects every 2 s for a minute.

Select the bridge per conversation with agent.cwd:

curl -s -X POST "$WP_URL/v1/conversations" -H "$AUTH" -H 'content-type: application/json' \
  -d '{"agent":{"cwd":"bridge://<bridgeId>/wpDurable"}}'          # or bridge://<bridgeId>/<root>/sub/path
# 201 {…,"agent":{…,"cwd":"bridge://<bridgeId>/wpDurable"},"env":{"kind":"bridge","bridgeId":"…","root":"wpDurable","online":true}}
curl -s -X PATCH "$WP_URL/v1/conversations/42" -H "$AUTH" -H 'content-type: application/json' -d '{"agent":{"cwd":null}}'   # back to the work directory
curl -s -H "$AUTH" "$WP_URL/v1/bridges"
# {"items":[{"bridgeId":"…","version":"1.4.1","roots":[{"name":"wpDurable","path":"/Users/steven/DevTemp/wpDurable"}],
#            "mode":"rw","network":"off","git":"ro","connectedAt":"…","lastSeenAt":"…"}]}

Isolation, secrets, and network

Persist, resume, exactly-once

Tasks and inspection

curl -s -H "$AUTH" "$WP_URL/v1/tasks"          # {count, tasks:{"<id>":{id,kind,conversatio