Connect a support-conversation channel (Chatwoot / Intercom)
SupaCloud can act as the AI-resolution backend behind Chatwoot and Intercom.
An inbound customer message is HMAC-verified, enqueued as a gated backlog item,
and worked by an agent that reads and replies through governed conversation.*
tools — one ConversationProvider seam, two transport adapters.


Prerequisites
Section titled “Prerequisites”- A Chatwoot account (self-hosted or cloud) with an agent-bot access token, or an Intercom workspace with a workspace access token.
- For Chatwoot: the instance base URL + the numeric
account_id. For Intercom: the region (us/eu/au) + the botadmin_id. - The webhook signing secret each platform will sign deliveries with (the
Chatwoot inbox HMAC secret / the Intercom app
client_secret). - A workspace where you can create resources and a workflow.
-
Create the channel resource.
In Resources → New, pick Chatwoot or Intercom.
- Chatwoot — set
base_url(e.g.https://chat.example.com) andaccount_id; paste the agent-bot access token as the secret. Because a self-hosted host is operator-supplied, every outbound call to it goes through SupaCloud’s SSRF-pinned client. - Intercom — choose the
regionand set the botadmin_id; paste the workspace access token as the secret. Intercom’s regional API host is a fixed allowlist, so no host needs SSRF-pinning.
- Chatwoot — set
-
Add an inbound conversation trigger.
On the workflow that should handle conversations, add a trigger of kind
chatwoot_conversationorintercom_conversation. Configure:resource_id— the channel resource from step 1 (auto-bound when the workspace has exactly one of that kind).signing_secret— the webhook HMAC secret the platform signs with.mode—auto-reply(agent answers autonomously, under your autonomy / budget gates),human-gate(answer held for human approval), ornotify-only(verified deliveries are acked but start no agent work).
The trigger cannot be enabled until both a
resource_idand asigning_secretare present. Copy its receiver URL from the workflow’s Triggers panel. -
Wire the platform webhook.
Point the platform at the trigger’s receiver URL:
-
Chatwoot (Settings → Integrations → Webhooks): subscribe to Conversation Created + Message Created, set the HMAC secret to your
signing_secret. SupaCloud verifiesX-Chatwoot-Signature= HMAC-SHA256 over{X-Chatwoot-Timestamp}.{body}, with a stale-timestamp replay guard.POST <server public_url>/api/workflows/triggers/chatwoot/<token> -
Intercom (Developer Hub → Webhooks): subscribe to
conversation.user.created+conversation.user.replied, set the trigger’ssigning_secretto your app’sclient_secret. SupaCloud verifiesX-Hub-Signature= HMAC-SHA1 over the raw body.POST <server public_url>/api/workflows/triggers/intercom/<token>
-
-
Let the agent resolve conversations.
A verified new conversation / incoming customer message is enqueued as a backlog item and dispatched under the same autonomy, budget and quiet-hours gates as any other work. The agent reads and acts on the conversation through the
conversation.*MCP tools:conversation.get/conversation.list(read), andconversation.reply/assign/resolve/handoff(ops). A customer-visible reply rides the same SSRF-vetted sender;handoffis the explicit escape hatch back to a human team.
Abuse protection on a public inbox
Section titled “Abuse protection on a public inbox”An inbox anyone can write to is the one cost path in SupaCloud a stranger can trigger. Every other one — launching a task, dispatching from the backlog, calling an MCP tool — needs an account, a membership or a token. This one needs the inbox address. So the ceilings below are on by default; you do not have to switch them on.
What is capped, and on which axis
Section titled “What is capped, and on which axis”Two axes, answering different questions.
Per contact — the cost axis. A stranger opening conversation after conversation is the attack that works. Note conversation, not message: a second message in an existing thread is already free, because the backlog entry is keyed on the conversation, so it is deduplicated before any model is asked anything.
| Ceiling | Default | What it bounds |
|---|---|---|
contact_conversations_per_day |
20 | New conversations one contact may open in a rolling day |
contact_messages_per_hour |
60 | Inbound messages from one contact in a rolling hour |
Exceeding one of these is a refusal: no model is called, the contact gets one short static line saying a person will take over, and an audit row is written. The important property — the one a workspace-wide budget does not have — is that an exhausted contact leaves every other contact untouched.
Per conversation — the quality axis. A thread that grows without end has outgrown what a bot should be answering.
| Ceiling | Default | What it bounds |
|---|---|---|
conversation_rounds |
40 | Inbound turns in one conversation |
conversation_context_tokens |
24000 | Estimated tokens of accumulated thread text |
Exceeding one of these is a hand-off, not a refusal: the customer is not the
problem. The thread is passed to the human team named in the resource’s optional
handoff_team, and the customer is told. Without handoff_team the customer is
still told; no team is assigned, because inventing a team id would route someone
where nobody is watching.
Tightening or loosening a ceiling
Section titled “Tightening or loosening a ceiling”Add an abuse_caps block to the trigger’s config. Anything you leave out keeps its
default:
{ "resource_id": "…", "signing_secret": "…", "mode": "human-gate", "abuse_caps": { "contact_conversations_per_day": 5 }}A malformed value — a string, a negative, or 0 — is ignored and the built-in
default stays in force. 0 in particular is refused rather than read as
“unlimited”: on a public inbox that would be the most expensive typo available.
The kill switch
Section titled “The kill switch”When an inbox is under attack, silence it without a deploy and without deleting the trigger:
PUT /api/operator/v1/switches/conversation_dispatch{ "enabled": false, "reason": "abuse incident" }Off means the autonomous half stops — and only that. A delivery is still authenticated, still stored in the thread, and still acknowledged so the provider stops retrying. Nothing a customer wrote is lost, and the integration stays wired; you flip it back when the incident is over.
The switch composes global-AND-workspace, so a workspace can silence its own inbox
but can never re-enable past a global “off”. It is a separate switch from
global_dispatch on purpose: that one bites at dispatch, which is after the
classifier has already been paid for.
What you can see afterwards
Section titled “What you can see afterwards”Every fired ceiling writes an audit row — conversation.rate_limited for a refusal,
conversation.handed_off for a hand-off — carrying which ceiling fired and on which
conversation. Without them a silenced inbox would look exactly like a quiet one.
Answer from the documentation first
Section titled “Answer from the documentation first”Most of what a public inbox receives is already answered by a page in the product documentation. Answering those from the page itself costs nothing: no model is called, so the reply is immediate and free. It is off by default and you turn it on per channel.
Add a docs_first block to the trigger’s config:
{ "resource_id": "…", "signing_secret": "…", "mode": "human-gate", "docs_first": { "enabled": true, "min_score": 0.6, "lang": "en" }}enabled— off unless you say otherwise. Answering your customers out of a document corpus is your decision, never something a deploy starts doing.min_score— how much of the question the winning page has to cover, above0and at most1.0.6means roughly “two content words out of three”. Raise it to answer less often and more certainly.lang— which half of the corpus to search,enorde. Never both: a German customer answered from an English page is a worse answer than no answer.
A value the block gets wrong falls back to the default above, and a channel that
carries no docs_first block behaves exactly as it did before.
What it will and will not answer
Section titled “What it will and will not answer”- It replies with a quotation and the page it came from. There is no rewording step, because rewording is a model call — the one thing this path exists to avoid — and an answer with no page behind it is a guess in a nicer font.
- A greeting or a two-word message is not a lookup, and is not answered.
- A long account of a problem is not a question either; that belongs to the agent.
- If no page covers enough of the question, nothing is sent and the conversation takes the ordinary route. Turning this on costs you no conversations.
- On a deployment with no documentation staged the path is simply inactive.
Where it sits, and why that is the whole design
Section titled “Where it sits, and why that is the whole design”The documentation answer runs after the abuse ceiling above, never before it. A reply the ceiling has not seen would be a free reply, and a stranger could pull an unlimited number of them out of a public inbox — the ceiling would be decoration. So a contact who is over their ceiling is refused; they do not get documentation answers instead.
Every answer writes a conversation.docs_answered audit row naming the page it
quoted, so what the cheap path saved is visible rather than assumed.