Omniscio documentation
Browse all documentation
  1. Getting Started13
  2. Sessions & Agents115
  3. Inbox & Notifications59
  4. Projects & Tasks95
  5. Automation & Scheduling75
  6. Knowledge & Memory26
  7. AI Features60
  8. Integrations100
  9. Plugins & Marketplace33
  10. Cloud & Teams56
  11. Settings & Customization58
  12. Account & Billing28
  13. Troubleshooting84
  14. CLI & API Reference22
  15. Legal & Policies4
  16. Uncategorised22

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 answers 410 naming peer-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 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 <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-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. 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