Skip to content

GET /agent-state (parsed agent card)

The mobile-side contract for the parsed agent state. This is the phone-friendly alternative to the raw PTY stream (WS /attach): for an agent pane you get a compact JSON card — what the agent is doing, its last message, and, when blocked, the exact question + choices it’s waiting on (which pair with POST /approve). Non-agent panes keep using raw /attach.

All examples below were captured from live Claude Code panes through the running bridge.


GET /agent-state?pane=<pane_id>
Authorization: Bearer <bearer>
Part Value
Method GET
Path /agent-state
Query pane — the Herdr pane_id (e.g. wQ:p2), from /snapshot. Required.
Body none
Auth header Authorization: Bearer <bearer> — the per-device bearer from /pair, or the admin token (dev). ?token=<bearer> also works, matching /attach.

Stateless per request: the bridge shells out to herdr (agent get + agent read) and parses; it stores nothing.

The app’s one-line “what’s it doing right now” (core/widgets/live_activity_line.dart) is the heaviest caller: it polls per agent tile, once on mount and then every 5s only while that agent is working — a settled agent’s line cannot change until its status does, so it is fetched once. Every list that shows agents mounts that same widget: Flock → Agents, Priority, and the space / overview pane cards. So the shape of the load is one request per visible agent, not one per screen, and the same agent visited on two screens is two callers.

Nothing here is stateful, so that is safe by construction — it is only a note on volume: a list of N working agents is N requests every 5s, each one a herdr round-trip.


Kind-agnostic: the same shape for every agent kind. No Claude-specific fields leak into the contract.

Field Type Notes
pane_id string Echoes the requested pane.
agent_kind string Herdr agent kind (claude, later codex, opencode, …).
agent_status string Authoritative status from Herdr: idle | working | blocked | done | unknown.
headline string One line: what it’s doing / its last step. The question when blocked. Always safe to render alone.
detail string Short plain-text body (current activity or last assistant message). ANSI/box-drawing already stripped. May contain \n.
blocked object | absent Present only when agent_status == "blocked". See below.
blocked.question string The prompt the agent is waiting on.
blocked.options array Selectable choices in display order. May be empty for a free-form prompt.
blocked.options[].index int The number the user types to pick it (1-based); 0 if unnumbered (see key).
blocked.options[].label string Choice text.
blocked.options[].selected bool The highlighted default — the one a bare Enter (/approve) accepts.
blocked.options[].key string | absent Set instead of index for an option with no menu number, reachable only via a raw keystroke — e.g. "esc" for the decline action on Claude’s single-choice approval form (❯ 1. Yes with no numbered “No”, just an “Esc to cancel” footer hint). Omitted for numbered options. Dispatch with POST /send { "pane": pane_id, "key": "esc" } (see docs/API.md), not /approve or a typed index.
blocked.category string | absent Coarse semantic class of the block, from Herdr’s own detection rule id (e.g. tool_approval, question_panel, dangerous_command_approval, write_file_approval, generic_permission_prompt). Filled via agent.explain — no per-agent plugin — and omitted when unavailable. Lets the app style/prioritise (e.g. flag a dangerous command).
transcript array<string> | absent Optional, best-effort recent plain-text lines.
parsed bool false ⇒ no dedicated parser for this kind; detail/transcript are a raw recent-text fallback.

How the app uses blocked (pairs with POST /approve and POST /send)

Section titled “How the app uses blocked (pairs with POST /approve and POST /send)”
  • One-tap “Yes” (the selected default): POST /approve { "agent": pane_id, "seq": state_change_seq } — the bridge presses Enter only if the agent is still blocked at that seq (idempotent).
  • Pick a non-default numbered option: POST /send { "pane": pane_id, "text": "2\n" } — type the option’s index then newline.
  • Pick a key-only option (no index): POST /send { "pane": pane_id, "key": "esc" } — dispatches the raw keystroke instead of typing.
  • state_change_seq comes from /snapshot (or the push payload), not from this endpoint.

OpenCode v2 questions come from the service, not the screen

Section titled “OpenCode v2 questions come from the service, not the screen”

OpenCode v2’s question tool is backed by the agent’s managed service API, so for an opencode pane the bridge reads the blocked prompt — question text and choices — from that service (GET /api/session/<id>/form, filtered to metadata.kind == "question") instead of scraping the TUI. That keeps the card correct when the panel’s wording changes: v2’s footer no longer matches Herdr’s bundled opencode screen-detection manifest. The same API answers it:

  • POST /approve replies with the first (default) choice.
  • POST /send with a bare option index replies with that choice’s value.
  • POST /send with any other text is the free-form “Type your own answer”.
  • POST /send { "key": "esc" } dismisses the form.

Herdr still owns agent_status (its opencode integration reports blocked on question.asked). A multi-question form — which the card cannot model — and an unreachable service both fall back to the screen parser.


