Skip to content
Select themeSelect language

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.

The Connections board listing each configured resource with its kind and status.The Connections board listing each configured resource with its kind and status.
  • 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 bot admin_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.
  1. Create the channel resource.

    In Resources → New, pick Chatwoot or Intercom.

    • Chatwoot — set base_url (e.g. https://chat.example.com) and account_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 region and set the bot admin_id; paste the workspace access token as the secret. Intercom’s regional API host is a fixed allowlist, so no host needs SSRF-pinning.
  2. Add an inbound conversation trigger.

    On the workflow that should handle conversations, add a trigger of kind chatwoot_conversation or intercom_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.
    • modeauto-reply (agent answers autonomously, under your autonomy / budget gates), human-gate (answer held for human approval), or notify-only (verified deliveries are acked but start no agent work).

    The trigger cannot be enabled until both a resource_id and a signing_secret are present. Copy its receiver URL from the workflow’s Triggers panel.

  3. 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 verifies X-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’s signing_secret to your app’s client_secret. SupaCloud verifies X-Hub-Signature = HMAC-SHA1 over the raw body.

      POST <server public_url>/api/workflows/triggers/intercom/<token>
  4. 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), and conversation.reply / assign / resolve / handoff (ops). A customer-visible reply rides the same SSRF-vetted sender; handoff is the explicit escape hatch back to a human team.

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.

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.

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.

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.

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.

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, above 0 and at most 1. 0.6 means roughly “two content words out of three”. Raise it to answer less often and more certainly.
  • lang — which half of the corpus to search, en or de. 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.

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