Skip to content

Channels

This content is not available in your language yet.

A Channel Connector wires an Agent directly to a messaging platform — Wetel receives the platform’s inbound messages, drives the agent’s session lifecycle, and sends the reply back out, with no bridge process for you to run. This is the packaged, zero-code alternative to the roll-your-own pattern documented in Headless Agents: worked example — build your own bridge only for a platform not listed below, or if you need behavior this resource doesn’t expose.

All operations on this page require a valid JWT (Authorization: Bearer <token>) — see Authentication. Every Channel Connector is scoped to the caller’s tenant. Every request must also include the x-huat-platform: customer header.

Telegram is fully implemented. WHATSAPP, SLACK, and LARK are valid ChannelType enum values, but every operation against a connector of one of those types currently fails with a clear error — the platform-specific adapter for each hasn’t shipped yet. Don’t build against them expecting a silent no-op; you’ll get a real, immediate error instead. Check this page again before building on a non-Telegram channel — this list will be updated the moment a new one ships.

A connector moves through four states, ChannelConnectorStatus: DRAFTACTIVE (via credentials + activation) → PAUSED (deactivated, can be reactivated) or ERROR (activation failed — see lastErrorMessage).

  1. createChannelConnector — creates a DRAFT connector for an Agent.
  2. updateChannelConnectorCredentials — sets the platform credential (for Telegram, a bot token). This validates the credential against the real platform (a live Telegram getMe call) before storing anything — a bad token fails immediately, not later.
  3. activateChannelConnector — runs a second live health check and automatically registers the platform’s webhook with Wetel (for Telegram, setWebhook) — there is no manual webhook-setup step. Flips to ACTIVE only if both succeed.
  4. deactivateChannelConnector — pauses an active connector. No inbound messages are processed while paused. Reactivating later does not require re-entering credentials.

There is currently no way to permanently delete a connector, and no single-connector-by-id query — see listChannelConnectors below, the only read operation on this resource.

FieldTypeNotes
idInt!
agentIdIntThe Agent this connector routes messages to.
channelTypeChannelType!TELEGRAM | WHATSAPP | SLACK | LARK — see “What’s live today” above.
externalIdentifierStringThe platform’s own id for this connector (for Telegram, the numeric bot id from getMe). null until credentials are set.
statusChannelConnectorStatus!DRAFT | ACTIVE | PAUSED | ERROR.
lastHealthCheckAtDateTimeTimestamp of the last successful activation health check.
lastErrorMessageStringPopulated when activation fails (bad credential, webhook registration failure, etc.); cleared on the next successful activation.
createdAtDateTime!
updatedAtDateTime!

The connector’s credential is never exposed through this or any other field — same write-once/never-re-expose treatment as an MCP connector’s credential.

enum ChannelType {
TELEGRAM
WHATSAPP
SLACK
LARK
}
enum ChannelConnectorStatus {
DRAFT
ACTIVE
PAUSED
ERROR
}

Attachments and channel facts — the [[...]] markers

Section titled “Attachments and channel facts — the [[...]] markers”

When a user sends a file through a channel (Telegram today — a photo or a document, resume/PDF being the common case), the gateway doesn’t pass the raw bytes into the workflow session directly. Instead, it downloads the file’s metadata from the platform, resolves a fetchable URL, and prepends a single marker line to the message text it forwards into the session:

[[WETEL_ATTACHMENT url="https://api.telegram.org/file/bot<token>/documents/file_1.pdf" mime="application/pdf" filename="resume.pdf"]]

Any caption text the user sent alongside the file follows on the next line, unchanged. A message with no attachment never carries this marker at all — there’s nothing to detect if the user just typed a normal message.

As of 2026-09-10, this marker (and OmniChat’s own [[trigger:...]]/[[nortia:...]] marker set) is parsed server-side, deterministically, before your workflow runs at all — not something your graph needs to extract itself. Every workflow run’s context is seeded with:

Context variableTypeNotes
attachmentobject | null{ url, mime, filename }, or null if the message carried no attachment marker. mime is synthesized from the filename/URL extension when the sending channel didn’t declare one.
channelobject{ trigger, nortiaEnabled, nortiaCompanyId, raw } — non-attachment facts from a partner channel’s own marker set (OmniChat today). raw holds every marker seen, verbatim, keyed namespace.key, as an escape hatch for a fact this parser has no typed field for yet.
attachmentUrlstringFlat mirror of attachment.url. Empty string ("") when there’s no attachment.
attachmentMimestringFlat mirror of attachment.mime. Empty string when absent.
attachmentFilenamestringFlat mirror of attachment.filename. Empty string when absent.

