@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
OutboundMessages, absorbing each channel’s quirks (text chunking, button limits, media fixups, interactive fallbacks)
@runflow-ai/sdk v1.5.0:
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, so credentials stay server-side — your agent code never holds a channel token.Who does what
The module deliberately owns no flow control and imposes no state contract:
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:main.ts
agent/runner.ts
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.
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 thereply helpers:
skipped — never attempted — so a platform retry can resend the tail without duplicating the head.
Providers
WhatsApp Cloud — meta()
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
.webpimage URLs rewritten to.jpg(Meta rejects webp with error131053, often silently)- Brazilian phone numbers normalized (legacy 12-digit numbers get the mobile
9inserted — 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 asflow-replyevents
Telegram — telegram()
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.
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:
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:
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: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.Multi-channel routing
provider can be a resolver — route per turn (e.g. by tenant or by binding):
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.
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:Glass box: hand-rolling the handler
createChannelHandler is sugar over exported pieces — hand-roll main if you want the whole flow in sight:
{ ok, sent, results }, where each SendResult is:
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.Bring your own channel
A provider is just a factory returning theChannelProvider contract — parse + send, with optional typing and fetchMedia. No registry to update, no handler changes:
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
Connectors
The transport channels send through — credentials stay server-side
Media Processing
Transcribe audio and process images from inbound events
Agents
The turn your channel handler runs
Memory
Persist conversation state across turns