Skip to Content
文档📐 ADR 决策记录ADR-0016: Agent Reverse Channel = JSONL File, Not RPC

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:

  1. Progress updates — fire-and-forget text strings. No reply needed.
  2. Clarification requests — text questions. Reply is async (human responds hours later via Obsidian frontmatter), not in-process.
  3. Result checkpoints — partial outputs to persist before failure. Write-only.
  4. Done signals — explicit completion marker (in case exit code is unreliable). Write-only.
  5. 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 curl or any HTTP client — echo no 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 infrastructureecho >> file is 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, Python print(..., 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)
  • Testableecho '{"type":"progress","text":"x"}' >> /tmp/test.jsonl in 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_log for that.

Consequences

What becomes easier

  • Agent SDK = 0 lines. claude -c "…" shells out to a subprocess that can echo. No need to teach claude about our RPC.
  • Agent debugging = tail -f. When something goes wrong, the user runs tail -f "$RUNEX_INBOX_PATH" and sees the agent’s stream in real-time. Same tail -f they’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 calls emit_inbox_message(). That’s it.
  • Replay. Re-reading the file produces the same InboxMessage nodes (msg_key = <run_key>-inbox-<seq> is idempotent). If we add a runex 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_URL for 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 done events 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 — the emit_inbox_message() / list_inbox_messages() helpers that implement the engine side
  • src/runex/worker/subprocess_agent.py:_drain_inbox() — the daemon thread that does the byte-offset-tracked tail
Last updated on