Use the flat mirrors, not the nested object, in a condition node’s expression and in fetchAsBase64Var: a condition expression that evaluates attachment.mime throws when attachment is null, failing the whole run instead of taking the false branch — attachmentMime == "application/pdf" against an empty string is always well-defined. fetchAsBase64Var names a bare context variable with no dot-path support, so attachmentUrl works there and attachment.url doesn’t. In a Handlebars template (promptTemplate/argsTemplate/messageTemplate) either form works.

No LLM node is needed to pull url/mime back out of the marker text anymore — the previous documented pattern (two narrow llm nodes, one per field) has been replaced by this server-side parse; see the file-intake cookbook for the current, simpler graph.

Only the leading, contiguous block of marker-only lines is parsed — a marker appearing after any real message text is ignored, which is what stops an end user from typing a fake [[nortia:resumeUrl=...]] marker mid-sentence to redirect a write. Markers themselves are not stripped from userMessage — a graph that still LLM-extracts them from the raw text (a partner’s own shared graph, for example) keeps working unchanged.

url is a real, fetchable link (Telegram’s own file-download URL, valid for a limited time) — it is not pre-authorized for any destination other than the platform it came from, so fetchAsBase64Var’s plain unauthenticated GET is the correct way to retrieve it, not a credentialed action/tool call. fetchAsBase64Var performs no content-type or magic-byte check on what it fetches — it’s whatever bytes the URL returned, not a guarantee the sender’s declared mime was accurate.

Validate attachmentMime before doing anything else with the file. A partner’s intake endpoint (e.g. Nortia’s résumé submission) is commonly narrower than “any file” — PDF/Word only, not an image. Reject unsupported types with a condition node before ever calling fetchAsBase64Var, so a user who sends a photo of their resume gets asked to resend as a PDF instead of the write silently failing (or worse, silently succeeding with unusable data) further down the graph.

