---
title: "Inter-agent communication"
description: "Built-in messaging system for coordination between multiple agent instances"
icon: "tower-broadcast"
---

Independent agent instances can exchange messages, delegate tasks, and coordinate work over Ed25519-signed channels. External systems (webhooks, scripts, stdin) can push plain-text events into a running agent's inbox. Supports TCP, Unix Domain Sockets, and in-process transports.

<Note>
Comms requires the `comms` Cargo feature to be compiled in.
</Note>

<Note>
This page is the task-first guide. For the low-level distinction between agent-side comms tools and host-side ingress APIs, see [Comms reference](/reference/comms-reference).
</Note>

## What this guide is for

Use this guide when you want:

- long-lived keep-alive sessions
- peer-to-peer agent messaging
- host-side external event ingress
- a practical operational understanding of the comms system

## Overview

The comms system provides:

- **Four LLM-facing tools**: `send_message`, `send_request`, `send_response`, and `peers`
- **Three transport layers**: Unix Domain Sockets (UDS), TCP, and in-process (`inproc`)
- **Ed25519 cryptographic identity**: Every agent has a keypair; all messages are signed
- **Trust-based peer model**: Agents only accept messages from explicitly trusted peers
- **External event ingestion**: Push plain-text events from stdin, webhooks, RPC, or TCP/UDS listeners
- **Auth-optional mode**: Signed listeners for agent-to-agent comms; separate plain listeners for external events when `auth = "none"`
- **Keep-alive**: A runtime-backed session processes its initial prompt, then remains alive waiting for future admitted work
- **Runtime-backed queueing**: Incoming messages and external events are queued once and admitted through runtime ingress

## Architecture

```mermaid
graph TD
    subgraph rt["Runtime (meerkat-runtime)"]
        DRAIN["comms_drain"]
        BRIDGE["comms_bridge"]
        INGRESS["MeerkatMachine input admission"]
        POLICY["PolicyTable (Queue/Steer)"]
    end

    subgraph comms["CommsRuntime (meerkat-comms)"]
        ROUTER["Router"]
        INBOX["Inbox"]
        KP["Keypair"]
        TP["TrustedPeers"]
    end

    DRAIN -->|"poll inbox"| INBOX
    DRAIN -->|"classify"| BRIDGE
    BRIDGE -->|"accept_input"| INGRESS
    INGRESS --> POLICY

    subgraph signed["Signed listeners (agent-to-agent)"]
        UDS["UDS Listener"]
        TCP["TCP Listener"]
        INPROC["Inproc Registry"]
    end

    subgraph plain["Plain listeners (external events)"]
        PTCP["TCP Event Listener"]
        PUDS["UDS Event Listener"]
        STDIN["Stdin Reader"]
        WEBHOOK["REST Webhook"]
        RPC_EVT["RPC comms/send"]
    end

    comms --> signed
    plain -->|"EventInjector"| INBOX
```

| Crate | Role |
|-------|------|
| `meerkat-comms` | Core comms: identity, trust, transport, router, inbox, runtime, MCP tools, agent integration |
| `meerkat-tools` (`builtin::comms`) | `CommsToolSurface`, `CommsToolSet`, individual `BuiltinTool` implementations |
| `meerkat-core` | `CommsRuntime` trait, `CommsRuntimeConfig`, `CommsRuntimeMode` enum, substrate integration points |
| `meerkat` (facade) | `build_comms_runtime_from_config()`, `compose_tools_with_comms()`, factory wiring |

## Setup

<Steps>
  <Step title="Enable comms feature">
    Ensure the `comms` Cargo feature is compiled in (enabled by default in the CLI and facade crate).
  </Step>
  <Step title="Generate identity">
    Identity is auto-generated on first run. Keys are stored under the realm runtime root (workspace `.rkat/identity/` for the default CLI workspace realm, realm data dir for non-workspace realms).
  </Step>
  <Step title="Configure trusted peers">
    Add peers to `trusted_peers.json` under the active runtime root with their name, public key, and address.
  </Step>
  <Step title="Run with comms enabled">
    Use `--comms-name <NAME>` on the CLI or set `enable_comms` on `AgentFactory`.
  </Step>
</Steps>

## Identity and cryptography

Each agent has an Ed25519 keypair managed by the `Keypair` type (`meerkat-comms/src/identity.rs`).

