A reply-target ambiguity is what happens when an agent finishes composing a reply while more than one conversation is waiting on it, and nothing recorded which one it meant. WorldOS runs what we call a switchboard: a single messaging bot serving many conversation threads at once, with one orchestrator agent subscribed to all of them — a direct line, several group topics, a handful of worker lanes checking in with status. Every inbound message, regardless of which thread it came from, takes the same four-hop path to reach that agent.
- 1Daemon polls the chat platform for new messages.
- 2Each message becomes a structured envelope: a stable id, its source channel, its text.
- 3A bridge looks the envelope's channel up in a routes table and hands it to the right agent session.
- 4An in-session channel plugin delivers the envelope and exposes a reply tool back to that channel.
Normally that's the whole story: one message in, one reply out. The trouble starts when two channels don't wait their turn.
02The moment it breaks
Picture the orchestrator mid-turn. It's already composing a reply to a message from Chat A. It hasn't finished — hasn't called its reply tool yet — when Chat B escalates a message of its own. Both messages are now in-flight: delivered to the agent, not yet acknowledged, sitting inside the same turn.
When the agent finally calls reply(), it passes back text — but nothing that says which of the two incoming messages it's answering. From the resolver's point of view, both are equally plausible.
03Why "most recent" is a trap
The obvious fallback — the one a lot of systems reach for — is to bind the reply to whichever message arrived last. It works right up until it doesn't.
Two things go wrong at once. First, the reply is misrouted: it lands in Chat B when it was actually about Chat A. Second, and sharper, the resolver marks Chat A's original message as handled and removes it from the inbox queue. Nobody sees an error. There's no crash, no retry, no log line demanding attention — the message is just gone, and whoever is waiting on Chat A never gets an answer.
04The fix: fail closed
The rule we landed on: teach the reply resolver to refuse rather than guess.
Backing it is an explicit ack ledger. Every envelope delivered to the agent is recorded with a trace id and a pending status. An envelope only leaves the inbox once its delivery is proven and acknowledged — never on a guess, never on a timeout.
| Trace id | Channel | Status |
|---|---|---|
| env_7f2a1c | Chat A | pending |
| env_9b3e40 | Chat B | pending |
| env_5d10aa | Worker lane | acked |
Trace ids and message counts above are illustrative examples, not real values.
When reply() is called, the resolver checks how many envelopes are currently pending for that agent:
- Exactly one pending — auto-resolve. No ambiguity is possible, so nothing stands in the way.
- Zero pending — reject. There's nothing left to reply to.
- Two or more pending — refuse the call outright and hand the agent the full list of in-flight candidate trace ids, forcing it to name the one it means.
In practice the agent almost never notices this rule exists — most turns really do have exactly one pending message, and resolution is instant. The rule only bites in the rare, genuinely ambiguous window. And when it bites, it bites loudly: an explicit error naming the candidates, never a silent wrong answer.
05The principle
When unsure where a reply goes, refuse loudly rather than misroute silently.
Fail closed is a boring name for an unglamorous decision, but it's the difference between a system that occasionally asks a clarifying question and one that occasionally lies with confidence. We'd rather the agent stall for a trace id than send the right words into the wrong room.
This is what building in public looks like day to day — not a shipped feature so much as a shipped invariant. The routing table didn't change. The daemon didn't change. What changed is one narrow rule at the handoff between "agent decides to reply" and "system decides where that reply goes" — and now an entire class of concurrency bug can't happen anymore.
