Channel setup — per-provider guide
How to connect an agent to each of the 82 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.
Telegram and Discord register themselves. For those two the server performs
that registration for you at create time, using the bot token you supplied —
setWebhook for Telegram, the /ask slash command for Discord — and reports
the outcome as a provision object on the create response (the dashboard renders
it under the inbound-URL banner). A rotate, or a PATCH that replaces the secret,
re-registers for the same reason it has to: the rotate invalidates the old
inbound URL, so a provider still pointing at it would deliver nothing while the
connector looked healthy. So does a config edit that changes something the
provider itself holds — for Discord, config.slashCommand (the command name)
and config.applicationId (where it is registered); without that, renaming the
command saved green while Discord kept serving the old name. A config edit the
provider never sees, and any Telegram config edit, still costs nothing (Telegram
registration is the inbound URL plus the secret token, both already covered).
Pass autoProvision: false to manage registration yourself. Registration never blocks the write — if the provider is down or the
token is wrong, the connector is still created and provision.manualCommand
carries the exact command to run by hand. 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 |
Automatic — the server calls setWebhook with the inbound URL (+ secret_token) on create and on every rotate/secret change. Long replies are auto-chunked at 4096. |
discord |
publicKey (Ed25519 hex), applicationId?, slashCommand? (default ask) |
token? = bot token (channel-post fallback and slash-command registration) |
Set the Interactions Endpoint URL to the inbound URL in the Developer Portal. Supply token and the server registers the /ask slash command for you (application id resolved from the token, or set config.applicationId). Discord delivers interactions only — a plain chat message never reaches an Interactions endpoint, so a connector with no registered command receives nothing; see the note below. 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. |
imessage |
— | inboundSecret?; secret.extra = apiKeyId, apiSecret |
Via the Sendblue gateway (paid intermediary — Apple has no iMessage API): register the inbound URL as the webhook in their dashboard. Echo guard on is_outbound; dedup by message_handle. |
rcs |
accountSid, from (rcs:agent_…) |
token = Twilio auth token (also the signature key) |
Twilio RCS sender webhook → inbound URL; same signature/media/1600-cap semantics as sms, SMS fallback on non-RCS handsets. |
intercom |
adminId |
token = access token, inboundSecret = client secret (required — fails closed) |
Subscribe conversation.user.created + conversation.user.replied to the inbound URL. The agent replies as the adminId teammate; admin/bot-authored parts are the echo guard. |
vk |
apiVersion?; secret.extra = confirmationString |
token = community access token, inboundSecret = callback secret |
VK community Callback API → server URL = inbound URL. The confirmation handshake is answered from confirmationString; the body secret is verified when set (else the signed URL is the gate). Subscribe message_new; sends dedup on random_id. |
ok |
secret.extra = confirmationString? |
token = bot access token, inboundSecret = callback secret |
Odnoklassniki bot callback → inbound URL. Same confirmation+body-secret shape as VK. CODE-COMPLETE, TEST-DEFERRED. |
threads |
threadsUserId, verifyToken? |
token = Threads access token, inboundSecret = app secret |
Meta webhook infra (X-Hub-Signature-256 + hub.challenge, same as whatsapp). Replies are a two-step create→publish threaded on the incoming post; text only (v1). |
groupme |
botId |
— | GroupMe bot callback → inbound URL. Unsigned (signed URL is the gate); the bot's own posts are filtered by sender_type. bots/post for replies. A bot lives in ONE group — group mode is ON by default (mention-gated; set config.wakeWords, config.groupMode:false for 1:1). |
threema |
gatewayId (*XXXXXXXX) |
token = API secret (also the inbound MAC key), inboundSecret = same secret |
Threema Gateway Basic mode (paid; gateway does the E2E, we exchange plaintext). Inbound fails closed on the gateway MAC. /send_simple out. E2E mode is v2. |
mattermost |
serverUrl |
token = bot token, inboundSecret = outgoing-webhook token (required — fails closed) |
Self-hosted team chat. An outgoing webhook posts to the inbound URL; replies via /api/v4/posts as the bot. |
rocketchat |
serverUrl; secret.extra = userId |
token = bot X-Auth-Token, inboundSecret = outgoing-webhook token (required) |
Self-hosted team chat. Outgoing webhook in; chat.postMessage out (X-Auth-Token + X-User-Id). |
zulip |
serverUrl; secret.extra = botEmail |
token = bot API key, inboundSecret = outgoing-webhook token (required) |
Open-source threaded team chat. Outgoing-webhook bot in; /api/v1/messages out (basic auth). Stream messages reply to the stream+topic; DMs to the sender. |
farcaster |
fid?; secret.extra = signerUuid |
token = Neynar API key, inboundSecret = webhook secret (required — fails closed) |
Crypto-native social via Neynar (Farcaster is its own protocol, NOT atproto). cast.created webhook in; publish-cast (threaded on the parent) out. |
tiktok |
businessId, region; secret.extra = clientKey, clientSecret, refreshToken |
token = business access token, inboundSecret = webhook secret (required — fails closed) |
TikTok API for Business → Business Messaging — the ONLY official DM path. TikTok-Signature HMAC + freshness window in; send keyed on conversationId out; access token auto-refreshed from the client triple. Beta, EXCLUDES US/UK/EEA/CH; needs Business Center onboarding + business verification + app review + a data-security audit (not self-serve). Consumer platform / reverse-engineering are NOT used. |
twitch |
broadcasterId, senderId, clientId |
token = app access token (user:read/write:chat grants), inboundSecret = EventSub secret (required) |
EventSub channel.chat.message → inbound URL (challenge auto-answered, signature-gated). Stream chat is a ROOM — group mode defaults ON (mention-gated); set config.wakeWords, or groupMode:false for 1:1. Replies via Helix chat/messages, truncated at 500. |
kick |
broadcasterUserId, botUserId?, publicKey (required — fails closed) |
token = access token (chat scope) |
Subscribe chat.message.sent webhooks to the inbound URL; verification is Kick's platform RSA key (paste the PEM from their docs / public-key endpoint). Stream chat is a ROOM — group mode defaults ON (mention-gated); set config.wakeWords. |
Support inboxes & team chat (the intercom template)
The agent answers a per-conversation surface — a support inbox or a bot-token
team chat. All are born closed: the platform always authenticates (a webhook
HMAC signature, a bot bearer, or an outgoing-webhook body token), so
inboundSecret is required and inbound refuses without it. The agent replies as
a teammate/operator; the platform's own outbound messages are the echo guard.
| type | config | secret | provider-side setup |
|---|---|---|---|
crisp |
websiteId |
token = plugin token identifier:key (base64), inboundSecret = webhook signing secret |
Crisp console webhook for message:send → inbound URL. Replies as an operator on the conversation. |
freshchat |
apiUrl, agentActorId |
token = API token, inboundSecret = webhook public key from the Freshchat console (PEM or bare base64 — Freshchat RSA-signs deliveries; there is no shared signing secret) |
Freshchat webhook for message-create → inbound URL. Replies as the agent actor. |
front |
— | token = API token, inboundSecret = application/rule webhook secret |
Front application/rule webhook (or custom channel) → inbound URL. Replies on the conversation. |
gorgias |
domain |
token = email:apiKey Basic (base64), inboundSecret = HTTP-integration secret |
Gorgias HTTP integration for new customer messages → inbound URL. Replies as an agent ticket message. |
kustomer |
apiUrl |
token = API key, inboundSecret = outbound-webhook secret |
Kustomer outbound webhook for inbound messages → inbound URL. Reply keyed on the conversation. |
helpscout |
— | token = OAuth2 access token, inboundSecret = webhook secret |
Help Scout webhook for convo.customer.reply.created → inbound URL. Replies on the conversation. |
livechat |
— | token = Agent API access token, inboundSecret = webhook secret |
LiveChat webhook for incoming_event → inbound URL. Sends via send_event as the bot agent. |
zohodesk |
apiUrl, fromEmail |
token = OAuth access token, inboundSecret = webhook secret |
Zoho Desk webhook for new ticket threads → inbound URL. Replies via sendReply (Zoho-oauthtoken). |
webex |
— | token = bot access token, inboundSecret = messages-webhook secret |
Cisco Webex bot; messages/created webhook → inbound URL (X-Spark-Signature HMAC-SHA1). The webhook carries only the message id — Webex never puts the text in it — so the text is fetched with the bot token (GET /v1/messages/{id}) before the turn, and the bot's own messages are dropped by comparing personId against GET /v1/people/me (Webex fires webhooks for those too). A revoked token surfaces as a connector error + 502, never a silent 204. Replies to the room via /v1/messages. |
chatwork |
— | token = API token, inboundSecret = webhook token |
Chatwork webhook for message-created → inbound URL (X-ChatWorkWebhookSignature). Replies to the room. |
zohocliq |
apiUrl |
token = OAuth token (bot message API), inboundSecret = outgoing-webhook token |
Zoho Cliq bot webhook handler → inbound URL (Slack-style body token). Replies via the bot message API. |
Developer platforms (issue / PR / ticket / forum threads)
The agent answers in a comment thread. These surfaces are inherently rooms,
so each sets inherentlyGroup — group mode is ON by default, keyed on the
thread and mention-gated: set config.wakeWords (the bot's handle) so the
agent answers when addressed rather than replying to every comment
(config.groupMode: false opts out). All are born closed (webhook signature or
a mandatory shared token).
| type | config | secret | provider-side setup |
|---|---|---|---|
github |
botLogin, wakeWords |
token = PAT / App token (issues:write), inboundSecret = webhook secret |
Repo/org webhook for issue_comment + pull_request_review_comment → inbound URL (X-Hub-Signature-256). Reply comments on the issue/PR. |
gitlab |
serverUrl?, botUsername, wakeWords |
token = PAT (api), inboundSecret = webhook Secret token |
Project/group webhook for Comments (Note events) → inbound URL (X-Gitlab-Token). Reply via /notes on the issue/MR. |
bitbucket |
botNickname, wakeWords |
token = access token (pullrequest:write), inboundSecret = webhook secret |
Repo webhook for PR/issue comment-created → inbound URL (X-Hub-Signature). Reply on the PR. |
jira |
site, botAccountId, wakeWords |
token = email:apiToken base64 (Basic), inboundSecret = shared token in X-Atlassian-Webhook-Token |
Jira Cloud webhook for comment_created → inbound URL. Reply via the issue comment API (v2 string body; ADF is a first-run enrichment). |
linear |
botUserId, wakeWords |
token = personal API key, inboundSecret = webhook signing secret |
Linear webhook subscribed to Comments → inbound URL (Linear-Signature). Reply via the commentCreate GraphQL mutation. |
discourse |
site, apiUsername, wakeWords |
token = API key, inboundSecret = webhook secret |
Discourse webhook for post_created → inbound URL (X-Discourse-Event-Signature). Reply via /posts.json on the topic. |
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. |
onlyfans |
accountId (vendor acct_…) |
token = OnlyFansAPI key, inboundSecret = webhook signing secret (required — inbound fails closed) |
— | Via the OnlyFansAPI proxy (OnlyFans has no official API): connect the creator account in their console, then create a messages.received webhook pointing at the inbound URL with the signing secret set. Fan media arrives as signed OF CDN URLs (host-pinned). Automated replies can violate OnlyFans ToS — account risk sits with the operator. v1 is text + inbound media; PPV/priced messages are not sent. |
fansly |
accountId |
token = vendor API key, inboundSecret = webhook signing secret (required — fails closed) |
— | Same vendor family as onlyfans (Fansly product line); same webhook + ToS-risk posture, media pinned to the Fansly CDN suffixes. |
fanvue |
apiVersion? |
token = Fanvue API key, inboundSecret = webhook signing secret (required — fails closed) |
— | Fanvue's official public API (best-compliance creator platform): register a message webhook at the inbound URL, replies via chats/{userId}/messages. |
zalo |
appId |
token = OA access token, inboundSecret = app secret key |
— | Zalo OA webhook → inbound URL; X-ZEvent-Signature = SHA256(appId+body+timestamp+secret). Replies via the v3 CS message API (errors ride { error != 0 } in a 200). |
douyin |
clientKey |
token = client access token, inboundSecret = event signing secret |
— | 抖音开放平台 enterprise IM (console-gated capability): event push → inbound URL (verify_webhook challenge auto-answered), im_receive_msg in, /im/send/msg/ out. CODE-COMPLETE, TEST-DEFERRED on an approved enterprise account. |
hubspot |
senderActorId, channelId, channelAccountId |
token = private-app access token, inboundSecret = app secret (required — fails closed) |
— | HubSpot Conversations: app webhook for conversation.newMessage → inbound URL (X-HubSpot-Signature-v3). Reply to the thread via the Conversations API. Needs a HubSpot app. |
zendesk |
instanceUrl, appId |
token = Sunshine keyId:secret Basic (base64), inboundSecret = integration webhook secret (required — fails closed) |
— | Zendesk Sunshine Conversations: integration webhook for conversation:message → inbound URL. Reply as business on the conversation. |
salesforce |
instanceUrl |
token = OAuth access token, inboundSecret = platform-event/webhook secret (required — fails closed) |
— | Salesforce Service Cloud Messaging: platform event/webhook → inbound URL. Enterprise onboarding; the exact conversation endpoint is pinned on first live run. CODE-COMPLETE, TEST-DEFERRED. |
google-chat |
audience (required — the Audience your Chat app is configured with: the Cloud project number, or the endpoint URL) |
inboundSecret not used — leave empty; token? = a hand-made OAuth token (expires hourly) |
clientEmail, privateKey (service-account JSON key → app token mint, cached) |
Google Chat app (Connection settings → HTTP endpoint URL = inbound URL). There is no shared verification token to configure: Google signs every request with a JWT from chat@system.gserviceaccount.com, verified against Google's published key set (www.googleapis.com/service_accounts/v1/jwk/…) with the configured audience — a missing audience fails closed. The reply posts to the space with a short-lived token minted from the service-account key. Needs a Google Cloud project. |
zoom |
robotJid |
inboundSecret = Secret Token (required — fails closed, X-Zm-Signature v0 + freshness) |
clientId, clientSecret, accountId (chatbot token mint, cached) |
Zoom Team Chat Chatbot app (Event endpoint = inbound URL). The URL-validation handshake is auto-answered (signed with the Secret Token). Reply via the chatbot message API. CODE-COMPLETE, TEST-DEFERRED. |
line-works |
botId |
token? = access token, inboundSecret = Bot Secret (required — fails closed, X-WORKS-Signature) |
clientId, clientSecret, refreshToken (access-token refresh, cached) |
LINE WORKS Bot API (business suite, KR/JP). Bot callback → inbound URL; reply via /bots/{botId}/…/messages. CODE-COMPLETE, TEST-DEFERRED. |
ringcentral |
serverUrl?, botId? |
token? = access token, inboundSecret = Verification Token (required — fails closed) |
clientId, clientSecret, refreshToken (access-token refresh, cached) |
RingCentral Team Messaging (Glip). Subscribe a posts webhook to the inbound URL; the bot's own posts are the echo guard. Reply via /glip/chats/{id}/posts. CODE-COMPLETE, TEST-DEFERRED. |
vonage |
from, channel? (default sms) |
token = apiKey:apiSecret base64 (Basic), inboundSecret = signature secret (required — the signed JWT is verified fail-closed) |
— | Vonage Messages API (the sms sibling). Inbound webhook → inbound URL (JSON + a signed JWT bearer). Reply via POST /v1/messages. CODE-COMPLETE, TEST-DEFERRED. |
Bridge transports (Pouchy's managed relay)
Persistent-connection networks served by Pouchy's managed relay worker (the
platform operator runs one shared instance; deployments without
RELAY_BRIDGE_URL have these transports configured-but-dormant). Nothing here
is a component you deploy.
| 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. One connector per number, platform-wide: signal is the only bridge transport with no credential of its own (the secret.extra column is empty because the operator's shared signal-cli daemon holds the linked device), so config.number alone selects the identity. The operator feed therefore serves a contested number to exactly ONE connector — the oldest claim by createdAt — and withholds the rest with a warning. A second connector naming a number someone else already linked never reaches the worker, and a duplicate inside one project is contested too (both would drain the same account's inbound, which is read-destructive). |
weibo |
sendUrl? |
accessToken |
Poll-based DM bridge; dedup by DM idstr. |
reddit |
clientId, username |
clientSecret, password |
Script-app poller over the account's private messages (t4 only — public threads stay out of scope); replies via /api/compose. Marks read only after a successful forward. |
xmtp |
env? |
walletKey |
Wallet-native DMs via @xmtp/node-sdk (optional dep); the bot is an EOA the operator provisions. EXPERIMENTAL — the v3 SDK moves fast. |
guilded |
botId? |
botToken |
Bot WebSocket in (Node ≥22 native WS), REST reply into the same channel. Rooms → group mode. |
youtube-live |
videoId, clientId? |
apiKey?, clientSecret?, refreshToken? |
liveChatMessages poller (cold-start seeded — a worker restart never replays backlog). Reads work on an API key; INSERT needs the OAuth triple. Danmaku is a ROOM — group mode defaults ON (mention-gated); set config.wakeWords. |
bilibili-live |
appId, accessKeyId, code (主播身份码) |
accessKeySecret |
Open-live (互动玩法) signed app/start + binary danmaku WS (op 7 auth / op 2 heartbeat / zlib frames). RECEIVE-ONLY — Bilibili has no third-party danmaku-send API; surface replies via overlay/TTS tooling. Danmaku is a ROOM — group mode defaults ON (mention-gated). |
minecraft |
host, port?, sayTemplate? |
password (RCON) |
Outbound via RCON (msg/tellraw, player-name validated). Inbound needs a server-side plugin POSTing { from, text } to the inbound URL with the inbound secret as bearer — the worker only sends. |
steam |
accountName |
password, sharedSecret |
Friend chat via steam-user (optional dep; reverse-engineered CM protocol — no official API). $5 spend unlocks friend invites; mutual friends only; ban risk sits with the operator (run the 30-day pilot before broad rollout). |
Discord: interactions only, and what that means
Discord has two delivery models and Pouchy uses the serverless-friendly one.
An Interactions Endpoint URL receives exactly one class of event: user interactions — slash commands and context-menu actions. It does not receive ordinary chat messages. Those travel over the Gateway, a persistent WebSocket the client must hold open, which a serverless runtime cannot do.
Two consequences worth stating plainly, because both look like bugs:
- Typing a normal message at the bot in a Discord channel does nothing.
That is the transport, not a fault — the message never reaches the endpoint.
Users talk to the agent with
/ask <message>. - With no slash command registered, the connector is inert. The endpoint is
configured and validating PINGs, but there is nothing for a user to invoke.
This is why the server now registers
/askat create time; before that, a correctly configured connector could sit silent with nothing obviously wrong.
The adapter's parseInbound does understand a Gateway-shaped message payload
(content, with the bot-mention/wake-word addressing rules and the bot-loop
guard), so a self-hosted Gateway client that forwards MESSAGE_CREATE to the
inbound URL works today. Pouchy's managed relay (below) does not carry Discord —
it hosts the transports that have no webhook option at all. Adding Discord there
would trade the serverless model for an always-on socket per connector, which is
a deliberate open question, not an oversight.
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.
Attachments (images & files)
Attachment messages become part of the agent turn on transports whose adapter
implements media fetch — currently Feishu (image / file / rich-post
messages), Telegram (photos and documents, caption preserved), Slack
(file_share uploads), Discord (gateway-bridged message attachments),
LINE (images, files and voice notes via the content API), WhatsApp
(image / document messages, caption preserved), Messenger / Instagram
(attachment CDN urls), Twilio MMS/RCS (MediaUrl parts), OnlyFans/Fansly (signed CDN urls), Sendblue iMessage (media_url) and Zalo (attachment urls). Images reach the
model as vision input; PDFs, audio and image files ride through the same
server-side understanding pipeline the knowledge upload uses (text extraction /
vision transcription / STT), and the extracted content is appended to the user
turn. Nothing to configure — the downloads authenticate with the connector's
existing credentials (Feishu tenant token, Telegram/Slack/LINE/WhatsApp bot or
channel token, Twilio account credentials; Slack additionally needs the
files:read OAuth scope on the bot token).
Bounds: at most 4 attachments per message are processed (4 images / 2 extracted files per turn); images cap at 8MB, files at 20MB (Telegram's Bot API itself stops at 20MB). An attachment that can't be fetched or read is reported to the model as a readable note — the agent will say what it couldn't open and why, instead of silently ignoring the message. On transports without a media adapter the agent is told an attachment arrived that this channel cannot read yet, so it can answer honestly.