# Channels

> Every channel the bot is connected to, on one route — the hand-off that connects one, the two keys the return leg writes, and the refusals that cannot be told apart.

Page: https://sdk.chatfuel.com/docs/modules/channels
Markdown: https://sdk.chatfuel.com/docs/modules/channels.md

## What it is [#what-it-is]

Nothing in this API carries somebody through a platform's consent screens. The OAuth round trip,
the callback and WhatsApp's embedded sign-up all belong to pages Chatfuel serves, so connecting is
a hand-off rather than a form: you press **Connect**, the app mints a one-shot platform link with
both redirects pointing back at this page, and the browser leaves for Chatfuel in the same tab.
You finish there and come back here with the channel connected.

Everything else on the page is what is already connected, read out of `bot.contactScopes` — the
WhatsApp number, the Instagram account, the TikTok account, the Facebook pages and the web widget,
each with what it is connected as and a Disconnect where the server allows one. The three that
take a link also carry **Refresh access**, which re-grants the permissions on the asset already
connected and touches nothing else.

## Installed as [#installed-as]

|             |                                           |
| ----------- | ----------------------------------------- |
| Wizard id   | `channels`                                |
| Install it  | `npx @chatfuel/wizard --modules channels` |
| Selected by | Default — `--yes` installs it             |
| Requires    | Nothing, beyond the implicit `core`       |
| Recommends  | Nothing                                   |
| Skill       | `chatfuel-channels`                       |

The rail says **Channels** and the shell's nav table puts the item under Settings. `core` is
implicit for every module, and this one leans on it: it ships no operations of its own, so the
channels read, the disconnect, the two link creates and the role check all come from core's
generated documents.

## Routes and views [#routes-and-views]

`/channels` is the whole address. There are no views, and the only query parameters are the two
the return leg of a hand-off writes:

| Parameter | What it does                                                            |
| --------- | ----------------------------------------------------------------------- |
| `result`  | `connected` or `failed` — which of the two redirects Chatfuel followed. |
| `channel` | The platform the hand-off was for: `whatsapp`, `instagram` or `tiktok`. |

Anything else in either key is no arrival at all. Both are read once, said once, and taken back
out of the address with a `replaceState` that leaves no entry in the nav stack — so a reload after
a hand-off is a plain load of the page.

What the page draws is a card per platform, in a fixed order: WhatsApp, Instagram and TikTok, then
Facebook and the web widget as lists. WhatsApp, Instagram and TikTok hold one asset each, because
the server refuses a second of the same platform. Facebook pages come in any number and are sorted
by name. The web widget is one per bot and prints no row at all: its name is the empty string on
every bot, and a row holding a blank is a card body with nothing in it — the chip beside the title
already says whether anything is connected.

An empty platform card carries **Connect**. A connected one carries **Refresh access** and
**Disconnect**, and Disconnect asks first: &#x2A;*The bot stops receiving messages on this channel.** A
Connect or a Refresh access the API refuses prints the reason under the card; a Disconnect that
fails keeps its confirm dialog open with the reason in it, where the reader is already looking.
The header carries a Refresh.

## The model [#the-model]

**The link is minted on the press and spent by the redirect that follows.** It is never shown,
never copied and never kept: it is a credential whose job starts immediately, and the person it
could be handed to is the one already sitting in front of the screen. Refresh access works the
same way, with the other kind of link. The address Chatfuel answers with is checked against the
same scheme allowlist as any other URL that came off the wire before the app follows it — and when
it is refused, the card says so rather than leaving quietly.

<Callout type="warn">
  **The redirects only exist over https.** The API refuses a redirect that is not `https://` with
  a host, so a deployment served over plain http — every `npm run dev` — mints the link with no
  redirects at all rather than one the API would refuse. The hand-off still works there; what is
  missing is the leg home, and the way back is the browser's own Back button. This is the first
  thing that looks like a bug and is not.
</Callout>

**Arrival is not proof.** Anybody can type the return address, so `result` decides what the page
*says*, never what is true: a success is a toast, a failure is a dismissable alert saying the
hand-off came back without finishing, and what is on screen either way comes from the read that
runs on mount.

**Two reads, and only one of them needs a permission.**

| Document      | Reads                    | Needs             | Who sees it |
| ------------- | ------------------------ | ----------------- | ----------- |
| `BotChannels` | `bot.contactScopes`      | Access to the bot | Every role  |
| `MyBotRole`   | The caller's permissions | —                 | Every role  |

The active-link maps — `Bot.activePlatformConnectionLinks` and
`activePlatformAccessRefreshLinks` — are not read here at all. A link this app spends in the same
gesture is never a thing to keep in sync. They exist for the other shape a link can be spent in:
an agency minting one to send to a client who has no account in the product, which is what the
`url`, the maps and the revoke mutations are for. Should you build that shape, keep the maps out
of the channels document: both fields are non-null and both need `Configure: Edit`, so one
document asking for scopes and links together answers nothing at all to a role that may read but
not manage.

**Creating a link replaces the active one for that platform and kind, silently** — no error, no
field that says so, and the API keeps no used, expired, revoked or replaced state to look it up
in. Here that costs nothing, because the link is spent in the gesture that made it. A link also
expires 24 hours after it is created, and one successful connection consumes it.

