> ## Documentation Index
> Fetch the complete documentation index at: https://prism.ntecdev.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# SPEC-034 v0.1: Agent-to-Agent Signal Delivery — bidirectional communication between Prism agents

> Agent-to-Agent Signal Delivery — bidirectional communication between Prism agents

<Info>**Status:** `draft` · **Version** `0.1` · Filed 2026-04-23</Info>

# 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:

```json theme={null}
{
  "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

1. Bidirectional signal delivery works (Lola→Donna and Donna→Lola).
2. Two different delivery strategies work in the same flow (push for Code, piggyback for Desktop).
3. Agents compose structured messages and act on them autonomously.
4. Identity-targeted addressing resolves correctly ("Donna" → her current session).
5. Acknowledgment closes the loop — sender knows the message landed.
6. 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:

1. Query Redis: scan `prism:project:{tenant}:{project}:sessions` set.
2. For each session, `HGET prism:session:{id} agent_identity`.
3. 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

```python theme={null}
@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.

### 5.3 Redis message format

```json theme={null}
{
  "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

1. Validate `signal_type` against known types.
2. Generate `signal_id` (UUID).
3. Persist to Postgres `signal_queue` table (always — durable record).
4. 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`.
5. If `to="*"` → PUBLISH to `prism:events:project:{tenant}:{project}`. Also persist.
6. 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

```python theme={null}
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:

```python theme={null}
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.

```python theme={null}
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.

```sql theme={null}
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:

```python theme={null}
# 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:

```json theme={null}
{
  "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:

* [ ] `SignalDeliveryStrategy` is an ABC, not a concrete class — new strategies plug in without modifying existing code.
* [ ] `STRATEGY_MAP` is configuration, not hardcoded logic — adding `"codex": AppServerInjectStrategy` is a one-line change.
* [ ] Signal schema is agent-agnostic — no Claude-specific fields in `PrismSignal`.
* [ ] Identity resolution is backend-side — agents don't need to know each other's session IDs.
* [ ] `signal_queue` persistence ensures signals survive across any agent restart pattern, regardless of agent runtime.
* [ ] Redis message format is plain JSON — any subscriber (Python, TypeScript, Elixir Codex SDK) can deserialize it.
* [ ] Broadcast signals (`to="*"`) work for any number of subscribers on the project channel.

## 14. Acceptance criteria

1. `prism_signal` verb exists and is callable from both Claude Desktop and Claude Code MCP servers.
2. Identity resolution: `to="Donna"` resolves to Donna's current active session via Redis lookup.
3. Online delivery: signal sent to an online agent is delivered within 5 seconds (push) or on next verb call (piggyback).
4. Offline queuing: signal sent to an offline agent is persisted in `signal_queue` and delivered on target's next `prism_start`.
5. Piggyback delivery: Lola receives signals appended to Prism verb responses as `pending_signals[]`.
6. Push delivery: Donna receives signals inline in her Claude Code session via MCP channels.
7. Threading: `in_reply_to` correctly links reply signals to their parent.
8. System signals: `MasterPreempted`, `PeerJoined`, `PeerLeft` are emitted by the controller and received by the subscriber thread.
9. Broadcast: `to="*"` delivers to all active agents on the project.
10. **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.
11. Smoke test `mcp/smoke_spec034_signal.py` covers send → receive → reply → ack round trip.
12. `signal_queue` table exists with correct schema and indexes.
13. Strategy selection is driven by `PRISM_AGENT_SURFACE` env var — no hardcoded agent detection.
14. 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
