> ## Documentation Index
> Fetch the complete documentation index at: https://docs.runflow.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Channels

> Connect agents to WhatsApp, Telegram, Kommo, Chatwoot or any webhook — normalized inbound events and channel-aware replies, without rewriting the plumbing

The **Channels** module (`@runflow-ai/sdk/channels`) is a thin **translation layer** between messaging channels and your agent. It does two things:

* **Parses** each raw webhook request into a normalized `InboundEvent`
* **Renders and sends** channel-agnostic `OutboundMessage`s, absorbing each channel's quirks (text chunking, button limits, media fixups, interactive fallbacks)

Available from `@runflow-ai/sdk` **v1.5.0**:

```typescript theme={null}
import { createChannelHandler, meta, reply } from '@runflow-ai/sdk/channels';
```

<Note>
  Everything is **explicit — no magic**. You import a provider factory (`meta()`, `telegram()`, …) and pass it. There is no global registry and no side-effect imports. Outbound messages are POSTed through the SDK [connector](/core-concepts/connectors), so credentials stay server-side — your agent code never holds a channel token.
</Note>

## Who does what

The module deliberately owns **no flow control** and imposes **no state contract**:

| Layer                           | Responsibility                                                                                                                                                                                                             |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Platform** (HTTP trigger)     | Flow control: coalesces the inbound burst by a configured key (debounce), dedup, ordering, retry, webhook auth. Delivers one turn with `input.events[]` already grouped — the coalescing key doubles as the user identity. |
| **Channels module** (this page) | Translation only: raw request → `InboundEvent`, `OutboundMessage` → channel wire format.                                                                                                                                   |
| **Your project**                | The turn: agent + domain logic, prompts, and your own state.                                                                                                                                                               |

When a user sends 3 quick messages, the platform groups them into a single execution and your handler receives all 3 events at once — no manual debouncing.

## Quick start

The whole entry point:

```typescript main.ts theme={null}
import { createChannelHandler, meta } from '@runflow-ai/sdk/channels';
import { runTurn } from './agent/runner';

export default createChannelHandler({
  provider: meta({ connector: 'whatsapp-acme', phoneNumberId: '123456789012345' }),
  run: runTurn,
});
```

Your domain logic receives the whole coalesced burst, already parsed:

```typescript agent/runner.ts theme={null}
import { reply, type RunTurn } from '@runflow-ai/sdk/channels';
import { agent } from './agent';

export const runTurn: RunTurn = async ({ ctx, events }) => {
  const text = events
    .map((e) => ('text' in e ? e.text : `[${e.type}]`))
    .join('\n');

  const result = await agent.process({
    message: text,
    userId: ctx.userId, // = the coalescing key = the identity
  });

  return [reply.text(result.message)];
};
```

Return an array of messages and the provider renders each one for the channel — chunking, button limits and media formats are handled for you.

## Inbound events

`InboundEvent` is a discriminated union. Every event carries `userId` (the stable conversation key), plus `messageId`, `profileName` and `raw` (the original payload, as an escape hatch) when the channel provides them.

