---
title: Cross-Session Messaging — Agent-to-Agent HTTP (part 3)
---

# Cross-Session Messaging — Agent-to-Agent HTTP (part 3)

## What it is

This is part 3 of the [Cross-Session Messaging — Agent-to-Agent HTTP](cross-session-messaging.md) 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`:

```json
{ "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](cross-session-messaging.md#why-peer-aside-is-gone).

## 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):

```json
{
  "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 **1,000 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](peer-message-interrupt-and-wake.md).

**Success response (200):**

```json
{
  "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](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](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.

```json
{
  "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](#why-peer-aside-is-gone).

### Example invocations

Fetch the bearer token from the Omniscio vault first:

```bash
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](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:

```bash
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:

```json
{ "ok": true, "delivered": true, "queued": true, "wokenFrom": null }
```

#### Two Omniscio sessions chatting (both transcripts show the exchange)

```bash
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 &lt;sender&gt;" bubble (the sender's resolved session name, a clickable link back), and the sender gets a compact "Sent to &lt;target&gt;" 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.

```bash
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](../../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](../../src/shared/real-conversation-turns.ts) un-hides it → it renders as a tagged "injected by &lt;source&gt;" 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](../../.claude/memory/contracts/peer-message-render-contract.md).
- HTTP routes: [src/main/services/cli/cli-server-peer-routes.ts](../../src/main/services/cli/cli-server-peer-routes.ts) — `registerCliPeerRoutes()` registered at startup from [src/main/index.ts](../../src/main/index.ts).
- Rate limiter: [src/main/services/peer-message-rate-limiter.ts](../../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](../../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-message`s racing on auto-wake / send for the same target.
- Idempotency store: in-memory `Map` in [src/main/services/cli/cli-server-peer-routes.ts](../../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](../../.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](../../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](../../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](../../tests/integration/peer-message-service.test.ts)), routes integration ([tests/unit/services/cli-server-peer-routes.test.ts](../../tests/unit/services/cli-server-peer-routes.test.ts)), durability guards ([tests/integration/peer-message-dead-letter.test.ts](../../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](cross-session-messaging.md) page, and what happens to an undelivered message plus how arrivals render is in [part 2](cross-session-messaging-part-2.md). The other agent-control endpoint family is [agent-driven sessions](agent-driven-sessions.md), and the gating rules for every control-server endpoint are in [cli-control](cli-control.md).
