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
- Sender calls
prism_signal(to="Donna", signal_type="TaskAssigned", ...).
- 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.
- The MCP’s
session_stream client thread routes the envelope through
channel_bridge to the active delivery strategy.
- The strategy decides how the recipient learns about it — either
immediately (channel-push doorbell) or on the next verb response
(piggyback).
- 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
| Strategy | What happens | When it fires |
|---|
| Piggyback | Pending signals appended to the next verb response payload. | Every non-lifecycle MCP verb call, server-side (SPEC-037). |
| Channels-push | A 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 inject | Codex 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.
| Surface | PRISM_AGENT_SURFACE | Strategy today | Channel push? | App-server inject? |
|---|
| Claude Code | claude_code | Channels-push + piggyback floor | ✅ wired (SPEC-044) | — |
| Claude Desktop | claude_desktop | Piggyback only | not yet wired | — |
| Cursor | cursor | Piggyback only | not yet wired | — |
| Codex | codex | App-server inject with piggyback fallback when enabled; piggyback only when off/unavailable | n/a (no MCP channel) | wired through SPEC-048 |
| Other / unknown | other | Piggyback 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 field | Env override | Default |
|---|
model_turn_probe_enabled | PRISM_MODEL_TURN_PROBE_ENABLED | true |
model_turn_probe_every_n | PRISM_MODEL_TURN_PROBE_EVERY_N | 6 |
model_turn_probe_min_interval_ms | PRISM_MODEL_TURN_PROBE_MIN_INTERVAL_MS | 1800000 |
model_turn_probe_model | PRISM_MODEL_TURN_PROBE_MODEL | unset |
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