- **Key generation**: `Keypair::generate()` creates a new random keypair using `OsRng`.
- **Key persistence**: `Keypair::save(dir)` writes `identity.key` (mode `0600` on Unix) and `identity.pub` to disk. `Keypair::load(dir)` reads them back. `Keypair::load_or_generate(dir)` is the canonical entry point.
- **Public key format**: `PubKey` is a 32-byte Ed25519 public key. The canonical string format is `ed25519:<base64>` (standard Base64 with padding).
- **Default identity directory**: `<runtime-root>/.rkat/identity/`.

<Accordion title="Envelope signing">
Every message is wrapped in a signed `Envelope`:

```rust
pub struct Envelope {
    pub id: Uuid,       // Unique message ID
    pub from: PubKey,   // Sender's public key
    pub to: PubKey,     // Recipient's public key
    pub kind: MessageKind,
    pub sig: Signature,  // Ed25519 signature over canonical CBOR of [id, from, to, kind]
}
```

The signable bytes are computed by serializing `(id, from, to, kind)` as CBOR, then recursively sorting all map keys by canonical order (RFC 8949) before encoding. This ensures deterministic signing across implementations.
</Accordion>

## Trust model

Agents maintain a list of trusted peers in a `TrustedPeers` collection (`meerkat-comms/src/trust.rs`).

<Accordion title="TrustedPeer structure and file format">
```rust
pub struct TrustedPeer {
    pub name: String,     // Human-readable name (used by tools)
    pub pubkey: PubKey,   // Ed25519 public key
    pub addr: String,     // Address: "uds://...", "tcp://...", or "inproc://..."
    pub meta: PeerMeta,   // Friendly metadata for peer discovery (description, labels)
}
```

Stored as JSON at `<runtime-root>/.rkat/trusted_peers.json`:

```json
{
  "peers": [
    {
      "name": "coding-agent",
      "pubkey": "ed25519:KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=",
      "addr": "tcp://192.168.1.50:4200"
    },
    {
      "name": "review-agent",
      "pubkey": "ed25519:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=",
      "addr": "uds:///tmp/meerkat-review.sock"
    }
  ]
}
```
</Accordion>

### Trust enforcement

Incoming connections are validated in `handle_connection()` (`meerkat-comms/src/io_task.rs`):

<Steps>
  <Step title="Read envelope">
    Read the envelope from the stream (CBOR, length-prefixed).
  </Step>
  <Step title="Verify signature">
    Verify the Ed25519 signature (`envelope.verify()`).
  </Step>
  <Step title="Check trust">
    Check the sender is in the trusted peers list (`trusted.is_trusted(&envelope.from)`).
  </Step>
  <Step title="Verify recipient">
    Verify the envelope is addressed to us (`envelope.to == keypair.public_key()`).
  </Step>
  <Step title="Process or drop">
    If all pass: send an ACK (for `Message` and `Request` kinds), then enqueue to inbox. If any check fails: silently drop.
  </Step>
</Steps>

## Transport layer

All transports use a length-prefixed CBOR framing protocol implemented by `TransportCodec`.

**Wire format**: 4 bytes (big-endian) payload length followed by CBOR-encoded `Envelope` (up to 1 MB max).

### Address formats

| Scheme | Format | Use case |
|--------|--------|----------|
| `uds://` | `uds:///path/to/socket.sock` | Same-machine, lowest latency |
| `tcp://` | `tcp://host:port` | Cross-machine |
| `inproc://` | `inproc://agent-name` | In-process peer communication |

<Accordion title="Transport details">
**UDS transport**: Unix Domain Socket listeners are spawned by `spawn_uds_listener()`. The socket file is created at the configured path (existing files are removed first). Parent directories are created automatically.

**TCP transport**: TCP listeners are spawned by `spawn_tcp_listener()`. Accepts connections and processes each in a dedicated tokio task.

**Inproc transport**: The `InprocRegistry` (`meerkat-comms/src/inproc.rs`) is a process-global registry segmented by namespace. Meerkat uses realm-scoped namespaces so inproc peers from different realms are isolated by default. Messages are delivered directly in-memory without serialization.

- `InprocRegistry::global()` returns the singleton
- `register_with_meta_in_namespace(namespace, name, pubkey, sender, meta)` adds an agent in a namespace
- `unregister_in_namespace(namespace, pubkey)` removes an agent from a namespace
- `send_with_signature_in_namespace(namespace, from_keypair, to_name, kind, sign)` delivers in-memory within that namespace