| `type`       | Payload fields                                  | Notes                                                               |
| ------------ | ----------------------------------------------- | ------------------------------------------------------------------- |
| `text`       | `text`                                          | Plain message                                                       |
| `button`     | `payload`, `text`                               | Quick-reply / inline button tap                                     |
| `audio`      | `mediaId`, `mimeType?`                          | Voice note — resolve with [`fetchMedia`](#media-audio-images-files) |
| `image`      | `mediaId`, `mimeType?`, `caption?`              |                                                                     |
| `video`      | `mediaId`, `mimeType?`, `caption?`              |                                                                     |
| `document`   | `mediaId`, `mimeType?`, `filename?`, `caption?` |                                                                     |
| `location`   | `latitude`, `longitude`, `name?`                |                                                                     |
| `contacts`   | `contacts: { name, phone }[]`                   | Shared contact cards                                                |
| `flow-reply` | `responseData`                                  | WhatsApp Flow form submission                                       |
| `unknown`    | `messageType`                                   | Unrecognized payload — inspect `raw`                                |

Malformed events never take down the burst: each event is parsed in isolation, and non-message payloads (status callbacks, a bot's own messages) are silently skipped.

## Replying

Build outbound messages with the `reply` helpers:

```typescript theme={null}
import { reply } from '@runflow-ai/sdk/channels';

return [
  reply.text('Pedido confirmado!'),
  reply.buttons('Como quer receber?', [
    { id: 'pickup', label: 'Retirar na loja' },
    { id: 'delivery', label: 'Entrega' },
  ]),
  reply.image('https://cdn.acme.com/receipt.png', 'Seu comprovante'),
  reply.document('https://cdn.acme.com/contract.pdf', { filename: 'contrato.pdf' }),
  reply.audio('https://cdn.acme.com/voice.ogg'),
  reply.link('Acompanhe seu pedido', 'Rastrear', 'https://acme.com/track/123'),
  reply.flow('1234567890', 'Finalize seu cadastro', 'Abrir formulário'),
];
```

Messages are **channel-agnostic** — the provider renders each type to the channel's wire format and **degrades gracefully** where the channel lacks a feature (e.g. buttons become a numbered text list on channels without interactive messages).

Replies are sent **sequentially** (chat order matters). If one fails, the remaining messages are marked `skipped` — never attempted — so a platform retry can resend the tail without duplicating the head.

## Providers

### WhatsApp Cloud — `meta()`

```typescript theme={null}
meta({ connector: 'whatsapp-acme', phoneNumberId: '123456789012345' })
```

| Option          | Default           | Description                                                                   |
| --------------- | ----------------- | ----------------------------------------------------------------------------- |
| `connector`     | trigger binding   | Connector instance slug                                                       |
| `phoneNumberId` | trigger binding   | The `phone_number_id` used on the send URL                                    |
| `sendResource`  | `'send-message'`  | Resource slug for sending (passthrough to `POST /{phone_number_id}/messages`) |
| `mediaResource` | `'get-media-url'` | Resource slug for resolving inbound media ids                                 |

The Meta provider bakes in real production lessons so you don't relearn them:

* Text clipped at 4,096 chars; interactive bodies at 1,024 (avoids error `131009`) — a longer body is sent as text followed by the buttons
* Max 3 buttons (Meta's limit) — zero or more than 3 degrade to a numbered text list; button titles clipped at 20 chars
* `.webp` image URLs rewritten to `.jpg` (Meta rejects webp with error `131053`, often silently)
* Brazilian phone numbers normalized (legacy 12-digit numbers get the mobile `9` inserted — landlines untouched)
* Typing indicator piggybacks on mark-as-read, through the same send resource
* `reply.flow()` renders a native WhatsApp Flow message; inbound Flow submissions arrive as `flow-reply` events

### Telegram — `telegram()`

```typescript theme={null}
telegram({ connector: 'telegram-acme' })
```

| Option      | Default                     | Description                                                                                                                                                                                                         |
| ----------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `connector` | trigger binding             | Connector instance slug (bot token lives in the connector base URL)                                                                                                                                                 |
| `resources` | Bot API names in kebab-case | Override any resource slug: `sendMessage` → `send-message`, `sendPhoto` → `send-photo`, `sendVoice` → `send-voice`, `sendDocument` → `send-document`, `sendChatAction` → `send-chat-action`, `getFile` → `get-file` |

Long texts are chunked at 4,096 chars, captions clipped at 1,024, and button ids clipped at Telegram's 64-**byte** `callback_data` limit. `reply.link()` and `reply.flow()` degrade to plain text (no Telegram equivalent).

### Kommo CRM — `kommo()`

Kommo is **CRM-as-channel**: it has no direct messaging API, so outbound = patch a lead custom field with the reply, then run the salesbot that delivers it.

```typescript theme={null}
kommo({ connector: 'kommo-acme', replyFieldId: 987654 })
```

| Option             | Default                 | Description                                 |
| ------------------ | ----------------------- | ------------------------------------------- |
| `connector`        | trigger binding         | Connector instance slug                     |
| `replyFieldId`     | trigger binding address | The lead custom-field id the salesbot reads |
| `patchResource`    | `'patch-lead'`          | Resource that PATCHes the lead custom field |
| `salesbotResource` | `'run-salesbot'`        | Resource that triggers the salesbot         |

The `userId` is the **lead id**. All rich message types degrade to plain text (buttons become a numbered list) — the salesbot field only carries text.

### Chatwoot — `chatwoot()`

Chatwoot is **helpdesk-as-channel**, conversation-based:

```typescript theme={null}
chatwoot({ connector: 'chatwoot-acme', accountId: 7 })
```

| Option         | Default                 | Description                                         |
| -------------- | ----------------------- | --------------------------------------------------- |
| `connector`    | trigger binding         | Connector instance slug                             |
| `accountId`    | trigger binding address | Chatwoot account id (path param on every call)      |
| `sendResource` | `'send-message'`        | Resource that creates a message in the conversation |

The `userId` is the **conversation id** — Chatwoot's native thread and the send target. The provider includes **bot-loop prevention**: `outgoing`, `agent_bot` and agent (`user`) messages are ignored, so the agent never answers itself.

### Generic webhooks — `custom()`

For an HTTP trigger fed by an arbitrary system (CRM, ERP, internal service), `custom()` replaces hand-rolled parsing. Declare where the fields live — a dot-path into the body or a function:

```typescript theme={null}
import { createChannelHandler, custom } from '@runflow-ai/sdk/channels';

export default createChannelHandler({
  provider: custom({
    text: 'data.text',        // dot-path (numeric segments index arrays)
    userId: 'contact.id',     // numbers coerce to string
    messageId: (body) => body.event_id,
  }),
  run: async ({ events }) => {
    // inbound-only by default: return [] and reply through your own transport
    return [];
  },
});
```

| Option      | Default     | Description                                                                                                     |
| ----------- | ----------- | --------------------------------------------------------------------------------------------------------------- |
| `text`      | `'message'` | Where the message text lives                                                                                    |
| `userId`    | `'userId'`  | Where the user/conversation id lives (usually the trigger's coalescing key field)                               |
| `messageId` | `'msgId'`   | Provider message id, for tracing                                                                                |
| `parse`     | —           | Full override: `(rawBody) => InboundEvent \| null` — wins over the extractors                                   |
| `send`      | —           | Optional outbound: without it, replies fail with a clear error (a generic webhook has no implied way to answer) |

Events without text are surfaced as `{ type: 'unknown', raw }` instead of being dropped, so your turn can still inspect data-only webhooks.

## Connector resources

Providers send through a **connector resource that you control**. The provider builds the correct API payload; your resource forwards it. Defaults match the catalog connectors, so **Meta, Telegram and Chatwoot work out of the box** — override the slugs to point at your own connector:

| Provider   | Catalog connector       | Default resources                                                                           |
| ---------- | ----------------------- | ------------------------------------------------------------------------------------------- |
| `meta`     | WhatsApp Business Cloud | `send-message`, `get-media-url`                                                             |
| `telegram` | Telegram Bot            | `send-message`, `send-photo`, `send-voice`, `send-document`, `send-chat-action`, `get-file` |
| `chatwoot` | Chatwoot                | `send-message`                                                                              |
| `kommo`    | — (create your own)     | `patch-lead`, `run-salesbot`                                                                |

```typescript theme={null}
// Point at your own connector + resource slugs
meta({ connector: 'meta-acme', sendResource: 'enviar-msg', phoneNumberId: '...' });
```

<Note>
  Connector and address resolve from **explicit factory options first**, then from the trigger's channel binding (`ctx.binding`) when your trigger delivers one. If neither supplies what the wire needs, the provider fails loud and early with a legible error.
</Note>

## Multi-channel routing

`provider` can be a resolver — route per turn (e.g. by tenant or by binding):

```typescript theme={null}
createChannelHandler({
  provider: (ctx) => ctx.binding?.provider === 'meta' ? meta() : telegram(),
  run: runTurn,
});
```

Because `InboundEvent` and `OutboundMessage` are channel-agnostic, the same `runTurn` serves every channel.

## Media (audio, images, files)

The boundary: **channels fetches, the SDK understands.** `provider.fetchMedia()` resolves a media event into something fetchable; transcription, vision and OCR stay in the SDK's [media layer](/core-concepts/media-processing).

```typescript theme={null}
import { transcribe } from '@runflow-ai/sdk';

export const runTurn: RunTurn = async ({ ctx, events, provider }) => {
  const audio = events.find((e) => e.type === 'audio');
  if (audio) {
    const file = await provider.fetchMedia!(ctx, audio);         // channel-specific
    const { text } = await transcribe({ audioUrl: file.url! });  // channel-agnostic
    return [reply.text(`Você disse: ${text}`)];
  }
  return [reply.text('Envie um áudio!')];
};
```

Per-channel behavior:

| Provider             | `fetchMedia` behavior                                                                                                                                                                                                  |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `meta`               | Resolves the media id via the Graph API. The returned URL is **Bearer-gated** — to get raw bytes, use a custom `mediaResource` that returns `{ base64 }` (the `{ download: true }` option is passed through as a hint) |
| `telegram`           | Returns a directly usable download URL by default; `{ download: true }` fetches the bytes                                                                                                                              |
| `kommo` / `chatwoot` | The inbound media id is already a public URL — returned as-is                                                                                                                                                          |

Credentials stay in the connector, server-side: the agent never holds the channel token.

## Typing indicators

On by default where the channel supports it (Meta, Telegram) — best-effort, never blocks the turn:

```typescript theme={null}
createChannelHandler({
  provider: meta({ ... }),
  run: runTurn,
  typing: (e) => e.type === 'audio', // false to disable, or a per-event predicate
});
```

## Glass box: hand-rolling the handler

`createChannelHandler` is sugar over exported pieces — hand-roll `main` if you want the whole flow in sight:

```typescript theme={null}
import { meta, parseEvents, sendAll } from '@runflow-ai/sdk/channels';
import { runTurn } from './agent/runner';

export default async function main(input: any) {
  const provider = meta({ connector: 'whatsapp-acme', phoneNumberId: '...' });

  const events = parseEvents(provider, input);            // parse + per-event isolation
  if (!events.length) return { ok: true, skipped: true };

  const ctx = { ...input.context, userId: input.context?.userId ?? events[0].userId };
  const replies = await runTurn({ ctx, events, provider });
  const results = await sendAll(provider, ctx, replies);  // sequential, per-message results

  return { ok: results.every((r) => r.status === 'sent'), results };
}
```

The handler returns `{ ok, sent, results }`, where each `SendResult` is:

| `status`  | Meaning                                                                                             |
| --------- | --------------------------------------------------------------------------------------------------- |
| `sent`    | Delivered to the channel API                                                                        |
| `failed`  | Attempted and errored (`error` has the message)                                                     |
| `skipped` | Never attempted because a prior message failed (`reason: 'prior_failed'`) — safe to resend on retry |

<Note>
  `parseEvents` reads `input.events[]` — the platform delivers each coalesced request as `{ method, path, query, headers, body, receivedAt }` and the channel payload is unwrapped from `.body` automatically. Inputs with a single `request.body` (no batching) also work.
</Note>

## Bring your own channel

A provider is just a factory returning the `ChannelProvider` contract — `parse` + `send`, with optional `typing` and `fetchMedia`. No registry to update, no handler changes:

```typescript theme={null}
import type { ChannelProvider, InboundEvent, OutboundMessage, TurnContext } from '@runflow-ai/sdk/channels';

export function myChannel(opts: { connector: string }): ChannelProvider {
  return {
    name: 'custom',
    parse(rawBody): InboundEvent | null {
      const b = rawBody as any;
      if (!b?.from) return null; // not a message → skip silently, never throw
      return { type: 'text', text: b.message ?? '', userId: String(b.from), raw: b };
    },
    async send(ctx: TurnContext, to: string, message: OutboundMessage) {
      // render `message` to your wire format and POST via a connector resource
    },
  };
}
```

Rules of the contract: `parse` must be defensive (return `null` for non-messages, never throw on malformed payloads) and `send` should throw on hard failures so the handler records a per-message result.

## Next Steps

<CardGroup cols={2}>
  <Card title="Connectors" icon="plug" href="/core-concepts/connectors">
    The transport channels send through — credentials stay server-side
  </Card>

  <Card title="Media Processing" icon="photo-film" href="/core-concepts/media-processing">
    Transcribe audio and process images from inbound events
  </Card>

  <Card title="Agents" icon="robot" href="/core-concepts/agents">
    The turn your channel handler runs
  </Card>

  <Card title="Memory" icon="brain" href="/core-concepts/memory">
    Persist conversation state across turns
  </Card>
</CardGroup>
