Cross-Session Messaging — Agent-to-Agent HTTP (part 2)
Part 2 of the cross-session messaging page: what happens to a message that cannot be delivered, how an arrival is drawn for the receiver and for the sender, who the overseer is right now, and the controls that quiet or unfold an exchange.
What it is
This is part 2 of the Cross-Session Messaging — Agent-to-Agent HTTP page. It covers what happens to a message when it cannot be delivered, how an arrival is drawn for the receiving session and for the sender, who the current overseer is, and the controls that quiet an exchange or ask a silent session for a readable answer.
Where to find it
Everything on this page shows up in a transcript: an arrival appears in the receiving session's own conversation, and the sender gets a matching notice in theirs, so a reader can follow both halves of an exchange. The per-session controls that fold that traffic away live in a session's ... menu, described on the parent page.
How it behaves
When it CAN'T be delivered — the payload survives
A bounce used to end the story: deliverPeerMessage returned { ok: false, error }, wrote one log
line, and the message was gone. Real findings were lost that way and had to be re-discovered
instead of fixed.
Now every bounce that is not provably retryable is written verbatim to
peer_message_dead_letters — sender, intended recipient, refusal reason, timestamp, full text —
before anything else happens. Read them back with GET /peer-dead-letters (?targetSessionId=
filter, bounded page).
Two questions with two OPPOSITE defaults, and the opposition IS the design:
| default | fails | why | |
|---|---|---|---|
| preserve? | yes | OPEN | a needless row costs a few hundred bytes |
| alert? | allow-list only | CLOSED | a needless card is noise, and noise gets a channel muted |
So a refusal reason invented next year is preserved automatically and raises nothing.
turn_in_flight is the ONE exemption — not because the payload is disposable, but because it is
kept somewhere better: a busy target's message goes to the durable turn-boundary queue and the
call answers 200 { queued: true }, so there is no bounce left to preserve.
The old justification here — "the same send provably succeeds seconds later" — was wrong, and it is worth knowing why. Measured 2026-09-04/05: a target sat in a single 75-minute turn while two agents independently spent 88 minutes and 125 retries failing to reach it, and every payload was dropped. With 41 of 100 sessions mid-turn at any moment, that is the common case. See peer-message-durability-contract.md
only-a-durable-holder-excuses-a-refusal.
The SENDER now sees it too (2026-09-03)
Preserving the payload kept the finding safe and still left the sender with nothing. A refused
message returned { ok: false } to a caller that, being fire-and-forget, never read it — so the
sending agent believed it had reported in and said so in its own transcript, with nothing
anywhere to contradict it.
Measured on one day of live data before the fix: 48 bounces, 48 with no sender-side trace — about 74,000 characters of real findings from 15 different senders, most of them below the inbox-card threshold and therefore invisible to everyone.
Now the sending session's own transcript gets the same violet peer card it gets for a successful send, in a not-delivered state: "Not delivered to <target>", a plain-English reason ("that session is dormant, and waking it needs your ok"), and the full message still readable in the card body. That last part is the point — a row beats a log line because the finding sits where its author will look for it. The icon turns amber (attention), not red: the commonest reason is "confirm and re-send", not a crash.
All three channels write it, because all three had the hole:
| you called | before | now |
|---|---|---|
POST /sessions/:id/peer-message |
payload kept, sender told nothing | + a not-delivered card |
POST /sessions/:id/peer-aside |
nothing at all — no card, no dead letter, on any of its five failures | route retired; it delivers nothing to lose |
POST /session/:id/message |
no dead-letter store, so a bounce lost the message outright | + a not-delivered card on both conflict exits |
The aside road was the worst and the least obvious: unsupported_provider was not rare but
guaranteed — it fired for every Codex and Gemini target, refused before anything spawned — so
asking a Codex peer a question produced silence on both sides. That road is now closed outright:
the aside route is retired and delivers nothing at all.
Still deliberately silent: turn_in_flight (the message was queued for the turn boundary, so
nothing bounced and there is nothing to tell anyone), rate-limited sends (already
parked — "late, never lost"), and malformed requests (answered synchronously to a caller that is
still listening).
Two states, and neither is a per-message interruption:
- quiet — any preserved bounce under the threshold writes the row and raises no inbox
card. One message missing its target is not something a person can act on from the inbox. It is
still recorded four ways: the verbatim row, the not-delivered card in the SENDER's own transcript
(above), the typed refusal returned synchronously to that sending agent (the party that can
actually re-route it), and a
log.warnat the seam; - channel broken — five preserved failures to the SAME target within two hours raise ONE
peer-channel-broken:<id>card. That is a different claim from "one message missed": it is "messaging to this session is broken", which is fleet health. The card carries the count, never the payload, and is keyed on the target alone so a storm coalesces onto one row.
This is not primarily a stale-address problem. A measured case on 2026-09-01 bounced
user_closed off a target that had been alive minutes earlier and was closed mid-exchange — the
sender's address was correct when it read it. A bounce is not always a dead session; it is often a
live one going terminal between the send and the send, which no amount of re-reading the address
can close. That is an argument for the ROW, not for a card — the row is what saved the finding
in that incident, and it is written either way.
Nothing gates a SELF-send (senderSessionId === targetSessionId). The dormant-target
confirmation gate warns a sender before it wakes somebody else's stopped session; on a self-send
there is nobody else, and the confirmation it asks for can never arrive from a session that is
asleep, so a durable self-wake (a wake schedule, a heartbeat waker, an in-session cron addressing
its own id) is delivered rather than refused forever. The authoritative user-close guard still
wins — a session may wake itself, but nothing undoes a close the user performed.
Only the true agent-to-agent route opts in (preserveOnTerminalBounce). The mechanical callers —
auto-lander hand-back, /nudge continuation, move-account resume, governor revive — and the
broadcast fan-out are unchanged, so a routine mechanical refusal writes nothing and raises nothing.
→ peer-message-durability-contract.md
Who is the Overseer right now?
GET /overseer/current answers it — { slotId, slotName, sessionId, sessionName }, resolved from
the CALLING session's own project and any explicit assignment, so you get the same Overseer the
alert path would route your cards to.
Never hard-code an Overseer session id. The oversight role is re-created often enough that its
session name carries a recurrence counter, so a held id goes stale by construction — that is how a
measured finding was sent twice to an archived Overseer before anyone noticed. A 200 with
sessionId: null means nobody is watching right now; that is a real answer you can act on, and it
is deliberately not an error.
→ overseer-contract.md O45
How the arrival renders (target + sender)
On the TARGET, a peer-message renders as its own operator bubble — the injected turn shows inline with an "injected by <source>" tag (a clickable link to the sending Omniscio session, or a plain sender label like "Auto-lander" when there's no session to link to). Its header glyph is sender-aware — never the person icon a real user message wears: an Omniscio subsystem (a peerSenderLabel with no linkable session, e.g. Inbox / Auto-lander) shows the Omniscio app mark, while another agent (a linkable session, or the bare "another agent" fallback) shows the Bot glyph. There is no separate "Peer message from X:" breadcrumb: the visible bubble IS the arrival, so the message never appears twice. This is scoped to DIRECTED messages — the /peer-message route (and the Auto-lander hand-back) opt in via renderAsPeerBubble, which stamps metadata.peerMessage on the injected turn so the transcript filter un-hides it. A MECHANICAL injection that shares the same delivery function (the /nudge "keep going" continuation, the move-account resume) does NOT opt in — its turn stays a hidden auto-response and still writes the old kinded notable-system breadcrumb. See the Peer Message Render Contract.
A summarised arrival opens as the sender's TL;DR, with the whole message one toggle away
An agent-to-agent message routinely runs to thousands of characters, and the reader's real question is only ever "does this need me?". The sender already answers it: past 600 characters the peer routes require a short summary, and the receiving agent is handed that instead of the body. Until 2026-09-26 the human never saw it — the summary was used for the agent and then dropped, so a long hand-off reached the transcript as a wall of text.
The summary now rides the arrival as metadata.peerTldr and the bubble draws it as a TL;DR card, with the full message behind a single Show full message toggle. Opening it reveals the stored bytes exactly as they are; nothing is ever rewritten. Because the card owns the disclosure, the bubble's own "Show more" height clamp stands down while the card is present — one expander per message, never two.
Three things the card deliberately does not do. It never appears on a turn you wrote, or on an agent's own reply — only on a tagged "injected by" arrival — so your words can never be hidden behind an agent's summary. It never collapses a short body: below 600 characters a summary is voluntary and the message is short enough to just read, so a card there would hide less than it showed. And the summary is drawn as plain text, never markdown, so a sender cannot render a link, a button, or anything else that would read as the product talking.
A row whose stored text IS the old machinery renders the same way. Rows written before 2026-09-17 on the non-Claude engines persisted the agent-facing block — the summary, a token count, and a curl command — as the message itself, so a person opening the transcript saw the machinery. Those rows still exist, and storage is never rewritten, so they are read differently instead: a recognised summary draws as the card and the toggle reveals the stored bytes. That recognition is by reading the text, never by a stored flag, so an ordinary message, a differently-shaped body, or a future format all render exactly as they always did. The storage defect itself is fixed on every engine at the same time.
On the SENDER (whenever you identify yourself with the required X-AMC-Source-Session-Id header), a matching breadcrumb is written so both transcripts show the conversation. Its stored content stays self-describing, so search + the Session Event Log are unaffected:
Sent peer message to <target.name>: <text>
But a peer-message sender breadcrumb no longer RENDERS as that raw string in a centered full-text wall. It is stamped metadata.peerSent (plus the target session id/label and the raw text), and the transcript renders it as a compact "Sent to <target>" notice (PeerSentRow) — a send icon, the target name as a clickable link to that session, a timestamp, and the message body collapsed to a preview with a Show more / Show less toggle. This is the SENDER-side mirror of the target's "injected by <source>" tag. See the Peer Message Render Contract (invariants S1-S4).
A message that was HELD rather than delivered gets the SAME card. When the target is
mid-turn, the message goes on its turn-boundary queue instead of interrupting it — and so does
anything you push through POST /session/:id/queue, the route to prefer over retrying a busy
peer. Both used to write the sender nothing at all: the receiver rendered the arrival perfectly
while the session that SENT it showed no trace, which is how a coordinating agent could report
"I messaged them" against a transcript that looked empty (measured 2026-09-06: 88 sends with no
sender-side row, 86 of them in one day). Both now mint the card with the queue row, in one call.
Holding is not a third state — delivering now and delivering late are the same event to a
reader, so the card reads exactly like a direct send.
The sender breadcrumb is kinded notable-system so it renders as a visible bubble — the transcript filter (R10 in real-conversation-turns.ts) drops unkinded system rows as status noise, so a breadcrumb written without that kind would land in the database but never appear on screen. It also carries peerSent to select the compact PeerSentRow render above. This lets Omniscio-to-Omniscio chats show both sides of the conversation.
Quieting an exchange the user need not read ([[OMNISCIO_COLLAPSE_EXCHANGE]])
The per-session "Hide agent messages" fold above is the USER's blunt instrument: all-or-nothing, one session at a time, and by design it never hides the agent work an arrival triggered. Its per-EXCHANGE counterpart is owned by the RECEIVING AGENT, which is the only party that knows whether a particular exchange mattered.
An agent ends its turn with the marker [[OMNISCIO_COLLAPSE_EXCHANGE]] alone on its own line.
The arrival it answered AND its whole reply then render as ONE quiet expandable line —
▸ 🤖 Agent exchange · Verdict 5:02 AM — instead of two full blocks. Click it to open the pair,
click again to fold it back.
- Nothing is deleted or hidden. It is a disclosure control. The rows are untouched in the
database and still appear in the
'peer'lane, in search, in exports, and in the Agent Messages panel. - Only an agent-to-agent exchange can be folded. The fold applies only when the turn was opened by a peer arrival. A turn the USER opened is never folded, whatever the agent emits — an agent can never fold away its answer to a person.
- A turn that needs the user is never folded — a pending question widget renders in full.
- A turn whose reply carries a real answer is never folded either, wherever it sits in the transcript and whoever sent the arrival. The fold decides how LOUD an exchange is, so it always yields to something the user would actually read — a report, a question, a deliverable, a link, or an explicit final-message marker. A quiet acknowledgement of a machine notice folds; the completion report the agent wrote above it does not. (Before 2026-09-15 this was a positional rule — "keep the last thing visible" — which hid a real report twice and refused folds that were never a risk. Content decides now, not position, and not who sent the notice.)
- A message nobody answered still folds. When the turn holds no agent output at all — an arrival that was never answered, because the app closed before a turn ever ran — the exchange folds on the arrival alone, so it cannot sit in the transcript as a full card forever. Whoever sent it: a machine notice and a message from another agent fold alike. A turn where the agent ran tools and was cut off before it replied still renders, because that is work you can see.
- A live turn is never folded, and "Show system messages" reveals everything.
- The marker is read from the turn's AGENT rows only, so a SENDING agent cannot embed it in the message it delivers and fold the recipient's reply out of view. The literal appearing in the reply-back footer below is safe for the same reason.
- Display only. It does not touch delivery, the inbox, or
needs_you. An agent that wants to skip the user's inbox entirely still emits[[OMNISCIO_SELF_ARCHIVE]]; the two compose. - Render-only and self-healing — the fold is decided when the chat is drawn, from the reply and the agent's own signals, with no migration, so a fix applies to old conversations on the next render.
A quiet sign-off folds too — and every heartbeat gets its own line (2026-09-25)
The agent does not have to use the marker. A round opened by a heartbeat or by another
agent's message also folds when the agent signed off quietly — when every part of its reply
either said nothing at all, carried the stay-quiet marker [[OMNISCIO_HIBERNATE]] (the one every
heartbeat offers for "nothing needs the user"), or is the part the app's "Hibernating until the
next scheduled wake" note belongs to. Every rule above still applies: a readable answer, a
question for you, a live turn or a turn you started never folds, and "Show system messages" shows
it all. That note speaks only for the part of the reply it follows, so one quiet part never folds
a later part that has words.
- A heartbeat round that folds reads differently —
▸ 💓 Heartbeat check-in · nothing new 4:18 PM, with the HEARTBEAT's own time rather than the reply's, so a column of them reads like the schedule. Every other folded round keepsAgent exchange · <sender>. - Every scheduled heartbeat shows as one line, fold or not. Its instruction prompt — the
agent's own note to itself — renders as
▸ 💓 Heartbeat check-in 4:21 AMinstead of the full violet "injected by Scheduled wake" card; one tap opens the unchanged card, and the agent's reply below it shows in full as before. Only a PURE heartbeat gets the line: a held batch that mixes a heartbeat with another agent's message, or any sender linked to a real session, keeps the card. - Why it changed. Agents answer a round with nothing to say using the stay-quiet marker alone, never the fold marker — measured on one coordinator session (2026-09-25): 67 such rounds, and only 1 folded, so every one painted its full heartbeat prompt and activity card.
Contracts: collapsed-exchange-contract.md
(a-quiet-sign-off-folds-the-round) and
peer-message-render-contract.md (I4d).
When a session has nothing to show you
Folding the machine traffic away leaves a question the fold cannot answer on its own: what if a session ends with no readable message at all — only acknowledgements and status notices?
Omniscio does not show you the noise in place of an answer. If a session has never produced anything you can read, nothing is running, nothing is being waited on, and the session is not closed, the app asks the agent for its final message and holds the session out of your inbox until it arrives. You see one quiet line in the transcript explaining what it is waiting on, and the session surfaces when there is something to read.
- Bounded. One ask per thing you asked for — it re-arms when you next send a message, never in a loop.
- Never disrupts work in progress. An armed background job, a running subagent, or a question the agent is already asking you all suppress it — those situations resolve themselves.
- Never on a session you closed. Self-archived, auto-archived, paused, snoozed and ended sessions are all out of scope.
- Its own kind. The ask is an app-injected message (
missing-final-message), labelled so the agent can tell it from you, and dropped from the transcript view like the other mechanical nudges.
Agents learn the marker at the moment an exchange arrives, never from the global system prompt, and there are two places it is taught: the reply-back footer below (for a directed agent-to-agent message), and the one-way mechanical notices that wake a session with news and expect nothing back — a land watch ("your branch is on master, stop checking") and a gate watch ("your gate passed, nothing to re-run"). Those carry no sender session, so they get no reply-back footer at all; each notice appends the guidance itself. That is the purest case for the fold — the session asked to be told a fact, was told it, and neither the notice nor the acknowledgement means anything to the person reading the transcript. The wording is conditional, so a notice you actually have to act on (a vanished branch, a failed gate) is simply answered normally. See collapsed-exchange-contract.md.
Reply-back instructions (peer-message)
A peer-message is fire-and-forget: the receiver processes the injected turn and, left to its own devices, just answers in its OWN chat — which never gets back to the sender. So the /sessions/:id/peer-message route auto-appends a <system-reminder> footer to the delivered turn telling the receiver exactly how to reply: that it came from another agent (naming the sender session), that a normal chat reply won't reach them, and the precise POST /sessions/<sender>/peer-message call to send their answer back. The receiver's reply is itself a peer-message, so it lands in the sender's transcript as a tagged "injected by …" bubble — closing the loop.
- Uses the GLOBAL token. The footer points the receiver at
~/.amc/cli-token—peer-messageiscliTokenOnly, so the scoped per-session$AMC_CLI_TOKENis refused there. - Opt-in, this route only. The footer is appended only when the caller passes
includeReplyInstructions: true, which only the/peer-messageroute does. The MECHANICAL callers ofdeliverPeerMessage(the/nudgecontinuation, the move-account resume, the Auto-lander hand-back, the governor revive) leave it off — their delivered text is byte-unchanged. - Gated on a real sender. No source SESSION (e.g. a cron-job sender identified only by
X-AMC-Source-Cron-Job-Id) → nowhere to reply → no footer. - Breadcrumbs untouched. Only the injected turn carries the footer; the sender/target breadcrumbs keep the original message text.
- Off switch.
AMC_DISABLE_PEER_REPLY_INSTRUCTIONS=1restores bare-text delivery.
The footer nudges the receiver NOT to send bare acknowledgements, which (with the 120/hr cap) keeps two agents from courtesy-looping. See the Peer Message Render Contract.
Related
The routes themselves, the delivery limits and the implementation reference are in part 3, and the overview of the feature — the three flavours, the wake rules and the repo-wide broadcast — is on the Cross-Session Messaging — Agent-to-Agent HTTP page. The confirmed escalations that wake a dormant session are documented in depth in peer-message interrupt and wake.
Last verified 2026-10-05