**Nothing subscribes to any of this.** A channel is connected on Chatfuel's own page, so the page
re-reads on reconnect, on a tab coming back after a minute away, and on the header's Refresh.
Disconnect needs no refetch — it answers `Bot` with the fresh scopes, and they replace what is
held. Nothing is optimistic, and an answer from a load that a refresh has already overtaken is
dropped rather than painted.

## Configuration [#configuration]

| Variable                     | Notes                                           |
| ---------------------------- | ----------------------------------------------- |
| `CHATFUEL_TOKEN`             | Secret. Server-side only.                       |
| `VITE_CHATFUEL_WORKSPACE_ID` | The workspace you picked during the wizard run. |
| `CHATFUEL_API_BASE`          | Defaults to `https://panel.chatfuel.com`.       |

Those three and nothing else. Every variable the app reads, and which side reads it, is on
[environment variables](/docs/deploy/environments).

## Permissions [#permissions]

| Object      | Action | Required for                                     |
| ----------- | ------ | ------------------------------------------------ |
| `Bot`       | `View` | Reading which channels are connected to the bot. |
| `Configure` | `Edit` | Connect, Refresh access and Disconnect.          |

A role without `Configure: Edit` sees the connection state and no control to change it: the three
buttons are absent rather than disabled. The gate is closed until `MyBotRole` answers, and an
error closes it.

That is a decision about what to offer, not an authorization boundary. Through the proxy the API
enforces the token owner's role, so a host that wants a harder line draws it in the proxy.

## Limits [#limits]

**Three platforms take a link.** `PlatformOperationLinkPlatform` has `whatsapp`, `instagram` and
`tiktok`. There is no `facebook` member: a Facebook page is connected by a signed-in operator
through the OAuth route, never by a link, and the web widget needs no connection at all. The
widget cannot be disconnected either — the server refuses with `CannotDisconnectWidgetScope` — so
that row carries no control.

**`contactScopes` has no pagination and no order.** The whole list arrives at once, in no order
worth relying on, so the page takes the first scope of each single-asset platform, sorts the
Facebook pages by name, and skips a `__typename` it does not know rather than throwing on it.

**A TikTok account may have no handle.** Both `username` and `name` are nullable there, where
Instagram's `username` is not, so the card falls back to the account id.

**Two refusals cannot be read from their code.** `CannotDisconnectWidgetScope` arrives nested, at
`errors[0].extensions.errors[0].extensions.code`, so a check that reads only the top level misses
it. And a contact scope the bot does not have answers `InternalServerError` — the same thing a
genuine fault answers — so every failed disconnect re-reads before it shows anything, and a card
left over from another tab does not survive the round trip.

| What                                 | Value                                                                                                        |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| A link's life                        | 24 hours from creation, or one successful use                                                                |
| Platforms a link can connect         | 3                                                                                                            |
| Assets per platform                  | One WhatsApp number, one Instagram account, one TikTok account; any number of Facebook pages; one web widget |
| A tab away before returning re-reads | 60 seconds                                                                                                   |

The codes the page has a sentence for:

| Code                                      | When                                                   | What the page does                                                                                                                  |
| ----------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `PlatformOperationLinkInvalidRedirectURL` | A redirect the API will not take                       | Says a redirect must be an `https://` address with a host — though the app builds its own, and sends none where it cannot build one |
| `NoConnectedContactScopeForPlatform`      | Refresh access with nothing of that platform connected | Says there is no access to refresh                                                                                                  |
| `PlatformNotSupportedForOperationLink`    | A platform outside the three                           | Says the platform does not take a link                                                                                              |
| `PlatformOperationLinkNotFound`           | A link that is not active any more                     | Treats it as already gone and re-reads                                                                                              |
| `CannotDisconnectWidgetScope`             | Disconnecting the web widget                           | Never draws the control; the code arrives nested                                                                                    |
| `ContactScopeDoesNotExist`                | Disconnecting a channel that is already disconnected   | Treats it as already gone and re-reads                                                                                              |
| `ContactScopeAlreadyConnected`            | A second asset of a platform that already holds one    | Says something on this platform is already connected to the bot                                                                     |
| `InternalServerError`                     | A scope the bot does not have — and a genuine fault    | Re-reads; the two cannot be told apart                                                                                              |
| `NotEnoughPermissions`                    | The role lacks `Configure: Edit`                       | Says the role cannot manage channels on this bot                                                                                    |
| `Unauthorized`                            | The token was rejected                                 | Says to rotate it and reload                                                                                                        |

`ContactScopeDoesNotExist` is matched as a raw string, because the API answers it and the bundled
`DefinedErrorCode` enum does not carry it.

<Cards>
  <Card title="Inbox" href="/docs/modules/inbox" description="Where the conversations these channels carry arrive, rendered per platform." />

  <Card title="Core" href="/docs/modules/core" description="The module under this one: the transport rules, the error envelope, and the operations this page reads and writes through." />

  <Card title="Generated operations" href="/docs/reference/api-client/operations" description="Why there is no channels namespace — the channel documents ship in core's family." />

  <Card title="Your agent's notes" href="/docs/concepts/agent-skills" description="The wizard installs .claude/skills/chatfuel-channels/ (Codex: .agents/skills/chatfuel-channels/) — the page model, the customization knobs, and the core skill's platform-links reference behind them." />
</Cards>
