Cross-Session Messaging — Agent-to-Agent HTTP
Cross-session messaging lets one Claude session in Omniscio send a message to another session in the same app — waking a stopped one, holding a message for a busy one, or telling every agent working the same repository something at once.
What it is
One Claude session in Omniscio sends a message to another Claude session in the same Omniscio instance. Three flavours: a wake-and-inject "send a real message" path, a one-shot "ask a quick question and get the reply back synchronously" path, and a repo-wide "tell every live agent at once" broadcast.
Each of those three is a way of getting a turn into another session's conversation as if you had typed it yourself; what separates them is how much you want back and whether you are willing to wait for it. They exist because a fleet of sessions working the same repository constantly needs to hand each other context — a finished migration, a moved branch, a question only another agent can answer — and having a person relay all of that by hand does not scale.
What it does
Omniscio runs many Claude Code sessions in parallel. Each one has its own transcript, its own working directory, its own status (running, paused, archived, ended, error). Cross-session messaging lets one session — or any external script holding the Omniscio bearer token — reach into another session and inject a new operator turn, as if the user typed it.
Two HTTP endpoints, both on the local CLI control server (127.0.0.1:19519), both authenticated with the same Authorization: Bearer <token> Omniscio uses everywhere else:
POST /sessions/:id/peer-message— wake the target if needed, inject a new operator turn, return immediately. The target processes it in the background.POST /sessions/:id/peer-aside— RETIRED (2026-09-11). It forked the target's transcript and returned the reply in the HTTP body. It now answers410namingpeer-message. See Why peer-aside is gone.POST /peer-broadcast— deliver one message to every live agent in the sender's own project (or a chosen subset of them viasessionIds), and return a per-recipient report. Takes no target id: the recipient set is derived from the sender. Snoozed sessions are skipped unless you passincludeSnoozed: true.
"The agent I need is BUSY — how do I reach it?" You already can. Do not write a retry loop.
peer-messagehandles this for you. A target that is mid-turn does not bounce: the message is written to that session's durable turn-boundary queue and you get200 { queued: true }with adetailsaying so. It is delivered the moment the turn ends, it never interrupts, and it survives an app restart and your own session dying. Aqueued: trueis a success — re-sending it just queues a second copy.
POST /session/:id/queue(body{"text":"…"}) is the same durable table (session_message_queue), addressable directly. Reach for it when you want to leave a note for a session without any chance of interrupting it, whatever it is doing.The one refusal left is
409 turn_in_flight, and it does NOT mean "the target is busy" — it means another send to that same target is landing at this instant, so a retry could deliver your message twice. Itsdetailnames the queue route. Do not spin on it.Why this box is shouting at you: agents kept concluding from a bare 409 that "there is no way to leave a message for a busy agent." That was never true, and on 2026-09-04/05 it cost two of them 88 minutes and 125 retries against a target sitting in one 75-minute turn.
You must identify yourself. Both routes REFUSE a source-less caller with
400before injecting anything — send-H "X-AMC-Source-Session-Id: $AMC_SESSION_ID"(an Omniscio-spawned agent already has that env var; a trusted local script cron sendsX-AMC-Source-Cron-Job-Idinstead). An in-app session token always carries its own source and passes automatically. This makes every injected turn traceable to its origin (and a plain/session/:id/messageinjection additionally shows a "via CLI · from <you>" link on its bubble). It is a hard requirement with no off-switch — the fix for an untraceable injected turn reaching a session. See the Session Provenance Contract, invariants I11/I12.
The target session's normal lifecycle applies — the agent's reply comes back through the usual streaming pipe, gets saved to the conversation, fires the usual notifications. The injected peer-message renders inline as a normal operator bubble carrying an "injected by <source>" tag — a clickable link to the sending session when it's an Omniscio session (via the resolved X-AMC-Source-Session-Id), or a plain sender label (e.g. "Auto-lander") otherwise. So the receiving agent and the user see ONE clean bubble, not the message text repeated as a separate breadcrumb. See the Peer Message Render Contract.
Why two endpoints?
These look similar but solve different problems:
| You want to… | Use |
|---|---|
| Wake another agent and have it pick up where it left off | peer-message |
| Ask another agent a one-off question | peer-message — ask it to reply to you |
| Notify a long-running agent of new context (no reply needed) | peer-message (they'll see the injected turn as a tagged "injected by …" bubble) |
| Get an answer back from another agent | peer-message — the answer arrives as a peer message, not an HTTP body |
| Send a multi-step instruction the target should actually act on | peer-message |
| Probe what file another agent is currently touching | GET /worktrees — read it, do not interrupt an agent to ask |
| Tell EVERY agent working this repo the same thing | peer-broadcast (one call, no target ids) |
peer-message is "send a Slack DM" — the message lands, the recipient deals with it on their schedule. peer-broadcast is "post in the team channel" — one message, everyone currently working the repo gets it.
Why peer-aside is gone
peer-aside was "tap on the shoulder": fork the target's transcript, get an answer in seconds, leave no trace in its main thread. It was retired on 2026-09-11, and the reasons generalise — do not rebuild it under another name.
- An aside is the HUMAN's channel. It is the side-question box in the session view. Omniscio does not use one to talk to an agent. Owner rule.
- The fork was amnesiac.
--fork-sessionclones the transcript and discards the clone, so the agent you asked remembers nothing about being asked. Ask the same question twice and you get the same answer twice, which is exactly how a swarm lead kept re-queuing work it had already handed out. - It dropped replies in silence. The fork bills against
asideCostCapUsd($0.50 by default) and forking a real transcript costs more than that. When the cap tripped mid-answer, the runner kept the partial text and RESOLVED — so the caller received an empty string with no error to distinguish it from "the agent had nothing to say". Measured across one week on the dev box: 81 aside runs, 80 over the cap, 72 truncated.
Do this instead: peer-message the agent and ask it to reply to you. It lands as a normal turn on the target's own transcript, so the agent remembers the exchange; it works on every engine instead of Claude-only; and its reply comes back to you as a peer message. You give up the synchronous HTTP body — that is the trade, and it is the right one.
Where to find it
This is not a screen you open — it is something that happens inside transcripts you already have. You meet it as an arrival in a session's conversation, and as a per-session control in that session's ... menu, where Hide agent messages and Agent messages fold the machine-to-machine traffic in or out. Both directions of an exchange are drawn, so a conversation between two agents reads as a conversation rather than a one-sided log.
The things involved are all surfaces you already use: the session list in the sidebar, the transcript, search, and the Session Event Log. Nothing new has to be learned or configured before it works.
Hiding the agent-to-agent chatter (or reading only it)
A session that coordinates other agents fills up with these. Measured on a real install 2026-09-05: 12,806 peer rows, and in the worst session 73% of the entire transcript was agent-to-agent traffic. Two controls, opposites of each other:
| You want | Do this | What happens |
|---|---|---|
| Your own conversation back | A session's ... menu -> Hide agent messages |
Both directions disappear from THAT session's transcript - the inbound arrival bubbles AND the outbound "Sent to ..." cards |
| To read just the machine chatter | A session's ... menu -> Agent messages |
Shows ONLY the agent-to-agent traffic, both directions; click again to go back to All |
Three things worth knowing:
- The agent's own replies are never hidden. Only the peer messages themselves go. If another agent pinged this session and it did work in response, that work still shows.
- Nothing is deleted. The rows stay in the database - search, export and the Session Event Log are unaffected. "Show system messages" reveals them again even with the hide on, which is the escape hatch if you ever need to audit an exchange.
- The hide is per-session, not global. It sits on the session row next to "Show system messages", so you fold the fleet-coordinating session that is drowning in chatter and leave every other transcript exactly as it was. There is no Settings toggle for it.
Implementation: Session.hidePeerMessages (the sessions.hide_peer_messages column) ->
BuildTurnsOptions.hidePeerMessages, which
drops the inbound row in isDroppedAutoResponse and the outbound peerSent row in the
kinded-system branch of real-conversation-turns.ts.
The 'peer' MessageFilter lane is the inverse and shares one predicate, isPeerMessageRow.
See peer-message-render-contract.md
invariants H1-H4.
How it behaves
peer-broadcast — the repo-wide fan-out
peer-message reaches one session, and a sender has no way to enumerate its siblings — so
"tell everyone working this repo X" was effectively impossible from inside a session.
peer-broadcast closes that gap. It is a convenience layer over the SAME delivery
primitive (deliverPeerMessage), so every guarantee above — provenance stamping, the
"injected by …" bubble, the reply-back instructions, the user-close refusal, the per-target
in-flight lock — applies to each delivery unchanged.
Scope is derived, never addressed. There is no :id in the path and no project field in
the body. Recipients are "every live session sharing the AUTHENTICATED sender's projectId"
— and since a project's folder IS its repo (one project per repo path), that is exactly
"every agent working my repo". A git worktree session keeps its parent project's id, so
agents in isolated worktrees of the same repo are correctly siblings. The security property
falls out of the design: a caller can only ever broadcast into the repo it is itself in.
Recipients are the canonical IN_USE_STATUSES live set MINUS the capacity park — net
running, starting, needs_you, stalled. needs_you is deliberately included: parked
agents are usually most of the fleet and are exactly who a repo-wide notice must reach.
Excluded: the sender itself,
ready blanks (pre-warmed, doing no work), waiting (a rate-limit capacity park that
auto-resumes — it IS a member of IN_USE_STATUSES, so the service subtracts it explicitly;
disturbing it would fight the capacity contract), terminating, error,
ended, archived, paused, silent recipe/automation lanes (an injected operator turn
would derail a scripted run), any user-closed session, and any snoozed session. A
broadcast never wakes an archived session (noWakeFromArchived) and never reopens a closed
one.
Snoozed agents are skipped by default. A snooze is the user saying "not now" about that
one session, and it is invisible to every other filter: snoozing writes only the deadline
and leaves the status alone, so a snoozed agent usually sits in needs_you — squarely
inside the live set. Delivering anyway would inject the very turn the user postponed AND
clear the snooze on the way in (a reply auto-unsnoozes), so a routine fan-out would quietly
undo a deliberate decision. Pass includeSnoozed: true only for a notice that genuinely
cannot wait — a stop-work order, a destructive-op warning. An expired-but-not-yet-swept
snooze still receives (the check is future-aware, not a bare not-null test), and
includeSnoozed lifts only this filter — the capacity park, silent lanes and closed
sessions stay excluded.
Targeting a subset. Pass sessionIds: ["…", "…"] to reach only those agents in one
call, instead of falling back to N one-by-one peer-message calls. It is applied as an
INTERSECTION with the derived recipient set above, never a union — so it can only ever make
the blast radius smaller, and naming a session buys no exemption from any exclusion (a
capacity-parked, silent, snoozed, user-closed or foreign-project id is refused, not
reached — for a snoozed one, add includeSnoozed: true). An id
that is not a live sibling comes back as not_a_live_target and is counted under skipped
rather than failed, so a never-attempted id is never mistaken for a delivery failure.
dryRun lists each recipient's id and name, which is where you get the ids to pick from.
An empty sessionIds: [] is a 400 on purpose — it would otherwise be ambiguous between
"nobody" and "everybody".
Cost. One slot of the shared peer bucket per CALL, not per delivery — so telling
every agent in a large repo costs the same as telling one, and a broadcast can never drain
the sender's 1:1 messaging budget. (It used to charge per delivery, which meant a repo with
112 live agents spent 112 of a 120/hour budget on a single broadcast.) The runaway bound is
what that one charge still buys: 120 calls/hour per sender plus the lane's all-senders
ceiling. A dryRun preview is metered too, so probing the blast radius cannot be used to
bypass the cap; it resolves and returns the recipient list while delivering nothing.
Failure isolation. Delivery is sequential and one bad recipient never aborts the rest: a
refusal or a thrown error is recorded against that recipient and the fan-out continues. The
response always reports every recipient's outcome. A recipient that is merely busy is not a
failure — it is queued for its turn boundary and counted as delivered: true, because the
message is safe.
Sender transcript. The fan-out writes ONE summary row (Broadcast to N agents in this repo: …, or Broadcast to N selected agents: … when sessionIds narrowed it) instead of N per-target breadcrumbs — the per-delivery breadcrumb is suppressed
via the opt-in suppressSenderBreadcrumb flag, which every other caller leaves off.
Idempotency. One X-Client-Request-Id covers the whole fan-out. A retry replays the
report rather than re-delivering. A partially-failed fan-out is CACHED, not released: some
recipients already received the message, so re-running would double-deliver to them. Only a
pre-delivery failure (sender_not_found) releases the key for a genuine retry.
TOKEN=$AMC_CLI_TOKEN
curl -s -X POST "http://127.0.0.1:19519/peer-broadcast" \
-H "Authorization: Bearer $TOKEN" \
-H "X-AMC-Source-Session-Id: $AMC_SESSION_ID" \
-H "Content-Type: application/json" \
-d '{"text":"Master moved — rebase before you tag ready-to-merge."}'
{
"ok": true,
"projectId": "…",
"dryRun": false,
"targeted": 3,
"delivered": 2,
"failed": 1,
"skipped": 0,
"results": [
{ "sessionId": "…", "name": "fix login bug", "delivered": true },
{ "sessionId": "…", "name": "docs sweep", "delivered": true },
{ "sessionId": "…", "name": "build gate", "delivered": false, "error": "user_closed" }
]
}
Errors: 400 invalid body or no source session · 401 bad token · 404 sender_not_found ·
409 duplicate_in_flight (same X-Client-Request-Id still running) · 429 bucket empty.
Implementation: peer-broadcast-service.ts (target policy + fan-out) and the route in cli-server-peer-routes.ts.
Auto-wake behaviour (peer-message only)
The target session can be in any status. For a dormant target (paused / archived /
ended / error), peer-message REFUSES by default with 409 inactive_target_needs_confirmation
rather than silently waking it — nothing is unpaused, unarchived, spawned, or written. The table
below is what happens once you confirm, by re-sending with confirmInactiveTarget: true (two
exemptions skip the confirmation entirely: a self-send, and a reply to a session that messaged
you within the last 24h — but neither reaches an archived target, which always needs the
confirmation). Full recipe, the exemptions, and the guards that still beat a
confirmed request (user_closed, noWakeFromArchived) are in
peer-message-interrupt-and-wake.md.
| Target status | What happens |
|---|---|
running / starting |
Mid-turn ⇒ HELD, never dropped and never interrupted. A target actually mid-turn — native (Claude-binary, via processManager.isTurnInFlight) or external-engine (gemini/codex/pi/openclaw) — has the message written to its durable turn-boundary queue and the call answers 200 { queued: true, detail }. It is delivered the moment that turn ends. Do not retry — you already succeeded. If it genuinely cannot wait, re-send with confirmInterruptTurn: true, which KILLS the work the target is doing right now. A target that is merely live but between turns is injected immediately (queued unset) — unless it already holds queued messages: then yours joins them at the back and they are delivered now, in order, and the call answers 200 { queued: true, behindBacklog: true, detail }. Do not re-send that one either. |
paused |
HELD — the pause stands, and nothing is woken. A pause is this box's one purely-human hold, so a delivery from an agent, a cron or a machine notice is written to that session's own turn-boundary queue and handed over when a person unpauses it — 200 { ok: true, delivered: true, queued: true, wokenFrom: null }. Nothing is lost: a held row waits for the unpause. Only a PERSON's own send (the composer, or an operator broadcast) still un-pauses it, and only that reports wokenFrom: "paused". |
archived |
unarchiveSessionService() first (un-soft-deletes), then inject — response field wokenFrom: "archived" |
ended |
No DB transition; sendMessageToSession() will spawn a fresh CLI process — response field wokenFrom: "ended" |
error |
Same as ended — response field wokenFrom: "error" |
wokenFrom is null if the target was already running, and also when a paused target HELD your
message instead of being woken — the two are told apart by queued: true, which is what a hold
carries and a plain live delivery does not. The caller can use the pair to log "the receiver has it
now" vs "the receiver will get it when a person unpauses it" vs "I talked to a live one".
Skipped the confirmation and got a 409 instead? That is the intended default — see peer-message-interrupt-and-wake.md for the exact refusal shape and the two-step recipe.
peer-message reaches a target on ANY engine — it drives the target through its own runtime rather than forking a transcript. (The retired peer-aside could not: it ran claude -p --resume … --fork-session, so it only ever worked against a Claude-managed transcript and refused Codex, Gemini, Hermes, Kimi Code and the one-shot runners outright. Engine-blindness is one of the things peer-message gains you.)
Related
The other agent-control surface is agent-driven sessions, which creates brand-new sessions and is approval-gated, and the whole local control surface is introduced in cli-control. The in-app, user-driven version of the same fork machinery is on the asides page.
This page is split across three parts: part 2 covers messages that could not be delivered, how an arrival is drawn on both sides, and the controls that quiet an exchange, and part 3 covers the delivery limits, the feature's boundaries and the full request and response reference.
See also
- peer-message-interrupt-and-wake.md — the two confirmed
escalations (
confirmInterruptTurn,confirmInactiveTarget) in depth, with recipes - .claude/memory/cli-server-gating.md — full gating-rules reference for every CLI endpoint
- agent-driven-sessions.md — the OTHER agent-control endpoint family (creates new sessions, approval-gated)
- asides.md — the in-app Ctrl+B aside feature; same fork-session machinery, user-driven instead of HTTP
- cli-control.md — overview of the CLI control server
Last verified 2026-10-05