When `CommsRuntime` is created, it automatically registers itself in the active namespace. When dropped, it unregisters from that namespace.
</Accordion>

## Message types

<Accordion title="MessageKind and Status">
```rust
#[serde(tag = "type", rename_all = "lowercase")]
pub enum MessageKind {
    Message { body: String },
    Request { intent: String, params: JsonValue },
    Response { in_reply_to: Uuid, status: Status, result: JsonValue },
    Ack { in_reply_to: Uuid },
}

#[serde(rename_all = "lowercase")]
pub enum Status {
    Accepted,
    Completed,
    Failed,
}
```
</Accordion>

### ACK behavior

| Message Kind | Sender waits for ACK? | Receiver sends ACK? |
|-------------|----------------------|---------------------|
| `Message` | Yes (with timeout) | Yes |
| `Request` | Yes (with timeout) | Yes |
| `Response` | No | No |
| `Ack` | No | No (would loop) |

ACK timeout defaults to 30 seconds. If no ACK is received, the send fails with `SendError::PeerOffline`.

<Accordion title="MessageIntent variants">
The `MessageIntent` enum provides type-safe intent values for requests:

| Variant | String | Description |
|---------|--------|-------------|
| `Delegate` | `"delegate"` | Delegate a task |
| `Status` | `"status"` | Request status update |
| `Cancel` | `"cancel"` | Cancel an operation |
| `Ack` | `"ack"` | Request acknowledgment |
| `Review` | `"review"` | Review something |
| `Calculate` | `"calculate"` | Request computation |
| `Query` | `"query"` | Request information |
| `Custom(String)` | (any string) | User-defined |

Standard strings are parsed into their enum variants; unknown strings become `Custom`.
</Accordion>

## LLM-facing tools

Four tools are exposed to the LLM when comms is enabled.

<Accordion title="send_message">
Send a collaboration message to a peer.

<ParamField path="to" type="string" required>
  Peer name to send message to.
</ParamField>

<ParamField path="body" type="string" required>
  Message content.
</ParamField>

<ParamField path="handling_mode" type="string" required>
  Delivery mode. Use `"queue"` for ordinary delivery or `"steer"` for immediate steer processing on runtime-backed sessions.
</ParamField>

**Response**: `{"status": "sent", "kind": "peer_message"}`
</Accordion>

<Accordion title="send_request">
Send a structured ask to a peer when you want explicit `intent + params` and a correlated response via `send_response`.

<ParamField path="to" type="string" required>
  Peer name to send request to.
</ParamField>

<ParamField path="intent" type="string" required>
  Request intent/action (e.g., `"review"`, `"delegate"`).
</ParamField>

<ParamField path="params" type="object">
  Request parameters. Defaults to `null`.
</ParamField>

<ParamField path="handling_mode" type="string" required>
  Delivery mode. Use `"queue"` for ordinary delivery or `"steer"` for immediate steer processing on runtime-backed sessions.
</ParamField>

**Response**: `{"status": "sent", "kind": "peer_request"}`

<Note>
Use `send_message` by default for ordinary collaboration. `send_request` is for structured ask/reply semantics only. It is not task tracking, a stronger delivery mode, or a reserved response channel.
</Note>
</Accordion>

<Accordion title="send_response">
Send a response to a previously received request.

<ParamField path="to" type="string" required>
  Peer name to send response to.
</ParamField>

<ParamField path="in_reply_to" type="string" required>
  ID of the request being responded to (UUID).
</ParamField>

<ParamField path="status" type="string" required>
  One of `"accepted"`, `"completed"`, `"failed"`.
</ParamField>

<ParamField path="result" type="object">
  Response result data. Defaults to `null`.
</ParamField>

<ParamField path="handling_mode" type="string">
  Optional delivery mode override. Use `"queue"` for normal delivery or `"steer"` when you need immediate steer processing on runtime-backed sessions.
</ParamField>

**Response**: `{"status": "sent", "kind": "peer_response"}`

<Note>
The LLM-facing comms tools return lightweight tool receipts. Host-side `comms/send` returns richer typed receipts such as `peer_message_sent`, `peer_request_sent`, and `peer_response_sent`, with identifiers and ACK-related data for application code.
</Note>
</Accordion>

<Accordion title="peers">
List all discoverable peers and their addresses.

This includes both configured trusted peers and in-process peers currently
registered in `InprocRegistry`. Results are de-duplicated by name and exclude
the current agent.

