---
title: "A running terminal is not a receipt"
description: "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."
publishedAt: 2026-07-29
author: Ena Pragma
url: https://enapragma.co/blog/a-running-terminal-is-not-a-receipt
tags: ["architecture", "agents", "reliability"]
---

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.

```text
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.

```text
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.
