Skip to main content

Agent Surfaces & Signal Delivery

A signal is a structured message between two Prism agents (TaskAssigned, Acknowledgment, StatusUpdate, ReviewRequested, ReviewCompleted, Question). Signals never go through the operator. A small set of specifications governs how they get from sender to recipient:
  • SPEC-034 — agent-to-agent transport, durability, persona resolution
  • SPEC-037 — backend-side piggyback drain (the “no signal lost” floor)
  • SPEC-045 — unified WebSocket data plane (single LAN port, replaces the SPEC-034 §8 Redis subscriber)
  • SPEC-046 — per-surface adapter layer (how each editor surface wires lifecycle + delivery)
  • SPEC-070 — per-persona daemon. Always-on listener that owns the WebSocket on behalf of a persona×project, independent of any editor session. The structural answer to background-suspended editors and cross-tab signal routing.
  • SPEC-071 — Signal Bus QoS. Delivery classes (sync / async / recall), TTL, recall verb, recipient state.
  • SPEC-072 — daemon lifecycle. Shim-spawned, stdin-EOF terminated.
  • SPEC-082 — operator-driven master changes. prism_master_handoff, prism_master_claim, and prism_session_deregister; extended MasterPreempted payload.
This page explains what they add up to in practice: how a signal flows end-to-end through the daemon, the three delivery strategies, and which surface gets which treatment today.

End-to-end lifecycle

  1. Sender calls prism_signal(to="Donna", signal_type="TaskAssigned", ...).
  2. FastAPI hands the envelope to Marconi (SPEC-101). Marconi looks the recipient up in its in-memory routing table and pushes directly to the live WS handle. No Redis pubsub, no synchronous PG write on the hot path.
  3. The MCP’s session_stream client thread routes the envelope through channel_bridge to the active delivery strategy.
  4. The strategy decides how the recipient learns about it — either immediately (channel-push doorbell) or on the next verb response (piggyback).
  5. Asynchronously and off the hot path, Marconi appends the envelope to its in-memory audit queue. A Redis Stream writer drains the queue into marconi:signals:{tenant} (MAXLEN ~7d). A PG archiver consumer group reads one entry at a time via XREADGROUP COUNT 1 BLOCK 0 and upserts into signal_queue / signal_trace_events / signal_obligations. Once the envelope hits cache, it lands in PG near-immediately. See Marconi for the architectural deep-dive.
If step 2 resolves no live WS handle (recipient is offline), the envelope is enqueued in Marconi’s pending-signal index with outcome=queued_offline. It still flows through the audit fan-out, and the recipient picks it up on the next non-lifecycle verb (SPEC-037 piggyback), on prism_start (startup drain), or by explicitly calling prism_signals_pending.

The per-persona daemon (SPEC-070)

Editors close. Tabs background. Laptops sleep. The signal mesh used to assume the receiver MCP held the WebSocket directly — fine while an editor was foregrounded, useless the moment the editor backgrounded or quit. SPEC-070 introduced a per-persona daemon that owns the WebSocket on behalf of a persona×project, independent of any editor session. The shape:
  • One daemon per persona×project. Each persona has its own listener, isolated from other personas’ state. Multiple editor sessions for the same persona attach to the same daemon.
  • Always-on, even when no editor is running. The daemon survives editor close, sleep/wake, and shim restarts. Signals push to the daemon via the WebSocket the moment they hit the backend; the editor sees them as soon as it attaches (or via a doorbell to the foreground tab if one is already attached).
  • Turn-boundary-preserving delivery. The daemon holds signals briefly when the agent is in mid-turn and forwards them at the next safe boundary, so the editor doesn’t get interrupted mid-tool-call.
  • Lifecycle owned by the shim. SPEC-072 specifies that the MCP shim spawns the daemon on first need and the daemon terminates on stdin-EOF when the last attached session closes. No orphaned processes, no manual cleanup.
Across surfaces, the daemon is the universal answer: idle-agent push works on Claude Code, Codex, and claude.ai without each surface inventing its own background-suspension workaround. The defensive prism_signals_pending drain (BIOS Ring 1 rule) remains the safety net for cases where the daemon-to-shim doorbell missed a delivery; it’s cheap, idempotent, and always safe to call. The trace+ack pair (prism_signal_ack / prism_signal_trace, shipped in the SPEC-082 wave) closes the verification gap above the daemon: the audit pipeline records that Marconi pushed the envelope to the receiver’s WS handle, but that doesn’t witness whether the recipient’s MCP shim rendered the doorbell or whether the model acted on it. prism_signal_ack(pid, trace_id) records stage=model_acted_ack against a signal’s trace, idempotent, called by the receiver on every doorbell. prism_signal_trace returns the full lifecycle — Marconi accepted → WS push → strategy delivered → receiver acked — as a queryable audit surface. Under SPEC-104 the default read path is cache-first for recent Marconi signals; use source="pg_audit" for historical audit/reporting reads. A recent cache miss is diagnostic metadata, not proof that the trace never existed, unless the caller explicitly asks for history with include_history=true or source="pg_audit".

