# Messaging

> The full messaging system: DID mesh messaging, delivery, security, and per-platform channel adapter setup walkthroughs

Source: https://github.com/christianbalevski/adf/blob/v0.7.4/docs/guides/messaging.md (adf v0.7.4)

ADF agents communicate through an asynchronous message-passing protocol built on DIDs (Decentralized Identifiers) and delivery URLs. This guide covers how messaging works, from basic sends to multi-agent collaboration.

## Overview

Each agent has two message stores:

- **Inbox** (`adf_inbox`) — Messages received from other agents or external platforms
- **Outbox** (`adf_outbox`) — Messages sent to other agents or external platforms

Messages are delivered using a DID+address model: the sender specifies the recipient's DID (for identity verification) and delivery URL (for routing). The full message (including attachments) is persisted in both sender's outbox and receiver's inbox for auditability. Delivery is a **single attempt** — there is no sender-side retry, queue, or automatic re-delivery. Recovering from a missed delivery is agent-driven (see [Store-and-Forward Reality](#store-and-forward-reality)).

### Source-Based Transport

Every message has a `source` field indicating its transport origin:

| Source | Description |
|--------|-------------|
| `mesh` | Default — delivered via the ADF mesh (local or HTTP) |
| `telegram` | Delivered via the Telegram channel adapter |
| `discord` | Delivered via the Discord channel adapter |
| `email` | Delivered via the Email channel adapter (IMAP) |
| `slack` | Delivered via the Slack channel adapter (Socket Mode) |
| `whatsapp` | Delivered via the WhatsApp channel adapter (Baileys) |

The `source_context` field stores platform-specific metadata from the originating platform. This enables reply threading and multi-recipient handling:

- **Telegram:** `chat_id`, `message_id`, `reply_to_message_id`, `chat_type`
- **Discord:** `channel_id`, `guild_id`, `message_id`, `channel_type` (`dm` or `guild`), `username`, `reply_to_message_id`
- **Email:** `message_id`, `to` (all recipients), `cc` (CC recipients), `in_reply_to`, `references`
- **Slack:** `chat_id` (channel id), `channel_type` (`im`/`mpim`/`channel`/`group`), `team_id`, `message_id` (the Slack `ts`), `thread_ts`, `username`, `reply_to_message_id` (set to `thread_ts` for thread replies)
- **WhatsApp:** `chat_id` (JID), `chat_type` (`dm`/`group`), `message_id`, `username` (push name), `sender_jid` (author inside a group), `reply_to_message_id` (quoted message stanza id)

The `original_message` field stores the raw platform message before ADF normalization (the full RFC 822 email source, the Telegram/Slack/Discord/WhatsApp message JSON). It is stripped from `msg_read` results by default; pass `include_original: true` to read it when the normalized fields aren't enough.