{
"pane_id": "w5:p18",
"agent_kind": "claude",
"agent_status": "idle",
"headline": "Both PRs are open against develop:",
"detail": "Both PRs are open against develop:\n\n- #1570 — fix/consolidated-window-earliest — consolidated refresh window uses the earliest per-key\ntimestamp (MIN not MAX), + the retry-deadline email fix.\nhttps://github.com/example/acme-app/pull/1570\n- #1571 — feat/report-detail-per-key-summary — per-key summary collapsible panel, gated to\nmulti-region accounts.\nhttps://github.com/example/acme-app/pull/1571\n\nBoth are file-disjoint and independent (no stacking), so they can be reviewed and merged in any order.\nNeither commit carries any Claude attribution.\n\nOne thing I did not do: the earlier temp branch feat/per-key-summary-detail still exists locally at\ndevelop's HEAD with no commits — harmless, but I can delete it if you want it cleaned up.",
"transcript": [
"Both are file-disjoint and independent (no stacking), so they can be reviewed and merged in any order.",
"Neither commit carries any Claude attribution.",
"✻ Worked for 2m 43s",
"※ recap: Goal was surfacing per-key summary data on the report-detail page plus fixing the consolidated",
"await review, or delete the leftover local branch if you want. (disable recaps in /config)"
],
"parsed": true
}
{
"pane_id": "wN:pB",
"agent_kind": "claude",
"agent_status": "working",
"headline": "Pushed to feat/alerts-liveness…",
"detail": "Pushed to feat/alerts-liveness…",
"transcript": [
"That's the inbox (reload reset navigation). Let me open Alerts via the bell to check the dimming:",
"Now both done and resolved recede (dimmed, muted title) — the green check is just a quiet success marker…",
"Pushed to feat/alerts-liveness…"
],
"parsed": true
}

When a working agent has done only tool calls for a while (no fresh prose in the visible window), headline falls back to the pane’s task title and detail to the current tool step — the card still says something useful.

blocked (Claude, wQ:p2) — a Bash-permission prompt

Section titled “blocked (Claude, wQ:p2) — a Bash-permission prompt”
{
"pane_id": "wQ:p2",
"agent_kind": "claude",
"agent_status": "blocked",
"headline": "Do you want to proceed?",
"detail": "Bash command\ntouch card_demo.txt\nCreate empty card_demo.txt file",
"blocked": {
"question": "Do you want to proceed?",
"options": [
{ "index": 1, "label": "Yes", "selected": true },
{ "index": 2, "label": "Yes, and always allow access to blocked-demo/ from this project", "selected": false },
{ "index": 3, "label": "No", "selected": false }
]
},
"transcript": [
"Running 1 shell command…",
"Bash command",
"touch card_demo.txt",
"Create empty card_demo.txt file",
"Do you want to proceed?",
"❯ 1. Yes",
"2. Yes, and always allow access to blocked-demo/ from this project",
"3. No"
],
"parsed": true
}

To approve the default (option 1): POST /approve {"agent":"wQ:p2","seq":<seq>}. To choose “No” (option 3): POST /send {"pane":"wQ:p2","text":"3\n"}.

{
"pane_id": "wQ:p2",
"agent_kind": "claude",
"agent_status": "done",
"headline": "Done — created card_demo.txt.",
"detail": "Done — created card_demo.txt.",
"transcript": [
"✻ Crunched for 6s",
"Ran 1 shell command",
"Done — created approve_me_demo.txt.",
"Ran 1 shell command",
"Done — created card_demo.txt."
],
"parsed": true
}

Fallback — parsed: false (unrecognised agent kind)

Section titled “Fallback — parsed: false (unrecognised agent kind)”

For a kind with no dedicated parser yet, the bridge degrades to a best-effort raw recent-text dump instead of erroring. blocked is never populated in this mode (agent_status is still authoritative). Shape is identical; only parsed flips to false. (Below: a hypothetical aider pane; claude is parsed today, codex and opencode are next behind this same contract.)

{
"pane_id": "wZ:p4",
"agent_kind": "aider",
"agent_status": "working",
"headline": "…best-effort last readable line…",
"detail": "…raw recent readable text, ANSI/rules/box-art removed…",
"transcript": [ "…up to ~12 recent lines…" ],
"parsed": false
}

App rule: render headline/detail/transcript as usual, but when parsed == false do not rely on blocked — fall back to raw /attach if the user needs to act.


Error bodies are plain text (not JSON), matching the other endpoints.

Status When Body (example)
400 pane query param missing want ?pane=<pane_id>
401 missing/invalid bearer (or ?token=) unauthorized
404 no agent in that pane / unknown pane no such agent
502 the underlying herdr command failed herdr agent get …: <stderr>

Notes:

  • Parsing never 500s. An unrecognised on-screen layout degrades to parsed:false with raw text — it does not produce an error status.
  • A momentary failure to read the terminal text (but the agent still exists) is non-fatal: you get a 200 built from whatever was available, possibly with a thin detail.

Terminal window
BASE=https://<host>.<tailnet>.ts.net:5338
TOKEN=<admin-or-device-bearer>
# parsed card for a pane
curl -s "$BASE/agent-state?pane=wQ:p2" -H "Authorization: Bearer $TOKEN"
# ?token= form (parity with /attach)
curl -s "$BASE/agent-state?pane=wQ:p2&token=$TOKEN"