# Agents drive tuios through one-shot commands, so the event bus could not reach them

URL: https://tuios.gaurav.zip/blog/a-bus-is-not-a-mailbox

> A hub only reaches whoever is subscribed right now, and agents almost never are. How tuios got a mailbox, ask-agent and an inbox for the person.

In July tuios got an event stream. `subscribe` holds a connection open and the
daemon pushes events down it: output, window exits, idle panes. `wait-for`
sits on top of it and blocks until a condition matches, so a script stops
polling `capture-pane` in a loop. In August the stream gained an `agent-state`
event, and `wait-for --until needs_input` could finally say "tell me when an
agent wants me". I checked that one against a real copilot pane: the wait
returned when the pane painted its folder-trust dialog.

So when the next question came up, how agents in different panes should talk
to each other, the obvious answer was already in the daemon. Publish a message
on the hub and let the other agent receive it.

That answer does not work, and the reason is in how the hub is built.

## A bus delivers to whoever is listening now

The hub delivers an event to the connections that are subscribed at the moment
it is published. It keeps no backfill. A slow subscriber gets its dropped
events reported as a gap marker, so one slow reader cannot stall the daemon,
but a connection that was not subscribed at all gets nothing, and nothing
remembers that it missed anything.

That is the right design for a client drawing panes, which is connected the
whole time. It is the wrong design for an agent. An agent does not hold a
connection to tuios. It runs a command, reads the output, and the command
exits:

```bash
tuios list-agents
tuios send-agent-message -w review 'rebased onto main, please retest'
```

Each of those is a new process that connects, does one thing and goes away.
Between calls the agent is not subscribed to anything. It is thinking, or
editing a file, or waiting for the model. If agent A published a message
while agent B was between calls, which is almost always, B would never see it.

