Probed live against a running Herdr server, most recently re-probed 2026-07-07 and cross-checked
against the bundled machine-readable schema — herdr api schema [--json | --output PATH]
(schema_version 1, covering requests, responses, errors, and events) is now the fastest way to
re-derive this contract without probing. These are the facts the bridge is built on; they confirm
the socket assumptions behind the design in ARCHITECTURE.md.
- Unix domain socket at
$HERDR_SOCKET_PATH(default~/.config/herdr/herdr.sock). - Newline-delimited JSON. Request:
{"id": <string>, "method": <string>, "params": <object>}.idmust be a string (integer →invalid_request).
- Response:
{"id", "result": {"type": "...", ...}}or{"id": "", "error": {"code", "message"}}. - RPC is one-shot: the server closes the connection after a single response. Send one request per connection. (Confirmed: a second request on the same connection never replies — the socket is already closed.)
- Malformed requests close the connection too, and the serde error message names the missing/ wrong field — which is how this contract was reverse-engineered without side effects.
- Exception:
events.subscribekeeps the connection open and streams events.
| Method | Params | Returns (result.type) |
|---|---|---|
session.snapshot |
{} |
session_snapshot → snapshot{workspaces[], tabs[], panes[], agents[], layouts[], focused_*} |
workspace.list |
{} |
workspace_list → workspaces[] |
pane.list |
{} |
pane_list → panes[] |
pane.read |
{pane_id, source, lines, format} |
pane_read → read{text, truncated, revision} |
pane.send_text |
{pane_id, text} |
(ack) |
pane.send_keys |
{pane_id, keys} |
(ack) |
agent.send |
{target, text} |
(ack) — writes literal text, no Enter |
pane.readsource∈visible | recent | recent-unwrapped;format∈text | ansi.format: "text"returns clean plain text (no ANSI escapes) → safe to render, no XSS surface.agent.sendwrites literal text only; to submit a reply, follow with an Enter keypress (pane.send_keys {keys: ["Enter"]}) — submit-key name needs live confirmation per agent.pane.send_textwrites RAW bytes — no bracketed paste. Live-probed 2026-07-27 (herdr 0.7.4) by sending into a pane running/usr/bin/cat -v, which renders control bytes visibly: the text came back bare, with no^[[200~/^[[201~framing. Two consequences worth keeping:- A PTY is an ordered byte stream, so a following
send_keyscannot overtake the text. Any "the Enter arrived before the text" theory is dead on arrival — including blaming the settle delay between the two calls (sendReplySteps,bridge/server.ts). See #34, where that was the first and wrong hypothesis. - A
\ninsidetextis delivered as a real newline keypress, not as pasted content. What the TUI does with it (submit vs. insert) is the harness's choice, not something the paste framing hides.
- A PTY is an ordered byte stream, so a following
- An ack means "herdr took the bytes", never "the TUI acted on them". Both
send_textandsend_keysreturn before the target program has read, let alone rendered, anything. So a successful RPC pair is not evidence a reply was delivered — a focused TUI dialog can swallow the text and consume the Enter with both calls reporting success. Anything that needs delivery confirmed must read the pane back and look (web/src/lib/reply-action.ts).
Herdr answers no
OSC 10/OSC 11background query, and relays SGR verbatim (live-probed 2026-07-29 in a pane running a raw-modesttyprobe: both queries returned nothing).Two things follow, and both matter to anything that renders pane output:
- A harness that asks what background it is on gets no answer and falls back to dark. Codex emits both queries at startup; with no reply its output is dark-authored (
#f6e2b7,#abdfa7— L=0.774 and 0.642, light values that only make sense on a dark ground). Collie cannot answer either: it reads a rendered buffer downstream of the PTY, so the negotiation happens between the harness and herdr on a channel Collie does not own.- Herdr does not rewrite escape codes into its own theme.
format:"ansi"returns what the program wrote —\x1b[38;5;1mstays a palette index, never a resolved RGB. So herdr's[theme]setting governs how herdr's own UI paints a pane, not what a client receives, and the same palette-index colour can legitimately differ between the desktop TUI and Collie (which applies its own 16-slot table). See.adr/0002.
session.snapshot {} → {"type":"session_snapshot","snapshot":{...}}. One-shot like every RPC —
no special connection handling, no streaming. The snapshot bundles everything a client needs to
bootstrap or resync in a single round trip:
Docs-blessed pattern: bootstrap with session.snapshot → events.subscribe → re-session.snapshot
on reconnect or staleness. CLI mirror: herdr api snapshot prints the raw reply — handy for
diffing shapes without writing a client.
Collie's bridge polls this method (one RPC per tick instead of the workspace.list + pane.list
tab.listtrio) and falls back to the trio on older servers that don't know the method. Old-server detection: the error reply is{"id":"","error":{"code":"invalid_request","message":"invalid request: unknown variant `session.snapshot`, expected one of ..."}}— the bridge treats anunknown varianterror onsession.snapshotspecifically as "fall back," not a hard failure.
The server validates every key and rejects unknown names with
{error:{code:"invalid_key", message:"unsupported key <X>"}} (pane lookup happens first, so probe
against a real pane). Empirically enumerated against Herdr 0.7.0 — it is NOT tmux syntax:
- Special keys (bare, case-insensitive):
UpDownLeftRightTabEnterEscapeSpaceBackspace(aliasBS), and function keysF1…F12. - Literal single characters: a one-character string is typed as that character — digits (
"1","2", …), letters, punctuation (live-verified 2026-07-04). This is what Collie's prompt-select taps send:{keys:["1"]}answers a permission dialog;{keys:["2","Enter"]}picks option 2 of an AskUserQuestion select. - Modifier chords (join with
+):ctrl+c,ctrl+u,ctrl+d,ctrl+l,ctrl+r,shift+tab,ctrl+left,alt+f, … Modifiers:ctrl/shift/alt/cmd/super(case-insensitive). This is the same grammar asconfig.toml [keys]. - Multi-modifier chords work, in any modifier order (live-verified 2026-07-20 against 0.7.3 on
a throwaway sandbox pane, with
PageUp→invalid_keyin the same run as proof the validator was active):ctrl+shift+p/shift+ctrl+p,alt+shift+p/shift+alt+p, triplectrl+alt+shift+p/ctrl+shift+alt+p, and modifier+specialalt+Upall ack. Independently confirmed against 0.7.4 by @bnivanov (issue #20). - NOT supported (all return
invalid_key): tmux-styleC-c/BTab; and the keysPageUpPageDownHomeEndInsertDelete(in any spelling). There is no forward-delete and no scrollback paging via keys — the web mirror is scrollable instead. ⚠️ Consequence: Ctrl-C isctrl+c, notC-c. Multiple keys per call are applied in order, e.g.{keys:["Down","Enter"]}.- Re-checked against 0.7.2's bundled schema: unchanged.
Three sibling RPCs set a display label on a workspace, tab, or pane. Live-verified 2026-07-18.
| Method | Params | label |
Returns (result.type) |
Event |
|---|---|---|---|---|
pane.rename |
{pane_id, label} |
string | null — null clears |
pane_info → {pane} |
none |
tab.rename |
{tab_id, label} |
string (non-null) |
tab_info → {tab} |
tab_renamed |
workspace.rename |
{workspace_id, label} |
string (non-null) |
workspace_info → {workspace} |
workspace_renamed |
pane.renameis the odd one out, twice over. Itslabelacceptsnull, which clears the label (thelabelkey then disappears from the pane record); the sibling two take a non-null string. And it emits NO event — a renamed pane surfaces only on the nextsession.snapshot/pane.listpoll.tab.rename/workspace.renameDO emit:tab_renamed→{type, tab_id, workspace_id, label},workspace_renamed→{type, workspace_id, label}(theeventfield is snake_case on the stream, as everywhere).- Errors: an unknown id →
{code:"pane_not_found" | "tab_not_found" | "workspace_not_found", message:"<kind> <id> not found"}. - No length limit; empty string accepted (stored as-is on tab/workspace). Re-verified on
tab.rename2026-07-19:label:""is stored literally (the tab's label becomes empty — it does not reset to the default number), andlabel:nullis rejected with{code:"invalid_request", message:"invalid request: invalid type: null, expected a string"}— confirming tabs/workspaces have no "clear" (onlypane.renameclears, vianull). Collie makes its own opposite choices per object: a blank pane "Save" clears (blank →null), while a blank tab "Save" is refused client- and bridge-side, since a literal-empty tab chip is useless. Seebridge/server.ts(normalizeTabLabel). - Undocumented field: once set, a pane's label rides along as
label?: stringinpane.list,pane.get,pane.current, andsession.snapshotpanes (omitted when unset — so it's absent from the base pane shape below). Workspaces already exposelabel; tabs likewise. agent.rename{target, name}also exists in the schema, but it is a DIFFERENT operation (renames an agent session, not a pane/tab/workspace) — unverified and unwired by Collie. Listed only so it isn't mistaken for the label renames above.
Two sibling structural ops remove panes. tab.close live-verified 2026-07-19 on the sandbox session.
| Method | Params | Returns (result.type) |
Event | Error (unknown id) |
|---|---|---|---|---|
pane.close |
{pane_id} |
ok |
pane.closed |
pane_not_found |
tab.close |
{tab_id} (schema: TabTarget) |
ok |
tab.closed |
tab_not_found |
tab.closeis a BULK pane-close: closing a tab terminates EVERY pane inside it. Verified by creating a throwaway tab holding a plain shell pane, thentab.close {tab_id}— the nextsession.snapshotno longer lists the tab or its inner pane. So it's no more privileged than closing those panes one-by-one (whichpane.closealready allows) — same remote-shell threat model.- Success is a bare
{"result":{"type":"ok"}}(same shape aspane.close), not a record reply like the renames — there's nothing left to describe. The closure surfaces on the next snapshot poll;tab.closealso emits atab_closedevent (which Collie doesn't consume). - Errors: unknown id →
{code:"tab_not_found", message:"tab <id> not found"}; a missingtab_id→{code:"invalid_request", message:"invalid request: missing field `tab_id` …"}.
Two sibling structural ops reorder objects. Both live-verified 2026-07-20 on the sandbox session.
| Method | Params | Returns (result.type) |
|---|---|---|
tab.move |
{tab_id, insert_index} |
tab_list → that workspace's tabs, post-move order |
workspace.move |
{workspace_id, insert_index} |
workspace_list → all workspaces, post-move order |
- Tabs: array order is authoritative,
numberis stable.tab.movereorders the array returned bytab.list/session.snapshotwithout renumbering — after movingt2(number 2) beforet1(number 1), the snapshot lists[t2, t1]with numbers unchanged. Herdr itself renders array order: the default label of an unlabeled tab is positional (post-move,t2displays as "1").⚠️ Consequence: a client that sorts tabs bynumberun-does the user's reorder — render tabs in array order, never number order. - Workspaces are the opposite:
workspace.moverenumbers. After movingw7(number 5) to the front, it becomesnumber 1and every other workspace shifts —numberalways equals position, so array order and number order never disagree and sorting workspaces bynumberis safe. insert_indexcounts positions in the PRE-removal list (workspace-scoped for tabs, clamped at the end). Moving an item toward the end therefore needstarget + 1: with[t2, t1],tab.move {tab_id: t2, insert_index: 1}is a no-op;insert_index: 2yields[t1, t2].- The event catalog lists sibling
tab.moved/workspace.movedevents (0.7.2); emission not observed here (no live subscription during the probe).
// workspace.list → workspaces[]
{ "workspace_id":"w0000000000000", "number":1, "label":"demo",
"focused":false, "pane_count":2, "tab_count":1,
"active_tab_id":"w0000000000000:t1", "agent_status":"done" }
// pane.list → panes[]
{ "pane_id":"w0000000000000:p1", "terminal_id":"term_…", "workspace_id":"w0000000000000",
"tab_id":"w0000000000000:t1", "focused":false, "cwd":"/…/demo",
"foreground_cwd":"/…/demo", "agent":"claude", "agent_status":"done",
"agent_session":{"source":"herdr:claude","agent":"claude","kind":"id","value":"…"},
"revision":0,
"scroll":{"offset_from_bottom":0,"max_offset_from_bottom":128,"viewport_rows":48} }agent_status ∈ idle | working | blocked | done | unknown. Panes without an agent omit/null agent.
agent_sessionhas TWO kinds, and it can outlive the agent that reported it (live-verified 2026-07-29 against claude, codex and pi panes). Each harness's herdr integration reports throughpane.report_agent_session, and what it reports differs:
kind:"id"+ a uuid — claude (source:"herdr:claude") and codex (source:"herdr:codex", from codex'sSessionStarthook; verified on codex 0.145.0).kind:"path"+ an absolute path to the log file — pi (source:"herdr:pi"). pi's integration prefersagent_session_pathover an id whenever its session manager has a file open.Two consequences. A harness only reports at all once
herdr integration install <agent>has been run — pi's was missing on this host and the pane simply carried no session of its own. And Herdr keeps reporting the LAST session announced for a pane, so relaunching a pane's agent as a different harness leaves the previous one's ref behind: a pane runningpiwas observed still advertising aherdr:claudeid. The record's ownagentfield is what distinguishes the two — compare it against the pane'sagentbefore trusting the ref (bridge/state-engine.ts).
Pane records now carry
scroll(new in 0.7.2, live-verified 2026-07-07):pane.list,pane.get,pane.current, andsession.snapshotpanes all includescroll: {offset_from_bottom, max_offset_from_bottom, viewport_rows} | null(alluint64;offset_from_bottom == 0means the pane is scrolled to the bottom). Collie doesn't consume it yet.
revisionis a stub on Herdr 0.7.x (live-verified 2026-07-05 on 0.7.0; reconfirmed unchanged on 0.7.2, live-verified 2026-07-07):pane.read,pane.list, andsession.snapshotall returnrevision: 0for every pane, including actively-changing ones. Treat it as advisory / future-proofing only — never as a load-bearing change detector (Collie's prompt-select race guard re-derives the menu from content for exactly this reason).
events.subscribe {subscriptions: [{type, pane_id?}]} keeps the connection open and streams
events. Empty subscriptions: [] → ack only, no events ever arrive. The ack and the event frames
are shaped differently — worth calling out explicitly:
- Ack:
{"id":"<id>","result":{"type":"subscription_started"}}. - Event:
{"event":"<snake_case>","data":{...}}. Note the split: subscriptiontypevalues are dot-form (pane.agent_status_changed), but theeventfield on each streamed line is snake_case (pane_agent_status_changed). Real example line:{"data":{"pane_id":"w6:p3","type":"pane_agent_detected","workspace_id":"w6"},"event":"pane_agent_detected"}.
The full event catalog (subscription type values), 0.7.2 additions marked *:
workspace.created workspace.updated workspace.renamed workspace.closed workspace.focused workspace.moved *
worktree.created worktree.opened worktree.removed
tab.created tab.closed tab.focused tab.renamed tab.moved *
pane.created pane.closed pane.focused pane.moved pane.exited
pane.agent_detected pane.output_matched pane.agent_status_changed
layout.updated * pane.scroll_changed *
* = new to the catalog in 0.7.2 (workspace.moved, tab.moved, layout.updated,
pane.scroll_changed); workspace.updated and pane.focused were already listed but are called
out here too since they're easy to miss in the block above.
- Scoping, verified:
pane.agent_status_changed,pane.scroll_changed, andpane.output_matchedrequirepane_idin the subscription (omit it →invalid_request: missing field `pane_id`). Everything else is global — subscribe with just{type}. layout.updated(global) payload is a fullPaneLayoutSnapshot:{workspace_id, tab_id, zoomed, area, focused_pane_id, panes:[{pane_id,focused,rect}], splits:[{id,direction,ratio,rect}]}— the same shape assession.snapshot'slayouts[].pane.scroll_changed(pane-scoped) payload:{pane_id, workspace_id, scroll}(scrollshape as in "Object shapes" above).- Rich payloads:
pane_created/workspace_createdcarry the full pane/workspace record, not just ids.pane_exitedcarries{pane_id, workspace_id}.pane_agent_detectedcarries{pane_id, workspace_id, agent?}and can fire in herd-wide bursts on re-detection — consumers should debounce it.
Collie now polls session.snapshot (above) as the source of truth, and additionally holds a
long-lived events.subscribe stream — global lifecycle events plus a per-agent-pane
pane.agent_status_changed subscription, resubscribed whenever the agent-pane set changes —
purely to poke the poller: an event triggers an immediate debounced re-poll, it never updates
state by itself. While the stream is healthy, interval polling relaxes to COLLIE_POLL_IDLE_MS
(default 12000 ms, min 1000 ms); when the stream is down or reconnecting, it drops back to the
fast COLLIE_POLL_MS cadence. Events accelerate; the snapshot stays authoritative — a missed
event costs one interval, never correctness.
Also visible in the 0.7.2 schema but unused by Collie: events.wait, pane.send_input,
agent.list, pane.wait_for_output — run herdr api schema for the full ~80-method catalog.
{ "version":"0.7.2", "protocol":16, "workspaces":[ /* same record shape as workspace.list → workspaces[] */ ], "tabs": [ /* same record shape as tab.list → tabs[] */ ], "panes": [ /* same record shape as pane.list → panes[] */ ], "agents": [ /* precomputed subset of panes[] that carry an agent */ ], "layouts": [ /* per-tab PaneLayoutSnapshot, see layout.updated below */ ], "focused_workspace_id":"w0…", "focused_tab_id":"w0…:t1", "focused_pane_id":"w0…:p1" } // focused_* are string | null