gothalo bridge — API contract (for the mobile app)
This is the contract the app codes against. The backend was rebuilt into a
gothalo CLI + bridge with QR pairing and per-device bearer tokens.
Base URL
Section titled “Base URL”https://<host>.<tailnet>.ts.net:5338The gothalo bridge, reachable over the tailnet (valid TLS) on port 5338. The base URL is not hardcoded in the real flow — the app derives it from the pairing QR’s origin (a tailnet URL today, a relay URL later).
Auth model
Section titled “Auth model”- Per-device bearer (normal): every request sends
Authorization: Bearer <bearer>, where<bearer>is returned by/pair. - Dev shortcut (until the pairing UI exists): you may use the admin token
as the bearer for manual testing. Get its value from the operator, or from
~/.gothalo/config.json(admin_token) / thegothalo servestartup log — it is not committed. It also unlocks the admin endpoints below (so you can mint your own pairing codes for testing). Treat it as dev-only.
Browser origins (CORS)
Section titled “Browser origins (CORS)”Only relevant to a web UI hosted somewhere other than the bridge; native apps
and curl never send an Origin and are unaffected.
A cross-origin call is preceded by a preflight OPTIONS carrying no
credentials, which every authenticated handler here would reject — so the
bridge answers preflights itself, before routing, for origins it permits.
- Permitted by default:
https://dipeshdulal.github.io, the project’s published app. - Anything else (a fork’s Pages site, a local dev server) must be listed in
transport.allowed_originsin~/.gothalo/config.json, orGOTHALO_ALLOWED_ORIGINSas a comma-separated list. Entries are exactscheme://host[:port]strings — no wildcards — and extend the default rather than replacing it. - An unlisted origin gets no
Access-Control-Allow-Origin, so the browser discards the reply. The request itself is still refused by the usual bearer check, not by CORS: an origin is permission to read a reply, never permission to act. - Responses never set
Access-Control-Allow-Credentials; the bearer travels in a header the app sets explicitly, so no cookie is ever attached.
WebSocket routes (/attach, /events) do not use this list — their handshake
is not preflighted and they carry the token in the query string.
Pairing flow (the real onboarding)
Section titled “Pairing flow (the real onboarding)”-
Operator runs
gothalo pairon the host; it prints a QR encoding a small JSON payload:{ "url": "https://<host>.<tailnet>.ts.net:5338", "code": "<8-hex one-time code>" } -
App scans the QR and parses the JSON —
urlis the bridge base URL,codeis the one-time code — then:POST <url>/pairContent-Type: application/json{ "code": "<code>", "device_name": "Dipesh S22", "fcm_token": "<this device's FCM token>" }The device names itself via
device_name(defaults to “device” if omitted). -
Response
200:{ "id": "92787cfa", "bearer": "<64-hex per-device bearer>", "name": "Dipesh S22" }Store
{ baseUrl: url, bearer }in secure storage; usebearerfor all calls. Errors:403invalid/expired/already-used code ·400bad body.Codes are one-time and expire in ~5 min.
To mint a code yourself for testing (admin token):
POST /admin/pairing?token=<admin> -> { "code", "url" }Endpoints (per-device bearer)
Section titled “Endpoints (per-device bearer)”| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /info |
— | {server_id, server_name} |
this bridge’s identity — map an incoming push’s server_id to a saved server |
| GET | /snapshot |
— | raw Herdr snapshot JSON | live agent state (shape below) |
| POST | /send |
{pane, text} |
{ok:true} |
types text into a pane |
| POST | /approve |
{agent, seq} |
{ok:true,applied:bool,reason?} |
idempotent one-tap approval (below) |
| GET | /agent-state |
— (query: pane) |
parsed agent state JSON | compact card for an agent pane (below); carries permission_mode for Claude |
| GET | /diff |
— (query: pane, context?) |
{branch, git{}, files[]} |
an agent pane’s working-tree changes — branch + git context (default branch, remote, ahead/behind, dirty, changed count, unfinished merge/rebase) + one unified diff per file. ?context=1 returns the git object alone and skips the diff (see CONTRACT-diff.md) |
| GET | /diff/expand |
— (query: pane, path, start, count) |
{path, start, lines[], eof, total} |
the unchanged lines around a hunk, for expanding a collapsed region in the diff viewer — git diff only ships 3 lines of context, so they aren’t in /diff at all (see CONTRACT-diff.md) |
| POST | /image |
raw image bytes (query: pane) |
{path, relative_path, content_type, bytes} |
drop a screenshot into any pane’s tree and get the path back, to paste into a prompt or type into the terminal (see CONTRACT-image.md) |
| GET | /timeline |
— (query: limit?, pane?) |
{entries[], limit} |
recent agent-activity log, newest first — one entry per status transition, each with how long the previous status lasted (below; see CONTRACT-timeline.md) |
| GET | /usage |
— | {fetched_at, claude} |
live Claude Code OAuth quota windows; unavailable when Claude is not installed/authenticated; credentials stay on the host |
| GET | /commands |
— (query: pane) |
{pane, agent_kind, commands[]} |
the slash commands an agent pane accepts, for the composer typeahead — discovered from disk plus the agent’s built-ins (below; see CONTRACT-commands.md) |
| GET | /suggestions |
— (query: pane) |
{pane, suggestions[]} |
the two or three one-tap actions worth offering for any pane, from what is running in it — open its dev server, review changes, resolve a stopped rebase, open a pull request, start an agent in an idle shell (below; see CONTRACT-suggestions.md) |
| GET | /ports |
— (query: pane?) |
{ports[]} |
raw host-wide dev-server scan behind the dev_server suggestion — every HTTP listener, attributed to the pane that spawned it. The app does not call this; it reads /suggestions (appendix of CONTRACT-suggestions.md) |
| POST | /agent-mode/cycle |
{pane} |
{ok:true,cycled:true,permission_mode?} |
advance a Claude pane’s Shift+Tab permission mode by one (below) |
| GET | /agents/available |
— | {agents[],known_kinds[],discovery} |
which agent kinds this host can actually launch (below) |
| POST | /agent/start |
{kind, pane_id|split_from|workspace_id, …} |
{pane_id,tab_id,workspace_id,kind,name,…} |
launch an agent, optionally in a pane it creates (below) |
| POST | /agent/restart |
{pane_id, prompt?} |
{restarted:true,pane_id,kind,…} |
replace the agent in a pane — loses the conversation (below) |
| POST | /agent/stop |
{pane_id} |
{stopped:true,pane_id,kind} |
quit the agent, keep the pane (below) |
| GET | /agent-transcript |
— (query: pane, token) |
WebSocket | streamed structured chat transcript for an agent pane (below) |
| GET | /attach |
— (query: pane, token) |
WebSocket | live terminal for any pane (below) |
| GET | /events |
— (query: token) |
WebSocket | unified push event stream: snapshot-on-connect, then deltas (below) |
| POST | /pane/new |
{split_from|workspace_id, …} |
{pane_id,tab_id,workspace_id} |
create a terminal, attach to it (below) |
| POST | /pane/close |
{pane_id} |
{closed:true,pane_id} |
close a pane (below) |
| GET | /browse |
— (query: path?, hidden?) |
{path, parent, roots[], entries[], truncated, limit} |
pick a directory on the host, so a space can be opened from the phone — read-only, directories-only (below; see CONTRACT-browse.md) |
| POST | /herdr |
{method, params, session?} |
{result} or {error} |
allowlisted generic proxy onto Herdr’s command surface (below) |
| GET | /branch-info |
— (query: workspace_id) |
{branch, default_branch, merged, deletable, …} |
preflight for “also delete the branch” when removing a worktree (below; see CONTRACT-branch-delete.md) |
| POST | /branch-delete |
{repo_root, branch, force?} |
{deleted, forced, sha, upstream, …} |
delete a local git branch, after its worktree is gone (below) |
| POST | /register-token |
{token} |
{ok:true} |
call on FCM token refresh to update THIS device |
| POST | /testpush |
— | {ok:true,sent:true} |
fan a sample push to all devices (test your FCM handler) |
/snapshot shape (what to render in the inbox)
Section titled “/snapshot shape (what to render in the inbox)”{ "result": { "snapshot": { "agents": [ { "agent": "claude", "agent_status": "idle|working|blocked|done|unknown", "attention_rank": 0, "recency_rank": 3, "pane_id": "wN:p2", "state_change_seq": 42, "terminal_title_stripped": "…", "workspace_id": "wN", "branch": "feat/x", "subagents": { "total": 21, "running": 4 }, "cwd": "/…" }] } } }Group by workspace_id; badge on agent_status; title = terminal_title_stripped;
pane_id is the id used for /send, /approve, and /attach.
state_change_seq is a per-agent monotonic counter Herdr bumps on every state
transition — pass it to /approve as the idempotency token (see below).
subagents is how many agents this session has delegated and how many are
still working — what puts “4 running” on a row without opening its chat. It is
gothalo-added and omitted entirely when the session delegated nothing or the
agent kind keeps sessions in a shared store that cannot be counted. Absent is not
{0,0}: a client given zeros for both cases cannot tell them from a session
whose delegated agents have all finished, so render nothing when the field is
missing. running comes from completion notifications for async agents and
from the spawning call’s own result for synchronous ones — see
CONTRACT-agent-transcript.md §Subagents for why
neither record answers it alone.
recency_rank is the second half of the list order: sort agents on
(attention_rank, recency_rank), both ascending. It is gothalo-added, always
present, unique within a snapshot, and lowest = most recently active — the
tiebreak within an attention rank, so what needs a human is unaffected. It is
an index into this snapshot on this bridge: don’t cache it, and don’t
compare it across servers (fall through to last_activity_ts there). Derivation
and the undated-agent rules are in CONTRACT.md §2.A.
attention_rank is the bridge’s authoritative priority ordering — sort the
inbox on it ascending: blocked 0, done 1, working 2, idle 3, unknown 4
(an unrecognised status also ranks 4, so it sorts last). It is added by gothalo,
not herdr, and is always present. Sorting every surface on this one field is
what keeps list order and the counts derived from it consistent — don’t
re-derive priority per screen. A surface that can’t show every agent may cut the
prefix of that order (the app’s Priority section does — D23), but it must
not re-sort, and it must not hide a blocked agent to stay short.
branch is likewise gothalo-added; both are
described in full in CONTRACT.md.
Push messages (what your FCM handler receives)
Section titled “Push messages (what your FCM handler receives)”Each alert arrives as two messages, both at android.priority: "high" and
sharing one tag, told apart by the render key:
render:"os"— carries anotificationblock. Android draws it with no app process involved, which is what makes it survive the app being killed. Android does not hand this one to your handler while backgrounded, so it can never have buttons.render:"app"— data-only, so your handler does run. Redraw the same tag (id0) with action buttons; it replaces the one above in place.
Ignore render:"os" in your handler: rendering it duplicates what Android
drew, and logging it double-counts, since a foreground app receives both.
type "alert"render "os" | "app"agent the pane_id (e.g. "wN:p2") -> deep-link targetstatus "blocked" | "done"state_change_seq the agent's seq at this transition (string int) -> pass to /approveserver_id which bridge sent this -> which server the tap should openserver_name that bridge's name, e.g. "Mac Studio"agent_title the pane's terminal titletitle, body the composed notification textquestion (blocked, best-effort) what the agent is actually askingoptions (blocked, best-effort) JSON [{index,label,selected,key}]category (blocked, best-effort) e.g. "dangerous_command_approval"server_id matters because one phone registers the same FCM token with every
bridge it pairs with: without it an alert can’t be attributed and its tap can’t
be routed. Resolve it against GET /info. Carry state_change_seq into any
lock-screen Approve so /approve can no-op a stale tap (D8).
dismiss — auto-clear a stale notification
Section titled “dismiss — auto-clear a stale notification”A second, data-only message the bridge sends when a notified agent is resolved from anywhere (this phone, another device, the desktop Herdr app, or the agent just moving on). It tells every device to cancel the tray notification it raised for that pane.
type "dismiss" <- the discriminator; an alert carries "alert"agent the pane_id (e.g. "wN:p2")server_id the sending bridge; cancel the notification tagged "<server_id>/<pane_id>"There is no title/body/status (data-only, so your background handler
runs and cancels silently). Triggered when the bus shows the pane leaving the
state it was notified about, or the pane closing (pane_closed / pane_exited)
— full contract in CONTRACT-notif-clear.md.
The complete payload, channel, tag, action and routing contract lives in
CONTRACT-notifications.md.
Native FCM setup
Section titled “Native FCM setup”Add an Android app to your Firebase project (see README “Push: bring your
own Firebase”) → download google-services.json into android/app/. Get the device token via
firebase_messaging, pass it as fcm_token during /pair, and
POST /register-token {token} whenever it refreshes.
POST /approve — idempotent one-tap approval (D8)
Section titled “POST /approve — idempotent one-tap approval (D8)”One-tap “yes” for a blocked agent, safe to fire from a stale lock-screen
banner. The bridge sends the agent’s confirm keystroke only if the agent is
still blocked at the seq you carried; otherwise it no-ops and tells you why.
POST /approve{ "agent": "wN:p2", "seq": 42 } // seq == the agent's state_change_seqResponse 200 (always 200 — applied tells you what happened):
{ "ok": true, "applied": true } // confirm keystroke sent{ "ok": true, "applied": false, "reason": "agent is working, not blocked" }{ "ok": true, "applied": false, "reason": "stale seq: approve carried 42, agent now at 45" }{ "ok": true, "applied": false, "reason": "no such agent" }The confirm keystroke is chosen per agent kind (claude, codex, …) from a
small server-side map, defaulting to Enter for unknown kinds — so the guard
and the key selection both live in the bridge and every approval surface inherits
them. 400 if the body lacks agent.
GET /agent-state — parsed agent card
Section titled “GET /agent-state — parsed agent card”A compact, parsed, plain-text state for a single agent pane — the phone
alternative to WS /attach’s raw terminal. Instead of a full PTY you get one JSON
struct: what the agent is doing, its last message, and — when blocked — the exact
question and choices it’s waiting on (which pair with POST /approve). Use it for
agent panes; keep raw /attach for non-agent panes.
GET /agent-state?pane=<pane_id>[&recent=1]Authorization: Bearer <bearer> // same auth as everything; ?token= also worksThe card is assembled from two sources, deliberately:
| Field | Source |
|---|---|
headline, detail, transcript |
the agent’s own transcript store (the same one /agent-transcript streams) |
blocked (question + options) |
the pane’s current screen |
blocked cannot come from a transcript: a permission or question prompt is UI the
agent is drawing right now to ask you something, not conversation, so nothing
records it. Everything else is read from structured data — already parsed, not
truncated by the viewport, and with no effect on the operator’s screen.
For a kind with no transcript reader, headline/detail/transcript fall
back to the current screen. ?recent=1 additionally reads the pane’s
scrollback for a richer fallback, but Herdr can only capture an
alternate-screen pane’s history by physically scrolling the pane, which
whoever is watching it sees as a jump, once per call. It is therefore off by
default and only worth requesting for a kind with no transcript.
Response 200 — the stable contract (kind-agnostic; the same shape for every
agent kind):
{ "pane_id": "wQ:p2", "agent_kind": "claude", // herdr agent kind "agent_status": "idle|working|blocked|done|unknown", "permission_mode": "auto", // Claude ONLY: Shift+Tab mode; OMITTED for other kinds / when unknown "headline": "…", // one line: what it's doing / last step (the question when blocked) "detail": "…", // short plain-text body, ANSI/box-drawing already stripped "blocked": { // present ONLY when agent_status == "blocked" "question": "Do you want to proceed?", "options": [ // may be empty for free-form prompts { "index": 1, "label": "Yes", "selected": true }, { "index": 2, "label": "Yes, and always allow…", "selected": false }, { "index": 3, "label": "No", "selected": false } ] }, "transcript": [ "…recent plain-text lines…" ], // optional, best-effort "parsed": true // false => unrecognised kind, raw text fallback}Field notes for the app:
headlineis always safe to render alone. When blocked it is the question.detailis phone-ready plain text (may contain\n); when blocked it’s the context being approved (e.g. the command).blocked.optionsare tap targets.selected:truemarks the default that a bare Enter accepts — so one-tap “Yes” isPOST /approve {agent, seq}(Enter). To pick a non-default numbered option, type its number then Enter viaPOST /send {pane, text:"2\n"}.indexis that number (0 if unnumbered — seekey). An option with noindexbut akey(e.g."esc") has no menu number at all — it’s Claude’s single-choice approval form, where decline is only reachable via a keystroke; dispatch it withPOST /send {pane, key:"esc"}.parsed:falsemeans the agent kind has no dedicated parser yet, sodetail/transcriptare a best-effort raw recent-text dump. The card still renders; just don’t rely onblocked. (claude is parsed today; codex and opencode are next behind the same contract.)agent_statusis authoritative (straight from herdr). Pair it with the samestate_change_seqfrom/snapshotfor/approve.permission_modeis Claude-specific and optional: present only foragent_kind == "claude"and only when the mode could be read; it is omitted for every other kind and when unknown, so the field’s absence is normal — never treat it as an error. Values:"default"·"acceptEdits"·"plan"·"auto"·"bypassPermissions"(a build that names a mode differently passes its raw lowercased label through). It pairs withPOST /agent-mode/cycle: cycle, then re-fetch/agent-stateto show the new mode. SeeCONTRACT-agent-mode.md.
Parsing never fails the request: an unrecognised layout degrades to parsed:false
rather than erroring. Errors: 400 missing pane · 401 bad bearer · 404 no
agent in that pane · 502 herdr command failed.
POST /image — attach a screenshot to a prompt
Section titled “POST /image — attach a screenshot to a prompt”Upload an image from the phone; the bridge writes it into the target pane’s working directory and returns the absolute path it wrote. Coding agents read an image when handed a path, so that path — pasted into the composer, or typed into the terminal, as ordinary text — is the whole attachment mechanism. No agent protocol is involved.
POST /image?pane=wN:p2Authorization: Bearer <bearer>Content-Type: application/octet-stream
<raw image bytes>Response 200:
{ "path": "/Users/dipesh/projects/gothalo/.gothalo/images/20260805-142530-9f86d081.png", "relative_path": ".gothalo/images/20260805-142530-9f86d081.png", "content_type": "image/png", "bytes": 184320 }The body is raw bytes, not multipart — deliberately, because a filename is
the one thing this endpoint must never accept. Nothing about the written file is
client-controlled: the pane picks the directory, the sniffed content type
(http.DetectContentType, never the declared one) picks the extension, and the
bridge picks the name. ?name=, ?filename= and Content-Disposition are not
read at all.
Accepts png/jpeg/gif/webp only, capped at 10 MiB inclusive. Files land in
<pane cwd>/.gothalo/images/, which is self-gitignored on first write and
pruned on every write (7 days / 40 files). The app inserts path where the user
is typing and does not send — they write the prompt around it.
Any pane: the drop directory is the agent’s cwd when the pane hosts one and
the pane’s own cwd when it doesn’t, so the terminal screen can type a path into
a plain shell too. (/diff stays agent-only — it asks a question a pane without
one can’t answer.) Accepts the session-qualified <session>/<pane> id form.
Errors: 400 missing pane or empty body · 401 bad bearer · 404 unknown
pane, or one Herdr reports no cwd for · 405 non-POST · 413 over the cap ·
415 not an accepted image type · 500 the drop directory couldn’t be written ·
502 herdr command failed.
Full details in CONTRACT-image.md.
POST /file — attach a document to a prompt
Section titled “POST /file — attach a document to a prompt”POST /image for documents: same raw-bytes wire format, same no-filename
rule, same response shape — a pdf, docx or pptx lands in
<pane cwd>/.gothalo/files/ and the returned absolute path is what the app
inserts into the composer (and, as ever, does not send).
POST /file?pane=wN:p2Authorization: Bearer <bearer>Content-Type: application/octet-stream
<raw document bytes>Response 200:
{ "path": "/Users/dipesh/projects/gothalo/.gothalo/files/20260910-142530-9f86d081.pdf", "relative_path": ".gothalo/files/20260910-142530-9f86d081.pdf", "content_type": "application/pdf", "bytes": 1843200 }Capped at 25 MiB inclusive — documents run larger than screenshots. The
type is decided from the bytes alone: a PDF by its magic, the OOXML pair by
opening the zip container and classifying the package parts inside (word/…
→ .docx, ppt/… → .pptx). A plain zip, an xlsx, or anything else is a
415; so are legacy .doc/.ppt, whose shared OLE container can’t be told
apart cheaply — the error says to convert first. Same drop-directory hygiene
and retention as images, in a sibling directory.
Errors: as /image, with the 25 MiB cap behind the 413 and the document
allowlist behind the 415.
Full details in CONTRACT-file.md.
GET /commands — slash commands for the composer typeahead
Section titled “GET /commands — slash commands for the composer typeahead”What the pane’s agent will actually accept after a /, so the phone offers a
list instead of asking the user to recall and thumb-type /compact.
GET /commands?pane=w5:p18{ "pane": "w5:p18", "agent_kind": "claude", "commands": [ {"name": "migrations", "description": "…", "source": "skill", "scope": "project"}, {"name": "compact", "description": "…", "argument_hint": "[instructions]", "source": "builtin"} ]}source is command (.claude/commands/**.md) · skill
(.claude/skills/<name>/SKILL.md) · builtin. The first two are read off disk
per request and are ground truth; builtin is a hand-maintained list, because
built-ins live inside the agent’s binary with no manifest to read — it is a
separate source precisely so the app can badge what it cannot verify. scope
is user/project for discovered commands, absent for built-ins. Sorted
most-specific first: project → user → built-in.
An agent kind with no command surface (codex, opencode) is 200 with an empty
list, not an error — “no typeahead here” is a normal state, and a 404 would put
an error in front of a working pane. Errors: 400 missing pane · 401 bad
bearer · 404 no such pane, a plain pane, or a bridge predating the endpoint
(the app hides the typeahead for all three). Full details, plus a live capture
and the plugin-commands gap, in CONTRACT-commands.md.
GET /suggestions — context actions for a pane
Section titled “GET /suggestions — context actions for a pane”The two or three things worth doing to this pane right now, given what is
actually running in it. The one endpoint the app asks “what can I do with this
pane” — dev-server discovery, which used to be a separate /ports chip, is one
source here now.
GET /suggestions?pane=acme/w1:p2{ "pane": "acme/w1:p2", "suggestions": [ { "kind": "dev_server", "performer": "app", "label": "Open :5173", "detail": "node · serving", "action": "open_url", "params": {"pane": "acme/w1:p2", "url": "http://100.84.12.3:5173", "port": "5173"}, "rank": 25 }, { "kind": "create_pr", "performer": "agent", "label": "Create PR", "detail": "feat/thing → main · 2 commits ahead", "action": "prompt_agent", "params": {"pane": "acme/w1:p2", "prompt": "Open a pull request for the work on feat/thing: …"}, "rank": 18 } ]}Sorted by rank descending, capped at three, and empty most of the time —
that is the design, not a degraded state. kind says why (it picks the icon
and its colour); action says what and is the only field the app branches on.
performer says who carries it out, and it is the one field here that is not
cosmetic. app suggestions the app performs itself. An agent suggestion is
sent to the agent in the pane as params.prompt, and the client must show that
prompt and let it be edited before sending. An absent or unrecognised value
reads as app — fail closed, because misreading the other way fires an
irreversible outward-facing action off one tap.
Actions: open_url (system browser; needs params.url), open_diff,
show_note (a dialog; needs params.note), start_agent, and prompt_agent
(the agent-performed one; needs params.prompt). An action a client does not
implement — or a known one missing its required param — must be dropped, not
rendered: that is what lets a newer bridge add a source without an app release.
Six sources today: git_conflict (an unfinished merge/rebase/cherry-pick, which
outranks and suppresses the rest), dev_server (a listener bound wide, reachable
directly), git_dirty (uncommitted changes), create_pr (a feature branch with a
remote and work on it — the only agent-performed one), dev_server_local (a
listener bound to loopback: an open_url through a relay the bridge opens on the
host, or a show_note explaining --host when no relay could be started), and
shell_idle (no agent, shell at its prompt, cwd inside a git work tree). The git-shaped ones require an agent in the pane; the dev-server ones do
not, since a server usually runs in a pane split off beside the agent.
Errors: 400 missing pane · 401 bad bearer · 404 no such pane, or a bridge
predating the endpoint (the app renders no chip row for either) · 502 herdr
unreachable. A pane with no agent is not an error, and neither is a failed
port scan — it just costs the dev-server chips, not the whole answer. Cached per
pane for 6s over a host-wide 5s port scan, with one git read per pane (the
same gitdiff call /diff?context=1 answers with); the app refetches on screen
open and on the pane’s agent-status changes rather than polling. Full details,
the cost model, and the /ports appendix in
CONTRACT-suggestions.md.
GET /usage — live Claude quota
Section titled “GET /usage — live Claude quota”The bridge reads Claude Code’s local OAuth credential on the host and calls
Claude’s OAuth usage endpoint. The access token is never returned to the app.
When Claude is not installed or authenticated, claude.available is false; the
Home screen omits its usage card rather than displaying zero.
GET /usage{ "fetched_at": "2026-08-10T12:00:00Z", "claude": { "available": true, "subscription_type": "max", "five_hour": { "utilization": 23.0, "resets_at": "2026-08-10T15:00:00Z" }, "seven_day": { "utilization": 4.0, "resets_at": "2026-08-16T12:00:00Z" } }}This is a live quota read, not local token/cost accounting. The app refreshes it once per minute and treats failures as an unavailable optional card. The Home screen displays all windows returned by this endpoint directly.
GET /timeline — recent agent activity
Section titled “GET /timeline — recent agent activity”The only read that describes the past. Every other endpoint says what is true now, which is why none of them can tell you whether an agent blocked fifty minutes ago or ten seconds ago — the status is the same either way.
GET /timeline?limit=100&pane=w4:p2Both query params are optional: limit defaults to 100 and is capped at 500;
pane (session-qualified, matched whole) restricts the log to one agent.
Response 200, newest first:
{ "entries": [ { "ts": 1785681000000, "pane": "w4:p2", "agent": "claude", "session": "default", "workspace": "w4", "from": "working", "to": "blocked", "prev_ms": 742000 }], "limit": 100 }prev_ms— how long the agent spent infrom. This is the whole point of the endpoint and the one fact/snapshotcannot reconstruct (state_change_seqis a counter, not a clock). Absent ≠0:0is a real instantaneous flip, absent means the bridge could not see where the span began. Render nothing, not “0s”.fromabsent = a first sighting of that pane, not a transition out of an unnamed state.to: "gone"= the pane closed or its process exited.- Answered from an in-memory ring — no herdr call — so it is cheap to poll and still answers while herdr is down. The ring is bounded (1500 entries / 72h) and persisted, so a bridge restart does not lose the recent past.
Errors: 400 limit present but not a positive integer (malformed is rejected,
never silently defaulted) · 401 bad bearer · 503 bridge running without a
recorder (distinct from an empty entries[], which just means nothing has
happened yet). Timeline entries are deliberately not carried on WS /events
— see CONTRACT-timeline.md for why, and for the full
schema and restart semantics.
POST /agent-mode/cycle — change a Claude agent’s permission mode
Section titled “POST /agent-mode/cycle — change a Claude agent’s permission mode”The mobile remote for Claude’s Shift+Tab key: it advances a Claude pane’s
permission mode by one step around its ring
(default → acceptEdits → plan → auto → …, the exact set/order is whatever the
running Claude build cycles through). This is Claude-specific — the concept
only exists for Claude’s TUI — so a non-Claude pane is rejected, never silently
keystroked.
POST /agent-mode/cycleAuthorization: Bearer <bearer> // same auth as everything; ?token= also works{ "pane": "wN:p2" }Response 200:
{ "ok": true, "cycled": true, "permission_mode": "plan" } // new mode, best-effort read-back{ "ok": true, "cycled": true } // sent, but read-back didn't settle in timepermission_mode is a convenience: after sending the keystroke the bridge
polls the live mode for ~1 s and echoes the new value if it observed the
change. It may be absent even on success (the TUI hadn’t redrawn yet). The
authoritative flow is: cycle → re-fetch GET /agent-state and read
permission_mode there. Setting a specific target mode is not a primitive —
cycle and read back until it matches.
Errors: 400 missing pane (or bad JSON) · 401 no/invalid bearer · 404 no
agent in that pane · 405 non-POST · 409 “mode switching not supported for
this agent kind: 502 herdr command failed. A
409 is the documented, expected response for codex/opencode/etc — the app should
just hide the mode control for those kinds (their /agent-state omits
permission_mode too).
WS /agent-transcript — streamed structured chat (agent panes)
Section titled “WS /agent-transcript — streamed structured chat (agent panes)”GET /agent-transcript?pane=<pane_id>&token=<bearer>[&subagent=<agent_id>] upgraded
to a WebSocket.
This is the chat view data source: instead of scraping the terminal (like
/agent-state) or streaming raw PTY bytes (like /attach), the bridge reads the
agent’s own transcript file (Claude Code writes JSONL at
~/.claude/projects/<encoded-cwd>/<session-id>.jsonl) and streams it normalized
into a kind-agnostic chat schema — messages, thinking, tool calls (command + diff),
and tool results.
hello also carries subagents: the session’s flat roster of conversations
delegated via the Task tool, joined to their spawning tool call by
tool_use_id. Pass one back as ?subagent=<agent_id> to stream that child
conversation through identical framing. See
CONTRACT-agent-transcript.md.
Frames are text JSON, one entry per frame (contrast /attach’s binary raw
bytes). On connect it sends a hello, replays the newest page (entry frames,
live:false, oldest→newest, ~150 newest — has_older/has_more flag older
elided, oldest_loaded_seq is the paging cursor), a backlog_complete marker,
then tails the file and pushes each new normalized entry (live:true) as the
agent appends it (250 ms poll). Correlate a tool call with its result via
tool.id == result.for_id; seq is the absolute 1-based position in the whole
file (stable cursor across pages and the tail — order/de-dupe on it).
Follows the pane across sessions (protocol 4). A pane’s agent session
rotates on /clear, /new, /resume or a restarted agent, and the old
transcript stops growing. The bridge re-reads agent_session.value every 2 s and,
on a change, re-points the socket at the new session and replays the opening
sequence in place: {"type":"session_changed","pane","from","to"}, then a fresh
hello (new session_id) + backlog + backlog_complete, then the live tail.
A client must discard everything it holds on session_changed — seq is
absolute within a session and restarts at 1, so kept entries collide with the
new ones. A rotation that happens while the socket is down has no
session_changed, so also reset whenever hello.session_id differs from the one
you hold. hello.subagents is re-read for the new session. A ?subagent= stream
is exempt — a delegated conversation belongs to the session that spawned it.
Paginated (protocol 2). To read older history the client sends a control
frame {"type":"load_older","before_seq":<int>,"limit":<int≤500, default 150>}
over the same socket; the server replies with that page (entry frames,
live:false, oldest→newest, seq < before_seq) then
{"type":"page_complete","requested_before_seq","oldest_loaded_seq","has_older"}.
The live tail keeps running while a page loads. Any other/garbage inbound frame
closes the socket cleanly (1000). Every page is read with a bounded ring buffer —
the whole file is never held in memory.
The transcript is resolved per agent kind from the pane’s cwd +
agent_session.value. Where it lives varies by agent and the wire protocol
does not change with it: claude appends a JSONL file
(~/.claude/projects/<encoded-cwd>/<session>.jsonl, session id == the filename);
hermes keeps every session in a SQLite database (~/.hermes/state.db, resolved
by session id alone — Hermes sessions are not keyed by cwd).
For the file-backed kinds the newest-matching-cwd fallback applies only when
agent_session.value is absent — a known session id with no file on disk yet
returns 404 rather than falling back, since the fallback would return a
neighbouring pane’s transcript. Install the Herdr agent integration (see the
README) so that id is always present; for hermes it is required, as there is no
cwd fallback to resolve with.
opencode is read through OpenCode v2’s local managed service API, using the
service registration at ~/.local/state/opencode/service.json (URL + password,
HTTP Basic opencode:<password>). The API returns one projected message list per
session; the legacy SQLite store (~/.local/share/opencode/opencode.db) is the
fallback when the service is not running.
This is READ-ONLY — prompts/approvals still go through POST /send /
POST /approve. claude, hermes and opencode are implemented; codex is
recognized but not yet wired (→ 404). Errors before the upgrade: 400 missing pane · 401 bad token · 404
no agent / no transcript / unsupported kind · 500 read failed · 502 herdr
failed. Close code 1000 on normal teardown.
The full wire protocol, the normalized entry schema (every field), captured
examples of each kind, the resolution rule, and all limits live in
CONTRACT.md.
WS /attach — live terminal (any pane)
Section titled “WS /attach — live terminal (any pane)”GET /attach?pane=<pane_id>&token=<bearer> upgraded to a WebSocket. Auth is
via ?token= (WS clients can’t always set an Authorization header); the same
bearer/admin token works. It attaches to ANY pane — agent panes, plain
shells, dev-servers, logs — not just agent ones.
- terminal → WS: server sends binary frames — raw terminal bytes; feed
them straight into your terminal emulator (
xterm.dart). - WS → terminal: send binary frames — raw keystrokes and control bytes
(the accessory key row and the floating arrow pad write Esc
0x1b, Ctrl-C0x03, arrows\e[A… here). - WS → resize (control): send a text frame
{"type":"resize","cols":C,"rows":R}to set the PTY geometry — send it on connect and on every viewport change, so the agent’s line-editing (autocomplete, wrapping, history) redraws at your actual width. Agent panes apply it withpty.Setsize; plain panes own their geometry via Herdr and ignore it. Unknown/malformed text frames are ignored. - WS → mode (control): the server sends a text frame
{"type":"mode","agent":<bool>}when the pane’s occupant changed and the backend was swapped underneath this same socket — see below.
Two backends behind the one contract, picked automatically by pane kind — the client can’t tell them apart:
-
Agent panes: unchanged —
herdr agent attach <pane>under a PTY, copied byte-for-byte both ways (identical to before). -
Plain panes: the bridge polls
herdr pane read(~5×/s) and repaints the socket (cursor-home + clear-screen + frame), and splits inbound bytes betweenherdr pane send-text(literal text) andherdr pane send-keys(Enter, Tab, Esc, arrows, Ctrl-*, Backspace). The split is required, not stylistic:send-texttypes text and silently drops control sequences, so an arrow sent as\e[Bnever reaches the pane — verified live against alesspane that stayed put forsend-textand scrolled forsend-keys down. Sequences Herdr has no key name for (Home, End, PageUp/Down) fall through as text. This is a full-frame repaint stream, so a plain pane refreshes on a short interval rather than character-by-character.The first frame is a scrollback seed, not a repaint: up to 1000 rows of
herdr pane read --source recent-unwrapped, sent without the clear-screen prefix so it lands in the emulator’s scrollback and the user can drag back through it. Every later frame is a normal repaint, and its erase-display clears only the viewport, leaving the seed intact. A client needs no new code — just enough buffer to hold it (Terminal(maxLines:)) — but should expect a large first frame: 82 KB for a busydocker compose logs -fpane.1000 rows is Herdr’s ceiling, not a choice:
pane readreturns at most that many however many are asked for, and there is no offset parameter, so deeper history is unreachable and a “load more” is not worth building. Agent panes get no seed — they run on the alternate screen, where Herdr holds no scrollback at all (see D21).
A pane’s kind is not fixed for the life of the socket. Type claude into a
plain shell and Herdr hosts an agent in it; exit that agent and it is a plain
shell again. The bridge follows both transitions and swaps the backend
underneath the same WebSocket — no reconnect, no new pane_id.
On each swap the server sends a text frame {"type":"mode","agent":<bool>}
before the new backend’s first byte. A client MUST reset its emulator when
it arrives: exit the alternate screen, clear the buffer, and start a fresh
UTF-8 decoder. The two streams are different shapes of output — an alt-screen
TUI versus whole-frame repaints — and one’s leftovers corrupt the other. Resend
your geometry too: a freshly started agent PTY is back at 80×24. Swapping to a
plain pane re-seeds scrollback, so expect another large first frame.
Detecting the transition is the bridge’s problem, not the client’s, and it is
subtler than it looks: Herdr’s pane.agent_detected fires on an agent
appearing and on one exiting, and the pane.updated trailing an exit still
carries the departed agent. Only pane.agent_status_changed with agent:""
reports the departure. So the bridge treats every signal as “re-resolve” and
lets herdr pane get decide.
Binary frames are raw terminal bytes; text frames are out-of-band control
messages (resize inbound, mode outbound). The backend (PTY process or
poller) is stopped when the socket closes (either side). Reconnect + re-fetch
/snapshot is the resilience story (no mosh-style state sync). The PTY starts
at 80×24 and is resized to the client’s geometry by the first resize frame.
Errors before the upgrade: 404 if pane doesn’t exist, 401 no/invalid
token, 400 missing pane.
WS /events — unified push event stream
Section titled “WS /events — unified push event stream”GET /events?token=<bearer> upgraded to a WebSocket carrying a single
unified event stream, so the app can stop refetching /snapshot after every
action. Auth is via ?token= (like /attach). The stream is text JSON:
- On connect, one snapshot frame — the full
/snapshotpayload plus the busseqit’s consistent with:{ "type":"snapshot", "source":"gothalo", "seq":420, "ts":<ms>,"snapshot": { "result": { "snapshot": { … } } } } // same JSON as GET /snapshot - Then a stream of delta frames — the unified envelope:
{ "source":"herdr"|"gothalo", "type":"<type>", "seq":<uint>, "ts":<ms>, "payload":{…} }
- Interleaved, a heartbeat every 20s —
{"type":"heartbeat","ts":<ms>}. It carries noseqon purpose: it means “still here”, not “something changed”. Skip it before your delta handling — treating it as a change signal would re-snapshot every 20 seconds for nothing. It exists so a client can detect a half-open socket (one that died without either end noticing); time out on silence and reconnect. The app uses 50s. Full rationale inCONTRACT.md§2.
seq is process-monotonic (shared across sources + the snapshot baseline); every
delta is > baseline. Track the last seq — a gap (seq > last+1) means
reconnect (which re-snapshots). The stream carries both Herdr’s normalized
events (source:"herdr" — pane_agent_status_changed, pane_created,
tab_*, workspace_*, layout_updated, …) and gothalo’s own system events
(source:"gothalo" — approve_applied, pane_created/pane_closed,
device_paired, push_sent, notification_cleared,
herdr_connected/herdr_disconnected/herdr_resync).
gothalo.notification_cleared (payload {pane}) fires whenever the bridge
dismisses a stale blocked push (see the dismiss push above). It’s a
consistency signal: a foreground app can clear its own UI from this event
without waiting for the FCM dismiss.
The bridge holds one Herdr socket subscription for the whole process and fans
it out; every client is just another in-process subscriber (never one Herdr
connection per client). A slow client is dropped with close code 4000 and
must reconnect + re-snapshot. Re-snapshot on: socket close, a 4000, a
gothalo.herdr_resync, or a seq gap. /events is server→client only — any
inbound frame ends the connection. Errors before upgrade: 401 no/invalid token.
The full envelope, the complete 25-type Herdr catalog + every gothalo type with
real captured examples, the reverse-engineered Herdr socket framing, and the
resync/reconnect rules live in CONTRACT.md at the repo root
— that’s what the app-side HerdrStore is built against.
pane_agent_status_changed payload is {pane_id, workspace_id, agent, agent_status}; it does not carry state_change_seq (Herdr’s event omits
it), so pair pane_id with the snapshot to get the seq for /approve.
Agent lifecycle — start / restart / stop
Section titled “Agent lifecycle — start / restart / stop”Full contract, live captures and the reasoning behind each rule:
docs/CONTRACT-agent-lifecycle.md. Summary:
GET /agents/available — the kinds this host can run right now. Discovered
on the bridge (Herdr’s own --kind catalog ∩ what resolves on the daemon’s PATH,
since Herdr documents a kind as its canonical executable); nothing is hardcoded.
{ "agents": [{ "kind": "claude", "path": "/opt/homebrew/bin/claude", "state_reporting": true }], "known_kinds": ["pi","claude","codex","…"], "discovery": "herdr agent kinds + PATH lookup" }state_reporting:false means the kind will run but Herdr can’t classify it — it
stays unknown forever and never raises an approval or a push. Only offer
kinds from agents[].
POST /agent/start — one of three targets, exactly one (naming none or
several is a 400):
{ "kind":"claude", "pane_id":"wN:p7" } // reuse an idle shell pane{ "kind":"claude", "split_from":"wN:p1", "direction":"down", "cwd":"…" } // split, agent in the new pane{ "kind":"claude", "workspace_id":"wN", "label":"review", "cwd":"…" } // new tab, agent in its root paneOptional: cwd (absolute, existing directory — rejected with pane_id),
prompt (the agent’s first message), name ([a-z][a-z0-9_-]{0,31}),
timeout_ms (clamped 5 000–300 000, default 60 000). Response 200:
{ "pane_id":"wN:p7", "tab_id":"wN:t5", "workspace_id":"wN", "kind":"claude", "name":"claude-wn-p7", "created_pane":true, "prompt_sent":true }pane_id is session-qualified, so it feeds /attach, /transcript and /send
directly. This call blocks 5–30 s — Herdr only returns once it has verified
the agent is really up — so raise the client’s receive timeout.
prompt_error is present only when a prompt was asked for and did not land.
The agent is running either way (hence still 200), but the client must be able
to tell an instructed agent from an empty one — show the reason rather than
navigating to it as if the prompt arrived.
cwd is validated server-side: absolute, canonical (every .././// form is
rejected, not normalised), must exist, must be a directory.
POST /agent/stop {pane_id} → {stopped:true,pane_id,kind}. Kills running
work; the pane survives. Herdr has no stop method, so the bridge sends repeated
ctrl+c and returns 200 only once it has observed the pane back at its
shell prompt. A 409 means the agent ignored the interrupts and is still
running — never treat it as a slow success.
POST /agent/restart {pane_id, prompt?} →
{restarted:true,pane_id,kind,name,cwd,prompt_sent,history_kept:false}. Stops the
agent and starts the same kind in the same pane and directory. The pane, its id,
its scrollback, its cwd and the agent’s name survive. The conversation does
not — the replacement is a new session with no memory of the old one, the
in-flight turn is lost, and so is queued input and permission/plan mode. Confirm
before calling.
Status codes: 400 bad shape / bad cwd / unknown kind · 401 · 404 unknown
pane or no agent in it · 405 wrong method · 409 pane busy, pane already hosts
an agent, kind not installed, or the agent would not stop · 502 Herdr failed
(a start that fails after creating a pane says so and names the pane).
These publish agent_started / agent_stopped / agent_restarted on
WS /events.
POST /pane/new — create a terminal from mobile
Section titled “POST /pane/new — create a terminal from mobile”Creates a pane and returns its identity so the app can immediately /attach to
it. Two modes, chosen by the body:
Split an existing pane (adds a pane to that pane’s tab):
POST /pane/new{ "split_from": "w4:p1", "direction": "down", "cwd": "/opt/app", "command": "npm run dev" }split_from(required for this mode): pane id to split.direction(optional):"right"|"down"— defaults to"down".
New tab in a workspace (opens the tab’s root pane):
POST /pane/new{ "workspace_id": "w4", "cwd": "/opt/app", "label": "logs", "command": "tail -f log" }workspace_id(required for this mode): workspace to add the tab to.label(optional): the new tab’s label.
Common optional fields: cwd (working directory for the new shell), command
(a command line typed and run in the new pane once created). split_from wins if
both it and workspace_id are present.
Response 200:
{ "pane_id": "w4:p7", "tab_id": "w4:t5", "workspace_id": "w4" }pane_id is what you pass to /attach, /send, and /pane/close. Errors:
400 neither split_from nor workspace_id given (or bad JSON) · 404 unknown
pane/workspace · 401 no/invalid token · 502 herdr failed. Note: if command
fails to run the pane is still created and returned 200 (it’s logged
server-side) — the app can attach regardless.
POST /pane/close — close a pane
Section titled “POST /pane/close — close a pane”POST /pane/close{ "pane_id": "w4:p7" }Response 200: { "closed": true, "pane_id": "w4:p7" }. Closing a tab’s last
pane closes the tab too. Errors: 400 missing pane_id · 404 unknown pane ·
401 no/invalid token · 502 herdr failed.
GET /browse — pick a directory on the host
Section titled “GET /browse — pick a directory on the host”A read-only, directories-only view of the host’s filesystem, so the phone can point at a project and open it as a Herdr space. It is the only way in when the session has nothing open: every other creating endpoint needs an existing pane or workspace to hang off, and with none the app has to name a directory outright.
GET /browse?path=/Users/you/projects (omit path to start at the first root)GET /browse?path=/Users/you&hidden=1 (include dot-directories)Response 200:
{ "path": "/Users/you/projects", "parent": "/Users/you", "is_repo": false, "roots": [ { "path": "/Users/you", "label": "Home", "kind": "home" } ], "entries": [ { "name": "gothalo", "path": "/Users/you/projects/gothalo", "is_repo": true, "is_symlink": false, "open_workspace_id": "wN" } ], "truncated": false, "limit": 500 }parentis""whenpathis a root — that is how you know not to offer “up”.pathcomes back resolved, so navigate with what you were given.is_repodecides which Herdr method opens the directory:worktree.openfor a checkout (richer metadata, idempotent),workspace.createotherwise. It is on each entry and on the listing itself, so “open here” and “open that one” agree about the same tree.open_workspace_id(session-qualified, omitted when absent) means a space is already open there — offer “go there”, not a duplicate.
Roots are derived on the host: the operator’s home directory plus the parents of
already-open spaces, minus anything above home. Symlinks and .. are resolved
and re-checked for containment, files are never returned, dot-directories need
hidden=1, and results are capped at 500. The full rules, the reasoning, and the
opening step live in CONTRACT-browse.md.
Status codes: 200 ok · 400 path not absolute · 401 no/invalid token ·
403 outside the allowed roots, or unreadable · 404 inside the roots but
missing, or a file · 405 non-GET · 503 no browsable roots on this host.
POST /herdr — allowlisted generic proxy (Herdr command parity)
Section titled “POST /herdr — allowlisted generic proxy (Herdr command parity)”One authenticated endpoint that forwards a Herdr socket method straight to Herdr and returns its result — so the app gets parity with Herdr’s command surface (new worktree, new tab, split/close pane, close tab, plus reads) without a bespoke bridge endpoint per operation. New Herdr methods become available with no bridge change, as long as they are added to the allowlist.
POST /herdr{ "method": "pane.split", "params": { "target_pane_id": "w4:p1", "direction": "down" } }methodis a Herdr socket method id (fromherdr api schema --json,schemas.request) — dotted, e.g.tab.create, NOT the CLI subcommand.paramsis forwarded verbatim; use the shapes from the schema. Omit or{}for reads that take no params.sessionpicks the Herdr session (D14). Usually unnecessary — it is inferred from session-qualified ids inparams— but required for methods whose params carry no id, notablyworkspace.create(a barecwd), which otherwise always lands in the default session.- Success
200:{ "result": <herdr result, verbatim> }. - Failure:
{ "error": "<message>" }with a status (see below).
Only allowlisted methods are proxied; everything else is 403. The
authoritative allowlist, each method’s params, and real captured examples live in
docs/CONTRACT-herdr-proxy.md — the contract the
worktree/tab/pane controls are built against. Currently allowed: reads
(session.snapshot, workspace.list/get, worktree.list, tab.list/get,
pane.list/get, agent.list/get) and mutations (worktree.create/open/remove,
workspace.create, tab.create/close/focus/rename, pane.split/close/focus,
agent.focus).
Status codes: 200 ok · 400 malformed body / missing method · 401
no/invalid token · 403 method not on the allowlist · 404 Herdr
target-not-found (e.g. pane_not_found) · 502 socket/herdr unreachable or
other Herdr error.
GET /branch-info + POST /branch-delete — delete a worktree’s branch
Section titled “GET /branch-info + POST /branch-delete — delete a worktree’s branch”Herdr’s worktree.remove drops the checkout and closes the workspace, and stops
there: Herdr has no branch concept, so the branch is left behind on every
removal. These two endpoints are the only place the bridge drives git
directly rather than proxying Herdr — there is no method to proxy.
GET /branch-info?workspace_id=w1F ← before the confirmPOST /branch-delete{ "repo_root": "/…/gothalo", "branch": "feat/x", "force": false } ← afterThey are split at the moment the user decides, and the order is not optional:
the preflight needs the workspace to still exist (it names the branch and the
repo root); the delete needs the checkout to be gone (git refuses to delete a
checked-out branch). If the worktree.remove between them fails, the branch
delete must not be attempted.
/branch-info answers 200 for every “nothing to offer” case too
(deletable:false + blocked_reason) — a plain workspace, a detached HEAD, the
repo’s main checkout. deletable:true with merged:false means “possible, but
it costs commits”; clients are expected to make that a distinct confirmation.
The safety rules, enforced on every delete regardless of what the client
saw: the repository’s default branch is never deleted (resolved from
refs/remotes/<remote>/HEAD, then a conventional local name — never assumed to
be main; unresolvable ⇒ nothing is deletable), a branch checked out in any
worktree is never deleted, and unmerged deletion (git branch -D) happens
only with force:true. force overrides that last rule and nothing else.
Deleting locally never touches the remote — upstream and
remote_deleted:false are returned so the UI can say so.
Status codes: 200 ok (including “not deletable, here’s why” on
/branch-info) · 400 missing/invalid params or repo_root · 401
no/invalid token · 404 no such branch (/branch-delete) or unknown session ·
405 wrong method · 409 a safety rule refused (/branch-delete) · 502
Herdr unreachable (/branch-info).
Full schemas, examples and rationale:
docs/CONTRACT-branch-delete.md.
Errors
Section titled “Errors”401 missing/invalid bearer · 403 invalid pairing code / method not allowlisted
(/herdr) · 400 bad body ·
404 unknown pane/tab/workspace, no agent in that pane (/agent-state,
/agent-mode/cycle, /agent-transcript, /agent/stop, /agent/restart), or no
transcript file / unsupported kind (/agent-transcript) · 405 wrong method
(/agent-mode/cycle non-POST, /image non-POST, /agents/available,
/agent/* non-POST) ·
409 mode switching not supported for the agent kind (/agent-mode/cycle on a
non-Claude pane), pane busy / already hosts an agent / kind not installed /
agent would not stop (/agent/*), or a branch-safety rule refused
(/branch-delete: default branch, still checked out, unmerged without
force) · 413 upload over the 10 MiB cap (/image) ·
415 body is not an accepted image type (/image) · 500 transcript read
failed (/agent-transcript), drop directory unwritable (/image) · 502 herdr
command failed.
