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.
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
| Field | Type | Description |
|---|---|---|
id | string | Public channel connection ID. |
project_id | string | The owning project. |
channel | string | The channel kind (whatsapp or discord). |
status | string | active or disabled. |
credential_source | string, nullable | Where this connection's credentials come from. |
phone_number_id | string, nullable | WhatsApp-specific identifier. |
waba_id | string, nullable | WhatsApp Business Account identifier. |
application_id | string, nullable | Discord application id. One channel per application. |
modes | object, nullable | Discord only — { dms, mention_threads }, the flows this channel serves. Null for other kinds. |
default | object | What 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_credential | boolean | Whether a credential is configured — never returns the credential itself. |
created_at | string (date-time) | |
updated_at | string (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}.
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_tokenandcodeare 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.
channelcannot 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:
- CLI
- SDK
- curl
naturali create-channel \
--project-id proj_V1StGXR8Z5jdHi6B \
--channel whatsapp \
--default '{ "action": "agent", "agent_id": "agent_V1StGXR8Z5jdHi6B" }'
const { data: channel } = await naturali.channels.createChannel({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
channel: 'whatsapp',
default: { action: 'agent', agent_id: 'agent_V1StGXR8Z5jdHi6B' },
},
});
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/channels \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"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:
- CLI
- SDK
- curl
naturali create-channel \
--project-id proj_V1StGXR8Z5jdHi6B \
--channel discord \
--application-id 1290000000000000009 \
--bot-token "$DISCORD_BOT_TOKEN" \
--default '{ "action": "agent", "agent_id": "agent_V1StGXR8Z5jdHi6B" }'
const { data: channel } = await naturali.channels.createChannel({
path: { project_id: 'proj_V1StGXR8Z5jdHi6B' },
body: {
channel: 'discord',
application_id: '1290000000000000009',
bot_token: process.env.DISCORD_BOT_TOKEN,
default: { action: 'agent', agent_id: 'agent_V1StGXR8Z5jdHi6B' },
},
});
// channel.has_credential is true; the token is never returned again.
curl -X POST https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/channels \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"channel": "discord",
"application_id": "1290000000000000009",
"default": { "action": "agent", "agent_id": "agent_V1StGXR8Z5jdHi6B" },
"bot_token": "'"$DISCORD_BOT_TOKEN"'"
}'
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:
- CLI
- SDK
- curl
naturali update-channel \
--project-id proj_V1StGXR8Z5jdHi6B \
--channel-id chan_V1StGXR8Z5jdHi6B \
--modes '{ "dms": true, "mention_threads": true }'
await naturali.channels.updateChannel({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
channel_id: 'chan_V1StGXR8Z5jdHi6B',
},
body: { modes: { dms: true, mention_threads: true } },
});
curl -X PATCH https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/channels/chan_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "modes": { "dms": true, "mention_threads": true } }'
Set the channel default to an agent, and restrict who may invoke it in a server:
- CLI
- SDK
- curl
naturali update-channel \
--project-id proj_V1StGXR8Z5jdHi6B \
--channel-id chan_V1StGXR8Z5jdHi6B \
--default '{
"action": "agent",
"agent_id": "agent_V1StGXR8Z5jdHi6B",
"config": { "discord": { "allowed_role_ids": ["1290000000000000042"] } }
}'
await naturali.channels.updateChannel({
path: {
project_id: 'proj_V1StGXR8Z5jdHi6B',
channel_id: 'chan_V1StGXR8Z5jdHi6B',
},
body: {
default: {
action: 'agent',
agent_id: 'agent_V1StGXR8Z5jdHi6B',
config: { discord: { allowed_role_ids: ['1290000000000000042'] } },
},
},
});
curl -X PATCH https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/channels/chan_V1StGXR8Z5jdHi6B \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"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.