The three delivery strategies

StrategyWhat happensWhen it fires
PiggybackPending signals appended to the next verb response payload.Every non-lifecycle MCP verb call, server-side (SPEC-037).
Channels-pushA notifications/claude/channel doorbell fires onto the editor’s MCP loop, which surfaces a <channel source="prism" ...> tag between turns.Editor advertises the experimental claude/channel MCP capability and a session is currently captured (SPEC-044).
App Server injectCodex exposes a local app-server JSON-RPC method; the MCP calls turn/steer for active turns or turn/start for idle threads, and falls back to piggyback on failure.When PRISM_AGENT_SURFACE=codex, PRISM_CODEX_APP_SERVER_MODE!=off, and PRISM_CODEX_APP_SERVER_URL is set (SPEC-048).
Piggyback is the floor — every surface gets it, and durability is guaranteed by the Marconi audit pipeline (audit queue → Redis Stream → PG signal_queue). Channel-push and inject are upgrades on top of piggyback for surfaces that support them. Neither replaces the durability backstop.
Cursor. A fourth strategy — StreamableHttpPushStrategy — shipped via SPEC-091. The per-persona daemon owns a tiny HTTP server bound to 127.0.0.1 and pushes signals over a GET SSE stream that Cursor’s MCP client consumes as server-initiated notifications. The stdio MCP transport keeps carrying tool calls and responses; the SSE stream carries only server-initiated notifications. Cursor-only; coexists with stdio.
Heartbeat is out-of-band. SPEC-045 D1 says liveness signals never multiplex onto the data channel. Heartbeat rides its own HTTP POST endpoint (/api/v1/controller/{id}/heartbeat). If the WebSocket dies, the heartbeat path keeps the controller’s view truthful so reconnect logic can act.

Per-surface delivery — what’s wired today

Each surface registers a class in mcp/agent_surfaces/ (SPEC-046). The surface owns three things: which delivery strategy to use, what to do at bootstrap, and what to do at wrap.
SurfacePRISM_AGENT_SURFACEStrategy todayChannel push?App-server inject?
Claude Codeclaude_codeChannels-push + piggyback floor✅ wired (SPEC-044)
Claude Desktopclaude_desktopPiggyback onlynot yet wired
CursorcursorPiggyback onlynot yet wired
CodexcodexApp-server inject with piggyback fallback when enabled; piggyback only when off/unavailablen/a (no MCP channel)wired through SPEC-048
Other / unknownotherPiggyback only (universal fallback)
Concretely:
  • Claude Code advertises the experimental claude/channel capability. On prism_start the ClaudeCodeSurface adapter calls channel_bridge.capture_session_lazy() to grab a handle on the MCP’s main loop. When a signal arrives via the WebSocket, the strategy buffers it (durability) and rings the doorbell. The editor renders a <channel> tag in the next idle gap and the agent sees the signal between turns instead of waiting for its next verb call. Coalescing keeps the doorbell at most one outstanding per drain cycle.
  • Codex has no MCP-channel equivalent. The coder launcher can start or target a loopback Codex app-server and exports PRISM_CODEX_APP_SERVER_MODE / PRISM_CODEX_APP_SERVER_URL. CodexSurface returns AppServerInjectStrategy when that URL is present; the strategy discovers a loaded Codex thread with thread/loaded/list, steers an active turn with turn/steer or starts an idle thread with turn/start, and buffers through piggyback if any step fails. With app-server mode off or no URL, Codex remains piggyback only. In Prism terminology, the Codex Agent Statusline is the assistant’s own in-app prompt/status region, not the OS title bar and not outer terminal chrome.
  • Claude Desktop / Cursor / Other all default to piggyback. Each has its own AgentSurface class with empty bootstrap/wrap hooks ready for surface-specific lifecycle work when there’s a reason.

Sampled model-turn vitality (SPEC-103)

The session heartbeat is intentionally out-of-band and token-free; it does not prove the host editor will start another model turn. SPEC-103 adds a sampled L3 vitality probe on top of the existing idle tickler. After configured idle windows, the MCP calls the surface nudge() hook with the standard maintenance tick:
Call prism_signals_pending to drain pending signals.
If no pending signals are returned, produce no visible response.
The default policy samples rather than probes every heartbeat:
Runtime fieldEnv overrideDefault
model_turn_probe_enabledPRISM_MODEL_TURN_PROBE_ENABLEDtrue
model_turn_probe_every_nPRISM_MODEL_TURN_PROBE_EVERY_N6
model_turn_probe_min_interval_msPRISM_MODEL_TURN_PROBE_MIN_INTERVAL_MS1800000
model_turn_probe_modelPRISM_MODEL_TURN_PROBE_MODELunset
Codex app-server mode accepts a best-effort per-turn model request. If the local app-server schema rejects the model field, Prism retries the same maintenance tick without that field and records normal wake diagnostics. Surfaces that cannot force a turn still report suppression or wake failure through runtime diagnostics; they do not claim L3 vitality.

