ADR-0016: Agent Reverse Channel = JSONL File, Not RPC
- Status: Accepted
- Date: 2026-07-06
Context
After M3 (Popen + streaming + cancel), agents could only communicate with the host in one direction: stdout → engine. There was no path for an agent to push progress updates, clarification questions, or “approval_request” events back to the host without the engine killing the process and reading JSON.
The agent-as-agent use case (one agent spawning sub-agents that report
back, or a long-running agent that wants to checkpoint intermediate
results) needed a reverse channel. OpenOPC — which this PRD is
partly modeled on — solves this with FIFO / TCP RPC: every agent
process gets an RPC server address via OPC_COLLAB_RPC_* env vars,
and the agent’s SDK calls chat.abort, send_message, etc. via that
RPC. The RPC is a real two-way protocol over Unix FIFO or TCP socket.
We had to choose: replicate OpenOPC’s RPC, or pick something simpler.
Why OpenOPC chose RPC
OpenClaw-style agents (and OpenOPC’s agents) often need to call back into the host for things like:
- “I need user input — pause me and prompt”
- “Here’s a partial result, write it but keep going”
- “Cancel this sub-task I spawned”
- “Subscribe to events from another agent”
The RPC model gives you bidirectional channels with request/reply semantics, auth, structured errors, and back-pressure. All of these are real needs in agent orchestration.
Why that model is overkill for v1
We surveyed what agents actually need to push back in the v1 use cases:
- Progress updates — fire-and-forget text strings. No reply needed.
- Clarification requests — text questions. Reply is async (human responds hours later via Obsidian frontmatter), not in-process.
- Result checkpoints — partial outputs to persist before failure. Write-only.
- Done signals — explicit completion marker (in case exit code is unreliable). Write-only.
- Approval requests — agent wants human review before continuing. Write-only.
Every v1 use case is write-only fire-and-forget. None of them need request/reply, structured errors, or back-pressure. RPC is over-engineered for what we need today.
What JSONL gives us
$RUNEX_INBOX_PATH — a file path env var injected into the agent
subprocess. The agent writes JSON lines (echo {...} >> "$RUNEX_INBOX_PATH").
The engine’s drain thread tails the file (byte-offset-tracked, like
tail -f).
For every v1 use case:
- Progress:
echo '{"type":"progress","text":"step 3"}' >> "$RUNEX_INBOX_PATH" - Clarification:
echo '{"type":"clarification","q":"which model?"}' >> - Checkpoint:
echo '{"type":"result_partial","note":"…"}' >> - Done:
echo '{"type":"done","summary":"…"}' >> - Approval:
echo '{"type":"approval_request","reason":"…"}' >>
The agent needs zero SDK — just echo and a shell. Any agent that
can write to a file can participate.
Decision
Agent → host reverse channel = a per-run JSONL file at
$RUNEX_INBOX_PATH. No FIFO, no TCP socket, no RPC SDK. The agent
writes lines; the engine drains them; each line becomes one
InboxMessage graph node.
Alternatives
方案 A — OpenOPC-style FIFO/TCP RPC (rejected for v1)
Env vars OPC_COLLAB_RPC_TRANSPORT=fifo,
OPC_COLLAB_RPC_PATH=/path/to/fifo, OPC_COLLAB_RPC_TOKEN=…. Agent
calls opc-collab chat.abort --run-id … via the RPC. Host receives
the RPC, validates the token, and acts.
Pros:
- Real bidirectional channels
- Auth tokens, structured errors, back-pressure
- Matches OpenOPC exactly — agent SDKs written for OpenOPC work unmodified
Cons (the reason rejected for v1):
- Every agent process needs to know the RPC URL — can’t be
claude -c, has to be a custom SDK wrapping the CLI - FIFO setup requires
mkfifo+ non-blocking open + careful cleanup; TCP needs port allocation + auth - Engine needs to fork an RPC server alongside the agent process; one more thing to crash
- For 5 v1 use cases, the RPC adds 500+ lines of code in the engine and ~50 lines per agent SDK. JSONL is ~80 lines total.
方案 B — HTTP localhost server (rejected)
Engine spawns an HTTP server on 127.0.0.1:<random_port>, injects the
URL via RUNEX_INBOX_URL. Agent POSTs JSON.
Pros:
- Standard protocol, easy to debug (curl)
- Auth via header token
Cons:
- Port allocation, race conditions on port reuse, firewall quirks
- Agent needs
curlor any HTTP client —echono longer works - Same infrastructure overhead as RPC, without FIFO’s file-as-queue advantage
方案 C — Chosen: JSONL file (ADR-0016)
Env var RUNEX_INBOX_PATH=/Users/.../runex/inbox/<run_key>.jsonl. Agent
appends JSON lines. Engine spawns a daemon thread that tails the file
(byte offset tracked) and emits one InboxMessage per line.
Pros (the reason chosen):
- Zero new infrastructure —
echo >> fileis in POSIX - Crash-safe — half-written lines are skipped by parser; full lines survive agent kills (WAL-mode SQLite + atomic appends are reliable enough)
- Agent needs zero SDK — bash
echo, Pythonprint(..., file=open(p,'a')), any tool that can write a file works - Observable — user can
tail -f "$RUNEX_INBOX_PATH"while the agent runs and see exactly what it’s pushing - Replayable — drain can re-read the file (idempotent msg_key); if the engine restarts mid-run, no message is lost
- Capacity — append-only JSONL is unbounded; a 1GB inbox is the same as a 1KB one to the engine (linear scan on drain)
- Testable —
echo '{"type":"progress","text":"x"}' >> /tmp/test.jsonlin a test fixture; no socket mocking
Cons:
- One file per run (not multiplexed) — fine because agents are one-per-work-item
- No back-pressure (agent can outpace engine’s drain) — fine for v1 because emit rate << drain rate
- No auth (any process with the path can write) — fine because the
path lives under
$RUNEX_HOME/inbox/with user-only permissions, and the file name is the run_key (random / UUID-ish) - No RPC for sub-task abort / progress on OTHER runs — cross-run
communication not supported. Use
tx_logfor that.
Consequences
What becomes easier
- Agent SDK = 0 lines.
claude -c "…"shells out to a subprocess that canecho. No need to teachclaudeabout our RPC. - Agent debugging =
tail -f. When something goes wrong, the user runstail -f "$RUNEX_INBOX_PATH"and sees the agent’s stream in real-time. Sametail -fthey’d use on any log file. - Engine code = ~80 lines. A daemon thread that opens the file,
tracks
f.tell(), reads new lines, parses JSON, and callsemit_inbox_message(). That’s it. - Replay. Re-reading the file produces the same
InboxMessagenodes (msg_key =<run_key>-inbox-<seq>is idempotent). If we add arunex inbox replay <run_key>command, we can re-emit from archived JSONL files.
What becomes harder
- Bidirectional RPC — if an agent needs request/reply semantics
(e.g. “host, please give me X”), JSONL can’t do that. v2 may add
a parallel
$RUNEX_RPC_URLfor the rare cases that need it. Today, the workaround is: agent writes{"type":"clarification","q":"…"}, the user (or a reactive rule) sees it in inbox, acts, and the next dispatch sees the new state. Async, not in-process. - Streaming-progress to the user — the InboxMessage appears in the
graph; the UI is
runex inbox list --unread. Same as email. No fancy push. Fine for v1; if the user wants a richer feed, v2 surface area is clear. - Agent-side auth — currently a malicious agent could write
forged
doneevents claiming success. v2 could add a per-message HMAC. Not done because v1 threat model assumes the agent is trusted (it’s a CLI binary the user installed).
When we would revisit
Documented trigger conditions — if any of these becomes true, this ADR is reopened:
- An agent use case requires in-process request/reply (e.g. agent spawns a sub-agent and waits for its result without exiting). Today the workflow is “sub-agent writes to its own inbox; parent watches via reactive rule” — workable, but if the latency matters, RPC.
- An agent needs back-pressure (it’s writing inbox faster than the engine drains). Today observed rates are ~1 msg/sec at most; engine drains at >100 msg/sec. Not a problem.
- Cross-run or cross-machine inbox becomes a requirement. Today inbox is per-run, single-host. If multi-host needed, RPC or a shared message queue is the right tool.
See also
- ADR-0012 (Agent dispatch via DSL primitive: agents-as-graph-nodes, same architectural stance — agents are first-class nodes, not external processes)
- OpenOPC source (
/Users/huanghe/repos-cloned/OpenOPC/opc/layer3_agent/external_broker.py:630) — the FIFO/TCP RPC implementation we deliberately did not copy src/runex/agent_inbox.py— theemit_inbox_message()/list_inbox_messages()helpers that implement the engine sidesrc/runex/worker/subprocess_agent.py:_drain_inbox()— the daemon thread that does the byte-offset-tracked tail