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 (part 3)

Part 3 of the cross-session messaging page: the delivery limits and retry safety, the plain-language boundaries of the feature, and the full request and response reference with the code that implements it.

What it is

This is part 3 of the Cross-Session Messaging — Agent-to-Agent HTTP page. It covers the mechanics and limits of delivery — how often you may send, and how to make a send safe to retry — the plain-language boundaries of the feature, and the full request and response reference for anyone calling these routes or maintaining them.

Where to find it

There is no button for any of this in the app: the surface is the local control server Omniscio runs on your own machine, which trusted scripts and other sessions talk to. The routes below are that surface, and the retired route that used to sit beside them is described in the same reference.

How it behaves

Rate limiting

Per-bearer-token sliding window: 120 calls per hour, shared across the peer routes. Hitting the cap returns:

429 Too Many Requests
{ "ok": false, "error": "rate_limited", "retryAfterMs": <ms-until-oldest-call-ages-out> }

In-memory only (process restart resets the bucket). Acceptable because this is a developer-tool feature, not a billing gate. An idempotent replay (see below) is served before the rate-limit check, so a retry of an already-succeeded call never consumes a slot or 429s.

Idempotency (retry-safe)

peer-message delivers a real, non-reversible side effect — an operator turn the target session then processes (burning tokens; it can also wake + re-spawn a stopped session). A network/LB retry, a double-click, or a queue redelivery would otherwise deliver it twice.

To make a call retry-safe, send an idempotency key as a header:

X-Client-Request-Id: <a unique id per logical request>

The first delivery is recorded; a retry with the same key replays the original result instead of delivering again — the response is byte-identical plus "idempotent": true:

{ "ok": true, "delivered": true, "wokenFrom": null, "idempotent": true }

Rules:

  • Key-only. With no header, behaviour is exactly as before (no dedup) — Omniscio never silently drops an un-keyed repeat.
  • One key per logical request. Reusing a key for a different payload replays the original result (the standard idempotency-key contract). Mint a fresh UUID per request.
  • Concurrent duplicate — a retry that arrives while the first is still being delivered returns 409 duplicate_in_flight instead of delivering twice.
  • Failures aren't cached — a call that returns an error (or times out) releases the key, so a genuine retry can still succeed.
  • In-memory, ~10-min window. Like the rate-limit bucket, the store is in-memory (a process restart clears it). It covers the realistic trigger — a retry within seconds against the live app — not a cross-restart replay.

What this is NOT

  • No sender-side UI (yet). There's no "Send peer message" button in the Omniscio sidebar — the v1 SEND surface is HTTP-only for agent-to-agent use. (The RECEIVING side DOES render: a directed peer-message shows as a tagged "injected by …" operator bubble.) A send UI can come later if humans want to drive it.
  • Not approval-gated. Unlike POST /agent/sessions (which spawns a brand-new session and lands in the inbox for approval), peer messaging assumes the target session already exists and the bearer-token holder already has authority over the Omniscio instance — same trust boundary as the /sessions/:id/follow-up endpoint.
  • Not loop-protected beyond rate limit. Two agents sending peer-messages back and forth will hit the 120/hour cap quickly — that's the only loop-break.
  • Not silent-flag-aware. Silent recipe sessions (the ones hidden from sidebar/inbox while running) stay silent on receive. The injected turn still lands; surface visibility is unchanged.
  • Not persistent across restart — the RATE-LIMIT BUCKET and the idempotency store only. Process restart resets both. Acceptable for a developer-tool feature; if this ever becomes a billing gate, persist to SQLite. An UNDELIVERED payload is a different matter and IS durable — it goes to peer_message_dead_letters and survives restart (see "When it CAN'T be delivered" above).

Why peer-aside is gone

The retired route below is documented here so nobody rebuilds it under another name; the reasoning behind the retirement, and what to do instead, is on the parent page.

For agents

API contract

