Skip to main content

Channels

Connect an agent to WhatsApp, Discord, and other messaging surfaces.

Overview​

The customer configures the agent; the platform runs the conversation. A channel connection is established via embedded signup / OAuth / keys, and always carries a default — what answers when nothing else matches. A project's own route table can answer differently per surface and condition, and an address — the identifier a message arrived at — can override both for one identifier. Each conversation maps 1:1 to a Session.

Two kinds are connectable today — whatsapp and discord. They share everything above: the same channel, binding and conversation resources, the same address continuity. Only the transport differs, which is why adding a kind adds no concepts, just the fields that kind needs.

How many an account may hold depends on its plan

The billing owner's plan sets how many active channels all their projects may hold together — 1 on Free, 10 on Pro, 100 on Business, and by contract on Enterprise. Connecting one past that answers 403 plan_limit_reached, with the plan and the limit in details, before any credential is acquired — so a refused connect leaves nothing provisioned behind.

Only active channels count. Setting one to disabled frees allowance without deleting it or losing its configuration: a disabled channel stops routing and releases what it costs. Setting it back to active past the allowance answers the same 403 plan_limit_reached.

After a downgrade, when the month ends, active channels past the new plan's allowance are disabled, oldest first. Disable the ones you don't need before then to choose which stay; an upgrade turns the disabled ones back on.

The limit applies to the formation channel resource too — both paths create a channel the same way.

See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Channels.

Data Model​

Channel​

FieldTypeDescription
idstringPublic channel connection ID.
project_idstringThe owning project.
channelstringThe channel kind (whatsapp or discord).
statusstringactive or disabled.
credential_sourcestring, nullableWhere this connection's credentials come from.
phone_number_idstring, nullableWhatsApp-specific identifier.
waba_idstring, nullableWhatsApp Business Account identifier.
application_idstring, nullableDiscord application id. One channel per application.
modesobject, nullableDiscord only — { dms, mention_threads }, the flows this channel serves. Null for other kinds.
defaultobjectWhat happens when no route matches and the address has no action of its own — { action, agent_id, text, repeat, language, config }. Required at connect.
has_credentialbooleanWhether a credential is configured — never returns the credential itself.
created_atstring (date-time)
updated_atstring (date-time)

Key Concepts​

The channel default​

Every channel carries a default action — agent (answer with an agent), oneshot (run an agent once, keeping nothing), message (deliver fixed text, no session created), or silence (answer nothing) — required on POST /v1/projects/{project_id}/channels and patchable through PATCH /v1/projects/{project_id}/channels/{channel_id}. It is what answers when nothing more specific does: no route matches, and the address has no action of its own. Connecting a channel without saying what it does is the misconfiguration, so it is caught here rather than discovered as silence in production. Connect a Discord channel connects one with an agent default.

The same three-field shape (action / agent_id or text / repeat) is what a route and an address carry too — one shape, learned once.

Dialogue or execution: agent and oneshot​

Both run an agent; they differ in what survives the message.

agent is a dialogue. The identity behind the message gets an address, an actor and a session, and every later message on that identity continues the same one — the agent reads what came before.

oneshot runs the agent once and keeps nothing: no address, no actor, no session, no channel conversation. Each message is answered on its own, and nothing accumulates to read back later. Choose it for work that is filed rather than discussed — an inbound that becomes a ticket, a capture, a notification — and agent whenever the answer depends on the history.

The difference shows up in more than cost. On Discord, a conversational agent owns the thread it opened, so every later message there is part of the conversation whether or not it mentions the bot. A oneshot registers no thread, so a mention is the only way in, every time.

A reply the account cannot pay for​

A channel reply clears the same checks as a generation through the API: a managed model must carry a price, a negative balance stops managed generation, and a Free account stops at its run allowance.

When one refuses, the agent does not run and nothing is created for the turn. The contact gets a fixed line instead — "This assistant can't reply right now. Please try again later.", in Portuguese or Spanish when the action's language is one of them — which never says why. The reason goes to the project's billing owner by email, at most once a day, since they are the one who can clear it. Both agent and oneshot answer this way; a Discord oneshot that only reacts, or delivers nothing, stays silent as it would on any run.

Retrying a create is safe with an idempotency key​

A timeout on a create is ambiguous — the resource may or may not exist — and a duplicate both spends an active-channel allowance and leaves two channels answering the same inbound traffic. Passing idempotency_key in the body settles it: the first request under a key does the work and answers 201, and any later request carrying the same key answers 200 with that same channel, so an ambiguous failure can simply be retried.

The key is claimed within your account and never silently expires, so a retry days later still returns the original rather than creating a second. Reusing a key with a different body is 409 idempotency_key_reused — a key names one request. A retry arriving while the original is still running is 409 idempotency_request_in_progress; retry once it has answered.

Derive the key from the thing you are creating rather than from the attempt — a key that changes per retry deduplicates nothing.

Do not bind an agent that has an output_schema​

