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:
😺🤖 lead, not a fresh
throwaway agent and not the sibling fleet. A chat that spawned a new agent per message would be a
different (easier, less useful) product.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:
claude agents --json lists sessions (and is already a session-hub collector), but has
no send or attach subcommand.~/.claude/daemon/
(a control.key, a dispatch directory, a roster). It is undocumented, unversioned, and
reading it in this workspace is blocked by the permission classifier — which is the correct answer.
Any design that pokes the daemon socket is rejected on principle: it would break on a
CLI upgrade, and it would be an agent reverse-engineering its own supervisor.bridgeSessionId and accepts prompts from claude.ai/code — but it is Anthropic-hosted, not
localhost, and not scriptable from here.
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.
| Lane | How a prompt arrives | Verdict |
|---|---|---|
| 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:
Monitor on a tiny waiter
script (scripts/obot-chat-wait) that blocks until an inbox file appears and then prints the
message. A monitor event wakes an idle session, so delivery is near-immediate.
Cost: one tool call per session, adopted in session-init.
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.
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)
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)
inbox/. Any producer can do it: the server, a
script, another agent, echo. That is the point of a file protocol — the dashboard is the
first client, not the only possible one.rename(inbox/x, delivered/x). Atomic on a local filesystem, so
concurrent claimers are safe, and “delivered” is observable on disk rather than inferred.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.
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:
| Entry | Rendered as |
|---|---|
assistant · content block thinking | a “thinking” pulse |
assistant · content block tool_use | an activity line — tool name plus its description/target |
assistant · content block text | the reply text, appended as it lands |
message.stop_reason === "end_turn" | turn complete — stop the spinner |
isSidechain: true | ignored (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:
outbox/ reply).😺🤖) session, a scrolling log, a queue-depth line, and a composer
(⌘/Ctrl+Enter to send).<meta http-equiv="refresh" content="60">, which would blow away a half-typed prompt.
Since chat already requires the server, the chat-enabled page drops the meta refresh and does a
soft refresh instead: fetch the page, swap the data region, leave the chat panel
untouched. This is precisely the “flicker-free partial refresh” that #24 D1 named as a server's only real
win — it arrives here as a side effect rather than as scope creep. Composer drafts also persist to
sessionStorage, and the log is rebuilt from the server, so even a hard reload loses
nothing.--watch process. Opening the same file from file:// still works — the chat
panel detects the unreachable server and degrades to a disabled composer with an explanation.reports/sessions/*.html on the public site carry no chat panel at all. Nothing to degrade,
nothing to leak..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:
POST requiring a JSON content type and passing an
Origin check, so a random web page cannot cross-site-post a prompt; and the server running
only while @jwildfire is looking at the dashboard (it is not a daemon and is not installed as one).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.
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.
outbox/ reply?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.
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.
needs-input from the page — the old orchestration D2,
still parked. Chat targets a session's normal input, not a permission prompt or a tool-approval dialog.
Approval gates stay in the terminal deliberately.| Risk | Mitigation |
|---|---|
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. |
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.end_turn.inbox/ file before delivery, a
delivered/ file with a timestamp and lane after.lsof), rejects cross-origin posts, and
ships no secret in the page. Published reports/sessions/*.html contain no chat markup.--auto sessions behave identically
with the hook installed.Sizing only; decomposed into sub-issues after sign-off. Everything lands in obot.agent — no hub or safety.viz work.
| # | Task | Size |
|---|---|---|
| 1 | lib/chat.mjs — the file protocol (enqueue, atomic claim, log) plus the transcript tail parser, with tests | S–M |
| 2 | session-chat.mjs — localhost server: page hosting, /api/targets, /api/send, SSE /api/events, /api/log | M |
| 3 | Chat panel in lib/render.mjs plus soft refresh, live mode only | M |
| 4 | hooks/chat-inbox-deliver.sh (Stop lane) plus scripts/obot-chat-wait (idle lane) and installer registration | S |
| 5 | Docs and adoption: protocol reference, how the lead adopts the monitor lane, session-dashboard starts the server | S |