The live route below carries the auth, the body shape, the rate limit and the envelope. (The retired peer-aside route shared them; see its entry below.)

POST /sessions/:id/peer-message

Headers:

Authorization: Bearer <amc-cli-token>
X-AMC-Source-Session-Id: <required — your own session id; an Omniscio agent sends $AMC_SESSION_ID. Identifies the sender; a source-less call is rejected 400 before anything is injected>
Content-Type: application/json
X-Client-Request-Id: <optional — same key on a retry replays the original result; see Idempotency>

Body (Zod-validated):

{
  "text": "The thing to say (1..32000 chars, required)",
  "tldr": "Your own one-line summary of `text`. REQUIRED once `text` passes 1000 chars (1..600)",
  "confirmInactiveTarget": "optional — wake a paused/archived/ended/error target past its 409 refusal",
  "confirmInterruptTurn": "optional — kill a busy target's in-flight turn instead of holding the message",
  "allowReopenUserClosed": "optional, internal — reopen a user-closed target (the Auto-lander hand-back)",
  "queueIfLimited": "optional — park the message under a spent rate-limit budget instead of 429"
}

tldr is what the recipient actually reads. Past 600 characters of text the send is REFUSED with 400 tldr_required unless you supply one — on every pathway an agent sends on: POST /sessions/:id/peer-message, POST /peer-broadcast, POST /session/:id/message and POST /session/:id/queue. The summary itself is capped at 600 characters, so a long one is refused with 400 invalid_payload; that cap applies to the tldr, never to your message — write two or three sentences, and the full text still reaches the transcript either way. The refusal is taken AFTER the body parse and BEFORE the rate-limit slot and the idempotency claim, so nothing is consumed and you can add the field and re-send under the same X-Client-Request-Id. When a summary is in play the recipient's MODEL receives it plus GET /peer-message/<bodyId> INSTEAD of your body, and spends the body's tokens only if the summary says it should — so write the summary for them, not as a restatement of your opening. The human transcript stores your message in full either way. A long summary of a body just past the threshold is trimmed to fit, so the pointer is NEVER bigger than the message it stands in for; below the threshold a summary is voluntary and is used only when it genuinely halves the message.

You can price a body without reading it. GET /peer-message/<bodyId> returns chars + tokens along with the text, and ?meta=1 answers the same question WITHOUT handing over the text — useful when you are budgeting context and have not decided whether to pull. A body you only measured does not count as a fetch, so it does not inflate the number that says whether the summaries are doing their job.

You can read back what YOU sent. GET /sessions/<your session id>/peer-messages-sent returns the stored bodies that session sent — newest first, with the verbatim text and the size of each — so you can confirm what actually went out. ?limit= is the page size (default 10, ceiling 50); a bad value falls back to the default rather than refusing. Your own pass reads your own list and no other: asking for a different session's answers 403 not_your_messages.

Read retainedScope before concluding anything — in BOTH directions. An empty list is NOT "you sent nothing": a body is stored whenever you attached a summary, so a message sent with no summary at all leaves no trace here. And a body is NOT proof that the recipient was handed the summary: a summary the composer judged not worth using is still stored, and that message went out in full. Use storeHoldsSince as your proof the read actually reached the store — it names the oldest body the store currently holds, and a read that returned nothing at all cannot produce one.

Sender identity is not a body field. A caller-supplied senderSessionId / senderLabel is rejected by the strict schema with 400 invalid_payload; the sender is derived from the required X-AMC-Source-Session-Id header above, and Omniscio writes the sender-side breadcrumb from it automatically. The two escalations above (confirmInactiveTarget, confirmInterruptTurn) are covered in depth, with recipes, in peer-message-interrupt-and-wake.md.

Success response (200):

{
  "ok": true,
  "delivered": true,
  "wokenFrom": "archived" | "ended" | "error" | null,
  "queued": true
}

