Agent comms gateway decision loop
The gateway agent runs as a long-lived interactive Claude Code session in a dedicated herdr workspace on flagg, on Max subscription auth. A zero-policy host driver pokes it. The event stream is its only durable memory. It renews itself by session cycling with stream replay, not by compaction. Every consumed event produces exactly one typed decision receipt before the cursor advances.
Context
The Agent Comms Gateway effort replaces deterministic message routing (kinds tables, lanes, suppression) with an always-on Fable agent that owns comms judgment in both directions. Prior steps settled: producers report facts and the agent decides what Joel hears; one inbound contract for every event from Joel; transport keeps mechanics only; the stream (event-sourcing log) is the agent’s only durable memory; a dead-simple Telegram fallback fires when the agent’s heartbeat is stale.
This ADR records the core loop architecture from the design-decision-loop grilling (2026-07-21). It amends one pinned call: the resilience-engineering brief had shaped this workload as a container service. Verified policy (Anthropic, Feb–Mar 2026) restricts subscription OAuth to Claude Code and claude.ai; the Agent SDK and claude -p are API-key only. Joel wants Max billing. An interactive Claude Code session is therefore the only sanctioned Max-billed surface — the harness decision follows by elimination and by preference.
Decision
1. Harness: herdr-resident Claude Code session plus a dumb driver
- The gateway agent is a named, long-lived interactive Claude Code session in a dedicated herdr workspace on flagg, with the
joelclaw-gatewayplugin loaded. Max subscription auth. - A driver — a zero-policy host process, transport-class — provides the wake mechanics an interactive session lacks:
- watches the stream and prompts the pane (
herdr agent prompt) when unhandled events exist and the session is idle; - fires aggregate deadlines the same way;
- emits and watches the heartbeat;
- asks the wake registry to SPAWN a fresh session when the pane dies or the loop retires;
- trips the transport’s Telegram fallback when the heartbeat is stale.
- watches the stream and prompts the pane (
- The driver says there is work; it never decides what to do with it.
- This amends “container service riding the bus”: the contract’s substance (heartbeat, external staleness watching, supervised restart) survives; driver + wake registry + herdr are the mechanism. The kill test now reads: kill the gateway session.
- Max-window exhaustion is a first-class failure mode: brain out of tokens → heartbeat stale → fallback delivers raw text. The fallback contract must treat it as a named trigger. Max-window contention with Joel’s own sessions is an accepted cost.
2. Lifecycle: session cycling with stream replay
- The loop renews by retiring and being reborn, not by compacting in place. Cycle at a soft token threshold (~150k) or a quiet moment. Harness compaction is a silent backstop: it appends a receipt and shows in telemetry; it never pages.
- Boot sequence (also the crash path, minus the note): prompt files → latest
gateway.handoffnote → mechanical replay from the gateway’s own cursor (unhandled events, open aggregates, undelivered decisions) → fresh herdr snapshot. - The note-to-successor (
gateway.handoff) is a first-class stream event: prose judgment state, capped (~2k tokens), written as the retiring session’s last act. It is advisory; replay is authoritative when they disagree. Standing judgments belong in the prose policy files, not the note chain. - Retirement waits for the in-flight decision only — never for open aggregates or quiet. Open storms hand over via the stream.
- Lifecycle states (XState v5):
booting → active (idle ⇄ deciding) → retiring → exited; supervisor (driver + wake registry) restarts tobooting.cycle.duedefers to the in-flight decision;compact.firedstays inactiveand appends a receipt.
3. Storms: agent-owned clocks, immutable aggregates
- Opening an aggregate is a decision receipt with
aggregateId, member event IDs, reason, and a self-chosenholdUntil. A dumb timer appendsaggregate.deadline.reachedto the stream; the agent then decides deliver / extend / hold. - Extensions are capped (~30 min): a rolling storm always produces at least a summary delivery or a “still holding, here’s why” receipt.
- Closed aggregates are immutable. A straggler after delivery starts a fresh decision, usually a successor aggregate with
follows: <oldAggregateId>, rendered as a platform reply to the earlier message. Delivered messages are never edited. - There is no mechanical correlation key. Grouping is judgment reading evidence; the member-event-ID list is the lineage.
- The digest is an aggregate with a slow clock. Three tiers of not-pinging-Joel: storm aggregate (minutes), standing digest note (hours/daily, same machinery), and
drop(truly never hears).
4. Fan-out: fire-and-receipt, never block
- Delegation is a
fanoutdecision receipt with ataskId. The worker’s brief instructs it to append its result to the stream as its last act; the result arrives as an ordinary event. agent.waitis a watchdog only: on worker turn-end-without-report or timeout, the loop reads the pane and appends a truthful harvest receipt (result / crashed / garbage). Worker DONE-text is not proof.- Pending fan-outs survive cycling and crashes: the dispatch receipt replays; the result event arrives whenever it arrives.
- Model/reasoning-tier selection is prose in
judgment.md, not a lookup table.
5. Decision receipts: exhaustive verbs, no silent cursor advance
One kind, gateway.decision.recorded, tagged union:
deliver | aggregate(open|join|extend|close-deliver) | escalate | fanout | route | drop- Every consumed event gets exactly one receipt naming a verb and a one-sentence reason before the cursor may advance.
dropis mandatory writing-down of “Joel never hears this.” - Envelope on every variant:
inputEventIds,reason,promptRevision(git rev of the prose policy files),decisionSeq. Semantic key:gateway:<firstInputEventId>:<decisionSeq>— re-decisions on the same input (deadline fires) increment the sequence without breaking idempotency. - This is the unhandled-work-receipts clause made mechanical, and the trust mechanism for full replacement without a shadow window: “dropped deliberately, here’s why” must be distinguishable from “lost.”
6. Inbound: judgment at every rung of the routing ladder
- Tiers stay ordered — live pane (
agent.prompt) → wake-registry revive → headless bus event by flowId — but a failed rung is never auto-descended. Each refusal is evidence; the next move (retry, descend, ask Joel) is a fresh decision with a receipt. - The tiers carry different meanings, not interchangeable deliveries: prompt continues a conversation; revive resurrects context deliberately; a bus event may execute something. Auto-descent acts confidently on stale context.
- Mechanical guarantee: the inbound event and every failed attempt are stream receipts, so an unresolved reply is unhandled work that replays on every boot until a decision closes it. The honest failure mode is “gateway asks Joel,” never silence.
7. Packaging: the joelclaw-gateway Claude Code plugin holds
- The plugin-surface map’s bundle stands: agent definition, prose policy files (
prompts/), MCP server with stream/herdr/wake tool families,SessionStarthook implementing the boot sequence,PostCompacthook appending the silent backstop receipt. - Amendments from this ADR: stream tools carry the §5 receipt appends; wake tools carry revive and the aggregate-deadline timer; the retire path is loop-owned (no hook).
- Host-residency deletes two mapped gaps: container-to-host socket path and the herdr host-bridge contingency. The do-not list stands: no policy in any herdr plugin, no second config format — prose files are the only steering surface.
- Cutover gates unchanged: Herdr protocol 17 (
agent.prompt) must be installed and proven; stream-effort seams (typed origin, named multi-consumer cursors, paginated replay) must exist.
Consequences
- Crash recovery, routine renewal, and mid-storm handoff are the same code path, exercised daily.
- The stream gains gateway-owned kinds:
gateway.decision.recorded,gateway.handoff,aggregate.deadline.reached, plus the stream-seam asks (raw inbound, interpretation, fallback marker). - The fallback-contract step must name window exhaustion as a trigger equal to process death.
- The
escalateverb rides the shared incident-semantics latch (@joelclaw/incident-latch, sixth resilience clause, bound 2026-07-21 in parallel with this grilling): the agent decides whether to escalate; the latch owns quiet-window/attempt-cap semantics on the hard-alert path. It is not duplicated in the loop. - Rejected: containerized Agent SDK harness (API-key-only, Joel wants Max);
claude -ploop (not sanctioned for long-running services); mechanical debounce in transport; auto-descending routing ladder; mutable aggregates; silent cursor advance.