·6 min read·architecture · agents · reliability

A running terminal is not a receipt

We wired inbound messages to coding agents by typing into a terminal. It worked immediately, which was the problem. Here's the transport architecture that replaced it, and the three receipts that never imply each other.

Contents

Every automated system eventually tells you it did something it did not do.

The cron job exits zero and processes nothing. The webhook returns 200 into a handler that threw. The deploy script prints SUCCESS against the wrong cluster. None of these are bugs exactly. They are all the same category error: something observable was treated as proof of something else that was never observed.

We hit our own version of this while wiring inbound messages to coding agents, and the fix turned out to be more interesting than the bug.

#The shim that works on the first try

If you want to send a message to an agent running in a terminal, there is an obvious move: type it in. tmux send-keys will do it. It works immediately, which is the problem.

send-keys injects terminal bytes. It does not call any conversation API. Its correctness depends on focus, pane identity, terminal mode, prompt state, timing, paste behavior, and the absence of an intervening screen. It will look successful while typing into the wrong surface, or while the agent is mid-task and not reading input at all.

The tell is that nothing in that list is about your message. They are all facts about a terminal. You wanted to know whether the agent received something. What you measured was whether a pane existed.

Terminals are a fine place to watch an agent. They are a bad place to talk to one.

#Both vendors already shipped the right door

The genuinely useful discovery is that we did not need to build the correct path. It was already there, in both runtimes, and we had walked past it.

Claude Code exposes a channel notification protocol. A channel server emits notifications/claude/channel and the running session takes that message as channel input, with its own metadata and reply tooling. Native injection, no terminal automation.

Codex exposes an app-server: a JSON-RPC surface with thread and turn methods, built for people writing rich Codex clients. A TUI can attach to it. So can a headless consumer. Same thread, same API.

Neither vendor hid this. We reached for the shim because the shim worked in ten minutes and reading the app-server docs took an afternoon. That is not a criticism of anyone's documentation. It is the ordinary economics of getting something running.

#One envelope, two projections

The architecture that came out of it is smaller than the problem suggested.

There is one normalized message, a ChannelEnvelope. It is runtime-neutral and it is the only thing the transport layer knows about. Each runtime then owns exactly one job: project that same envelope into its native conversation.

authenticated seat mailbox
  -> one leased consumer
  -> normalized ChannelEnvelope
  -> harness projection
       Claude: notifications/claude/channel
       Codex:  thread/inject_items (agent_message) + empty turn/start
  -> harness-owned conversation
  -> reply and delivery receipt

Normalize once, project per runtime. The transport layer never learns anything runtime-specific, and the runtime adapters never learn anything about routing, authentication, or delivery policy. When a third runtime shows up, it writes one projection and touches nothing else.

#Two calls, and the second one is the interesting one

The Codex projection is deliberately two calls, and flattening them into one is the easiest way to misread this design.

thread/inject_items puts the envelope into the thread as a Responses API agent_message. That is context. It sits in the rollout. Nothing is running.

Then an empty turn/start wakes the thread.

The second call carries no content. It is not a no-op and it is not a formality. It is the difference between the message is present and the model has been asked to act on it, and those are genuinely different states of the world. Most systems never separate them, which is why most systems cannot tell you which one they achieved.

#Why an agent message

A fair question: why is an inbound message injected as an agent_message rather than as user text?

Because it is not the user talking. It is context arriving from elsewhere in the system, and forging a user turn to deliver it means the transcript now contains a sentence the user never said. Every later read of that thread, by a human or a model, inherits the fiction. The vendor's item model already has a category for "something the system is putting in front of you," so we use it.

#Three separate receipts, and none of them implies another

This is the part worth stealing even if you never touch either runtime.

transport receipt   the authenticated envelope exists in the seat mailbox
acceptance receipt  that exact envelope is persisted in the thread's rollout
outcome receipt     the turn reached terminal state, and the seat replied

Three separate facts. Three separate artifacts. None of them may be inferred from any other.

The failure modes live in the gaps. A message can sit committed in a mailbox nobody drains. It can land in a thread that never wakes. A turn can start and die. A turn can complete having done nothing you wanted. Every one of those is invisible if you only check the stage on either side of it.

And when a call's network outcome is unknown, the answer is not to retry. It is to go read the thread and find out which of the three you actually achieved. A blind replay in that state is how one message becomes two.

#Your heartbeats have to ride the same rail

The unglamorous rule, and the one we would defend hardest.

Health checks must travel the exact path that real traffic travels. If your monitoring has its own delivery route, you are monitoring a route nobody uses, and it will keep reporting green across an outage in the path that matters.

Our scheduled heartbeat seats enter through the same mailbox and the same native consumer as everything else. This is not elegance. It means the boring traffic continuously exercises the seam that the important traffic depends on, and a break shows up in the cheap thing first.

If you have ever had a synthetic check pass while the real path was broken, you have already paid for this lesson.

#Pin the version, and mind what is experimental

Two honest limits, because an architecture post that only lists strengths is an advertisement.

The WebSocket transport used by codex --remote is documented as experimental in the current Codex app-server documentation. That is the vendor's word, not ours. Anything production-shaped built on it should pin a known Codex version, probe protocol compatibility at boot, fail closed on schema drift, and keep the mailbox item for retry. We do. You should assume the method names in this post have a shelf life; the three-receipt model does not depend on them.

And what we have proven is bounded. A contained sandbox, isolated CODEX_HOME, localhost app-server, real attached TUI: two agent_message injections persisted and rendered, including one after an app-server restart and same-thread resume, both woken with an empty turn/start, with tmuxUsed: false on the receipt. That is an acceptance gate. It is not a claim about how this behaves under load, across versions, or over months.

We are describing an architecture we believe in and a test we ran. We are not telling you our messages always arrive.