wokenFrom is never "paused" any more: a machine delivery to a paused target is held in that session's own queue (queued: true, wokenFrom: null) and delivered when a person unpauses it. queued: true also appears when a merely-busy target takes the message at its turn boundary, so read it as "the receiver will get it later", never as "the receiver has it now". See peer-message-interrupt-and-wake.md § A paused target is HELD.

Error responses:

Status error When
400 invalid_payload Zod parse failure — text empty / >32k / wrong type, tldr over its 600-character cap, or an unknown body field such as a caller-supplied senderSessionId/senderLabel (rejected by the strict schema). detail names the field that failed, so read it rather than re-sending the same body
401 unauthorized Missing or wrong bearer token
404 session_not_found :id doesn't match a non-deleted row
409 turn_in_flight Only ever the in-flight LOCK now — another peer-message to this same target is landing at this instant, so re-sending risks delivering it twice. A merely busy target no longer reaches here: it is queued and answered 200 { queued: true }. detail names POST /session/:id/queue, the durable way to leave a message. Do not spin on this error.
409 inactive_target_needs_confirmation Target is paused/archived/ended/error and you have not confirmed. detail names the target + what proceeding does + the field to set. Re-send with confirmInactiveTarget: true to proceed — see peer-message-interrupt-and-wake.md. For a paused target, confirming only clears this 409: the delivery is then HELD in that session's queue and still reports wokenFrom: null with queued: true — nothing unpauses it but a person. Two exemptions skip this entirely (self-send, reply-to-dormant-asker); user_closed and noWakeFromArchived beat even a confirmed request.
409 duplicate_in_flight A retry with the same X-Client-Request-Id arrived while the first is still being delivered
409 user_closed A person closed this session; confirmInactiveTarget does NOT reopen it
429 rate_limited Sender's bearer hit 120 calls in the last hour (retryAfterMs set)
500 wake_failed unpauseSessionService / unarchiveSessionService rejected (detail set)
500 send_failed sendMessageToSession threw for a genuine reason (NOT the busy-target case, which is queued and answered 200 { queued: true }) (detail set)

POST /sessions/:id/peer-aside — RETIRED

Always answers 410. Nothing is delivered, nothing is billed, no rate-limit slot is taken.

{
  "ok": false,
  "error": "retired",
  "detail": "Asking another agent with an aside is no longer supported. Send it with POST /sessions/:id/peer-message instead — …"
}

It answers rather than 404s on purpose: the route was documented here for months, and an unregistered path returns a bare 404 that reads exactly like a typo'd URL. See Why peer-aside is gone.

Example invocations

Fetch the bearer token from the Omniscio vault first:

TOKEN=$(powershell.exe -NoProfile -File "$HOME/.claude/secrets/get-secret.ps1" amc-cli | tr -d '\r\n')

Message a paused agent — it is HELD, not woken

A dormant target (paused/archived/ended/error) needs confirmInactiveTarget: true or this refuses with 409 inactive_target_needs_confirmation — see peer-message-interrupt-and-wake.md for the full two-step recipe and its exemptions/guards. A paused target is the one member that is not woken by it — the message is queued for the unpause, so the response carries queued: true and wokenFrom: null rather than the "paused" this example used to show:

curl -s -X POST http://127.0.0.1:19519/sessions/<target-uuid>/peer-message \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-AMC-Source-Session-Id: $AMC_SESSION_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Hey, the migration script finished — you can run the smoke tests now.",
    "confirmInactiveTarget": true
  }'

Response — the message is safely queued and the pause still stands:

{ "ok": true, "delivered": true, "queued": true, "wokenFrom": null }

Two Omniscio sessions chatting (both transcripts show the exchange)

curl -s -X POST http://127.0.0.1:19519/sessions/<target-uuid>/peer-message \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-AMC-Source-Session-Id: $AMC_SESSION_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Are you still working on search.rs? I want to refactor the nearby tokenizer."
  }'