> **Agent-facing quick reference**: the dense contract reference for all of this — addressing, content types, form render contracts, answer flow — is [channels.md](https://agentdocumentformat.org/guides/channels). This guide is the narrative/setup documentation.

### Group Context (`meta.group`)

Adapter messages arriving from a group chat carry descriptive chat context in the inbox `meta` column under the single key `group` (visible via `msg_read`). It is deliberately separate from `source_context`, which is reply-routing data that gets echoed onto outbound replies.

```jsonc
"meta": {
  "group": {
    "platform": "slack",
    "chat_id": "C0123ABC",
    "chat_type": "channel",
    "title": "#project-x",
    "description": "Cross-team project channel",
    "participants": [{ "id": "U01AA", "name": "Alice" }],  // capped at 20
    "participant_count": 42,          // true total (may exceed the list)
    "participants_truncated": true,
    "participants_scope": "page"      // what the list represents — see below
  }
}
```

What each platform can report:

| Platform | `chat_id` | `title` | `participants` | `participants_scope` | `participant_count` |
|----------|-----------|---------|----------------|----------------------|---------------------|
| telegram | chat id | chat title | admins only — the Bot API cannot enumerate members | `admins` | `getChatMemberCount` |
| discord | channel id | `#channel-name` (+ guild name in `description`) | users mentioned in the message (+ author). A full roster is not currently available — the adapter does not request the privileged GuildMembers gateway intent, so `config.fetch_members: true` is not yet functional | `mentions` | guild member count |
| slack | channel id | `#channel-name` (+ topic/purpose in `description`) | first page of `conversations.members` (20) | `page` | `num_members` |
| whatsapp | group JID | group subject | full participant list with roles (names unavailable — JIDs only) | `all` | participants length |
| email | `email:<thread-root message-id>` (sender address when no thread id) | subject | all `to` + `cc` addresses | `all` | recipient count |

Participant lists are always capped at 20 entries with `participants_truncated` and `participant_count` telling the agent what was cut. Metadata fetches are cached (~10 min for successes, ~1 min for failures) and a failed fetch never drops the message — the group meta degrades to title-only or is omitted. The fetch itself runs before ingestion, so a slow platform API can delay delivery of that message.

### Chat Lookup From Code (`adf.chat_info`)

When `meta.group` isn't enough, agents can query a chat's live metadata through a connected adapter from sandbox code:

```javascript
const info = await adf.chat_info({ adapter: 'slack', chat_id: 'C0123ABC', limit: 50 })
// → { platform, chat_id, chat_type, title, description, participant_count,
//     participants: [{id, name?, role?}], participants_truncated, participants_scope, fetched_at }
// or { supported: false, reason } — e.g. adapter not connected, or email (no live roster)
```

`chat_info` is read-only — it never sends, joins, or mutates platform state. It ships enabled but **not visible**: it doesn't occupy a slot in the LLM tool schema and is intended to be called as `adf.chat_info` from `sys_code`/lambdas — though, like any enabled tool, it remains callable by name (e.g. through `adf_shell`) even while invisible. Flip `visible: true` in the agent's tool config to expose it as a visible tool. Email doesn't implement it (no live query surface) — thread recipients are already in `source_context.to`/`cc`.

## Sending Messages

Agents send messages using the `msg_send` tool. There are three addressing modes:

### Mode 1: Direct Send (recipient + address)

Provide the recipient's DID and delivery URL:

```
msg_send(
  recipient: "did:adf:9gvayMZx5m...",
  address: "http://127.0.0.1:7295/agents/monitor/inbox",
  content: "Status update"
)
```

### Mode 2: Reply via parent_id

Provide a `parent_id` referencing an inbox message — the runtime resolves the recipient DID from the message's `from` field and the delivery URL from `reply_to`:

```
msg_send(
  parent_id: "msg-abc123",
  content: "Got it, thanks!"
)
```

### Mode 3: Bare handle (local runtime only)

Provide a bare handle as `recipient` with no `address` and no `parent_id`. The runtime resolves the address from the local mesh registry and enforces the recipient's visibility tier:

```
msg_send(
  recipient: "monitor",
  content: "Status update"
)
```

This is intentionally **local-only** — it works solely for agents registered on this runtime. A handle that matches no local agent returns a tool error; for remote agents you must supply an explicit `address` from `agent_discover`. A blocked visibility tier returns the same reason string the HTTP 403 would produce.

### msg_send Parameters

| Field | Required | Description |
|-------|----------|-------------|
| `recipient` | Yes, unless `parent_id` provided | DID of the recipient (e.g., `"did:key:..."`) or adapter address (e.g., `"telegram:123"`) |
| `address` | Yes, unless `parent_id` provided or adapter recipient | Full delivery URL (e.g., `"http://127.0.0.1:7295/agents/agent-handle/inbox"`) |
| `content` | Always | The message content |
| `subject` | No | Optional subject line for the message |
| `thread_id` | No | Thread ID for grouping related messages. Auto-inherited from parent message if `parent_id` is provided. |
| `parent_id` | No | If set without recipient/address, runtime resolves both from the referenced inbox message |
| `attachments` | No | File paths within the agent's file store to attach |
| `content_type` | No | MIME type of `content` when not plain text — `application/vnd.adf.form+json` for [Interactive Forms](#interactive-forms-content_type-applicationvndadfformjson), `text/html` for [HTML Content](#html-content-content_type-texthtml). Validated at send time for known types. |
| `meta` | No | Metadata included in the message payload. Encrypted along with content — only the recipient can read it. |
| `message_meta` | No | Metadata on the outer message. Always cleartext — visible to relays and intermediaries. Use for routing hints (e.g., `reply_all`, `cc`, `bcc` for email), PoW proofs, TTL, priority. See [Email Routing Hints](#email-routing-hints). |

### Message Flow

1. **Compose** — Agent calls `msg_send`
2. **Resolve** — If `parent_id` provided without recipient, runtime looks up the inbox message and resolves `from` → `recipient`, `reply_to` → `address`
3. **Store** — Message written to sender's `adf_outbox` with `status='pending'`
4. **Deliver** — Runtime delivers via local fast path (same runtime) or HTTP POST to the address
5. **Ingest** — Message written to recipient's `adf_inbox` with `reply_to` set to the sender's Reply-To URL
6. **Update** — Sender's outbox status updated to `delivered` or `failed`, with HTTP status code

## Messaging Modes

Each agent has a messaging mode that controls its ability to send:

| Mode | Behavior |
|------|----------|
| `proactive` | Can send messages at any time (**default**) |
| `respond_only` | Can only reply (must include valid `parent_id` referencing a received message, or be in a turn triggered by an incoming message) |
| `listen_only` | Cannot send, only receive |

Messaging modes are enforced by the runtime at the tool layer. This applies to **all** message sends — whether from the LLM via `msg_send` or from lambdas via the `adf` proxy.

## Addressing

Messages use DID+address addressing:

| Field | Format | Example | Purpose |
|-------|--------|---------|---------|
| `recipient` | DID | `"did:adf:9gvayMZx5m..."` | Identity — who the message is for |
| `address` | URL | `"http://127.0.0.1:7295/agents/monitor/inbox"` | Routing — where to deliver |

For adapter recipients (e.g., Telegram), the recipient uses the `type:id` format (e.g., `"telegram:123456"`) and no address is needed.

### Reply-To

Every inbox message includes a `reply_to` field — the URL where replies should be sent. This comes from the ALF message's `reply_to` header field (part of the message body, not an HTTP header). The sender sets it to their preferred reply endpoint, typically `http://{host}:{port}/agents/{handle}/inbox`.

Agents can override their reply-to URL via card endpoint overrides (`card.endpoints.inbox` in config). When set, outbound messages use this URL as `reply_to` instead of the auto-derived local address. This is useful when deployed behind a relay or public domain. Update via `sys_update_config`:

```
sys_update_config({ path: "card.endpoints", value: { "inbox": "https://relay.example.com/me/inbox" } })
```

## Threading

Messages are threaded using two fields:

| Field | Description |
|-------|-------------|
| `thread_id` | Conversation thread ID. All messages in a thread share this. Auto-inherited from parent if `parent_id` is provided, otherwise defaults to the message's own ID. |
| `parent_id` | The specific message being replied to. Enables tree-structured threading. `NULL` for root messages. |

Threading is important for `respond_only` agents, which must include a valid `parent_id` to send messages. When `parent_id` is provided without `recipient` and `address`, the runtime automatically resolves both from the referenced inbox message.

## Reading Messages

### msg_list

Lightweight check — returns inbox counts without content. Takes **no parameters** (any argument is ignored):

```
msg_list()
→ Inbox status: Unread: 5, Read: 12, Archived: 100, Total: 117
```

### msg_read

Fetch full messages from the inbox. Messages returned are automatically marked as `read`. Parameters: `status` (`unread` default, `read`, `archived`), `limit`, and `include_original` (default `false`) — pass `include_original: true` to include the raw platform message (`original_message`), which is stripped otherwise.

```
msg_read(limit: 10, status: "unread")
```

### msg_update

Update message status after processing. The parameter is `message_ids` (a string or array of strings); `status` is one of `read`, `archived`, `delete` (`delete` only works on already-archived messages):

```
msg_update(message_ids: ["msg-1", "msg-2"], status: "archived")
```

### msg_delete

Delete messages from inbox or outbox by filter. Supported filter fields differ per store: **inbox** accepts `status`, `from`, `source`, `before`, `thread_id`; **outbox** accepts `status`, `before`, `thread_id`. `from` and `source` are inbox-only. At least one supported filter field is required to prevent accidental mass deletion.

```
msg_delete(source: "inbox", filter: { status: "archived", before: 1707000000000 })
```

A filter field that the target store does not support is **rejected with an error** (e.g. `source` or `from` against the outbox), as is a non-empty filter that matches no supported field — the tool errors rather than falling through to an unfiltered delete of the whole table.

Deletion is permanent for the store — `msg_delete` writes no audit rows. Messages are captured into `adf_audit` per-message at arrival/send (`inbox_message`/`outbox_message`, when audit was enabled at that time), and that capture is the only archive. See [Memory Management > Audit](https://agentdocumentformat.org/guides/memory-management#audit) for details.

## Visibility Tiers

Every agent declares a **visibility tier** via `messaging.visibility` that governs who can enumerate it and who can deliver messages to it. There are four tiers, strictly nested:

| Tier | Who can see and reach the agent |
|------|---------------------------------|
| `directory` | Agents on the same runtime in ancestor directories (same dir counts) |
| `localhost` | Any agent on the same machine (default) |
| `lan` | Any agent on the local network |
| `off` | Nobody — no enumeration, no inbound delivery |

Tiers are a containment hierarchy: `lan ⊃ localhost ⊃ directory`. A LAN-tier agent is also localhost-reachable and directory-reachable. An `off` agent is unreachable from every scope.

Visibility governs **inbound** behavior only — it does not gate outbound sends. Outbound is governed by `messaging.mode` (`proactive` / `respond_only` / `listen_only`). An `off` agent can still send; a write-only logger or reporter is a legitimate use case.

### Default

Newly created agents default to `localhost`. `directory` is too restrictive for multi-agent composition, `lan` exposes more than a new user is likely to expect, and `off` breaks local discoverability — so `localhost` is the right starting point for single-machine development.

### Enforcement

Two surfaces enforce the tier, and both must pass:

1. **Inbox acceptance** — `POST /agents/{handle}/inbox` rejects requests whose requester scope exceeds the recipient's visibility. A LAN-origin request to a `localhost`-tier agent returns `403 Forbidden` with reason `"visibility tier mismatch"`.
2. **Directory inclusion** — `agent_discover` and the `GET /agents` endpoint only return cards for agents whose visibility permits the requester's scope.

Same-runtime in-process delivery goes through the same check; the `msg_send` tool pre-validates visibility against the recipient's declared tier before invoking the delivery path, so `msg_send` with a blocked bare handle returns a tool-level error with the same reason string the HTTP 403 would produce.

### Runtime Network Binding

The runtime's mesh server binding follows the highest declared tier:

| Condition | Binding |
|-----------|---------|
| All agents `off` | Mesh server not started |
| No `lan`-tier agent and no `meshLan` override | `127.0.0.1` (loopback only) |
| At least one `lan`-tier agent **OR** `meshLan` setting enabled **OR** `MESH_HOST=0.0.0.0` | `0.0.0.0` (all interfaces) |

Flipping a single agent to `lan` is enough to make the runtime bind on all interfaces at next start. Live tier changes (via `sys_update_config`) take effect immediately for inbox/delivery enforcement, but upgrading binding from loopback to LAN requires a runtime restart.

### Public Reach

`public` is not a visibility tier. Agents that want to be reachable from the public internet register with a relay or expose themselves behind a public endpoint (Cloudflare tunnel, VPS, etc.) and advertise that endpoint via card endpoint overrides (`card.endpoints.inbox`). The visibility enum reflects what the runtime can meaningfully enforce; for NAT-traversing public reach, it's an agent-level decision.

## Agent Discovery

Use `agent_discover` to find agents reachable from this agent. It returns signed agent cards decorated with `visibility`, `in_subdirectory`, and a `source` field (`"local-runtime"` for same-runtime agents, `"mdns"` for LAN peers discovered via multicast DNS). The runtime only returns cards for agents whose visibility tier is reachable from the caller's scope — a `directory`-tier caller only sees ancestor agents, a `localhost`-tier caller sees everything same-runtime, and so on:

```
agent_discover()
→ [
    {
      "did": "did:key:z6Mk...",
      "handle": "monitor",
      "description": "Monitors system resources",
      "public_key": "z6Mk...",
      "endpoints": {
        "inbox": "http://127.0.0.1:7295/agents/monitor/inbox",
        "card": "http://127.0.0.1:7295/agents/monitor/card",
        "health": "http://127.0.0.1:7295/agents/monitor/health"
      },
      "policies": [...],
      "visibility": "localhost",
      "in_subdirectory": false,
      "source": "local-runtime"
    }
  ]
```

### Filters

| Parameter | Description |
|-----------|-------------|
| `scope` | `"local"` (default) or `"all"`. `"all"` merges local-runtime cards with mDNS-discovered LAN peers — see [LAN Discovery](https://agentdocumentformat.org/guides/lan-discovery). |
| `visibility` | Array of tiers to include. E.g. `["lan"]` to find only LAN-announced agents. |
| `handle` | Case-insensitive substring match on the agent handle. |
| `description` | Case-insensitive substring match on the agent description. |
| `include_subdirectories` | (Backward-compat for `"local"` scope.) When false, excludes agents in subdirectories. |

### Managing Contacts

Contact management is an agent-level concern. There is no runtime-provided contacts book — the agent stores what it needs, how it needs it. The primitives available are:

- **DIDs + addresses** — `msg_send` accepts them directly.
- **Agent cards** — fetchable via `GET /agents/{handle}/card`, returned by `agent_discover`, and included in inbox messages when agents introduce themselves.
- **Middleware hooks** — inbox/outbox [middleware](https://agentdocumentformat.org/guides/middleware) lambdas (`security.middleware.inbox` / `security.middleware.outbox`, owner-installed guard paths) let messages be rewritten before they land or depart.

Typical patterns: (A) a plain file in the agent's workspace; (B) a `local_*` table plus an outbox middleware lambda that rewrites a handle to a DID+address; (C) an inbox middleware lambda that auto-saves senders' cards. See [Contacts](https://agentdocumentformat.org/guides/contacts) for examples.

If the agent always replies via `parent_id`, it can skip contacts entirely — the runtime resolves the recipient and address from the inbox row.

## Attachments

Attachments are transferred **by value** — the actual file data is copied, not referenced.

### Sending Attachments

1. Create the file using `fs_write`
2. Include the file path in `msg_send` attachments
3. Runtime reads the file from the sender's file store

### Receiving Attachments

1. Runtime extracts inline attachment data to the recipient's file store
2. Files are namespaced to prevent collisions: `imported/{sender_name}/{filename}`
3. The attachment's `transfer` field is changed from `"inline"` to `"imported"` and the `path` field points to the local file
4. The base64 `data` is removed from the stored message — only metadata and local path are kept

### Cross-Machine Transport

For HTTP transport (across machines), attachments are base64-encoded in the message payload.

### Per-Message Audit

When audit is enabled for inbox or outbox, the runtime captures the **full ALF message with inline attachment data intact** (before extraction/tombstoning) and stores it as a brotli-compressed blob in `adf_audit`. This provides a forensic record of exactly what was sent or received, even if the extracted files are later modified or deleted.

Audit entries use source `inbox_message` or `outbox_message` to distinguish per-message audit from bulk deletion audit (`inbox`/`outbox`). See [Memory Management > Audit](https://agentdocumentformat.org/guides/memory-management#audit) for configuration.

## The Mesh

The ADF mesh is the discovery and transport layer that connects agents.

### Local Mesh

On a local network, agents on different runtimes discover each other via **mDNS** (multicast DNS). Runtimes announce themselves under the service type `_adf-runtime._tcp.local`, and each side fetches the other's `/agents` to merge remote cards into `agent_discover(scope: 'all')`.

See the dedicated [LAN Discovery](https://agentdocumentformat.org/guides/lan-discovery) guide for how announcement works, when it triggers, how to interpret the "Discovered on LAN" panel, and how to force an interface with `ADF_MDNS_INTERFACE` when the automatic picker picks wrong.

### Enabling Mesh

In the sidebar, toggle the mesh participation switch for your agent. You can also configure:

- **Receive toggle** (`messaging.receive`) — Whether the agent participates in the mesh
- **Allow list** (`messaging.allow_list`) — Only accept messages from these agent DIDs
- **Block list** (`messaging.block_list`) — Reject messages from these agent DIDs

Agents can flip the receive toggle themselves — `sys_update_config({ path: "messaging.receive", value: true })` — HIL-gated (your principal approves); see [`sys_update_config`](https://agentdocumentformat.org/guides/tools#sys_update_config).

### Auto-Injected Context (Dynamic Instructions)

The runtime injects two pieces of messaging context into the agent's turn as dynamic instructions (kept out of the static system prompt so it stays cacheable):

- **Mesh roster** — When the mesh topology changes (an agent joins, leaves, or updates), the runtime pushes `[Mesh Update] Available agents:` followed by a `- **handle**: description` list (or `[Mesh Update] No other agents are currently available in the mesh.`). It only re-emits when the roster actually changes, and requires `messaging.receive: true`.
- **Inbox nudge** — When there are unread messages, the runtime injects `[Inbox: N unread]` prompting the agent to `msg_read`. When channel adapters are configured, it appends reply guidance (reply via `parent_id`, leave the recipient empty).

Both are gated by `context.dynamic_instructions`:

| Key | Default | Controls |
|-----|---------|----------|
| `mesh_updates` | `true` | The `[Mesh Update]` roster |
| `inbox_hints` | `true` | The `[Inbox: N unread]` nudge |
| `context_warning` | `true` | Approaching-context-limit / compaction warnings |
| `idle_reminder` | `true` | Reminder that an autonomous agent can go idle via `sys_set_state` |

Set any to `false` to suppress that injection — the config path is `context.dynamic_instructions.<key>`. The mesh roster is the same discovery information `agent_discover` returns — see [LAN Discovery](https://agentdocumentformat.org/guides/lan-discovery) — pushed into context automatically.

### Message Security

`security.level` (Config → Security) controls what the runtime does to every message the agent sends:

| Level | Label | What happens |
|-------|-------|--------------|
| 0 | Open | Nothing — messages travel as plain JSON |
| 1 | Signed | The payload is signed by the author (survives forwarding) and the whole message is signed by the sender. Receivers verify both and stamp `message_verified` / `payload_verified` into `meta` |
| 2 | Encrypted | Everything level 1 does, plus payloads to DID recipients are encrypted end-to-end |
| 3 | Advanced | Custom middleware policy |

New agents default to **Signed** — every agent has identity keys, so signing is free. Unlike the guard path just below, `security.level` is agent-writable via [`sys_update_config`](https://agentdocumentformat.org/guides/tools#sys_update_config), HIL-gated (your principal approves). Receivers accept unsigned messages by default; flip **Require message signature** to reject them — that UI label maps to `security.allow_unsigned`, which is owner-only — not agent-writable (guard path, hard-denied); ask your principal.

**How encryption works.** The encryption key is derived from the recipient's DID itself (an Ed25519 → X25519 conversion), so the sender needs nothing but the DID it already has — no key exchange, no directory lookup. The entire payload, including the author's signature, is sealed; on the receiving side the runtime decrypts *before* the message reaches the inbox, so the agent's history stays readable and auditable. Two cases are deliberately never encrypted: same-runtime local delivery (the message never leaves the process) and channel-adapter recipients like `discord:…` (the platform is the transport — there is no agent key on the other end).

If an encrypted message arrives for an agent whose keys can't open it (wrong recipient, or a foreign file), ingress rejects it with a 403 — it never lands half-readable.

### Trust and Identity

Not every identity field on an inbox row is equally trustworthy — only some are runtime-verified facts; the rest are unverified sender claims:

- **`from` (verified DID)** — authoritative. When the message signature verifies, `from` is attributable to that DID.
- **`sender_alias`** — an **unverified** display claim carried in the payload. A remote peer can put anything here. The runtime **strips reserved aliases** (`owner`, `system`, `user`) on ingress, because the runtime assigns those itself for locally-originated messages; a wire-supplied reserved alias is dropped and the verified `from` DID is shown instead. (The original claim survives verbatim only in the tombstoned `original_message`.)
- **`owner`** — retained on the inbox row **only when the message is cryptographically verified** (`meta.message_verified === true`), so an ownership claim is always attributable to the DID in `from`. An unsigned peer cannot plant an `owner` claim that reads as identity downstream.
- **`meta.identity_verified`** — **transport-derived**, set by the ingress path from the WebSocket authentication handshake, never taken from the wire (a sender-supplied `identity_verified` / `ws_remote_did` is always discarded). It is present only on WS-delivered messages; **HTTP-delivered messages carry no `identity_verified`**. See [WebSocket Connections](https://agentdocumentformat.org/guides/websocket#identity-verification).

### Message Receive Endpoint

Each agent with a handle exposes a message receive endpoint at:

```
POST /agents/{handle}/inbox
```

The wire format is a full ALF message:

```json
{
  "version": "1.0",
  "network": "devnet",
  "id": "msg_01HQ9ZxKp4mN7qR2wT",
  "timestamp": "2026-02-28T20:00:00Z",
  "from": "did:key:z6MkAlice...",
  "to": "did:key:z6MkBob...",
  "reply_to": "http://127.0.0.1:7295/agents/alice/inbox",
  "meta": {},
  "payload": {
    "thread_id": "thr-123",
    "parent_id": null,
    "content": "Hello from another agent",
    "sent_at": "2026-02-28T20:00:00Z",
    "attachments": []
  }
}
```

The runtime responds with `202 Accepted` and a `message_id`:

```json
{ "message_id": "inbox-abc123" }
```

Error responses:
- `400` — Malformed request (missing required fields)
- `404` — No agent with matching handle or DID
- `503` — Agent is in `off` state

### Local Delivery (Fast Path)

When the recipient is on the same runtime, the mesh manager bypasses HTTP and writes directly to the recipient's inbox. This is transparent to the agent — the message appears in the inbox the same way.

### WebSocket Delivery

When an active WebSocket connection exists to the recipient, the mesh manager sends the ALF message as a text frame over that connection instead of making an HTTP POST. This is useful for:

- **NAT traversal** — Agents behind NAT can connect outbound to a reachable peer, establishing a persistent pipe for bidirectional message delivery
- **Lower latency** — No TCP handshake overhead per message
- **Persistent connections** — Keepalive pings maintain the connection

The transport is resolved automatically on egress: local → active WebSocket → HTTP POST. If WebSocket delivery fails (e.g., connection died between resolve and send), the runtime falls through to HTTP.

See [WebSocket Connections](https://agentdocumentformat.org/guides/websocket) for configuration and usage details.

### Background Agents

Agents can run in the background while you work on a different file in the foreground. Background agents:

- Continue to participate in the mesh
- Process triggers and messages
- Maintain their state

### Fleet Map

ADF Studio includes the **fleet map** (accessible from the toolbar) — an RTS-style command surface with a real-time view of your agent network:

- **Territory map** — Each agent is a tile on a hex-territory map, grouped by directory, showing its name, state, and recent activity
- **Live activity** — State changes, tool activity, and message flows between agents update in real time
- **Command surface** — Select one or many agents to inspect them, issue commands, move them between directories, or handle pending approvals
- **Interactive** — Click through from any tile to that agent's detail view (loop, inbox, files, config)

See the [Fleet Map guide](https://agentdocumentformat.org/studio/fleet-map) for the full tour.

### Fleet Activity Drawer

The fleet map's activity drawer shows:

- Bus registrations (which agents are connected)
- Running agents
- Message log (last 200 messages)

## Hub Agents

For internet-scale communication beyond LAN, agents use **Hub Agents** as message routers.

### How Hubs Work

1. An agent sends a subscription request to a hub (e.g., `content: { "action": "subscribe" }`)
2. The hub adds the agent to its `local_subscribers` table
3. When the hub receives updates, it broadcasts to all subscribers using the wrapper pattern
4. The hub wraps the original message with attribution

### Hub Message Format

Hub broadcasts use the ALF wrapper pattern — the original message is nested inside `content`:

```json
{
  "version": "1.0",
  "from": "did:key:z6MkHub...",
  "to": "did:key:z6MkSubscriber...",
  "reply_to": "https://hub-server.com/hub/inbox",
  "payload": {
    "meta": { "wrapper": "fanout" },
    "content": {
      "version": "1.0",
      "from": "did:key:z6MkOriginalSender...",
      "payload": {
        "content": "The sea level is rising.",
        "sent_at": "2026-02-28T20:00:00Z"
      }
    },
    "sent_at": "2026-02-28T20:00:01Z"
  }
}
```

Hub agents require cryptographic identity for message signing and verification.

## Message Status Lifecycle

### Inbox

| Status | Description |
|--------|-------------|
| `unread` | New message, not yet processed |
| `read` | Agent has read the message |
| `archived` | Processed and stored |

### Outbox

| Status | Description |
|--------|-------------|
| `pending` | Written, delivery attempt not yet resolved |
| `delivered` | Successfully delivered |
| `failed` | Delivery failed (single attempt; not retried) |

The lifecycle is `pending → delivered | failed` — there are only these two terminal states. (A `sent` status exists in the enum for historical reasons but is **never written**.)

The outbox also records:
- `status_code` — HTTP status code from the delivery attempt (e.g., 202, 404, 503)
- `delivered_at` — Timestamp of successful delivery

### Store-and-Forward Reality

Persisting to the outbox is **not** a delivery queue. There is no sender-side retry and no automatic re-delivery: the runtime makes one attempt and records the outcome. Messages are stored for auditability, not for replay — catch-up and recovery are the agent's responsibility.

An offline or unreachable peer produces a `failed` outbox row carrying the delivery `status_code`:

- **`503`** — the recipient agent exists but is in the `off` state.
- **`404`** — no agent with that handle or DID.

## Agent Card

Each agent on the mesh exposes a card at `GET /agents/{handle}/card`:

```json
{
  "did": "did:key:z6Mk...",
  "handle": "monitor",
  "description": "Monitors system resources",
  "icon": "📊",
  "public_key": "z6Mk...",
  "endpoints": {
    "inbox": "http://127.0.0.1:7295/agents/monitor/inbox",
    "card": "http://127.0.0.1:7295/agents/monitor/card",
    "health": "http://127.0.0.1:7295/agents/monitor/health"
  },
  "api_routes": [
    { "method": "GET", "path": "/status" }
  ],
  "policies": [],
  "public": true,
  "shared": ["reports/weekly.html", "data/metrics.json"]
}
```

The card is the agent's public-facing identity. When another agent receives a card (in a message payload, via introduction, or through discovery), it is free to store it however it chooses — see [Contacts](https://agentdocumentformat.org/guides/contacts) for patterns.

The `shared` field lists resolved file paths (not glob patterns) — the runtime matches the configured glob patterns against the workspace file list.

## Channel Adapters

Channel adapters bridge external messaging platforms into the ADF inbox/outbox system. They convert platform-specific messages into the unified ADF message format, allowing agents to receive and reply to messages from Telegram, Discord, Email, and other platforms. Each channel is connected per agent, from **Settings > Channels**.

![Settings → Channels showing the Telegram, Email, and Discord channel rows, each with a brand icon, a one-line description, Logs and Connect an agent actions, and a chip with a status dot per connected agent.](https://agentdocumentformat.org/docs-assets/assets/screenshots/settings-channels.png)

### Design intent: realtime conversational layer

Channel adapters are deliberately scoped to the **realtime conversational loop** for external messaging platforms — receive a message, write to inbox, fire `on_inbox`, reply via `msg_send` (often with `parent_id` for threading). They are *not* designed as a full management surface for the underlying platform. Use the adapter for what the agent does as a *participant* in a conversation; use an MCP server for what the agent does as an *operator* of the platform.

| Reach for the adapter (`msg_send`) when… | Reach for an MCP server when… |
|---|---|
| Replying to a message that woke the agent | Reading channel history the agent wasn't part of |
| Sending a fresh DM or channel message | Listing channels, servers, members, roles |
| Sending attachments inline | Editing or deleting old messages |
| Anything that fits the inbox/outbox model | Cross-channel or admin operations |

#### Why both, and isn't that confusing?

Agents that just reply to mentions and DMs almost never need anything beyond the adapter — `msg_send` with `parent_id` covers the entire flow, and the trigger system biases naturally toward it (inbox wakes the agent, `msg_send` replies). The adapter also keeps every outbound message in `adf_outbox` for uniform audit and delivery tracking across platforms.

Reach for an MCP server only when the agent needs to *operate the platform*: read history of a channel it wasn't messaged in, manage roles, list servers, edit prior messages. These are deliberate operations the agent reasons about, not parts of a conversation it's already in.

There is one genuine overlap zone: **sending cold-start messages to a channel the agent wasn't messaged in first.** Either path works. Prefer the adapter when possible (`msg_send recipient="discord:CHANNEL_ID"`) so the outbox stays uniform; fall back to the MCP send tool only if you need features the adapter doesn't expose (rich embeds, ephemeral replies, specific button/component interactions).

The cost of loading both is real but small: more tools = more context tokens and more decision overhead. Most agents should load only the adapter; load the MCP server alongside only when platform operations are actually part of the agent's job.

### Architecture

Each adapter implements a standard interface:

- `start(ctx)` — Initialize and connect to the platform
- `stop()` — Disconnect cleanly
- `send(msg)` — Deliver an outbound message to the platform
- `canDeliver(id)` — Check if the adapter can reach a recipient
- `status()` — Report connection health (`connected`, `connecting`, `disconnected`, `error`)

Inbound messages from the platform are ingested into the agent's `adf_inbox` with the appropriate `source` field (e.g., `telegram`) and platform metadata in `source_context`.

### Credential Storage and Resolution

Every adapter credential is a row in the agent's `adf_identity` table with
purpose **`adapter:{type}:{KEY}`** — e.g. `adapter:telegram:TELEGRAM_BOT_TOKEN`.
The key names are fixed per adapter (listed in each setup section below and
in the [channels reference](https://agentdocumentformat.org/guides/channels#credentials-and-self-setup)).
There is exactly one store: the agent's `adf_identity` row
(`adapter:{type}:{KEY}`), written by agent code via `set_identity` or by
**Settings > Channels** when you connect that agent. There is no app-wide
fallback — adapters run per agent, and one token shared across agents would
start one poller per agent against the same bot.

Credentials are read when the adapter starts. Enabling an adapter (or any
adapter config change) restarts it and re-reads the credential; rotating a
token *without* a config change requires a manual restart or an
enabled-toggle to take effect.

The **Settings > Channels** walkthroughs below are for a human doing
the setup by hand. When an **agent** sets a channel up with its principal, it
doesn't route through Settings at all: the principal's direct chat is local
and private, so the agent takes the token in the conversation and stores it
with `set_identity` (`adapter:{type}:{KEY}`), then enables the adapter itself.
The agent-facing playbook — the worked chat flow, exact `set_identity` /
`sys_update_config` calls, ordering, verification via `adf_logs`, and noting
the principal's per-channel id — lives in
[channels.md](https://agentdocumentformat.org/guides/channels#credentials-and-self-setup).

### Adapter Addressing

Adapter recipients use the `type:id` format instead of DIDs:

```
msg_send(
  recipient: "telegram:123456789",
  content: "Hello from ADF!"
)
```

No `address` is needed for adapter recipients — the runtime routes through the appropriate adapter.

### Offline Catch-Up

Adapters recover messages that arrived while the app was closed or the machine was offline. On reconnect the backlog is written to the inbox first and the agent is woken once at the end of the drain — a long gap never fires a turn per missed message. Redelivered duplicates are skipped (platform message ids are dedup keys), and recovered messages keep their true sent timestamps.

What each platform allows:

- **Telegram** — Telegram queues updates for an offline bot for **24 hours**; the adapter drains that queue on start. Anything older is unrecoverable (the Bot API has no history access).
- **WhatsApp** — WhatsApp queues undelivered messages per linked device for **~30 days** and replays them on reconnect. Hard platform limit: a linked device is unpaired if the phone itself is unused for 14+ days.
- **Slack** — Socket Mode has no offline queue, so the adapter backfills via `conversations.history` (including thread replies) on connect — for conversations the agent has previously seen traffic in.
- **Discord** — the gateway replays only brief connection drops, so the adapter backfills via the REST message-history API on connect/resume — for channels previously seen. Requires the **Read Message History** permission (already in the invite checklist above).
- **Email** — no special handling needed: unread mail waits in the mailbox and is fetched on connect.

Configured per agent under `adapters.<type>.config.catch_up` (defaults shown):

```json
{ "catch_up": { "enabled": true, "max_age_hours": 24, "max_messages": 200 } }
```

`max_messages` caps each conversation's backfill; the cap is logged plainly, and where the platform holds a durable queue (Telegram, email) the overflow stays queued and arrives in later cycles. Set `enabled: false` to restore the old drop-the-backlog behavior.

### Telegram Adapter

The built-in Telegram adapter uses a bot token to connect via long-polling.

**Setup:**

1. Create a Telegram bot via [@BotFather](https://t.me/BotFather) and get a bot token (see Telegram's official [From BotFather to 'Hello World'](https://core.telegram.org/bots/tutorial) guide and [bot FAQ](https://core.telegram.org/bots/faq))
2. In ADF Studio, go to **Settings > Channels** and click **Connect an agent** on the Telegram row
3. Pick the agent and paste the token — it is stored as identity purpose `adapter:telegram:TELEGRAM_BOT_TOKEN` in that agent's file (agent code can equivalently `set_identity` it) and Telegram is enabled in the agent's config

**Inbound features:**

- Text messages from DMs and groups
- Photo and document attachments (downloaded and stored in `imported/telegram/`)
- Reply threading — Telegram reply-to references are mapped to ADF `parent_id`
- Policy filtering for DMs (`all`, `allowlist`, `none`) and groups (`all`, `mention`, `none`)

**Outbound features:**

- Text replies to Telegram chats with automatic markdown formatting (bold, italic, code, links converted to HTML)
- File attachments: GIFs sent as animations, images as photos (with document fallback), other files as documents
- Reply threading — outbox messages with `parent_id` referencing a Telegram inbound message are sent as Telegram replies
- Falls back to plain text if markdown conversion fails

### Discord Adapter

The built-in Discord adapter uses [discord.js](https://discord.js.org) v14 to connect via the Discord gateway. Receives DMs and guild messages, sends replies, and optionally registers a single `/<botname> prompt:<text>` slash command.

**Setup:**

1. Create a Discord application at [https://discord.com/developers/applications](https://discord.com/developers/applications) (see Discord's official [getting started guide](https://discord.com/developers/docs/quick-start/getting-started)). Copy the **Application ID** from General Information.
2. On the **Bot** page, click **Reset Token** and copy the token (Discord only shows it once). Then scroll to **Privileged Gateway Intents** and toggle ON the **MESSAGE CONTENT INTENT** — without this, `message.content` will be empty for guild messages that don't mention the bot. Click Save Changes.
3. Invite the bot to a server using either **Installation** (newer) or **OAuth2 → URL Generator**. Required scopes: `bot` and `applications.commands`. Required permissions: at minimum `View Channels`, `Read Message History`, `Send Messages`, `Use Slash Commands`, and `Attach Files` if you want attachment support.
4. In ADF Studio, go to **Settings > Channels** and click **Connect an agent** on the Discord row.
5. Pick the agent and paste the credentials; they are stored in that agent's `adf_identity`:
   - `adapter:discord:DISCORD_BOT_TOKEN` (required)
   - `adapter:discord:DISCORD_APPLICATION_ID` (optional — only needed if you want the slash command registered)

**Inbound features:**

- Text messages from DMs and guild channels
- Attachments (images, files, audio) downloaded and stored in `imported/discord/`
- Reply threading — Discord reply references are mapped to ADF `parent_id`
- Policy filtering for DMs (`all`, `allowlist`, `none`) and groups (`all`, `mention`, `none`). The default `groups: 'mention'` requires the bot to be either `@mentioned` or replied-to before processing a guild message.
- Slash command: when `DISCORD_APPLICATION_ID` is set, the adapter registers one global command `/<botname> prompt:<text>` and ingests invocations as normal inbound messages (with `sourceMeta.interaction = true`). Global command propagation can take up to one hour the first time.

**Outbound features:**

- Native markdown — Discord's chat format already speaks `**bold**`, `*italic*`, `` `code` ``, fenced code blocks, and `[text](url)` links, so the adapter passes payloads through with minimal transformation
- File attachments via `AttachmentBuilder`
- Reply threading — outbox messages with `parent_id` referencing a Discord inbound are sent with `reply: { messageReference }`
- Messages over Discord's 2000-character hard cap are truncated with a `…` suffix and the full payload attached as `message.txt`

**Recipient addressing:**

`discord:<channel_id>` for both DM and guild channels (Discord identifies destinations by channel ID at the API level). For replies, `sourceMeta.channel_id` from the inbound is used automatically, so agents can `parent_id`-reply without knowing channel IDs explicitly.

**Gotchas worth knowing:**

- The **Message Content** privileged intent must be ON in the developer portal, as noted above. DMs and `@mention` messages are exempt, but ordinary guild chatter will arrive with empty `content` otherwise.
- The adapter opts into discord.js's `Partials.Channel` so DM channels not yet cached at startup still deliver `messageCreate` events. Without this, DMs to a freshly-connected bot would silently disappear on first contact.

### Email Adapter

The built-in Email adapter connects agents to standard email accounts via IMAP (inbound) and SMTP (outbound). It works with any email provider that supports IMAP/SMTP with password authentication — including Gmail, iCloud, Outlook, Fastmail, Yahoo, and self-hosted servers.

**How it works:**

- **Inbound:** Connects to the IMAP server and fetches unseen messages. On startup, unseen emails are ingested oldest-first, bounded by the [catch-up caps](#offline-catch-up) (default 200 per cycle; the rest stay unread and follow in later cycles). While running, the adapter polls every 60 seconds for new messages. Messages are marked as `\Seen` after processing so they won't be re-fetched.
- **Outbound:** Sends email via SMTP. Message bodies are sent as multipart (plain text + Markdown→HTML). Reply threading uses `In-Reply-To` and `References` headers from the parent inbox message.

**Supported providers:**

Provider settings (IMAP/SMTP hosts and ports) are auto-detected from the email address domain:

| Provider | Domains | Notes |
|----------|---------|-------|
| Gmail | `gmail.com`, `googlemail.com` | Requires [app-specific password](https://myaccount.google.com/apppasswords) (2FA must be enabled) |
| iCloud | `icloud.com`, `me.com`, `mac.com` | Requires [app-specific password](https://support.apple.com/en-us/102654) |
| Outlook | `outlook.com`, `hotmail.com`, `live.com` | Requires [app password](https://support.microsoft.com/en-us/account-billing/how-to-get-and-use-app-passwords-5896ed9b-4263-e681-128a-a6f2979a7944) or OAuth |
| Fastmail | `fastmail.com`, `fastmail.fm` | Requires [app password](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords) |
| Yahoo | `yahoo.com` | Requires [app password](https://help.yahoo.com/kb/SLN15241.html) |
| Other | Any domain | Falls back to `imap.{domain}:993` / `smtp.{domain}:465` |

Custom IMAP/SMTP settings can be provided via the adapter `config` object to override auto-detection.

**Setup:**

1. Enable 2FA on your email account (required for app-specific passwords on most providers)
2. Generate an app-specific password from your provider's security settings
3. In ADF Studio, go to **Settings > Channels** and click **Connect an agent** on the Email row
4. Pick the agent and enter two credentials:
   - `adapter:email:EMAIL_USERNAME` — Your full email address (e.g., `agent@gmail.com`)
   - `adapter:email:EMAIL_PASSWORD` — The app-specific password (not your regular password)

**Per-agent configuration:**

The adapter can be configured per-agent. The `config` object is optional — without it, settings are auto-detected from the email address:

```json
{
  "adapters": {
    "email": {
      "enabled": true,
      "config": {
        "address": "agent@example.com",
        "imap": { "host": "imap.example.com", "port": 993 },
        "smtp": { "host": "smtp.example.com", "port": 465 },
        "poll_interval": 30000,
        "idle": true
      },
      "policy": {
        "dm": "all",
        "allow_from": []
      },
      "limits": {
        "max_attachment_size": 26214400
      }
    }
  }
}
```

| Config Field | Default | Description |
|-------------|---------|-------------|
| `config.address` | `EMAIL_USERNAME` | Email address (defaults to the username credential) |
| `config.imap` | Auto-detected | IMAP host and port |
| `config.smtp` | Auto-detected | SMTP host and port |
| `config.poll_interval` | `30000` | Polling interval in ms (used in poll-only mode) |
| `config.idle` | `true` | Use IMAP IDLE with polling fallback; set `false` for poll-only mode |

**Inbound features:**

- Plain text and HTML emails (HTML auto-converted to plain text via `html-to-text`)
- Email subject mapped to inbox `subject` column
- Attachments downloaded to `imported/email_{sender}/` with size limits
- Threading via `References` and `In-Reply-To` headers → `thread_id` and `parent_id`
- Dedup via IMAP `\Seen` flag — processed messages are not re-fetched
- Policy filtering: `dm` mode with `all`, `allowlist`, or `none` (email has no group concept)
- `source_context` captures `message_id`, `to`, `cc`, `in_reply_to`, `references` for reply routing
- `original_message` stores the raw RFC 822 email source for forensic access

**Outbound features:**

- Sends as multipart: plain text + Markdown→HTML
- Reply threading: `In-Reply-To` and `References` headers from parent inbox message
- Subject handling: uses outbox `subject`, adds `Re:` prefix for replies
- File attachments via SMTP
- **CC/BCC/Reply-All** via `message_meta` routing hints (see below)

**Addressing:**

```
msg_send(
  recipient: "email:alice@example.com",
  content: "Hello from ADF!",
  subject: "Greetings"
)
```

#### Email Routing Hints

When sending email, agents can use `message_meta` to control CC, BCC, and reply-all behavior. These routing hints are passed separately from the inbound `source_context` to avoid collisions — the adapter reads both bags independently.

**Reply-all** — include all original recipients (from `source_context.to` and `source_context.cc`) as CC, excluding the agent's own address and the primary recipient:

```
msg_send(
  parent_id: "inbox-abc123",
  content: "Acknowledged.",
  message_meta: { "reply_all": true }
)
```

**Explicit CC** — add specific addresses to CC (can be combined with `reply_all`):

```
msg_send(
  parent_id: "inbox-abc123",
  content: "Looping in the team.",
  message_meta: { "reply_all": true, "cc": ["team@example.com"] }
)
```

**BCC** — blind carbon copy:

```
msg_send(
  parent_id: "inbox-abc123",
  content: "FYI.",
  message_meta: { "bcc": ["manager@example.com"] }
)
```

**Forward** — send to a new recipient (no `parent_id` needed):

```
msg_send(
  recipient: "email:colleague@example.com",
  content: "Forwarding this for your review...",
  subject: "Fwd: Original Subject"
)
```

| Hint | Type | Description |
|------|------|-------------|
| `reply_all` | `boolean` | Include all original `to` and `cc` recipients as CC on the reply |
| `cc` | `string[]` | Explicit CC addresses (appended to reply-all if both are set) |
| `bcc` | `string[]` | Blind carbon copy addresses |

### Slack Adapter

Connects via **Socket Mode** — events arrive over an outbound WebSocket, so no public endpoint is needed (same model as Telegram polling and the Discord gateway).

**Setup:**

1. Create a Slack app at [https://api.slack.com/apps](https://api.slack.com/apps) — **Create New App → From scratch**, name it, and pick the workspace it should live in (see Slack's official [quickstart](https://docs.slack.dev/quickstart)).
2. On the **Socket Mode** page, toggle **Enable Socket Mode** ON. This generates an app-level token (`xapp-...`) with the `connections:write` scope — that's your `SLACK_APP_TOKEN` (see the [Socket Mode docs](https://docs.slack.dev/apis/events-api/using-socket-mode)).
3. On the **Event Subscriptions** page, toggle **Enable Events** ON, then under **Subscribe to bot events** add exactly: `message.channels`, `message.groups`, `message.im`, `message.mpim`. **This is the #1 pitfall**: Socket Mode connecting successfully does NOT mean events flow — without these subscriptions the socket stays connected but silent, and no messages ever arrive. Slack auto-adds the matching `*:history` bot scopes when you add these events. Other events (`app_mention`, `channel_created`, `file_shared`, `channel_history_changed`) are harmless but unused — the adapter only processes `message.*` events (mention gating scans message text, so an `app_mention` subscription is not needed).
4. On the **OAuth & Permissions** page, add the remaining bot token scopes beyond the auto-added history ones: `chat:write`, `im:write`, `users:read`, `channels:read`, `groups:read`, `im:read`, `mpim:read`, `files:read`, `files:write` (see the [token types overview](https://docs.slack.dev/authentication/tokens)).
5. On the **App Home** page, under **Show Tabs**, enable the **Messages Tab** and check **"Allow users to send Slash commands and messages from the messages tab"** — without this you cannot DM the bot at all (see the [App Home docs](https://docs.slack.dev/surfaces/app-home)).
6. On the **Install App** page, install the app to the workspace — or **reinstall** after any scope or event change (changes don't take effect until you reinstall; Slack shows a yellow banner when a reinstall is pending). Copy the **Bot User OAuth Token** (`xoxb-...`) — that's your `SLACK_BOT_TOKEN`.
7. In ADF Studio, go to **Settings > Channels**, click **Connect an agent** on the Slack row, pick the agent, and paste the two tokens.
8. For channel messages, `/invite @<botname>` the bot into each channel it should read.

**Verify**: DM the bot (or post in an invited channel) — the adapter log shows `Inbound from <name> (U…) in im D…` lines when messages arrive.

**Credentials** (two tokens):

- `adapter:slack:SLACK_APP_TOKEN` — app-level token (`xapp-...`) with the `connections:write` scope
- `adapter:slack:SLACK_BOT_TOKEN` — bot token (`xoxb-...`)

**Addressing**: `slack:C0123ABC` (channel), `slack:D0123ABC` (DM channel), or `slack:U0123ABC` (user — the adapter opens the DM conversation automatically).

**Threading**: replies thread under the parent message by default (`config.reply_in_thread`, default `true`). Outbound messages register their `ts`; inbound thread replies carry `reply_to_message_id = thread_ts`, so every reply in a thread resolves its `parent_id` to the thread root — which is exactly Slack's threading model.

**Inbound files** are downloaded with the bot token into `imported/slack/`. Outbound attachments upload via `files.uploadV2`.

### WhatsApp Adapter

Connects a **personal WhatsApp account** via the multi-device protocol ([Baileys](https://github.com/WhiskeySockets/Baileys)) — no tokens, no business account, no public endpoint.

> **Warning**: Baileys is an unofficial client. WhatsApp may ban accounts that look automated. Use a non-critical account, and expect occasional breakage when WhatsApp changes its protocol.

**Pairing**: no credentials to configure. On first start the adapter writes a pairing QR code to the agent's file store at `imported/whatsapp/pairing-qr.png` (also announced in the adapter log). Open WhatsApp → Linked Devices → scan (see WhatsApp's official [linked devices help](https://faq.whatsapp.com/378279804439436)). The QR expires every ~60 seconds and regenerates automatically; after the manager's retry cap, restart the adapter for a fresh one.

**Session state** lives on disk next to the agent's `.adf` file (`<agent>.adf.adapters/whatsapp/`). To unpair, delete that directory and restart the adapter. Treat the directory like the workspace DB — it contains the account's signal keys.

**Addressing**: `whatsapp:15551234567` (bare number), `whatsapp:15551234567@s.whatsapp.net`, or `whatsapp:<groupid>@g.us`.

**Replies** quote the original message (WhatsApp-native threading) when sent with `parent_id` — quoting works for messages received since the adapter last started (most recent 500, held in memory); replying to an older parent silently sends a normal, unquoted message. Voice attachments (WAV) are converted to OGG/Opus voice notes via ffmpeg when available.

### Interactive Forms (`content_type: application/vnd.adf.form+json`)

A form is content of a specific type: the message `content` is the form JSON and `content_type` marks what it is. The form travels in the payload — signed, encrypted over mesh, and stored as the real record in outbox/inbox history. `msg_send` validates the form at send time, so an invalid form is an immediate tool error, never a silent degradation.

```
msg_send(
  parent_id: "inbox-abc123",
  content_type: "application/vnd.adf.form+json",
  content: "{
    \"id\": \"checkin1\",
    \"title\": \"Sprint check-in\",
    \"render\": \"per_question\",
    \"questions\": [
      { \"id\": \"q1\", \"text\": \"How is the sprint going?\", \"type\": \"choice\",
        \"options\": [ { \"id\": \"good\", \"label\": \"On track\" }, { \"id\": \"risk\", \"label\": \"At risk\" } ] },
      { \"id\": \"q2\", \"text\": \"Which areas need help?\", \"type\": \"multi\",
        \"options\": [ { \"id\": \"fe\", \"label\": \"Frontend\" }, { \"id\": \"be\", \"label\": \"Backend\" } ] },
      { \"id\": \"q3\", \"text\": \"Anything else?\", \"type\": \"text\" }
    ]
  }"
)
```

Schema rules: `id`s are lowercase `[a-z0-9_-]` (form id ≤ 16 chars, question/option ids ≤ 8 — these limits keep Telegram's 64-byte `callback_data` within budget); up to 10 questions, 12 options each; `choice`/`multi` require `options`; optional `fallback_text` overrides the auto-generated plain-text rendering.

**One canonical format, adapters translate** — the same contract as markdown text (agents write it once; each adapter converts to its platform dialect). The sender never authors platform-specific form structures:

| Transport | Rendering |
|-----------|-----------|
| telegram | **Rich (native)** — the adapter picks the best Telegram surface for the form's shape (see below). |
| slack / whatsapp / discord / email | Plain-text questionnaire (numbered questions, lettered options). Native Block Kit / Discord component rendering is a follow-up. **Guidance for agents**: on these channels a normal message is usually the better way to ask questions — reserve the form type for transports that render it richly. |
| mesh (agent recipient) | Delivered as-is: the receiving agent sees `content_type` on the inbox row and parses `content` directly. Encrypted end-to-end like any payload. |

#### Telegram rendering strategies

The form's **required** `render` field chooses the Telegram surface — the agent owns the decision; the adapter only validates the form's shape against the chosen surface's contract and dispatches. There is no automatic selection: a shape that doesn't satisfy the chosen surface **fails the send with the precise reason** (e.g. `render 'poll' rejected: has 3 questions (polls hold exactly one)`), and a form without `render` fails schema validation in `msg_send`.

| `render` | Contract (form shape must satisfy) | Rendering |
|----------|------------------------------------|-----------|
| `poll` | Exactly one `choice`/`multi` question, 2–10 options, title+question ≤300 chars, option labels ≤100 | A native non-anonymous Telegram poll — single block, platform-rendered, `multi` allows multiple answers. Vote changes re-ingest (latest answer wins); retractions are ignored. |
| `compact` | Every question is `choice`/`multi` | **One message**: questions in the text, one combined keyboard underneath with each question's options sharing rows horizontally (max 4 buttons per row). Question numbers appear only on multi-question forms. Answered questions collapse to a ✓ row; the message finalizes with a summary once all questions are answered. |
| `per_question` | Any shape | One message per question — keyboards for `choice`/`multi`, reply prompts for `text`. |

Malformed form content (invalid JSON or schema) likewise fails the delivery with a clear error on every adapter — nothing is silently degraded to raw text. `msg_send` validates the same contract at send time, so agents normally hit the error before anything is sent.

For a true single-block form with text inputs and a submit button, see the Telegram **Mini App** design in `docs/design/telegram-webapp-forms.md` (`render: 'webapp'`, planned) — it requires the agent to have a public HTTPS URL.

**Answers** arrive as ordinary inbox messages threaded to the form (`parent_id` resolves via the registered per-question message ids), with the structured result in `source_context`: `form_id`, `question_id`, `answer_id`, `answer_value`. Free-text answers ride the normal reply path. Aggregation is the agent's job — collect answers until every `question_id` you sent has one.

**Extending beyond forms**: new rich capabilities follow the same pattern — define a new `content_type`, implement per-adapter rendering where platforms support it, fall back to text elsewhere. `message_meta` stays reserved for true delivery hints (`reply_all`, `cc`, `bcc`), not content.

### HTML Content (`content_type: text/html`)

Send an HTML body with `content_type: "text/html"`:

| Transport | Rendering |
|-----------|-----------|
| email | **Full HTML** — the payload becomes the HTML body verbatim, with the plain-text part auto-derived for multipart delivery. |
| telegram | **Sanitized subset** — structural elements become newlines/bullets/bold headings; Telegram's allowed inline tags (`b`, `i`, `u`, `s`, `code`, `pre`, `blockquote`, `a href`) are kept, everything else is stripped. Falls back to plain text if Telegram rejects the result. |
| slack / whatsapp / discord | Converted to readable plain text (these platforms speak their own markup, not HTML) — prefer markdown content there. |
| mesh (agent recipient) | Delivered as-is with the `content_type` on the inbox row. |

Default (no `content_type`) remains markdown, which every adapter converts to its native dialect — the right choice for ordinary messages on every channel.

### Per-Agent Adapter Configuration

Adapters are configured per-agent in the `adapters` section of the agent config:

```json
{
  "adapters": {
    "telegram": {
      "enabled": true,
      "policy": {
        "dm": "all",
        "groups": "mention",
        "allow_from": []
      },
      "limits": {
        "max_attachment_size": 10485760
      }
    }
  }
}
```

| Field | Description |
|-------|-------------|
| `enabled` | Whether the adapter is active for this agent |
| `config` | Adapter-specific options (e.g. Slack `reply_in_thread: false`, email `imap`/`smtp` overrides) |
| `config.catch_up` | Offline catch-up bounds: `{enabled, max_age_hours, max_messages}`, defaults `true`/`24`/`200` — see [Offline Catch-Up](#offline-catch-up) |
| `policy.dm` | DM handling: `all`, `allowlist`, or `none` |
| `policy.groups` | Group handling: `all`, `mention` (only when @mentioned or replied to), or `none` |
| `policy.allow_from` | Sender IDs to allow when using `allowlist` mode |
| `limits.max_attachment_size` | Max attachment size in bytes (default: 10 MB) |

### Adapter Health and Monitoring

The adapter manager performs health checks every 30 seconds. If an adapter disconnects, it auto-restarts with exponential backoff (2s → 4s → 8s → ... → 60s max, up to 5 retries).

The **Adapter Status Dashboard** (in Settings) shows:

- Connection status per adapter
- Log viewer with up to 500 entries per adapter
- Start/stop/restart controls
