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, accessTokenSecret — replies 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 aninboundSecreton 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 avisitorIdcan 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
401and 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 a400(#136): the dashboard's create form, its config editor, andPOST/PATCHon both/v1/projects/{id}/channelsand/v1/admin/channels. That includes the case where the flag is already on and aPATCHreplaces the secret without carryinginboundSecretforward — 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/sessionsand 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, Slackok:false, Viberstatus) are detected and audited. - Every delivery failure writes a
channel.delivery_failedaudit row (Logs → audit); async turns that never delivered writechannel.async_undelivered. - Group mode (
config.groupMode) shares one agent brain per room with a mention/wake-word gate; seechannel-group-mode.md.