Both sessions now show the exchange: the target renders the injected turn as a tagged "injected by <sender>" bubble (the sender's resolved session name, a clickable link back), and the sender gets a compact "Sent to <target>" notice — a link to the target session with the message body collapsed to a preview.

Asking another agent a question

There is no synchronous form. Send a peer-message and ask it to answer you; the reply arrives as a peer message of its own.

curl -s -X POST http://127.0.0.1:19519/sessions/<target-uuid>/peer-message \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-AMC-Source-Session-Id: $AMC_SESSION_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "What file are you currently editing? Reply to me with peer-message."
  }'

Implementation notes (for agents touching this code)

  • Service: src/main/services/peer-message-service.ts — deliverPeerMessage(). (deliverPeerAside() no longer exists; the route it served is retired.)
  • A DIRECTED peer-message renders inline; a MECHANICAL injection keeps the breadcrumb. The /peer-message route (and the Auto-lander hand-back) pass renderAsPeerBubble: true, which stamps metadata.peerMessage on the injected operator turn (so isDroppedAutoResponse in real-conversation-turns.ts un-hides it → it renders as a tagged "injected by <source>" operator bubble) AND skips the target-side breadcrumb. A MECHANICAL caller of deliverPeerMessage (the /nudge continuation, the move-account resume) leaves the turn hidden and still writes the target breadcrumb — and THAT breadcrumb (plus both sender-side breadcrumbs) MUST stay kinded notable-system, or the R10 filter drops it as "status noise" and a hidden arrival renders nowhere (the peer-message-invisible bug). See peer-message-render-contract.md.
  • HTTP routes: src/main/services/cli/cli-server-peer-routes.ts — registerCliPeerRoutes() registered at startup from src/main/index.ts.
  • Rate limiter: src/main/services/peer-message-rate-limiter.ts — Map keyed by tokenHash, in-memory.
  • No aside spawn. The retired peer-aside route forked a paid sidechain through src/main/process/aside-runner.ts; deliverPeerMessage does not — it injects a turn into the session that already exists.
  • Per-target in-flight lock: in-memory Set<string> in peer-message-service.ts — prevents two concurrent peer-messages racing on auto-wake / send for the same target.
  • Idempotency store: in-memory Map in src/main/services/cli/cli-server-peer-routes.ts keyed by peer-message:<X-Client-Request-Id> — records the first success and replays it for ~10 min; a synchronous in-flight claim collapses a concurrent same-key duplicate to 409 duplicate_in_flight, and a failed delivery releases the claim so a retry can re-deliver. Reset in tests via resetPeerIdempotencyForTest(). → .claude/memory/contracts/peer-route-idempotency-contract.md.
  • Durability funnel: deliverPeerMessage is a thin wrapper over deliverPeerMessageInner, so every one of the inner function's NINE ok: false returns passes through a single preservation point. Calling the preserve helper at each return site would work today and rot tomorrow — a return added later would skip preservation, which is the exact silent-drop class this removes. (If you ever recount them: one of the nine spans several lines, so a single-line return { ok: false grep reports eight and looks convincing.) Classification lives in src/shared/alert-features/peer-undelivered-alert.ts, pure so the part that can be wrong is testable without a DB; storage in src/main/db/queries-peer-dead-letters.ts. alert-service is imported LAZILY — a static edge drags the auth stack into this module's import graph and has broken suites at link time before.
  • Tests: rate limiter unit, lifecycle unit, service integration (tests/integration/peer-message-service.test.ts), routes integration (tests/unit/services/cli-server-peer-routes.test.ts), durability guards (tests/integration/peer-message-dead-letter.test.ts — written to FAIL if a terminal bounce drops a payload, or if a transient one raises an alert).

Related

The overview of the feature is on the Cross-Session Messaging — Agent-to-Agent HTTP page, and what happens to an undelivered message plus how arrivals render is in part 2. The other agent-control endpoint family is agent-driven sessions, and the gating rules for every control-server endpoint are in cli-control.

Last verified 2026-10-05