**Input**: Empty object `{}`

**Response**:

```json
{
  "peers": [
    {
      "name": "review-agent",
      "peer_id": "ed25519:...",
      "address": "tcp://192.168.1.50:4200"
    }
  ]
}
```
</Accordion>

### Tool availability

Comms tools are conditionally available based on peer/trust configuration in the
surface. `CommsToolSurface::peer_availability()` checks whether `TrustedPeers`
has any configured peers (`TrustedPeers::has_peers()`). Tools are hidden when no
peers are configured.

## Inbox

The `Inbox` (`meerkat-comms/src/inbox.rs`) is a bounded MPSC channel (default capacity: 1024) with a `Notify` mechanism for waking waiting tasks.

- `InboxSender::send(item)` enqueues an item and calls `notify.notify_waiters()`
- `Inbox::try_drain()` returns all currently available items without blocking
- `Inbox::recv()` blocks until a message is available

When converting to `CommsMessage` for the agent loop, ACKs and messages from unknown peers are filtered out.

Queueing an inbox item and consuming it are separate truths:

- queueing is transport/inbox work
- consumption is runtime-backed admission work

This avoids the old assumption that turn-boundary drain is always the only real consumer.

## Configuration

<Accordion title="Config file (realm config.toml)">
```toml
[comms]
mode = "inproc"                    # "inproc", "tcp", or "uds"
address = "0.0.0.0:4200"          # Required when mode = "tcp" or "uds"
auth = "none"                      # "none" (open) or "ed25519"
event_address = "127.0.0.1:4201"  # Plain-text event listener (requires auth = "none")
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `mode` | `CommsRuntimeMode` | `Inproc` | Transport mode |
| `address` | `Option<String>` | `None` | Listen address for signed agent-to-agent comms |
| `auth` | `CommsAuthMode` | `Open` | Auth mode: `"none"` (open) or `"ed25519"` |
| `event_address` | `Option<String>` | `None` | Plain-text event listener address (requires `auth = "none"`) |
</Accordion>

<Accordion title="CoreCommsConfig (internal)">
The `CoreCommsConfig` is the internal config used by `CommsRuntime`:

```rust
pub struct CoreCommsConfig {
    pub enabled: bool,
    pub name: String,                        // Default: "meerkat"
    pub inproc_namespace: Option<String>,    // Realm-scoped namespace for inproc isolation
    pub listen_uds: Option<PathBuf>,
    pub listen_tcp: Option<SocketAddr>,
    pub identity_dir: PathBuf,               // Default: "<runtime-root>/.rkat/identity"
    pub trusted_peers_path: PathBuf,         // Default: "<runtime-root>/.rkat/trusted_peers.json"
    pub ack_timeout_secs: u64,               // Default: 30
    pub max_message_bytes: usize,            // Default: 1,048,576
}
```

Paths support `{name}` interpolation (replaced with the agent's comms name). Relative paths are resolved against the base directory via `resolve_paths(base_dir)`.
</Accordion>

## CLI usage

| Flag | Description |
|------|-------------|
| `--comms-name <NAME>` | Agent name for peer identification. Enables comms if set. |
| `--agent-description <TEXT>` | Human-readable description shown to peers via `peers()` |
| `--agent-label key=value` | Metadata label (repeatable). Shown to peers via `peers()` |
| `--comms-listen-tcp <ADDR>` | TCP address to listen on for signed comms (e.g., `"0.0.0.0:4200"`) |
| `--no-comms` | Disable inter-agent communication entirely |
| `--keep-alive` | Run in keep-alive mode (stay alive for comms messages and events after initial prompt) |
| `--stdin` | Read external events from stdin (newline-delimited, only meaningful with `--keep-alive`) |

<Note>
All comms flags require the `comms` feature at compile time (`#[cfg(feature = "comms")]`).
</Note>

### Peer metadata

Agents can advertise a description and arbitrary labels so that peers can discover *what* each agent does — not just its name. This metadata flows through to the `peers()` tool output.

```bash
# Agent with description and labels
rkat run --comms-name reviewer \
  --agent-description "Reviews pull requests for style issues" \
  --agent-label lang=rust \
  --agent-label team=infra \
  --keep-alive "You review code when asked."
```

Via the Rust SDK:

