---
title: Cross-Session Messaging — Agent-to-Agent HTTP
---

# Cross-Session Messaging — Agent-to-Agent HTTP

## 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 answers `410` naming `peer-message`. See [Why peer-aside is gone](#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 via `sessionIds`), and return a per-recipient report. Takes no target id: the recipient set is derived from the sender. Snoozed sessions are skipped unless you pass `includeSnoozed: true`.

> ### "The agent I need is BUSY — how do I reach it?" You already can. Do not write a retry loop.
>
> **`peer-message` handles 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 get `200 { queued: true }` with
> a `detail` saying so. It is delivered the moment the turn ends, it never interrupts, and it
> survives an app restart _and your own session dying_. **A `queued: true` is 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. Its `detail` names 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 `400` before 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 sends `X-AMC-Source-Cron-Job-Id` instead). 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/message` injection additionally shows a "via CLI · from &lt;you&gt;" 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 &lt;source&gt;" 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](../../.claude/memory/contracts/peer-message-render-contract.md).

### 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-session` clones 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](../../src/shared/real-conversation-turns.ts).
The `'peer'` MessageFilter lane is the inverse and shares one predicate, `isPeerMessageRow`.
See [peer-message-render-contract.md](../../.claude/memory/contracts/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.

```bash
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."}'
```

```json
{
  "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](../../src/main/services/peer-broadcast-service.ts)
(target policy + fan-out) and the route in
[cli-server-peer-routes.ts](../../src/main/services/cli/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](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](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](agent-driven-sessions.md), which creates brand-new sessions and is approval-gated, and the whole local control surface is introduced in [cli-control](cli-control.md). The in-app, user-driven version of the same fork machinery is on the [asides](asides.md) page.

This page is split across three parts: [part 2](cross-session-messaging-part-2.md) 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](cross-session-messaging-part-3.md) covers the delivery limits, the feature's boundaries and the full request and response reference.

### See also

- [peer-message-interrupt-and-wake.md](peer-message-interrupt-and-wake.md) — the two confirmed
  escalations (`confirmInterruptTurn`, `confirmInactiveTarget`) in depth, with recipes
- [.claude/memory/cli-server-gating.md](../../.claude/memory/cli-server-gating.md) — full gating-rules reference for every CLI endpoint
- [agent-driven-sessions.md](agent-driven-sessions.md) — the OTHER agent-control endpoint family (creates new sessions, approval-gated)
- [asides.md](asides.md) — the in-app Ctrl+B aside feature; same fork-session machinery, user-driven instead of HTTP
- [cli-control.md](cli-control.md) — overview of the CLI control server
