On this page

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

Source-Based Transport

Every message has a source field indicating its transport origin:

SourceDescription
meshDefault — delivered via the ADF mesh (local or HTTP)
telegramDelivered via the Telegram channel adapter
discordDelivered via the Discord channel adapter
emailDelivered via the Email channel adapter (IMAP)
slackDelivered via the Slack channel adapter (Socket Mode)
whatsappDelivered 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. 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.

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

Platformchat_idtitleparticipantsparticipants_scopeparticipant_count
telegramchat idchat titleadmins only — the Bot API cannot enumerate membersadminsgetChatMemberCount
discordchannel 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 functionalmentionsguild member count
slackchannel id#channel-name (+ topic/purpose in description)first page of conversations.members (20)pagenum_members
whatsappgroup JIDgroup subjectfull participant list with roles (names unavailable — JIDs only)allparticipants length
emailemail:<thread-root message-id> (sender address when no thread id)subjectall to + cc addressesallrecipient 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:

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

FieldRequiredDescription
recipientYes, unless parent_id providedDID of the recipient (e.g., "did:key:...") or adapter address (e.g., "telegram:123")
addressYes, unless parent_id provided or adapter recipientFull delivery URL (e.g., "http://127.0.0.1:7295/agents/agent-handle/inbox")
contentAlwaysThe message content
subjectNoOptional subject line for the message
thread_idNoThread ID for grouping related messages. Auto-inherited from parent message if parent_id is provided.
parent_idNoIf set without recipient/address, runtime resolves both from the referenced inbox message
attachmentsNoFile paths within the agent’s file store to attach
content_typeNoMIME type of content when not plain text — application/vnd.adf.form+json for Interactive Forms, text/html for HTML Content. Validated at send time for known types.
metaNoMetadata included in the message payload. Encrypted along with content — only the recipient can read it.
message_metaNoMetadata 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.

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:

ModeBehavior
proactiveCan send messages at any time (default)
respond_onlyCan only reply (must include valid parent_id referencing a received message, or be in a turn triggered by an incoming message)
listen_onlyCannot 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:

FieldFormatExamplePurpose
recipientDID"did:adf:9gvayMZx5m..."Identity — who the message is for
addressURL"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:

FieldDescription
thread_idConversation 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_idThe 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 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:

TierWho can see and reach the agent
directoryAgents on the same runtime in ancestor directories (same dir counts)
localhostAny agent on the same machine (default)
lanAny agent on the local network
offNobody — 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:

ConditionBinding
All agents offMesh server not started
No lan-tier agent and no meshLan override127.0.0.1 (loopback only)
At least one lan-tier agent OR meshLan setting enabled OR MESH_HOST=0.0.0.00.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

ParameterDescription
scope"local" (default) or "all". "all" merges local-runtime cards with mDNS-discovered LAN peers — see LAN Discovery.
visibilityArray of tiers to include. E.g. ["lan"] to find only LAN-announced agents.
handleCase-insensitive substring match on the agent handle.
descriptionCase-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 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 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 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 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.

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:

KeyDefaultControls
mesh_updatestrueThe [Mesh Update] roster
inbox_hintstrueThe [Inbox: N unread] nudge
context_warningtrueApproaching-context-limit / compaction warnings
idle_remindertrueReminder 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 — pushed into context automatically.

Message Security

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

LevelLabelWhat happens
0OpenNothing — messages travel as plain JSON
1SignedThe 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
2EncryptedEverything level 1 does, plus payloads to DID recipients are encrypted end-to-end
3AdvancedCustom 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, 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.

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:

{
  "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:

{ "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 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 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:

{
  "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

StatusDescription
unreadNew message, not yet processed
readAgent has read the message
archivedProcessed and stored

Outbox

StatusDescription
pendingWritten, delivery attempt not yet resolved
deliveredSuccessfully delivered
failedDelivery 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:

{
  "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 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.

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 agentReading channel history the agent wasn’t part of
Sending a fresh DM or channel messageListing channels, servers, members, roles
Sending attachments inlineEditing or deleting old messages
Anything that fits the inbox/outbox modelCross-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). 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.

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

{ "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 and get a bot token (see Telegram’s official From BotFather to ‘Hello World’ guide and bot 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 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 (see Discord’s official getting started guide). 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 (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:

ProviderDomainsNotes
Gmailgmail.com, googlemail.comRequires app-specific password (2FA must be enabled)
iCloudicloud.com, me.com, mac.comRequires app-specific password
Outlookoutlook.com, hotmail.com, live.comRequires app password or OAuth
Fastmailfastmail.com, fastmail.fmRequires app password
Yahooyahoo.comRequires app password
OtherAny domainFalls 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:

{
  "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 FieldDefaultDescription
config.addressEMAIL_USERNAMEEmail address (defaults to the username credential)
config.imapAuto-detectedIMAP host and port
config.smtpAuto-detectedSMTP host and port
config.poll_interval30000Polling interval in ms (used in poll-only mode)
config.idletrueUse 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"
)
HintTypeDescription
reply_allbooleanInclude all original to and cc recipients as CC on the reply
ccstring[]Explicit CC addresses (appended to reply-all if both are set)
bccstring[]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 — Create New App → From scratch, name it, and pick the workspace it should live in (see Slack’s official 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).
  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).
  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).
  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) — 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). 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: ids 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:

TransportRendering
telegramRich (native) — the adapter picks the best Telegram surface for the form’s shape (see below).
slack / whatsapp / discord / emailPlain-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.

renderContract (form shape must satisfy)Rendering
pollExactly one choice/multi question, 2–10 options, title+question ≤300 chars, option labels ≤100A native non-anonymous Telegram poll — single block, platform-rendered, multi allows multiple answers. Vote changes re-ingest (latest answer wins); retractions are ignored.
compactEvery question is choice/multiOne 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_questionAny shapeOne 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":

TransportRendering
emailFull HTML — the payload becomes the HTML body verbatim, with the plain-text part auto-derived for multipart delivery.
telegramSanitized 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 / discordConverted 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:

{
  "adapters": {
    "telegram": {
      "enabled": true,
      "policy": {
        "dm": "all",
        "groups": "mention",
        "allow_from": []
      },
      "limits": {
        "max_attachment_size": 10485760
      }
    }
  }
}
FieldDescription
enabledWhether the adapter is active for this agent
configAdapter-specific options (e.g. Slack reply_in_thread: false, email imap/smtp overrides)
config.catch_upOffline catch-up bounds: {enabled, max_age_hours, max_messages}, defaults true/24/200 — see Offline Catch-Up
policy.dmDM handling: all, allowlist, or none
policy.groupsGroup handling: all, mention (only when @mentioned or replied to), or none
policy.allow_fromSender IDs to allow when using allowlist mode
limits.max_attachment_sizeMax 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