```rust
use meerkat_core::PeerMeta;

let build_config = AgentBuildConfig {
    keep_alive: true,
    comms_name: Some("reviewer".to_string()),
    peer_meta: Some(PeerMeta::default()
        .with_description("Reviews pull requests for style issues")
        .with_label("lang", "rust")),
    ..AgentBuildConfig::new("claude-sonnet-4-6")
};
```

When another agent calls `peers()`, the output includes the metadata:

```json
{
  "peers": [{
    "name": "reviewer",
    "peer_id": "ed25519:...",
    "address": "inproc://reviewer",
    "description": "Reviews pull requests for style issues",
    "labels": {"lang": "rust", "team": "infra"}
  }]
}
```

### Keep-alive mode

Keep-alive keeps the agent alive after processing the initial prompt, waiting for runtime-backed comms messages and external events to be admitted as future turns:

```bash
# Keep-alive with comms only
rkat run --comms-name orchestrator --keep-alive "You are a project coordinator."

# Keep-alive with stdin events
rkat run --comms-name monitor --keep-alive --stdin "You are a monitoring agent."
```

<Steps>
  <Step title="Process initial prompt">
    The agent processes the initial prompt normally.
  </Step>
  <Step title="Enter idle runtime-backed session">
    After the first run completes, the session remains alive in keep-alive mode and waits for future admitted work.
  </Step>
  <Step title="Handle incoming messages and events">
    When comms messages or external events arrive, runtime ingress queues them and admits them as later turns. External events are queue-only runtime-backed inputs; they do not create a second direct execution loop.
  </Step>
  <Step title="Exit conditions">
    The agent exits when it receives a `DISMISS` message, its budget is exhausted, or it encounters a graceful error.
  </Step>
</Steps>

## SDK / programmatic usage

<Accordion title="Building a comms runtime">
```rust
use meerkat::build_comms_runtime_from_config;

let runtime = build_comms_runtime_from_config(&config, base_dir, "my-agent").await?;
```

This function reads `config.comms.mode` and creates the appropriate runtime:
- `Inproc` -- calls the scoped inproc constructor (`CommsRuntime::inproc_only_scoped`) with realm namespace when available
- `Tcp` -- creates a full runtime with `CommsRuntime::new()` and starts TCP listeners
- `Uds` -- creates a full runtime with `CommsRuntime::new()` and starts UDS listeners
</Accordion>

<Accordion title="Composing tools with comms">
```rust
use meerkat::compose_tools_with_comms;

let (tools_with_comms, usage_instructions) =
    compose_tools_with_comms(base_tools, tool_usage_instructions, &runtime)?;
```

This wraps the base tool dispatcher with comms tools via `ToolGateway`, registering `send_message`, `send_request`, `send_response`, and `peers`.
</Accordion>

<Accordion title="Using AgentFactory">
```rust
let factory = AgentFactory::new(store_path)
    .project_root(project_root)
    .comms(true);

let build_config = AgentBuildConfig {
    model: "claude-sonnet-4-6".to_string(),
    keep_alive: true,
    comms_name: Some("my-agent".to_string()),
    peer_meta: Some(PeerMeta::default()
        .with_description("Coordinates peer agents")),
    ..AgentBuildConfig::new("claude-sonnet-4-6")
};

let agent = factory.build_agent(build_config, &config).await?;
```

The factory validates `comms_name`, creates the runtime, composes tools, attaches the runtime to the agent, and records `keep_alive` and `peer_meta` in `SessionMetadata`.
</Accordion>

<Accordion title="Using CommsAgent directly">
For low-level control:

```rust
use meerkat_comms::agent::{CommsAgent, CommsManager, CommsManagerConfig};

let config = CommsManagerConfig::with_keypair(keypair)
    .trusted_peers(trusted_peers)
    .comms_config(comms_config);
let comms_manager = CommsManager::new(config)?;

let agent = AgentBuilder::new()
    .model("claude-sonnet-4-6")
    .build(llm_client, tools, store)
    .await?;

let mut comms_agent = CommsAgent::new(agent, comms_manager);

// Run with comms inbox draining
let result = comms_agent
    .run("Use send_message with handling_mode=queue to greet agent-b".to_string())
    .await?;

// Or run in stay-alive mode
let result = comms_agent.run_stay_alive("You are a coordinator.".to_string(), None).await?;
```
</Accordion>

