Channel setup — per-provider guide

How to connect an agent to each of the 30 channel transports: which credentials go where, what to configure in the provider's console, and each provider's caveats. The dashboard Channels page collects everything below; the same fields apply via the Admin API (POST /v1/projects/{projectId}/channels and the admin-key mirror) with the body { type, agentId, config, secret }.

The shared model

Every connector stores three buckets:

bucket stored dashboard field typical content
config plaintext per-type fields or the config JSON textarea non-secret addressing: ids, URLs, verify tokens
secret.token encrypted "Bot token" the outbound bot/API token
secret.inboundSecret encrypted "Inbound secret" the inbound signing secret / shared gate
secret.extra encrypted "Protocol credentials" multi-part credentials: OAuth 1.0a keys, app id+secret pairs, protocol key material

Creating a connector mints a signed inbound URL — register it as the provider's webhook/callback endpoint. Rotating the connector invalidates old URLs. GET verification handshakes (Meta hub.challenge, WeChat echostr, Twitter CRC, Feishu url_verification, QQ op-13, Slack url_verification, Discord PING) are answered automatically.

When inboundSecret is optional, it is not decorative. Most adapters verify the provider's signature/bearer only once you have supplied the credential — until then the signed inbound URL is the only gate, and the end-user identity in the request body is taken at face value. Anyone holding that URL can therefore speak as any user of the connector, including addressing a companion instance another connector (or an SDK /v1/sessions mint) created on the same agent, since instances are keyed by the end-user id. Four transports refuse an unconfigured inbound outright — slack, discord, activitypub, nostr (the last two by explicit opt-in flags). For every other transport: set the inbound secret, and treat the inbound URL as a credential (it is shown once at create and on rotate; rotating invalidates the old one).

Fast-ACK model: providers enforce short webhook response windows (Slack 3s, WeChat/WeCom/Kakao ~5s, Alexa ~8s) while a real agent turn takes 5–60s. Every transport with an out-of-band send API acks the webhook immediately and delivers the reply asynchronously; redeliveries are deduped by each provider's stable event id. The two voice assistants (below) are the exception — they have no out-of-band path.

Webhook-ready (live on save)

type config secret provider-side setup
web verifyVisitor? inboundSecret? (required when verifyVisitor) POST to the inbound URL from your site; the reply rides the HTTP response (CORS *, the signed URL is the auth). Read “Web widget visitor identity” below before you choose a visitorId.
matrix homeserverUrl, botUserId, roomId? token = bot access token, inboundSecret = hs_token Register a bot/appservice on the homeserver pointing at the inbound URL.
telegram token = bot token, inboundSecret = secret_token setWebhook with the inbound URL (+ secret_token). Long replies are auto-chunked at 4096.
discord publicKey (Ed25519 hex) token? = bot token (channel-post fallback) Set the Interactions Endpoint URL to the inbound URL. Slash-command replies edit the deferred response; long replies chunk at 2000 with followups.
slack token = xoxb-…, inboundSecret = signing secret Events API → Request URL = inbound URL; subscribe message.im + app_mention.
activitypub actorId (+allowUnsignedInbound only for a trusted pre-verifying bridge) token? Register the inbound URL as the actor's inbox. Inbound requires HTTP Signatures; replies are signed Create(Note).
sms accountSid, from token = Twilio auth token (also the signature key) Point the number's messaging webhook at the inbound URL. Replies truncate at 1600 chars.
voice accountSid token = auth token Twilio Voice webhook → inbound URL. Replies are best-effort live-call updates (TwiML response mode is deferred).
email from token = Postmark server token, inboundSecret? = basic-auth gate Postmark inbound webhook → inbound URL.
dingtalk inboundSecret = robot signing secret Enterprise-internal robot, callback = inbound URL. Replies POST to the per-message sessionWebhook (pinned to oapi.dingtalk.com/api.dingtalk.com).
alexa inboundSecret? = shared bearer Skill endpoint = inbound URL. The reply must ride the HTTP response within ~8s. A turn that beats that answers normally; one that doesn't gets a spoken "still working on that — say 'continue'" inside the window while the turn finishes in the background, so the answer is in the session history for the follow-up utterance. Dedup via request.requestId.
google-assistant inboundSecret? Fulfillment webhook = inbound URL. Same shape on a tighter ~5s window (the adapter parses BOTH Actions-SDK and Dialogflow requests and takes the stricter bound). Dedup via responseId/requestId.

Business-account providers

These need the provider's verified/business account before credentials exist. Multi-part app credentials go in Protocol credentials (secret.extra).