*[An interactive figure goes here. Open the page to use it.](https://tuios.gaurav.zip/blog/a-bus-is-not-a-mailbox)*

So the one thing missing was store-and-forward. The commit that added it says
so in its first paragraph, and it deliberately adds only that. Delivery to live
subscribers, filtering and blocking reads stay with the hub. What is new is a
per-session ring the daemon keeps messages in until someone reads them, and
four verbs over it: `list-agents`, `send-agent-message`,
`read-agent-messages` and `ask-agent`. It landed on 2026-08-23 as about 1,300
lines of code and 480 of tests.

The rest of this post is the decisions inside that ring, because each one has
a reason, and a few of them I got wrong first.

## The address is the pane

The first question in any messaging system is what an address is. I did not
want to invent a second namespace of agent names, because then there has to be
a rule for when an agent name and a window name disagree, and every such rule
is a bug report waiting to happen.

Every tuios verb already takes a window target: an id, an id prefix, an index,
or an exact name. So `-w` on `send-agent-message` takes the same thing. An
agent finds out its own address from `$TUIOS_PANE_ID`, which every pane has,
and finds the others with `list-agents`, whose ID and NAME columns are exactly
what `-w` accepts.

Using the pane as the address has one consequence I wanted on purpose: an inbox
dies with its window. The inbox belongs to the window's id, not to its name. If
you close pane `B` and open a new pane called `B`, the new one starts with an
empty inbox, and the old mail reads back as `undeliverable: the recipient
window is gone`. The alternative is handing an instruction written for one
agent to whatever process takes the name next, and that process has none of
the context the instruction assumed.

The same reasoning keeps the ring off disk. It hangs off the daemon, not off
the saved session state. A restored session comes back with new shells, so a
queued instruction would be addressed to an agent that no longer exists.

## Every queue gets a bound

An unread queue with no bound is a memory leak with a friendly name. That line
is in the source as a comment above the constants, and each constant has its
reason next to it:

| Bound            | Value                                       | Why                                                                           |
| ---------------- | ------------------------------------------- | ----------------------------------------------------------------------------- |
| One message body | 8 KiB                                       | Bigger than a paragraph, smaller than a file. A file goes as an attachment.   |
| Subject          | 120 characters                              | The one line a reader scans.                                                  |
| Attachments      | 8 per message                               | They are paths, never bytes.                                                  |
| The ring         | 256 messages or 512 KiB per session         | Whichever comes first. Evictions are counted and reported.                    |
| Sending          | a burst of 10, then 30 a minute, per sender | Hitting it almost always means two agents are answering each other in a loop. |

The byte cap matters more than it looks. A ring of messages at the 8 KiB limit
fills 512 KiB after 64 of them, so the count cap alone would allow four times
as much memory as the design meant.

Attachments are references because copying is the expensive part. A megabyte
image sitting in an in-memory ring that nobody reads is the same unbounded
growth with a bigger constant. kitty's graphics protocol reached the same
conclusion, which is why its file and shared-memory transmission modes pass a
path instead of pixels.

The rate cap is easy to hit on purpose. On a scratch daemon, one sender got ten
messages in back to back and the eleventh came back like this:

*[An interactive figure goes here. Open the page to use it.](https://tuios.gaurav.zip/blog/a-bus-is-not-a-mailbox)*

## ask-agent: the half that works with agents that exist

Here is the uncomfortable fact about a mailbox for agents. None of the agent
harnesses that exist today reads one. All of them read their keyboard.

So a mailbox alone only works for an agent that has been told, in its prompt,
to check its mail. That is useful, and the skill that `tuios --skill` prints
now tells agents how to stay reachable. But the half that works with every
agent as it is today has to go through the keyboard.

`ask-agent` is that half. It is a composition that is easy to get wrong by
hand:

1. Wait until the target is not mid-turn. Typing at an agent that is working
   interleaves your text with whatever it is doing. The default wait is 30
   seconds; after that the call fails with `not_ready` and types nothing.
2. Take a baseline of the pane, type the question, press Enter.
3. Wait until the target has dealt with it, then answer with everything the
   pane printed after the baseline.

Step three is the hard one. The reliable signal is the target's
agent state coming back to rest, stamped after the question was sent. A state
report from before the question says nothing about the question, and returning
on one was the exact bug that check guards. A pane that reports no state falls
back to going quiet for two seconds. The answer says which one ended the wait,
in `settled_by`, so the caller knows whether it got a signal or a guess.

The first version had a hole I found the same day. Both waits listened for the
target window closing, and neither listened for the whole session going away.
Kill a session in the middle of an ask and the caller sat there for the full
five minute timeout, waiting for an answer that could not arrive. The fix was
small: each wait now also fails when the session closes. The lesson was that "the target went away" has two shapes
and I had written down one.

Another fix that day came from running the CLI rather than reading it. The
generic hint renderer turned a refusal into "call the X verb to see valid
targets", which is nonsense for a refusal about readiness. The caller is not
looking for a target. The hints now name the exact command to run instead.

### It refuses to close a cycle

Messaging between agents invents one failure mode that did not exist before:
two agents that hold each other's address. If A asks B, and B, while working
on A's question, asks A, both are blocked on each other until the timeouts
run out.

The daemon keeps a small graph of asks in flight, one edge per blocked
`ask-agent` call. Before it adds an edge it checks whether the target can
already reach the caller along existing edges. If it can, the ask is refused
before anything is typed:

```go
if !d.agents.openAsk(from, target.ID) {
    // "this ask would close a loop with one already in flight"
}
defer d.agents.closeAsk(from, target.ID)
```

It is a plain breadth-first walk, because the graph only holds agents that are
blocked right now, so it is tiny. The test covers the direct cycle, a three
hop one, and that releasing an edge opens the path again. This is what it
looks like against a real daemon, with A holding an ask open on B and B asking
A back:

*[An interactive figure goes here. Open the page to use it.](https://tuios.gaurav.zip/blog/a-bus-is-not-a-mailbox)*

The graph is keyed on the window each caller claims with `--from`, so it is
exactly as trustworthy as that claim. Leave out `--from` and there is no
loop detection. That is enough for what it is for: it stops an orchestrator
that wired A to B to A by mistake. It does not pretend to stop a caller that
lies.

## One agent's output is another agent's input

This is the part I think about most. An agent reads its own terminal to see
what a command returned. When that command is `read-agent-messages`, the
output contains text another agent wrote. The reader has no way to tell a line
tuios printed from a line another program asked tuios to print.

That is prompt injection, and the place to deal with it is the output format.
Every body the CLI prints is fenced, named with its sender, and labelled as
data:

*[An interactive figure goes here. Open the page to use it.](https://tuios.gaurav.zip/blog/a-bus-is-not-a-mailbox)*

The `ask-agent` reply is fenced the same way, because it is also another
program's output. The JSON form carries a constant field, `"untrusted": true`,
on every read. A constant field looks redundant, and that is deliberate: a
consumer that keys on it is right every time, and one that never read the
skill runs into it in the shape of the answer.

The fence is not a defence against a determined attacker. It is a label in the
one place every agent will look, which is what an agent can actually act on.

## Try it

The sandbox below is a model of the daemon's rules, not a live daemon. Three
agent panes and the person's inbox share one ring. The send budget, the
eviction, the thread ids, the cycle check and the refusal texts follow the
source. The ring starts at six entries so you can watch it evict.

*[An interactive figure goes here. Open the page to use it.](https://tuios.gaurav.zip/blog/a-bus-is-not-a-mailbox)*

Things to try: send eleven messages from A in a row; ask B from A and then
try to ask A from B; reply to a message after the ring has dropped it; close C
and open a new C; flip the fence off and read B's inbox with the injection
preset in it.

## Threads are resolved when a message is stored

Five days later, `--reply-to` arrived, because a message marked read only means
it was handed over. It does not mean the other agent understood it or did
anything about it. A reply is the only acknowledgement that means anything.

Every message carries a `thread_id`: the id of the message the thread started
from. The obvious implementation walks `reply_to` links back to the root when
someone reads. That breaks because the ring is bounded. The moment the root
ages out, the walk stops finding it, and the same conversation would answer
to two different ids depending on when it was read.

So the thread is resolved once, when the message is stored. Three cases:

- No `reply_to`: the message starts its own thread, and its thread is its id.
- The parent is in the ring: the reply takes the parent's thread, so a reply
  to a reply lands where the first reply did.
- The parent has been evicted: the thread is the parent's own id, the one
  stable name left for it. The reply is stored anyway, with
  `reply_to_missing` set, and the reader sees "the message this answers has
  been dropped from the ring".

Refusing that last reply would be wrong. The sender cannot know the ring moved
on, and the reply is still the answer. An id past the last one ever issued is
different. That is a typo, not the ring forgetting, and it is refused:
`reply_to names a message that has never existed`.

`ask-agent` did not change. Its reply is what the pane printed, not mail, and
making its wait mean "a message answering my ask" would look precise while
breaking it against every harness that exists, since none of them send mail.

## Paths go bad, so the session holds a copy

Attachments as paths had a known cost, and the first version said so in the
output: a reader that comes late may find the file gone and sees `MISSING:
the sender's file is gone`. In practice every agent that wanted to hand over
a file invented its own `/tmp` convention to work around it.

The stash, two days after that, is the other half of the same split.
`tuios stash put <file>` copies the file into a directory the daemon owns and
prints the stored path, ready for `--attach`:

```bash
path=$(tuios stash put /tmp/flame.png)
tuios send-agent-message -w review --attach "$path" 'the hot path is in decode'
```

The decisions, briefly. The lifetime is the session's: the files go on
`kill-session`, on daemon shutdown, and again on the next start, which covers
a daemon that was killed rather than stopped. The directory is always next to
the socket, never in the home directory, because a home directory fallback
would make the files permanent. The store is addressed by the sha256 of the
content, so two agents stashing the same file share one copy, and the daemon
builds the stored name itself so a source file's punctuation never reaches a
path. The caps are 16 MB a file and 256 MB a session. Past the session cap the
oldest file goes first, but never one a message still in the ring points to.
When only referenced files are left, the put is refused rather than breaking a
message that still reads.

The stash is also how a file crosses machines. When mail started travelling
between tuios daemons over a link, a path on one host meant nothing on the
other, so `stash put` takes the bytes, `stash get` hands them back, both
capped at 8 MB there, and a message from another machine is refused any
attachment that is not stashed.

## The person could see none of it

By early September, agents could find each other, leave messages, ask
questions and wait for replies. The person sitting in tuios could see none of
it. The mailbox had no address for them, nothing pushed to the attached
client, and nothing drew it.

On 2026-09-09 that changed. `human` is a reserved inbox. An agent that needs
a decision writes to it and waits on its own inbox for the reply:

```bash
tuios send-agent-message -w human --from "$TUIOS_PANE_ID" \
  --subject 'which retry policy?' 'exponential or fixed? both pass the suite'
tuios wait-for agent-message -w "$TUIOS_PANE_ID"
```

The daemon now pushes every stored message to the session's attached clients,
and the client keeps a mirror of the ring, fed by the push and read once per
attach, so an idle client does no work for mail. Mail to the person raises the
same alerts as an agent in `needs_input`. The mail overlay opens on
Ctrl+B M, from the palette, or with
i on a rail row, lists threads, and sends a reply from `human`
threaded on the newest message.

`ask-agent -w human` is refused with `no_keyboard`, because there is no pane
behind that inbox to type into. And a finished `ask-agent` now leaves a record
of kind `ask` in the ring, so an exchange that happened entirely between two
agents' keyboards shows up in the person's view too. Before that, it was
visible to nobody.

## One prompt, several worktrees

The same day added the case where the person is the one talking to many
agents at once. `tuios fan` creates N git worktrees, a session in each, starts
the named agent in each, and types the same prompt into every one:

```bash
tuios fan 3 --agent claude 'Add a retry with backoff to the HTTP client.'
tuios worktree ls --group fan/add-retry-backoff-http
tuios fan keep api-fan-add-retry-backoff-http-2
```

It does not type the prompt when the agent starts. It waits until each agent
is at rest, the same idea `ask-agent` uses, so the prompt never lands in a
start-up screen. But the two verbs disagree about one state, and on purpose.

For `ask-agent`, `needs_input` counts as rest. An agent waiting for input is at
its prompt and can be told something. For `fan`, `needs_input` is not rest. A
freshly started agent in a new directory can ask whether to trust the
folder, and that question belongs to the person. Typing a coding prompt into a
trust dialog would answer it for them. So the prompt waits until the person has
answered, and `worktree ls --group` shows each prompt as `pending`, `sent` or
`not_sent`. The rail groups the worktree sessions under their repository, so
three agents on one task read as one row that opens.

## What I keep from this

I started with an event hub that worked and a feature request that sounded
like "publish on the hub". The mismatch was not in the hub. It was in who was
listening, and an agent that drives tuios through one-shot commands is almost
never listening.

Most of what followed came from asking who owns each thing once the sender has
gone. The address belongs to the window, so it dies with the window. The
message belongs to the ring, so the ring needs a bound. The file belongs to
the sender, so the session needs its own copy. The thread belongs to the
moment of storing, because the ring will forget the root. And the whole
conversation belonged to nobody the person could see, until it had an inbox
of its own.

The reference is in the [agent messaging](https://tuios.gaurav.zip/docs/agent-messaging) and
[worktrees](https://tuios.gaurav.zip/docs/worktrees) docs, and agents get the same material from
`tuios --skill`.