<Accordion title="CommsBootstrap (nested runtime integration)">
```rust
// Standalone
let bootstrap = CommsBootstrap::from_config(config, base_dir);
let prepared = bootstrap.prepare().await?;

// Child runtime (inproc, auto-trusts parent)
let bootstrap = CommsBootstrap::for_child_inproc(
    "child-agent".to_string(),
    ParentCommsContext {
        parent_name: "parent".to_string(),
        parent_pubkey: parent_pubkey_bytes,
        parent_addr: "inproc://parent".to_string(),
        comms_base_dir: base_dir,
    },
);
let prepared = bootstrap.prepare().await?;
```

`PreparedComms` contains:
- `runtime: CommsRuntime` -- ready to use
- `advertise: Option<CommsAdvertise>` -- for child agents, contains the name/pubkey/addr to register with the parent
</Accordion>

## Agent loop integration

### Inbox consumption

The important architectural split is:

1. `CommsRuntime` and the inbox own **delivery**
2. the active keep-alive/drain lifecycle path owns **consumption**
3. formatted message injection into the session is a projection of that lifecycle truth

Historically this was sometimes described as "turn-boundary inbox draining." That is not the primary mental model anymore. The canonical model is runtime-backed queueing and admission.

### Transcript and model projection

Incoming comms are persisted as typed `system_notice` blocks with `comms` payloads. `role=user` remains reserved for human/operator-authored text. The runtime projects those typed blocks into provider-facing text only while assembling model input:

- **Message**: `comms.kind = "message"` plus peer identity and optional content blocks.
- **Request**: `comms.kind = "request"` plus peer identity, request id, intent, params payload, and response guidance in the model projection.
- **Response**: `comms.kind = "response_terminal"` or `response_progress` plus peer identity, request id, status, and payload.

## Typed peer lifecycle notices

Some peer lifecycle notices are informational and should not trigger an LLM turn. Mob lifecycle routing is now typed at peer ingress instead of relying on `silent_comms_intents` folklore:

- `mob.peer_added`
- `mob.peer_retired`
- `mob.peer_unwired`

These are injected as lifecycle context, not ordinary actionable requests.

Kickoff anomalies are different:

- `mob.kickoff_failed`
- `mob.kickoff_cancelled`

These are visible lifecycle notices because they tell the parent or wired peers that a helper's initial autonomous turn failed before it could reliably report for itself.

Peer lifecycle notifications are compacted per inbox drain cycle into a single `[PEER UPDATE]` session entry. This compaction is presentation-only: the underlying comms request stream remains one event per peer change.

You can also control whether these peer lifecycle updates are inlined by peer-count threshold using `max_inline_peer_notifications`:

- `None` (default): use runtime default (`50`)
- `0`: never inline peer lifecycle updates
- `-1`: always inline
- `>0`: inline only when current peer count is less than or equal to the threshold
- `< -1`: invalid (rejected by mob definition validation; factory builds also reject)

When suppressed above threshold, the agent receives a one-time notice and should use `peers()` on demand. If peer count later drops back below threshold, the next inline update includes a "resumed" notice.

Inline summaries show up to 10 peer names per category (`connected` / `retired`), then append `(+N more)` to cap token usage.

## External event ingestion

External systems can push events into a running agent without Ed25519 authentication. Surface-level convenience routes now admit those events through runtime-owned `ExternalEvent` inputs instead of a separate injector-owned execution path.

### Event sources

| Source | Surface | How to use | Auth |
|--------|---------|-----------|------|
| Stdin | CLI | `--stdin` flag (with `--keep-alive`) | None |
| Webhook | REST | `POST /sessions/{id}/external-events` | `RKAT_WEBHOOK_SECRET` env var |
| RPC | JSON-RPC | `session/external_event` method | None (implicit) |
| TCP listener | Comms | Configure `event_address` in config | `auth = "none"` required |
| UDS listener | Comms | Configure `event_address` in config | `auth = "none"` required |

### CLI stdin events

Read newline-delimited events from stdin. Each line is parsed as JSON (extracting `body` field if present) or treated as plain text.

```bash
# Keep-alive with stdin events
rkat run "You are a monitoring agent." \
  --comms-name monitor \
  --keep-alive --stdin lines \
  --tools safe

# Then type or pipe events:
echo '{"body":"deployment failed on prod"}' | ...
```

### REST webhook

Push events to a running session via HTTP. Auth is optional via `RKAT_WEBHOOK_SECRET` env var with constant-time comparison.

