POST /herdr (allowlisted Herdr command proxy)
The contract the mobile app builds its worktree / tab / pane controls
against. POST /herdr is one authenticated endpoint that forwards an
allowlisted Herdr socket method to the running Herdr multiplexer and returns
its result (or a normalized error). It gives the app parity with Herdr’s command
surface without a bespoke bridge endpoint per operation; new Herdr methods become
available by adding them to the allowlist — no other bridge change.
Verified end-to-end against herdr 0.7.5, protocol 17 on the live socket
(tab.rename and the pane.rename findings against 0.8.0, protocol 19).
Every example below is a real captured request/response. The two space-opening
methods (workspace.create, worktree.open) were likewise re-captured against
herdr 0.8.0, protocol 19, via the herdr CLI — which returns the identical
result object the proxy forwards verbatim.
Envelope
Section titled “Envelope”Request — POST /herdr, auth required (per-device Authorization: Bearer <bearer> or ?token=; the admin token also works for dev):
{ "method": "<herdr socket method>", "params": { … }, "session": "<name>" }method— a Herdr socket method id fromherdr api schema --json(schemas.request). These are dotted (tab.create,pane.split), not the CLI subcommands. Required.params— forwarded to the socket verbatim. Use the exact shapes from the schema (documented per method below). Omit it or send{}for methods that take no params.session— which Herdr session to run against (D14). Optional, and usually unnecessary: the bridge infers it from any session-qualified id insideparams("pane_id": "acme/w1:p2"), strips the prefix before forwarding, and re-qualifies the ids in the result. Send it explicitly whenparamscarries no id to infer from —workspace.createtakes a barecwd, so without it the space always lands in the default session."default"and""mean the same thing. An explicit session that contradicts the ids inparams, or params mixing ids from two sessions, is a400.
Success 200:
{ "result": <herdr result object, passed through verbatim> }The result is exactly what Herdr’s socket returns for that method (each carries
its own "type" discriminator, e.g. worktree_created, pane_info, ok). The
bridge does not reshape it.
Error — normalized, with an HTTP status:
{ "error": "<message>" }The bridge auth-reject bodies (401) are plain text (unauthorized), matching
the other endpoints; all other errors use the JSON { "error": … } shape above.
Allowlist (the full set)
Section titled “Allowlist (the full set)”Only these methods are proxied. Anything else → 403 ({"error":"method not allowed: <method>"}) without ever touching the socket. Source of truth:
internal/server/herdr_proxy.go (herdrProxyAllowlist).
Reads (safe)
Section titled “Reads (safe)”| method | params | returns (result.type) |
|---|---|---|
session.snapshot |
{} |
full session tree |
workspace.list |
{} |
workspace_list |
workspace.get |
{ "workspace_id": "wN" } |
workspace_info |
worktree.list |
{ "cwd"?: string, "workspace_id"?: string } |
worktree_list |
tab.list |
{ "workspace_id"?: string } |
tab_list |
tab.get |
{ "tab_id": "wN:tM" } |
tab_info |
pane.list |
{ "workspace_id"?: string } |
pane_list |
pane.get |
{ "pane_id": "wN:pM" } |
pane_info |
agent.list |
{} |
agent_list |
agent.get |
{ "target": "<pane id / agent>" } |
agent_info |
Mutations (deliberately allowed)
Section titled “Mutations (deliberately allowed)”| method | params | returns (result.type) |
notes |
|---|---|---|---|
worktree.create |
{ "cwd"?, "branch"?, "base"?, "path"?, "label"?, "workspace_id"?, "focus"?=false } |
worktree_created |
creates a git worktree and opens it as a workspace |
worktree.open |
{ "cwd", "path" | "branch", "label"?, "workspace_id"?, "focus"?=false } |
worktree_opened |
open an existing checkout as a workspace. cwd is required alongside path — see below. Idempotent (already_open) |
worktree.remove |
{ "workspace_id": "wN", "force"?=false } |
worktree_removed |
DESTRUCTIVE — gate behind an in-app confirm. Removes the checkout only; the branch is left behind (see below) |
workspace.create |
{ "cwd"?, "label"?, "env"?, "focus"?=false } |
workspace_created |
open any directory as a new workspace — the app’s “open a project” |
tab.create |
{ "workspace_id"?, "cwd"?, "label"?, "env"?, "focus"?=false } |
tab_created |
new tab + its root pane |
tab.close |
{ "tab_id": "wN:tM" } |
ok |
DESTRUCTIVE — in-app confirm |
tab.focus |
{ "tab_id": "wN:tM" } |
ok |
|
tab.rename |
{ "tab_id": "wN:tM", "label": string } |
tab_info |
the client must validate label — see below |
pane.split |
{ "direction": "down"|"right"|"up"|"left", "target_pane_id"?, "cwd"?, "ratio"?, "env"?, "workspace_id"?, "focus"?=false } |
pane_info |
the app’s “new pane”; direction is required |
pane.close |
{ "pane_id": "wN:pM" } |
ok |
DESTRUCTIVE — in-app confirm; closing a tab’s last pane closes the tab |
pane.focus |
{ "pane_id": "wN:pM" } |
ok |
|
agent.focus |
{ "target": "<pane id / agent>" } |
ok |
focus an agent’s pane |
worktree.removedoes not delete the branch, and no Herdr method does. Herdr has no notion of branches anywhere on this socket — it removes the checkout, closes the workspace, and the ref stays forever. Deleting it is the bridge’s own typed endpoint pair,GET /branch-info+POST /branch-delete(seeCONTRACT-branch-delete.md), which drives git directly because there is nothing here to proxy. It is deliberately NOT part of this proxy: “params verbatim” would mean no server-side validation, and the safety rules (never the default branch, never a checked-out branch, unmerged only on an explicit force) are the whole substance of that feature. Call the branch delete afterworktree.removesucceeds, never before — git refuses to delete a branch that is still checked out.
tab.renamevalidates nothing. Herdr accepts any string, including"", which blanks the tab’s label — verified on the live socket (tab.renamewith""returnedtab_infowith"label": ""). Nothing in Herdr or the bridge stops a client leaving a nameless tab behind, so the label rule belongs to the client: the app trims, rejects empty, and caps at 60 characters (normalizeTabLabelinapp/lib/features/herdr_actions.dart). An unknowntab_idis a propertab_not_found, which the bridge maps to404.
pane.renameis deliberately not allowlisted (yet). It exists on the socket and works ({ "pane_id", "label"? }→pane_info; a nulllabelclears it), but unliketab.renameit emits no event at all — measured against herdr 0.8.0 / protocol 19 by subscribing to all 23 global kinds the ingester uses and renaming a throwaway pane: nothing was delivered. A rename driven from the phone would therefore not reach any other client until some unrelated change happened to trigger a re-snapshot. The app also does not carry a panelabelin its snapshot model. Revisit together with those two.
agent.view.set/agent.view.clearare deliberately not allowlisted. Herdr accepts the projection and reports it active, but as of herdr 0.8.0 (protocol 19) no read applies it —agent.listandsession.snapshotboth return the unprojected list, and there is no projected read method — so forwarding these only let a client mutate daemon state to no visible effect. Attention ordering is served byattention_rankon every agent inGET /snapshotinstead (seeAPI.md), which the bridge computes so every surface orders identically. Revisit if Herdr ever applies the view to a read.
focus defaults to false everywhere, so app-created panes/tabs do not steal
the operator’s foreground pane on the host. Pass "focus": true to override.
Opening a directory as a space — which method
Section titled “Opening a directory as a space — which method”workspace.create and worktree.open both turn a directory into a workspace,
and the app picks between them on whether the directory is a git checkout (which
GET /browse reports as is_repo — see CONTRACT-browse.md):
- a repository →
worktree.open. It attaches theworktreeblock Herdr groups a project and its worktrees by, so the space lands under the right heading in the app’s Spaces list, and it is idempotent: a second call returns the existing workspace withalready_open: truerather than opening a duplicate space on the same tree. - anything else →
workspace.create. It takes any directory. A project that is not a repository still needs a space, andworktree.openrefuses it withnot_git_worktree.
worktree.open needs both cwd and path, even when they are the same
directory. With path alone it answers not_git_worktree for a perfectly valid
checkout — cwd is the resolution context Herdr looks the repository up from.
Captured below.
Confirmed not allowlisted (→
403):server.stop,server.reload_config,pane.send_text,pane.send_keys,agent.prompt,events.subscribe,agent.view.set,agent.view.clear,pane.rename, and every other method not in the table above. The app’s existing typed endpoints (/send,/approve,/attach,/events, …) remain the path for those.
Real captured examples
Section titled “Real captured examples”All captured against the live socket via POST /herdr (worktree ops run against
a throwaway git repo).
worktree.create
Section titled “worktree.create”→ { "method": "worktree.create", "params": { "cwd": "/…/repo", "branch": "cc-demo", "label": "cc demo" } }
← 200{ "result": { "type": "worktree_created", "workspace": { "workspace_id": "wZ", "number": 17, "label": "cc demo", "focused": false, "pane_count": 1, "tab_count": 1, "active_tab_id": "wZ:t1", "agent_status": "unknown", "worktree": { "repo_key": "/…/repo/.git", "repo_name": "repo", "repo_root": "/…/repo", "checkout_path": "/Users/…/.herdr/worktrees/repo/cc-demo", "is_linked_worktree": true } }, "tab": { "tab_id": "wZ:t1", "workspace_id": "wZ", "number": 1, "label": "1", "focused": false, "pane_count": 1, "agent_status": "unknown" }, "root_pane": { "pane_id": "wZ:p1", "terminal_id": "term_658113d68271e39", "workspace_id": "wZ", "tab_id": "wZ:t1", "focused": false, "cwd": "/Users/…/.herdr/worktrees/repo/cc-demo", "agent_status": "unknown", "revision": 0 }, "worktree": { "path": "/Users/…/.herdr/worktrees/repo/cc-demo", "branch": "cc-demo", "is_bare": false, "is_detached": false, "is_linked_worktree": true, "open_workspace_id": "wZ", "label": "repo" } } }The new workspace is result.workspace.workspace_id (wZ); its root pane is
result.root_pane.pane_id (wZ:p1). Keep the workspace_id — it is what
worktree.remove takes.
Keep the root pane id too: it is a shell already sitting in the new
checkout, so it is where an agent for this worktree belongs. The app’s
create-and-launch flow feeds it straight to POST /agent/start rather than
re-listing panes to find it — see
CONTRACT-worktree-launch.md.
workspace.create — open a directory as a space
Section titled “workspace.create — open a directory as a space”Captured on the live socket (herdr workspace create), which returns the
same result object the proxy passes through verbatim. Paths shortened.
→ { "method": "workspace.create", "params": { "cwd": "/…/demo-project", "label": "browse demo" } }
← 200{ "result": { "type": "workspace_created", "workspace": { "workspace_id": "w19", "number": 20, "label": "browse demo", "focused": false, "pane_count": 1, "tab_count": 1, "active_tab_id": "w19:t1", "agent_status": "unknown" }, "tab": { "tab_id": "w19:t1", "workspace_id": "w19", "number": 1, "label": "1", "focused": false, "pane_count": 1, "agent_status": "unknown" }, "root_pane": { "pane_id": "w19:p1", "terminal_id": "term_65863840201fd8b", "workspace_id": "w19", "tab_id": "w19:t1", "focused": false, "cwd": "/…/demo-project", "foreground_cwd": "/…/demo-project", "agent_status": "unknown", "revision": 0 } } }Note the workspace has no worktree block even though the directory is a
git repository — workspace.create does not look. That is the reason a
repository goes through worktree.open instead.
worktree.open — open a checkout as a space
Section titled “worktree.open — open a checkout as a space”→ { "method": "worktree.open", "params": { "cwd": "/…/demo-project", "path": "/…/demo-project" } }
← 200{ "result": { "type": "worktree_opened", "already_open": false, "workspace": { "workspace_id": "w1A", "number": 20, "label": "demo-project", "focused": false, "pane_count": 1, "tab_count": 1, "active_tab_id": "w1A:t1", "agent_status": "unknown", "worktree": { "repo_key": "/…/demo-project/.git", "repo_name": "demo-project", "repo_root": "/…/demo-project", "checkout_path": "/…/demo-project", "is_linked_worktree": false } }, "tab": { "tab_id": "w1A:t1", "workspace_id": "w1A", "number": 1, "label": "1", "focused": false, "pane_count": 1, "agent_status": "unknown" }, "root_pane": { "pane_id": "w1A:p1", "workspace_id": "w1A", "tab_id": "w1A:t1", "cwd": "/…/demo-project", "agent_status": "unknown", "revision": 0 }, "worktree": { "path": "/…/demo-project", "branch": "main", "is_bare": false, "is_detached": false, "is_linked_worktree": false, "is_prunable": false, "open_workspace_id": "w1A", "label": "demo-project" } } }Asked a second time for the same path it returns "already_open": true and the
same w1A — the app can call it without checking first.
Without cwd:
→ { "method": "worktree.open", "params": { "path": "/…/demo-project" } }
← 502{ "error": "herdr: not_git_worktree: Herdr worktree actions require a workspace inside a Git work tree" }tab.create
Section titled “tab.create”→ { "method": "tab.create", "params": { "workspace_id": "wZ", "label": "demo-tab" } }
← 200{ "result": { "type": "tab_created", "tab": { "tab_id": "wZ:t2", "workspace_id": "wZ", "number": 2, "label": "demo-tab", "focused": false, "pane_count": 1, "agent_status": "unknown" }, "root_pane": { "pane_id": "wZ:p2", "terminal_id": "term_658113dffa5d73a", "workspace_id": "wZ", "tab_id": "wZ:t2", "focused": false, "cwd": "/Users/…/.herdr/worktrees/repo/cc-demo", "agent_status": "unknown", "revision": 0 } } }New tab is result.tab.tab_id (wZ:t2); its root pane result.root_pane.pane_id
(wZ:p2).
pane.split
Section titled “pane.split”→ { "method": "pane.split", "params": { "target_pane_id": "wZ:p1", "direction": "down" } }
← 200{ "result": { "type": "pane_info", "pane": { "pane_id": "wZ:p3", "terminal_id": "term_658113e0188af3b", "workspace_id": "wZ", "tab_id": "wZ:t1", "focused": false, "cwd": "/Users/…/.herdr/worktrees/repo/cc-demo", "agent_status": "unknown", "revision": 0 } } }The new pane is result.pane.pane_id (wZ:p3), added to the target pane’s tab
(wZ:t1).
pane.close
Section titled “pane.close”→ { "method": "pane.close", "params": { "pane_id": "wZ:p3" } }
← 200{ "result": { "type": "ok" } }tab.rename
Section titled “tab.rename”Captured against herdr 0.8.0 (protocol 19) on a throwaway workspace.
→ { "method": "tab.rename", "params": { "tab_id": "wZ:t2", "label": "api server" } }
← 200{ "result": { "type": "tab_info", "tab": { "tab_id": "wZ:t2", "workspace_id": "wZ", "number": 2, "label": "api server", "focused": false, "pane_count": 1, "agent_status": "unknown" } } }The result echoes the whole tab, so a client can read the applied label back
rather than assuming its own string landed. Renaming also emits a
tab_renamed event on WS /events —
{"type":"tab_renamed","tab_id":"wZ:t2","workspace_id":"wZ","label":"api server"}
— so every connected client re-snapshots without being told to.
An unknown tab is a 404:
→ { "method": "tab.rename", "params": { "tab_id": "w999:t9", "label": "x" } }
← 404{ "error": "herdr: tab_not_found: tab w999:t9 not found" }tab.close
Section titled “tab.close”→ { "method": "tab.close", "params": { "tab_id": "wZ:t2" } }
← 200{ "result": { "type": "ok" } }worktree.remove
Section titled “worktree.remove”→ { "method": "worktree.remove", "params": { "workspace_id": "wZ", "force": true } }
← 200{ "result": { "type": "worktree_removed", "workspace_id": "wZ", "path": "/Users/…/.herdr/worktrees/repo/cc-demo", "forced": true } }Disallowed method → 403
Section titled “Disallowed method → 403”→ { "method": "server.stop", "params": {} }
← 403{ "error": "method not allowed: server.stop" }Herdr target-not-found → 404
Section titled “Herdr target-not-found → 404”→ { "method": "pane.get", "params": { "pane_id": "wZ:p99" } }
← 404{ "error": "herdr: pane_not_found: pane wZ:p99 not found" }Error / status table
Section titled “Error / status table”| status | when | body |
|---|---|---|
200 |
success | { "result": <herdr result> } |
400 |
malformed JSON body, or missing/empty method |
{ "error": "want {method, params}" } |
400 |
session contradicts a qualified id in params, or params mixes ids from two sessions |
{ "error": "session \"x\" contradicts ids qualified with \"y\"" } |
401 |
no / invalid bearer or token | unauthorized (plain text) |
403 |
method is not on the allowlist |
{ "error": "method not allowed: <method>" } |
404 |
Herdr rejected with a *_not_found code (unknown pane/tab/workspace) |
{ "error": "herdr: <code>: <message>" } |
502 |
socket unreachable, or any other Herdr error (e.g. invalid_params) |
{ "error": "herdr: <code>: <message>" } or transport error text |
405 |
non-POST method | { "error": "POST only" } |
404 vs 502: a Herdr error whose code ends in _not_found maps to 404
(the target you named does not exist — usually a client bug, safe to surface as
“gone”). Any other Herdr error, or a failure to reach the socket at all, is 502
(the bridge could not complete the operation).
Implementation notes (for maintainers)
Section titled “Implementation notes (for maintainers)”- Handler:
internal/server/herdr_proxy.go(handleHerdrProxy), registered atmux.HandleFunc("/herdr", …)ininternal/server/server.go. - Transport:
herdr.(*Client).Request(method, params)ininternal/herdr/socket.goopens a dedicated short-lived connection per call (SocketConn.Do), sends{id, method, params}with a uniquegothalo-req-<n>id, and returns theresultfor the matching id. It is separate from the event ingester’s long-lived subscription connection, so the proxy never disturbs the event stream. Concurrency-safe: each call gets its own connection and a monotonically-minted id, so the app can fire several at once. - Timeout: a 15s read/write deadline bounds each round-trip; a wedged socket
surfaces as
502rather than hanging the handler. - To extend: add the method (and a one-line comment) to
herdrProxyAllowlist. Nothing else changes — params pass through verbatim.
