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

# Inter-agent communication

> Built-in messaging system for coordination between multiple agent instances

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 theme={null}
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 theme={null}
  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 theme={null}
  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 theme={null}
  {
    "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 theme={null}
  #[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 theme={null}
  {
    "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 theme={null}
  [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 theme={null}
  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 theme={null}
# 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 theme={null}
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 theme={null}
{
  "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 theme={null}
# 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 theme={null}
  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 theme={null}
  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 theme={null}
  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 theme={null}
  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 theme={null}
  // 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 theme={null}
# 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 theme={null}
# 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 theme={null}
{
  "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) theme={null}
[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 theme={null}
# 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 theme={null}
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