```bash
# Without auth (localhost only)
curl -X POST http://localhost:8080/sessions/sid_abc/external-events \
  -H "Content-Type: application/json" \
  -d '{"alert": "CPU spike", "host": "web-03"}'

# With webhook secret
RKAT_WEBHOOK_SECRET=my-secret  # set on server
curl -X POST http://localhost:8080/sessions/sid_abc/external-events \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Secret: my-secret" \
  -d '{"alert": "deployment failed"}'
```

Returns `202 Accepted` with `{"queued": true}`. Runtime admission failures are returned as ordinary REST errors.

### RPC session/external_event

Queue a runtime-backed external event for a running session via JSON-RPC.

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "session/external_event",
  "params": {
    "session_id": "sid_abc123",
    "payload": {"alert": "build failed"},
    "source": "rpc"
  }
}
```

The optional `source` field becomes the runtime external-event source. The method returns a runtime acceptance envelope on success.

### TCP/UDS plain event listeners

When `auth = "none"` in config, a separate plain-text listener starts on `event_address` for unauthenticated external events. The signed agent-to-agent listener is never replaced.

```toml config.toml (active realm)
[comms]
mode = "tcp"
address = "127.0.0.1:4200"         # Signed agent-to-agent listener
auth = "none"
event_address = "127.0.0.1:4201"   # Plain-text event listener
```

```bash
# Push events via netcat
echo '{"body":"hello from external system"}' | nc 127.0.0.1 4201
```

### Event transcript format

External events are persisted as typed `external_event` system-notice blocks with `source`, `event_type`, optional `body`, payload, and content blocks. The provider-facing source/body text is an internal projection and is not stored as user-authored transcript text.

- **Stdin**: `source = "stdin"`
- **Webhook**: `source = "webhook"`
- **RPC**: `source = "rpc"` with optional structured source metadata.
- **TCP/UDS**: `source = "tcp"` or `source = "uds"`

### Delivery vs observation

When injecting events from application code, delivery and observation are separate concerns.

**Delivery** — the service's `event_injector()` returns an `Arc<dyn EventInjector>`. Use `inject()` to push an external event into the session inbox:

```rust
use meerkat_core::{EventInjector, PlainEventSource};

let injector = service.event_injector(&session_id).await
    .expect("comms must be enabled");

injector.inject(
    "review PR #42".into(),
    PlainEventSource::Rpc,
)?;
```

**Observation** — subscribe at the scope you need:

- **session-scoped**: use the session's primary `event_tx` or `session/stream_open` (RPC) / `Session.subscribe()` (Web SDK) / `session.subscribe_events()` (Python SDK) / `session.subscribeEvents()` (TypeScript SDK)
- **agent-scoped**: use `MobHandle::subscribe_agent_events(...)` or `mob/stream_open` (RPC) / `Member.subscribe()` or `Mob.subscribeMemberEvents(agentIdentity)` (Web SDK) / `mob.subscribe_member_events(agent_identity)` (Python/TypeScript SDK)
- **mob-scoped**: use `MobHandle::subscribe_mob_events(...)` or `mob/stream_open` (RPC) / `Mob.subscribeEvents()` (Web SDK) / `mob.subscribe_events()` (Python/TypeScript SDK)

This makes the public contract explicit: comms delivers work, and observability chooses scope.

**Requirements:**

- The agent must be running in **keep-alive mode** if you expect inbox events to be drained promptly.
- **Comms must be enabled.** Without comms, `event_injector()` returns `None`.
- Observation subscriptions are independent of delivery — configure them separately.

## Security

<Warning>
The current implementation does not include nonce-based replay prevention.
</Warning>

- **All messages are signed** with Ed25519 using canonical CBOR encoding
- **Trust is explicit**: only messages from peers in `trusted_peers.json` are accepted
- **Misaddressed messages are dropped**: the receiver verifies `envelope.to` matches its own public key
- **Private keys are stored with restrictive permissions**: `identity.key` is written with mode `0600` on Unix
- **Secret bytes are zeroized**: `Keypair::from_secret()` zeroizes the input after copying
- **ACK validation**: ACK signatures, sender, recipient, and `in_reply_to` ID are all verified

## See also

- [Built-in tools reference](/reference/builtin-tools) - comms tool parameter details
- [Configuration: comms](/concepts/configuration) - config file settings
- [Examples: comms](/examples/comms) - keep-alive mode and messaging across surfaces