type config secret secret.extra provider-side setup
whatsapp phoneNumberId, verifyToken token = permanent Graph token, inboundSecret = app secret Meta for Developers → WhatsApp webhook = inbound URL (+ verify token).
messenger / instagram pageId?, verifyToken token = page access token, inboundSecret = app secret Subscribe the page/IG messaging webhook to the inbound URL.
line token = channel access token, inboundSecret = channel secret Messaging API webhook = inbound URL. Reply tokens expire ~1min; expired replies auto-fall back to the push API.
teams token? = pre-minted bearer (short-lived) appId, appPassword (AAD mint, cached) Azure Bot messaging endpoint = inbound URL. Prefer extra over a static bearer.
wechat (公众号) inboundSecret = server Token appId, appSecret (access-token mint, cached ~2h) Verified 服务号 with customer-service message permission. Server URL = inbound URL. The webhook is acked with the literal success; replies deliver via the CS message API.
wecom (企业微信) agentId inboundSecret = callback Token corpId, corpSecret (token mint), encodingAesKey (safe mode) App receive-message URL = inbound URL.
feishu inboundSecret = Verification Token appId, appSecret (tenant-token mint), encryptKey Event subscription URL = inbound URL. With an Encrypt Key configured, events must be signed (keyed off the Encrypt Key, ±5min freshness) — no token-echo downgrade.
qq appId token = AppSecret (Ed25519 seed + token mint) QQ open-platform bot webhook = inbound URL.
twitter inboundSecret = consumer secret (CRC + inbound HMAC) consumerKey, consumerSecret, accessToken, accessTokenSecretreplies fail without all four Paid Account Activity API; register the inbound URL (CRC auto-answered).
kakao inboundSecret? = shared bearer Kakao i Open Builder skill URL = inbound URL. Enable the AI callback toggle — without it Kakao's ~5s sync window drops slow replies. Callback POSTs are pinned to kakao.com/kakaoi.ai hosts.
viber botName? token = auth token (also the signature key) Set the bot webhook to the inbound URL.

Bridge transports (Pouchy's managed relay)

Persistent-connection networks served by the workers/relay-bridge worker (the operator runs one shared instance; deployments without RELAY_BRIDGE_URL have these transports configured-but-dormant).

type config secret.extra notes
nostr relays[], trustBridgePreVerify: true nsec The relay verifies event signatures upstream and strips them — keep the trust flag set; without it sig-less inbound is rejected.
atproto identifier, service password (app password) Bluesky mentions/replies; dedup by AT-URI.
xmpp service, username, domain password Chat stanzas via the bridge's XMPP client.
signal number Needs a one-time per-number device link (QR) on the relay — the dashboard's Link action drives it. Dedup by envelope timestamp.
weibo sendUrl? accessToken Poll-based DM bridge; dedup by DM idstr.

Web widget visitor identity

The web transport is the only one whose end-user identity is client-asserted: the inbound route is CORS * by design (that is what lets a browser POST from your page and read the reply inline), so the visitorId in the body is whatever the caller typed. Two consequences, and what the platform does about each.

Your visitors can never reach another origin's companion. A web connector's instances are keyed under a reserved web: namespace, so an asserted visitorId cannot resolve to the instance a Telegram/Slack/email inbound or a POST /v1/sessions mint created for the same agent — even when the raw strings are identical. This is enforced in the transport, not by convention, and a regression is a failing test (web-namespace.test.ts). Instances also carry an originTransport stamp from the moment they are created, and a client-asserted inbound that tries to adopt an instance a verified path minted is refused with 403 plus a channel.origin_refused audit row.

Within your own widget, ids are still only as good as you make them. Nothing stops one of your visitors from claiming another's visitorId. So:

  • Never use a guessable or enumerable id. Sequential ids (user_4211), email addresses, phone numbers and any id you also use on another channel are all bad choices. Use a per-visitor random token (a UUID) that your server issues and the browser cannot invent.

  • Better: turn on config.verifyVisitor. Set an inboundSecret on the connector, then have your server — which already knows who is signed in — send a signature alongside the id:

    visitorHmac = hex( HMAC-SHA256( inboundSecret, visitorId ) )
    

    POST body: { "visitorId": "...", "visitorHmac": "...", "text": "..." }. Only your backend can mint that value, so a visitorId can no longer be typed by hand. This is the same "identity verification" pattern Intercom and Crisp use.

    The flag is off by default and enabling it is a wire change: once it is on, an unsigned or wrongly-signed request is refused with 401 and the connector shows a Failing health chip in the dashboard — so roll the signature out in your page before you tick the box.

    It also fails closed if the connector has no inboundSecret, so every write plane refuses that combination with a 400 (#136): the dashboard's create form, its config editor, and POST/PATCH on both /v1/projects/{id}/channels and /v1/admin/channels. That includes the case where the flag is already on and a PATCH replaces the secret without carrying inboundSecret forward — secrets are stored as one blob, so such a patch would otherwise silently drop the key the verifier needs. Turning the flag back off is always allowed, secret or not.

Migration note (#135). The web: namespace changed web instance identity. Conversations that widgets held under the old bare id still exist but are no longer reachable from the widget — those visitors start a fresh companion. Nothing else moved: other transports, /v1/sessions and the schedule sweep key exactly as before.

Delivery semantics (all transports)

  • Inbound events are deduped on (connector, eventId) for 24h; a failed turn releases the claim so the provider's redelivery is the recovery path.
  • Outbound sends are bounded (8s), SSRF-guarded, and non-idempotent-safe: an ambiguous timeout is NOT retried (no double-posting); provider-level errors reported inside HTTP-200 bodies (WeChat errcode, Slack ok:false, Viber status) are detected and audited.
  • Every delivery failure writes a channel.delivery_failed audit row (Logs → audit); async turns that never delivered write channel.async_undelivered.
  • Group mode (config.groupMode) shares one agent brain per room with a mention/wake-word gate; see channel-group-mode.md.