Skip to main content
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.
Comms requires the comms Cargo feature to be compiled in.

Overview

The comms system provides:
  • Two LLM-facing tools: send (with kind parameter for message/request/response), 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

CrateRole
meerkat-commsCore comms: identity, trust, transport, router, inbox, runtime, MCP tools, agent integration
meerkat-tools (builtin::comms)CommsToolSurface, CommsToolSet, individual BuiltinTool implementations
meerkat-coreCommsRuntime trait, CommsRuntimeConfig, CommsRuntimeMode enum, substrate integration points
meerkat (facade)build_comms_runtime_from_config(), compose_tools_with_comms(), factory wiring

Setup

1

Enable comms feature

Ensure the comms Cargo feature is compiled in (enabled by default in the CLI and facade crate).
2

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).
3

Configure trusted peers

Add peers to trusted_peers.json under the active runtime root with their name, public key, and address.
4

Run with comms enabled

Use --comms-name <NAME> on the CLI or set enable_comms on AgentFactory.

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/.
Every message is wrapped in a signed Envelope:
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.

Trust model

Agents maintain a list of trusted peers in a TrustedPeers collection (meerkat-comms/src/trust.rs).
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:
{
  "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"
    }
  ]
}

Trust enforcement

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

Read envelope

Read the envelope from the stream (CBOR, length-prefixed).
2

Verify signature

Verify the Ed25519 signature (envelope.verify()).
3

Check trust

Check the sender is in the trusted peers list (trusted.is_trusted(&envelope.from)).
4

Verify recipient

Verify the envelope is addressed to us (envelope.to == keypair.public_key()).
5

Process or drop

If all pass: send an ACK (for Message and Request kinds), then enqueue to inbox. If any check fails: silently drop.

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

SchemeFormatUse case
uds://uds:///path/to/socket.sockSame-machine, lowest latency
tcp://tcp://host:portCross-machine
inproc://inproc://agent-nameIn-process peer communication
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.

Message types

#[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,
}

ACK behavior

Message KindSender waits for ACK?Receiver sends ACK?
MessageYes (with timeout)Yes
RequestYes (with timeout)Yes
ResponseNoNo
AckNoNo (would loop)
ACK timeout defaults to 30 seconds. If no ACK is received, the send fails with SendError::PeerOffline.
The MessageIntent enum provides type-safe intent values for requests:
VariantStringDescription
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.

LLM-facing tools

Two tools are exposed to the LLM when comms is enabled.
Send a fire-and-forget text message to a peer.
to
string
required
Peer name to send message to.
kind
string
required
Must be "peer_message".
body
string
required
Message content.
Response: {"status": "sent"}
Send a request to a peer. The sender waits for an ACK (not the response itself).
to
string
required
Peer name to send request to.
kind
string
required
Must be "peer_request".
intent
string
required
Request intent/action (e.g., "review", "delegate").
params
object
Request parameters. Defaults to null.
Response: {"status": "sent"}
Send a response to a previously received request.
to
string
required
Peer name to send response to.
kind
string
required
Must be "peer_response".
in_reply_to
string
required
ID of the request being responded to (UUID).
status
string
required
One of "accepted", "completed", "failed".
result
object
Response result data. Defaults to null.
Response: {"status": "sent"}
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:
{
  "peers": [
    {
      "name": "review-agent",
      "peer_id": "ed25519:...",
      "address": "tcp://192.168.1.50:4200"
    }
  ]
}

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

[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")
FieldTypeDefaultDescription
modeCommsRuntimeModeInprocTransport mode
addressOption<String>NoneListen address for signed agent-to-agent comms
authCommsAuthModeOpenAuth mode: "none" (open) or "ed25519"
event_addressOption<String>NonePlain-text event listener address (requires auth = "none")
The CoreCommsConfig is the internal config used by CommsRuntime:
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).

CLI usage

FlagDescription
--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=valueMetadata 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-commsDisable inter-agent communication entirely
--keep-aliveRun in keep-alive mode (stay alive for comms messages and events after initial prompt)
--stdinRead external events from stdin (newline-delimited, only meaningful with --keep-alive)
All comms flags require the comms feature at compile time (#[cfg(feature = "comms")]).

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.
# 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:
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-5")
};
When another agent calls peers(), the output includes the metadata:
{
  "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:
# 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."
1

Process initial prompt

The agent processes the initial prompt normally.
2

Enter idle runtime-backed session

After the first run completes, the session remains alive in keep-alive mode and waits for future admitted work.
3

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.
4

Exit conditions

The agent exits when it receives a DISMISS message, its budget is exhausted, or it encounters a graceful error.

SDK / programmatic usage

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
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 and peers.
let factory = AgentFactory::new(store_path)
    .project_root(project_root)
    .comms(true);

let build_config = AgentBuildConfig {
    model: "claude-sonnet-4-5".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-5")
};

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.
For low-level control:
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-5")
    .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 with kind=peer_message 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?;
// 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

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 0.5 mental model. The canonical model is runtime-backed queueing and admission.

Message injection format

Incoming messages are formatted as text for the LLM:
  • Message: [COMMS MESSAGE from <peer>]\n<body>
  • Request: [COMMS REQUEST from <peer> (id: <uuid>)]\nIntent: <intent>\nParams: <json>\n\nTo respond, use send with kind=peer_response, peer="<peer>", request_id="<uuid>"
  • Response: [COMMS RESPONSE from <peer> (to request: <uuid>)]\nStatus: <status>\nResult: <json>

Silent comms intents

Some comms intents are informational and should not trigger an LLM turn. For example, mob.peer_added and mob.peer_retired notifications tell the agent about peer lifecycle changes but don’t require a response. Silent comms intents are configured via AgentBuildConfig.silent_comms_intents. When a Request arrives whose intent matches a silent intent, it is injected into the session context (like a response) instead of being processed through the LLM. This avoids wasting an LLM turn on a notification that the prompt already says to ignore.
let mut config = AgentBuildConfig::new("claude-sonnet-4-5");
config.silent_comms_intents = vec![
    "mob.peer_added".into(),
    "mob.peer_retired".into(),
];
Mob meerkats have mob.peer_added and mob.peer_retired configured as silent intents by default.
Silent intents are only checked in the host-mode classification phase. Non-matching intents are processed normally through the LLM.
Peer lifecycle notifications (mob.peer_added, mob.peer_retired) 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

SourceSurfaceHow to useAuth
StdinCLI--stdin flag (with --keep-alive)None
WebhookRESTPOST /sessions/{id}/external-eventsRKAT_WEBHOOK_SECRET env var
RPCJSON-RPCsession/external_event methodNone (implicit)
TCP listenerCommsConfigure event_address in configauth = "none" required
UDS listenerCommsConfigure event_address in configauth = "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.
# Keep-alive with stdin events
rkat run "You are a monitoring agent." \
  --comms-name monitor \
  --keep-alive --stdin \
  --enable-builtins

# 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.
# 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.
{
  "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.
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
# Push events via netcat
echo '{"body":"hello from external system"}' | nc 127.0.0.1 4201

Event injection format

Events are injected into the agent’s context with source tagging:
  • Stdin: [EVENT via stdin] <body>
  • Webhook: [EVENT via webhook] <body>
  • RPC: [EVENT via rpc] <body> (with optional [source: name] prefix)
  • TCP/UDS: [EVENT via tcp] <body> or [EVENT via uds] <body>

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:
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(meerkatId) (Web SDK) / mob.subscribe_member_events(meerkat_id) (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

The current implementation does not include nonce-based replay prevention.
  • 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