Design: chat with the orchestrator from the live session dashboard

Requirement jwildfire/obot.roadmap#77 · 2026-07-24 · Decisions D1–D6 open for @jwildfire
Extends #24 (session hub) and deliberately supersedes its D1 “static watch loop, no server” for this feature only. Promoted from discussion #62. A working prototype of the recommendation below is up as a draft PR on obot.agent.

1. Context, constraints, and what “chat” has to mean

The session hub is a read-only page: it answers “where are we?” without a terminal. #77 asks for the other direction — @jwildfire types a prompt on the dashboard, the orchestrator receives it, and the answer streams back on the same page. Three constraints were fixed in triage on discussion #62 and are treated here as given:

2. The hard part: there is no supported way to talk to a running session

Everything else in this requirement is easy. The difficulty is one sentence: Claude Code exposes no documented local API for injecting a prompt into an already-running session. Verified on CLI 2.1.220 while drafting this design:

What is supported, documented, and already load-bearing in this workspace is the hook contract. A Stop hook may print {"decision":"block","reason":"…"}, and the reason becomes input the session acts on. The workspace has shipped exactly that since 2026-07-14: hooks/scratchpad-heartbeat.sh nudges a stale session into logging by blocking its Stop with a sentence of instructions. A chat message is the same move with different text. That observation is the whole design.

3. Delivery lanes — the four candidates, measured