A channel delivers the assistant's reply text verbatim to a person. An agent configured with an output_schema returns its answer as JSON, and a session turn has nowhere to put a parsed object — so the JSON arrives as the message text and the human on the other end reads {"approved":true}.

Structured output is for agents you call through POST /v1/projects/{project_id}/agents/{agent_id}/generate from your own code. Leave channel-facing agents unconstrained.

Who may connect one​

Every route needs the member role — see Membership and the billing owner.

Conversations​

GET /v1/projects/{project_id}/channels/{channel_id}/conversations lists conversations for a channel (filterable by identifier); each maps 1:1 to a session. A conversation is normally created by the inbound path, as a side effect of a real message. The one exception is the outbound-first case: POST /v1/projects/{project_id}/channels/{channel_id}/conversations opens one for an identifier that has not written yet, resolving the same three layers an inbound message would — a 409 when that does not land on an agent, because there is nothing to open. Connect a Discord channel reads the conversation and its messages after a real DM.

DELETE /v1/projects/{project_id}/channels/{channel_id}/conversations/{conversation_id} forgets one dialogue: the conversation and its session go, while the address, its actor and its other conversations stay. The address's next message opens a fresh conversation. Erasing the address itself is DELETE /v1/projects/{project_id}/addresses/{identifier}.

Two things share the word "conversation"

A channel conversation is an address's dialogue on a channel, and it lives under the channel that owns it. The Conversations module is the runtime's own message thread — the transcript a session records into, which a channel conversation points at. The mapping is: channel conversation → session → conversation.

Discord​

Discord adds two things no other kind has: modes, which picks whether the bot serves direct messages, server threads or both, and an allowlist deciding which server members may invoke it. Both — plus the Discord-side setup they depend on, the intents and permissions each flow needs, and why a Discord bot goes quiet — are on the Discord page.

Declaring a channel in a deploy template​

A channel can be declared as a channel resource in a runtime deploy template, alongside the agent it answers with, instead of being connected through the API:

{
"resources": {
"SupportAgent": {
"type": "agent",
"properties": { "name": "Support" }
},
"SupportChannel": {
"type": "channel",
"properties": {
"channel": "whatsapp",
"phone_number_id": "15550001111",
"access_token": { "param": "WhatsAppToken" },
"default": { "action": "agent", "agent_id": { "ref": "SupportAgent" } }
}
}
}
}

The property names are the ones this module's API already takes, and the result is the same row a POST produces — one implementation serves both, so a template and a request cannot disagree about what connecting a channel means. The runtime resolves { "ref": "SupportAgent" } to the agent it created first.

Three things behave differently from a plain API call:

  • The credential is never stored. access_token, bot_token and code are declared write-only, so the runtime sends them and then drops them from the snapshot it keeps to compare against on the next deploy. Supply one through a template parameter rather than inline.
  • Every deploy re-sends it. With nothing stored to compare against, the credential always reads as changed, so an update rotates it. That is safe — the rotation path replaces the stored credential and re-points the send tool before dropping the old one — but it does mean a redeploy is not a no-op for a channel the way it is for most resources.
  • channel cannot change. A channel's identity and its credential belong to the kind that acquired them, so switching kinds is refused; delete the resource and declare a new one.

Examples​

Connect a WhatsApp channel:

naturali create-channel \
--project-id proj_V1StGXR8Z5jdHi6B \
--channel whatsapp \
--default '{ "action": "agent", "agent_id": "agent_V1StGXR8Z5jdHi6B" }'

Connect a Discord app for DMs only — the default, and the one that needs no privileged intent:

naturali create-channel \
--project-id proj_V1StGXR8Z5jdHi6B \
--channel discord \
--application-id 1290000000000000009 \
--bot-token "$DISCORD_BOT_TOKEN" \
--default '{ "action": "agent", "agent_id": "agent_V1StGXR8Z5jdHi6B" }'

The token is checked with Discord before anything is stored: 400 invalid_bot_token when Discord rejects it, 400 application_mismatch when it belongs to another application. A token reset later, or a flow needing an intent the app has not enabled, makes Discord refuse the connection; the channel then reads gateway_error (invalid_bot_token or disallowed_intents) until a PATCH of bot_token, modes or status clears it and reconnects.

Turn the thread flow on later, once MESSAGE_CONTENT is enabled on the app:

naturali update-channel \
--project-id proj_V1StGXR8Z5jdHi6B \
--channel-id chan_V1StGXR8Z5jdHi6B \
--modes '{ "dms": true, "mention_threads": true }'

Set the channel default to an agent, and restrict who may invoke it in a server:

naturali update-channel \
--project-id proj_V1StGXR8Z5jdHi6B \
--channel-id chan_V1StGXR8Z5jdHi6B \
--default '{
"action": "agent",
"agent_id": "agent_V1StGXR8Z5jdHi6B",
"config": { "discord": { "allowed_role_ids": ["1290000000000000042"] } }
}'

Per-surface and per-condition answers — a public agent in server threads, a different one in DMs, a paywall message until an identifier is admitted — are the route table and addresses.