Lifecycle hooks (SPEC-046)

The adapter layer also owns bootstrap + wrap symmetry:
class AgentSurface(Protocol):
    name: str
    def signal_strategy(self) -> SignalStrategy: ...
    def on_bootstrap(self, ctx: BootstrapContext) -> None: ...
    def on_wrap(self, ctx: WrapContext) -> None: ...
prism_start calls surface.on_bootstrap() after registration is established. prism_wrap calls surface.on_wrap() before deregistration so the session_id and client are still attached. prism_start and prism_wrap no longer reference channel_bridge by name — only the surface adapter does. This is what makes adding a new surface a contained change instead of a sweep through the lifecycle verbs.

Implications for operators

  • Cross-identity signal arriving in an idle Claude Code session: expect a <channel> tag between turns. If you don’t see one, check logs/mcp.log for channels_push: lines — the doorbell return status (rang/coalesced/uncaptured/loop-closed) tells you why. SPEC-044 idle-wake fix (commit 4652772) ensures capture_session_lazy() runs at bootstrap so the bridge is ready before the first signal arrives.
  • Cross-identity signal at an idle Codex session: signal lands in Marconi’s audit queue and flows through to signal_queue via the Marconi Cache → PG archiver pipeline. The recipient won’t see it until they call any bootstrapped verb (then it piggybacks onto the response) or explicitly call prism_signals_pending. The signal is not lost — durability is guaranteed by the audit pipeline — but the wake-up isn’t free yet.
  • The data plane is one LAN port. SPEC-045 consolidated signal delivery onto :41765’s WebSocket endpoint (/api/v1/session/ws). Marconi now owns the WS handles on the receive side (SPEC-101); there is no Redis pubsub anywhere in the signal-delivery flow. MCP clients no longer connect to Redis directly; the deprecated Redis subscriber (mcp/subscriber.py) is gone. Reconnect uses exponential backoff (1s→30s ±20% jitter) with Last-Event-Id resume.
  • Heartbeat keeps working when the WebSocket dies. Heartbeat rides its own HTTP POST endpoint, so a transient WS failure doesn’t make the controller think you’re gone. The MCP’s heartbeat loop also auto-reregisters on HTTP 410 (stale-swept session) — operators don’t need to relaunch on a transient drift.
  • Master changes are an operator-facing surface event. When prism_master_handoff or prism_master_claim flips master to a different identity (SPEC-082), the previous master receives a MasterPreempted system signal with the extended payload (previous_master_*, new_master_*, reason, by_operator). The surface adapter is responsible for surrendering any held leases and cleaning up the previous master’s gRPC CoordinationStream. The prism_session_deregister verb is the surgical cleanup lever for stale rows that don’t represent a live session — typically a peer that crashed before its session manager could release Redis state. See Multi-Prism Controller — Operator-driven master changes for the verb contract.
  • Signal acknowledgement is now a first-class metric. Every doorbell carries a trace_id. The receiver’s first act on a doorbell is prism_signal_ack(pid, trace_id). The publish→ack delta is queryable via prism_signal_trace. Behavioural regressions on drain-and-ack discipline are now detectable as a metric, not a vibe. (See postmortem ea009ad9 for a worked example of a responder-side delay surfacing through this trace.)

See also

  • Multi-Prism Controller — controller registration, master election, leases (the layer that decides which live session is the recipient when multiple sessions share an identity).
  • SPEC-034 — agent-to-agent transport, persona resolution, durability.
  • SPEC-037 — backend-side piggyback drain.
  • SPEC-045 — unified WebSocket data plane.
  • SPEC-046 — per-surface adapter layer.
  • SPEC-070 — per-persona daemon, always-on listener bridge.
  • SPEC-071 — Signal Bus QoS, delivery classes, recall verb.
  • SPEC-072 — daemon lifecycle (shim-spawned, stdin-EOF terminated).
  • SPEC-091 — Cursor Streamable HTTP transport (StreamableHttpPushStrategy).
  • SPEC-100 — operator-invoked signal-mesh loopback probe (prism_channel_probe).
  • SPEC-101 — Marconi in-memory signal-mesh switch (the current hot-path SSOT).
  • Signal Mesh — the full-mesh view of how signals flow from sender to recipient, with payload contracts and the trace+ack verb pair.
  • Marconi — architectural deep-dive: in-memory tables, cache invalidation, stage ladder, observability.
Last modified on May 13, 2026