If you’re building your own channel bridge (a Headless Agent calling sdkSendMessage directly, rather than a packaged Channel Connector): any user-typed text you forward must have its own [[ sequences neutralized before you prepend your own marker line — otherwise an end user can type a [[...]]-shaped line that gets parsed as if the platform itself had sent it. A single .replace(/\[\[/g, '[') (or stripping both bracket characters entirely) on the user-supplied portion of the text is sufficient; only the marker(s) your own bridge constructs should ever reach the wire unmodified.

Self-serve session reset — Telegram’s /start

Section titled “Self-serve session reset — Telegram’s /start”

Telegram’s own reserved /start command — the button every Telegram client shows automatically at the start of a chat, or typed manually — is handled specially by the gateway, before any of your workflow’s normal message handling runs. Sending it:

  1. Ends whatever Wetel session is currently mapped to that chat, if any.
  2. Replies immediately with a greeting, sourced from the connected Agent’s own openingGreeting (falling back to a generic line if the agent has none configured) — never forwarded into the workflow as a real message.
  3. Leaves the chat ready for a genuinely fresh session: the user’s very next message starts a brand-new session with clean history.

This needs no configuration — it’s built into the Telegram connector for every agent. It exists specifically as a self-serve way out of a long-running conversation that’s accumulated confusing or stale context (including, historically, generic error-fallback text from an unrelated earlier bug) — without it, the only fix is an operator manually clearing the session mapping, which isn’t something an end user can ever do for themselves. If you’re building a workflow meant for long or exploratory conversations, mention /start in your agent’s own greeting or a troubleshooting reply, since Telegram doesn’t surface it as a visible hint on its own beyond the auto-shown button.


Lists every channel connector belonging to the caller’s tenant. Returns a plain array, not a Relay connection — there is no cursor pagination or server-side filtering by agentId or channelType; filter client-side if you need connectors for one specific agent.

Auth: JWT

listChannelConnectors: [ChannelConnectorDto!]!

Request

query ListChannelConnectors {
listChannelConnectors {
id
agentId
channelType
status
externalIdentifier
lastErrorMessage
}
}

Response

{
"data": {
"listChannelConnectors": [
{
"id": 1,
"agentId": 42,
"channelType": "TELEGRAM",
"status": "ACTIVE",
"externalIdentifier": "8236000293",
"lastErrorMessage": null
}
]
}
}

Creates a new channel connector in DRAFT status for the given Agent and channel type. externalIdentifier is null until updateChannelConnectorCredentials is called — this mutation only reserves the connector, it does not talk to any platform yet.

Auth: JWT

createChannelConnector(input: CreateChannelConnectorInput!): ChannelConnectorDto!

CreateChannelConnectorInput fields

FieldTypeRequired
agentIdInt!yes
channelTypeChannelType!yes

Request

mutation CreateChannelConnector($input: CreateChannelConnectorInput!) {
createChannelConnector(input: $input) {
id
status
channelType
}
}
{
"input": {
"agentId": 42,
"channelType": "TELEGRAM"
}
}

Response

{
"data": {
"createChannelConnector": {
"id": 1,
"status": "DRAFT",
"channelType": "TELEGRAM"
}
}
}

Sets or rotates a connector’s platform credential. credential’s shape is platform-specific JSON, not a fixed schema — pass it as a GraphQL variable, never an inline literal (see Workflows: nodes/edges are GraphQLJSON for why an inline object literal fails GraphQL syntax — the same GraphQLJSON scalar is used here). For Telegram: { "token": "<bot token from @BotFather>" }.

This validates the credential against the real platform (a live Telegram getMe call) before persisting anything — an invalid token fails this call immediately, with nothing written. The connector stays DRAFT until activateChannelConnector is called separately; setting credentials alone does not activate it.

Auth: JWT

updateChannelConnectorCredentials(input: UpdateChannelConnectorCredentialsInput!): ChannelConnectorDto!

UpdateChannelConnectorCredentialsInput fields

FieldTypeRequiredNotes
connectorIdInt!yes
credentialJSON!yesPlatform-specific — see above.

Request

mutation UpdateChannelConnectorCredentials(
$input: UpdateChannelConnectorCredentialsInput!
) {
updateChannelConnectorCredentials(input: $input) {
id
status
externalIdentifier
}
}
{
"input": {
"connectorId": 1,
"credential": { "token": "123456789:AAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
}
}

Response

{
"data": {
"updateChannelConnectorCredentials": {
"id": 1,
"status": "DRAFT",
"externalIdentifier": "8236000293"
}
}
}

Error: this bot/account is already registered to another connector

Section titled “Error: this bot/account is already registered to another connector”

If the platform identifier your credential resolves to (for Telegram, the bot id from getMe) is already claimed by a different connector — yours or another tenant’s — this mutation fails with a clear BadRequestException rather than a raw database error:

{
"errors": [
{
"message": "This TELEGRAM identifier is already registered to another channel connector (#1) — use a different bot/account, or deactivate and reassign the existing connector first.",
"extensions": { "code": "BAD_REQUEST" }
}
]
}

Wetel does not auto-reassign a bot from an active connector to another — deliberately, to avoid silently rerouting someone else’s live webhook traffic. If the existing connector is deactivated first (deactivateChannelConnector, see below), it releases its claim on the identifier, and a different connector can then successfully claim it via updateChannelConnectorCredentials. If it’s still ACTIVE, use a different bot token or have that connector’s owner deactivate it first.

Runs a live health check against the configured credential and automatically registers this connector’s webhook with the platform — for Telegram, a real setWebhook call. Flips the connector to ACTIVE only if both the health check and webhook registration succeed. On failure, the mutation throws, status stays whatever it was (not flipped to ACTIVE), and lastErrorMessage is set to the real failure reason.

Auth: JWT

activateChannelConnector(input: ActivateChannelConnectorInput!): ChannelConnectorDto!

ActivateChannelConnectorInput fields

FieldTypeRequired
connectorIdInt!yes

Request

mutation ActivateChannelConnector($input: ActivateChannelConnectorInput!) {
activateChannelConnector(input: $input) {
id
status
lastErrorMessage
}
}
{ "input": { "connectorId": 1 } }

Response

{
"data": {
"activateChannelConnector": {
"id": 1,
"status": "ACTIVE",
"lastErrorMessage": null
}
}
}

Calling this before updateChannelConnectorCredentials (i.e. on a connector with no credential set at all) fails with "Connector #<id> has no credentials configured yet".

Pauses an active channel connector — statusPAUSED. No inbound messages are processed for this connector while paused. Also releases the connector’s claim on its platform identifier (externalIdentifier is cleared, along with webhookVerifyToken) so a different connector — this tenant’s or another’s — can claim the same bot/account via updateChannelConnectorCredentials. The stored credential itself (bot token, etc.) is left in place, but reactivating a deactivated connector now requires calling updateChannelConnectorCredentials again first — a bare activateChannelConnector on a connector with no externalIdentifier fails with "has no externalIdentifier yet — credentials must be saved before activation".

Auth: JWT

deactivateChannelConnector(input: DeactivateChannelConnectorInput!): ChannelConnectorDto!

DeactivateChannelConnectorInput fields

FieldTypeRequired
connectorIdInt!yes

Request

mutation DeactivateChannelConnector($input: DeactivateChannelConnectorInput!) {
deactivateChannelConnector(input: $input) {
id
status
}
}
{ "input": { "connectorId": 1 } }

Response

{
"data": {
"deactivateChannelConnector": {
"id": 1,
"status": "PAUSED"
}
}
}