Status: draft · Version 0.1 · Filed 2026-04-23
SPEC-034 v0.1 — Agent-to-Agent Signal Delivery
Status
draft
1. Summary
Enable bidirectional real-time communication between Prism agents (Lola↔Donna initially) using Redis pub/sub as the transport layer, with pluggable delivery strategies per agent surface. Agents can send structured signals (task requests, review feedback, acknowledgments) to named peers without human relay. The design is MVP-scoped to Claude Desktop↔Claude Code but architecturally prepared for Codex (App Server thread/inject_items), Cursor, and ACP-compatible agents.
2. Motivation
Today, Lola and Donna can see each other via prism_status but cannot communicate. When Lola reviews a spec and wants Donna to act on it, or when Donna ships code and Lola needs to know, the only path is Frank relaying between chat windows. This is the human-as-message-bus antipattern — it wastes human attention on routing that the system should handle.
With SPEC-032 Phase A deployed, both agents have active Redis-backed sessions with stable heartbeats. The pub/sub infrastructure exists (prism:events:session:{id} and prism:events:project:{tenant}:{project} channels per SPEC-032 §5.6). What’s missing is: (1) a subscriber that listens, (2) a delivery strategy that gets the signal to the agent, and (3) a verb that lets agents compose and send signals.
3. The Acceptance Test (drives the entire spec)
This test defines “done.” If this scenario works end-to-end, SPEC-034 is complete.
Preconditions
- Lola (Claude Desktop, master) and Donna (Claude Code, peer) both registered and heartbeating on PID-PGR01.
- Signal infrastructure deployed (subscriber threads + delivery strategies).
Step 1 — Lola sends a task to Donna
Frank says: “Ask Donna to review SPEC-033.”
Lola calls:
prism_signal(
pid="PID-PGR01",
to="Donna", # identity-targeted, not session-targeted
signal_type="ReviewRequested",
payload={
spec_id: "SPEC-033",
instructions: "Summarize SPEC-033 (Prism Platform Architecture), review it, and provide feedback on gaps or concerns. Reply via signal when complete."
}
)
Backend resolves “Donna” → her current active session via Redis lookup → PUBLISHes to prism:events:session:{donna_session_id}.
Step 2 — Donna receives and acts
Donna’s MCP server subscriber thread dequeues the event. Her ChannelsPushStrategy pushes it into her Claude Code session via MCP channels. Donna sees inline:
📨 Signal from Lola (ReviewRequested): “Summarize SPEC-033, review it, and provide feedback on gaps or concerns. Reply via signal when complete.”
Donna reads SPEC-033 via semantic_recall, writes her review, then replies:
prism_signal(
pid="PID-PGR01",
to="Lola",
signal_type="ReviewCompleted",
in_reply_to="{original_signal_id}",
payload={
spec_id: "SPEC-033",
summary: "Five-layer reference model covering...",
gaps: ["§5 deployment matrix missing Windows native path", "§6 doesn't address offline agents"],
recommendation: "Accept with amendments"
}
)
Step 3 — Lola receives feedback
Lola’s MCP server subscriber thread dequeues the ReviewCompleted event. Since Claude Desktop uses PiggybackStrategy, the event buffers. On Lola’s next Prism verb call, the response includes:
{
"pending_signals": [{
"signal_id": "...",
"from": "Donna",
"signal_type": "ReviewCompleted",
"in_reply_to": "{original_signal_id}",
"payload": {
"spec_id": "SPEC-033",
"summary": "...",
"gaps": ["...", "..."],
"recommendation": "Accept with amendments"
},
"received_at": "2026-04-23T23:15:00Z"
}]
}
Lola reads the feedback, presents gaps to Frank, then acknowledges:
prism_signal(
pid="PID-PGR01",
to="Donna",
signal_type="Acknowledgment",
in_reply_to="{original_signal_id}",
payload={
message: "Thanks Donna. Received your review. Frank or I will get back to you with decisions on the gaps."
}
)
Step 4 — Donna receives acknowledgment
Donna sees inline via MCP channels push:
📨 Signal from Lola (Acknowledgment): “Thanks Donna. Received your review. Frank or I will get back to you with decisions on the gaps.”
Loop closed. Zero human relay for agent↔agent communication.
What this test proves
- Bidirectional signal delivery works (Lola→Donna and Donna→Lola).
- Two different delivery strategies work in the same flow (push for Code, piggyback for Desktop).
- Agents compose structured messages and act on them autonomously.
- Identity-targeted addressing resolves correctly (“Donna” → her current session).
- Acknowledgment closes the loop — sender knows the message landed.
- Conversation threading works via
in_reply_to.
4. Architecture
4.1 Signal flow
Agent A Backend Redis Agent B
│ │ │ │
│ prism_signal(to="B") │ │ │
├────────────────────────►│ │ │
│ │ resolve "B" → session │ │
│ ├────────────────────────►│ │
│ │ PUBLISH to B's channel │ │
│ ├────────────────────────►│ │
│ │ │ subscriber thread │
│ │ ├────────────────────────►│
│ │ │ strategy.deliver(event) │
│ │ │ │
4.2 Identity resolution (not session targeting)
Signals target agent identities (“Donna”, “Lola”), not session IDs. The backend resolves identity → current active session at send time:
- Query Redis: scan
prism:project:{tenant}:{project}:sessions set.
- For each session,
HGET prism:session:{id} agent_identity.
- Match target identity → get session_id → PUBLISH to
prism:events:session:{session_id}.
If the target identity has no active session (offline), the signal is persisted to Postgres (signal_queue table) and delivered on the target’s next prism_start. This makes signals durable across agent restarts — Redis is the real-time delivery layer, Postgres is the durability backstop.
4.3 Broadcast signals
Some signals target all agents on a project, not a specific identity:
prism_signal(pid="PID-PGR01", to="*", signal_type="SpecStatusChanged", ...)
These PUBLISH to prism:events:project:{tenant}:{project} — the broadcast channel all subscribers already listen to.
5. Signal schema
5.1 Signal record
@dataclass
class PrismSignal:
signal_id: str # UUID, generated by backend
pid: str # project scope
from_identity: str # sender agent identity ("Lola", "Donna")
from_session: str # sender session ID (for reply routing)
to_identity: str # target identity ("Donna", "Lola", "*")
signal_type: str # typed event (see §5.2)
payload: dict # freeform JSON, type-specific content
in_reply_to: str | None # signal_id of parent (threading)
created_at: datetime # when sent
delivered_at: datetime | None # when delivery confirmed
delivery_method: str | None # "channels_push" | "piggyback" | "startup_drain"
5.2 Signal types (MVP)
| Type | Direction | Purpose | Payload shape |
|---|
ReviewRequested | any→any | Ask peer to review an artifact | {spec_id, instructions} |
ReviewCompleted | any→any | Deliver review feedback | {spec_id, summary, gaps[], recommendation} |
Acknowledgment | any→any | Confirm receipt | {message} |
TaskAssigned | any→any | Delegate work to peer | {description, todo_number?, priority} |
StatusUpdate | any→any | Inform peer of progress | {description, artifacts[]} |
MasterPreempted | system→session | Election result notification | {preempted_by, new_master_session} |
PeerJoined | system→project | New agent registered | {identity, surface, session_id} |
PeerLeft | system→project | Agent deregistered/expired | {identity, surface, reason} |
Agent-originated types (top 5) use prism_signal verb. System-originated types (bottom 3) are emitted by the backend controller on registration/election events — no verb call needed.
{
"signal_id": "uuid",
"signal_type": "ReviewRequested",
"from_identity": "Lola",
"from_session": "77016bac-...",
"to_identity": "Donna",
"payload": { ... },
"in_reply_to": null,
"created_at": "2026-04-23T23:00:00Z"
}
6. The prism_signal verb
6.1 Contract
prism_signal(
pid: str, # required — project scope
to: str, # required — target identity or "*" for broadcast
signal_type: str, # required — from §5.2 type list
payload: dict, # required — type-specific content
in_reply_to: str = None # optional — signal_id for threading
) → {
signal_id: str,
delivered: bool, # true if real-time delivery succeeded
queued: bool, # true if target offline and signal queued for startup
resolved_to_session: str | None # which session received it (for debugging)
}
6.2 Backend behavior
- Validate
signal_type against known types.
- Generate
signal_id (UUID).
- Persist to Postgres
signal_queue table (always — durable record).
- Resolve
to identity → active session via Redis.
- Found → PUBLISH to
prism:events:session:{target_session_id}. Set delivered=true.
- Not found (offline) → signal stays in
signal_queue with delivered_at=null. Set queued=true.
- If
to="*" → PUBLISH to prism:events:project:{tenant}:{project}. Also persist.
- Return response.
6.3 Startup drain
On prism_start, the backend checks signal_queue for any undelivered signals where to_identity matches the caller’s identity. Returns them in the prism_start response as pending_signals[]. Marks them delivered_at=now(), delivery_method='startup_drain'.
7. Delivery strategies (pluggable per agent surface)
7.1 Strategy interface
class SignalDeliveryStrategy(ABC):
@abstractmethod
async def deliver(self, signal: PrismSignal) -> bool:
"""Deliver signal to the agent. Returns True if agent received it."""
@abstractmethod
def supports_push(self) -> bool:
"""Can this strategy push without waiting for a verb call?"""
7.2 MVP strategies
ChannelsPushStrategy (Claude Code — Donna)
- Uses MCP server channels/push API to inject signal directly into the Claude Code session.
supports_push() = True
- Signal appears inline in Donna’s conversation immediately.
- Delivery confirmation: MCP channels API returns success/failure.
PiggybackStrategy (Claude Desktop — Lola)
- Buffers signals in a thread-safe queue within the MCP server process.
supports_push() = False
- On every Prism verb response, checks queue and appends
pending_signals[] to the response payload.
- Delivery confirmation: set
delivered_at when signal is included in a verb response.
7.3 Future strategies (designed for, not implemented)
AppServerInjectStrategy (Codex)
- WebSocket client connected to Codex App Server (
ws://host:port).
- On signal → call
thread/inject_items with signal formatted as a system context item.
supports_push() = True
ACPStrategy (Zed, JetBrains, future ACP-compatible editors)
- ACP protocol adapter — depends on ACP notification spec.
supports_push() = True (ACP supports server-initiated messages)
WebhookStrategy (generic integration)
- POST signal as JSON to a configured webhook URL.
supports_push() = True
- Useful for Slack/Discord notifications, CI integrations, custom dashboards.
7.4 Strategy selection
On MCP server startup, detect PRISM_AGENT_SURFACE and select:
STRATEGY_MAP = {
"claude_code": ChannelsPushStrategy,
"claude_desktop": PiggybackStrategy,
"codex": AppServerInjectStrategy, # future
"cursor": PiggybackStrategy, # default until Cursor-specific adapter exists
}
Fallback: PiggybackStrategy — always works, lowest common denominator.
8. Subscriber thread
8.1 Lifecycle
Spawned on prism_start, alongside the heartbeat thread (SPEC-032 §5.3). Same concurrency pattern: dedicated asyncio event loop on a daemon thread.
def subscriber_loop(redis_client, session_id, tenant, project, strategy, stop_event):
"""Dedicated thread. Subscribes to session + project channels."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def _run():
pubsub = redis_client.pubsub()
await pubsub.subscribe(
f"prism:events:session:{session_id}",
f"prism:events:project:{tenant}:{project}"
)
while not stop_event.is_set():
message = await pubsub.get_message(timeout=1.0)
if message and message["type"] == "message":
signal = PrismSignal.from_json(message["data"])
await strategy.deliver(signal)
loop.run_until_complete(_run())
8.2 Thread management
- Spawned by
mcp/server.py on prism_start return, after heartbeat thread.
- Stopped by setting
stop_event on prism_wrap, before the release pipeline.
- If subscriber thread dies unexpectedly, signals are not lost — they’re persisted in Postgres
signal_queue and will drain on next prism_start.
9. Postgres signal_queue table
Durable backing store. Redis pub/sub is fire-and-forget; signal_queue ensures signals survive agent restarts and Redis unavailability.
CREATE TABLE signal_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
project_id UUID NOT NULL REFERENCES projects(id),
signal_id UUID NOT NULL UNIQUE,
from_identity TEXT NOT NULL,
from_session UUID NOT NULL,
to_identity TEXT NOT NULL, -- agent identity or "*"
signal_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}',
in_reply_to UUID, -- FK to signal_queue.signal_id
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
delivered_at TIMESTAMPTZ,
delivery_method TEXT, -- channels_push | piggyback | startup_drain
INDEX idx_signal_queue_undelivered (to_identity, delivered_at) WHERE delivered_at IS NULL
);
Retention: Delivered signals are retained for 30 days (audit/debugging), then archived per SPEC-024 retirement pattern. Undelivered signals have no TTL — they wait until the target agent starts.
10. Integration with existing infrastructure
10.1 System signals from controller
The controller already emits events on registration/election. SPEC-032 §5.6 defines the pub/sub channels. SPEC-034 standardizes the message format:
controller_service.register → if preempting, PUBLISH MasterPreempted to preempted session’s channel.
controller_service.register → PUBLISH PeerJoined to project broadcast channel.
controller_service.release → PUBLISH PeerLeft to project broadcast channel.
These system signals do NOT go through prism_signal verb — they’re emitted directly by the backend. But they use the same PrismSignal schema and are received by the same subscriber thread.
10.2 Piggyback injection point
For PiggybackStrategy, every Prism verb response (from any endpoint) checks the MCP server’s signal buffer:
# In every verb handler's response construction
response = build_normal_response(...)
if signal_buffer.has_pending():
response["pending_signals"] = signal_buffer.drain()
return response
This is architecturally similar to how rules_reminders are injected into prism_start responses today — a cross-cutting concern appended to normal verb returns.
10.3 prism_start integration
prism_start response gains a new field:
{
"context": { ... },
"rules_reminders": [ ... ],
"pending_signals": [ ... ], // NEW — drained from signal_queue
"controller_status": { ... }
}
11. What this does NOT cover (future specs)
- Signal-driven autonomous task execution. SPEC-034 delivers signals; it does not define how an agent should autonomously act on a
TaskAssigned signal without human approval. That’s a methodology/approval question.
- Signal routing across projects. MVP is project-scoped. Cross-project signals (e.g., Prism agent sending to MemRGR agent) are future.
- Signal encryption. MVP trusts the Redis + Postgres security posture (loopback/ufw/TLS per SPEC-032 §8). End-to-end signal encryption is future.
- Rate limiting. MVP has no throttle. If an agent loops on
prism_signal, it floods. Rate limiting (per sender per minute) is a Phase 2 concern.
- UI for signal history. No dashboard. Signals are queryable via
semantic_recall (they’re persisted as deltas or in signal_queue). A visual signal log is future.
12. Deployment — file-by-file diff
| File | Change | What |
|---|
backend/alembic/versions/xxx_signal_queue.py | New | Migration for signal_queue table |
backend/app/models/signal.py | New | PrismSignal dataclass + DB model |
backend/app/services/signal_service.py | New | send_signal, resolve_identity, drain_pending |
backend/app/api/v1/signal.py | New | POST /api/v1/signal endpoint |
backend/app/services/controller_service.py | Modified | Emit system signals (PeerJoined/Left/Preempted) on register/release |
backend/app/api/v1/controller.py | Modified | prism_start response includes pending_signals |
mcp/subscriber.py | New | Subscriber thread — Redis SUBSCRIBE + strategy dispatch |
mcp/strategies/__init__.py | New | SignalDeliveryStrategy ABC |
mcp/strategies/piggyback.py | New | PiggybackStrategy — buffer + drain on verb response |
mcp/strategies/channels_push.py | New | ChannelsPushStrategy — MCP channels API push |
mcp/server.py | Modified | Spawn subscriber thread on prism_start; inject pending_signals on verb responses |
mcp/prism_signal_verb.py | New | prism_signal MCP verb handler |
backend/tests/test_signal_service.py | New | Unit tests for send, resolve, drain |
backend/tests/test_signal_integration.py | New | Integration tests with fakeredis |
mcp/smoke_spec034_signal.py | New | End-to-end smoke: send → receive → reply → ack |
13. Codex/Cursor readiness checklist
These are not implemented in MVP but the architecture must not block them:
14. Acceptance criteria
prism_signal verb exists and is callable from both Claude Desktop and Claude Code MCP servers.
- Identity resolution:
to="Donna" resolves to Donna’s current active session via Redis lookup.
- Online delivery: signal sent to an online agent is delivered within 5 seconds (push) or on next verb call (piggyback).
- Offline queuing: signal sent to an offline agent is persisted in
signal_queue and delivered on target’s next prism_start.
- Piggyback delivery: Lola receives signals appended to Prism verb responses as
pending_signals[].
- Push delivery: Donna receives signals inline in her Claude Code session via MCP channels.
- Threading:
in_reply_to correctly links reply signals to their parent.
- System signals:
MasterPreempted, PeerJoined, PeerLeft are emitted by the controller and received by the subscriber thread.
- Broadcast:
to="*" delivers to all active agents on the project.
- Full acceptance test (§3) passes end-to-end: Lola sends ReviewRequested → Donna receives and reviews → Donna sends ReviewCompleted → Lola receives on next verb call → Lola sends Acknowledgment → Donna receives. Zero human relay.
- Smoke test
mcp/smoke_spec034_signal.py covers send → receive → reply → ack round trip.
signal_queue table exists with correct schema and indexes.
- Strategy selection is driven by
PRISM_AGENT_SURFACE env var — no hardcoded agent detection.
- Adding a new strategy for Codex/Cursor requires only: (a) new strategy class implementing
SignalDeliveryStrategy, (b) one entry in STRATEGY_MAP.
15. Relationship to other specs
- Depends on SPEC-032 (Redis session plane — pub/sub channels, session registration, heartbeat thread pattern).
- Depends on SPEC-019 (env resolution —
PRISM_AGENT_SURFACE detection).
- Extends SPEC-030 (controller — system signal emission on register/release/preempt).
- Extends SPEC-033 (architecture — Layer 4 orchestrator evolving from tool server to coordination plane; Layer 5 Redis as broadcast domain).
- Informs future adapter spec (Codex
AppServerInjectStrategy, ACP adapter).
- Informs SPEC-028 (TS MCP — subscriber thread pattern in TypeScript/Node.js).
16. Authorship
- Architecture + acceptance test: Frank — defined the Lola↔Donna test scenario as the driving requirement.
- Spec author: Lola (Claude Desktop, session 77016bac) 2026-04-23.
- Design constraint: MVP is Lola↔Donna; architecture must not block Codex/Cursor.
Status: draft