LaneHow a prompt arrivesVerdict
A. CLI attach / send A claude subcommand that writes into a live session. Does not exist No such surface on 2.1.220. Nothing to build on.
B. Daemon control plane Speak the private IPC protocol under ~/.claude/daemon/. Rejected Undocumented, unversioned, classifier-blocked. Guaranteed to break; wrong on principle (§2).
C. Remote Control bridge (#46) Reuse the existing claude.ai/code → session channel. Not local Already works today as a product — if @jwildfire wants to type at a session from a phone, RC is the answer and #77 is redundant. It cannot be pointed at a localhost page, and the classifier blocks agent writes to RC settings. Kept as the honest “do nothing” alternative in D1.
D. File-based inbox + Stop-hook delivery The server writes a JSON message into a per-session inbox directory; a Stop hook claims it and hands it to the agent as a blocked-stop reason. Recommended Built only on documented surfaces (hooks + files). Works for any session — interactive, background, sibling, lead — with no per-session code. Delivery is at a turn boundary, which is also the right queueing semantic (§4).

Lane D has one gap, worth stating plainly rather than hiding: the Stop hook only fires when a turn ends. If the orchestrator is already idle and waiting for input, no Stop event is coming, and a queued message would sit there indefinitely. The fix is a second, opt-in lane over the same inbox:

Both lanes claim messages with an atomic rename() into delivered/, so a session with both armed cannot double-deliver: whichever claims first wins. The combination is the design — hook = delivery while working, monitor = delivery while idle — and each is useful without the other.

4. The recommended architecture: a file-based inbox + a localhost server

browser (dashboard)                    localhost server                     orchestrator session
───────────────────                    ────────────────                     ────────────────────
type a prompt ──POST /api/send──►  write inbox/<ts>-<id>.json  ─────────►  Stop hook claims it
                                                                            → {"decision":"block",
   SSE ◄──activity / text / done──  tail ~/.claude/projects/…jsonl ◄──────     "reason": <message>}
                                     (assistant blocks, from a
                                      byte offset recorded at send)

The wire format (a protocol, not an implementation detail)

Everything lives under one directory, keyed by the session's own UUID — the same sessionId the session hub already reads from state.json, and the same session_id the hook payload carries. No new identifier, no mapping table:

<workspace>/.claude/session-chat/<sessionId>/
  inbox/<epochMs>-<id>.json     # pending: {id, from, text, createdAt}
  delivered/<id>.json           # claimed: same + {deliveredAt, lane: "hook"|"monitor"}
  outbox/<id>.json              # optional explicit reply from the agent
  log.jsonl                     # server-maintained chat log (user + agent turns)

Why not have the agent poll?

Discussion #62 floated “the dashboard writes a file the agent polls”. Polling costs a tool call per check and only happens when the agent chooses to look, which is exactly when it is least likely to (mid-task). Hook delivery costs nothing until a message exists, and the monitor lane is one armed watch rather than a loop. Same file, far better economics.

5. Reading the reply: the transcript tail

The reply lane needs no cooperation from the session at all. Every session appends to ~/.claude/projects/<slug>/<sessionId>.jsonl, and the useful shape was verified against live transcripts on 2026-07-24:

EntryRendered as
assistant · content block thinkinga “thinking” pulse
assistant · content block tool_usean activity line — tool name plus its description/target
assistant · content block textthe reply text, appended as it lands
message.stop_reason === "end_turn"turn complete — stop the spinner
isSidechain: trueignored (subagent chatter, not the session speaking)

The server records the transcript's byte offset at send time and streams blocks that appear after it. Two honest limitations:

6. Dashboard UI and refresh behaviour

7. Security and the trust model

State it plainly: this feature builds a lane that injects instructions into a running agent session. That agent holds a GitHub App token path, merge tooling, and write access to the workspace. Anything that can write a file into .claude/session-chat/<id>/inbox/ can make the orchestrator act. The inbox is a privilege boundary and deserves to be treated as one rather than as a message queue.

What follows from that:

8. Decisions Open — @jwildfire

D1 — Build the local lane at all, or just use Remote Control?
Recommendation: build it. RC (#46) already lets @jwildfire type at a background session from claude.ai/code, so #77's marginal value is specifically chat in the same view as the ops picture — one page for state and steering, no context switch, works offline, and the file protocol is reusable by non-browser clients. If the honest answer is “RC is enough”, this is the moment to say so and close #77 as won't-do; that is a cheaper outcome than a prototype nobody opens.
D2 — Delivery lanes: hook only, or hook + monitor?
Recommendation: both, with the hook as the floor. The hook needs no per-session action and covers the common case (steering an agent that is working). The monitor lane costs one tool call in session-init and is the only thing that reaches an idle session, which is exactly when a human is most likely to be typing. Shipping only the hook means “I sent a message and nothing happened” whenever the agent is at rest — the worst first impression available.
D3 — Mid-task behaviour: queue or interrupt?
Recommendation: queue, one message per turn boundary, with visible depth. Interrupting is not cleanly available anyway (§2), but it is also the wrong default: derailing a running task to answer a question is how work gets lost. The dashboard shows n queued and the age of the oldest, so the wait is legible. An explicit “urgent” variant that blocks every Stop until the queue drains is a small follow-up if queueing ever feels too slow.
D4 — Reply source: transcript tail, or an explicit outbox/ reply?
Recommendation: transcript tail for v1, outbox defined but optional. The tail needs zero cooperation, works for sessions that never heard of chat, and gives the activity feed for free. Its cost is that unrelated post-delivery work shows in the chat log (§5). Requiring an outbox write would make the log strictly conversational but adds a rule every session must remember, and a forgotten write reads as “the agent ignored me”. Both are implemented; the question is which one the panel treats as canonical.
D5 — Loopback bind alone, or bind plus a shared-secret token?
Recommendation: loopback only for v1. The token's marginal protection is against local processes and cross-origin pages; the Origin check plus a non-daemon lifetime covers the realistic version of that, and a secret in the bookmark is friction on every open. Revisit the moment the server ever wants to outlive the dashboard session or listen off-loopback — it should not.
D6 — Who runs the server, and when?
Recommendation: the session-dashboard skill starts it on demand and leaves it to the session. Same shape as today's watch loop — nothing installed, nothing supervised, no launchd agent. Making it a background service would mean an always-open prompt-injection port on the machine, which §7 says should not exist while nobody is looking at the page.

9. Out of scope

10. Risks

RiskMitigation
The transcript JSONL is a harness-internal format — the same caveat #24 already carries for state.json — so block shapes could change on a CLI upgrade. One parser module, pinned to the §5 field list; unknown block types are ignored rather than fatal; a parse failure degrades the panel to “delivered, reply not readable” instead of crashing. Re-verify on CLI upgrades, alongside the existing state.json check.
Hooks are read at session start, so a session already running when the hook is installed will never deliver chat. Documented as the adoption rule: chat reaches sessions started after installation. The dashboard marks a target chat-capable only when its chat directory exists, so an unreachable session is visible as such rather than silently swallowing messages.
Stop-hook blocks extend a session that was trying to finish — including in the autonomous #18 lane. One message per Stop (the stop_hook_active guard the workspace hooks already use), never blocking twice in a row. An unattended run with an empty inbox is bit-for-bit unaffected.
Chat becomes a way to route around approval gates and conventions (“just tell it to merge”). Nothing about chat changes the guards: the merge hook, the approval convention, and the no-writes-outside-jwildfire rule are enforced where they already are. Chat delivers prompts; it grants nothing.
Two records of the same conversation (chat log + transcript) drift. The transcript is the source of truth; log.jsonl is a derived render cache that can be deleted at any time without loss.

11. Acceptance criteria

  1. With the server running and the dashboard open at http://127.0.0.1:<port>, a prompt typed into the chat panel reaches the target session and its reply appears on the page without touching a terminal.
  2. Sending while the agent is working shows the message queued, then delivered at the next turn boundary; sending while it is idle (monitor lane armed) is delivered within seconds.
  3. While the agent works, the panel shows activity (thinking / tool lines); the spinner stops at end_turn.
  4. Every message is auditable on disk: an inbox/ file before delivery, a delivered/ file with a timestamp and lane after.
  5. Degradation: server down → composer disabled with a reason, rest of the dashboard unaffected; unknown transcript shape → “reply not readable”, no crash; hook absent → target shown as not chat-capable.
  6. The server listens on 127.0.0.1 only (verified with lsof), rejects cross-origin posts, and ships no secret in the page. Published reports/sessions/*.html contain no chat markup.
  7. An empty inbox changes nothing measurable: unattended --auto sessions behave identically with the hook installed.

12. Task preview

Sizing only; decomposed into sub-issues after sign-off. Everything lands in obot.agent — no hub or safety.viz work.

#TaskSize
1lib/chat.mjs — the file protocol (enqueue, atomic claim, log) plus the transcript tail parser, with testsS–M
2session-chat.mjs — localhost server: page hosting, /api/targets, /api/send, SSE /api/events, /api/logM
3Chat panel in lib/render.mjs plus soft refresh, live mode onlyM
4hooks/chat-inbox-deliver.sh (Stop lane) plus scripts/obot-chat-wait (idle lane) and installer registrationS
5Docs and adoption: protocol reference, how the lead adopts the monitor lane, session-dashboard starts the serverS