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.
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),
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.
The same three-field shape (action / agent_id or text / repeat) is what
a route and an address carry too — one
shape, learned once.
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}/generations
or a board column. Leave channel-facing agents unconstrained.
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. Conversations are created only by the inbound
path — there is no create endpoint.
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.
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_API_KEY" \
-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_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "discord",
"application_id": "1290000000000000009",
"default": { "action": "agent", "agent_id": "agent_V1StGXR8Z5jdHi6B" },
"bot_token": "'"$DISCORD_BOT_TOKEN"'"
}'
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_API_KEY" \
-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_API_KEY" \
-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.