Channels API
Channel providers hold the credentials for a messaging platform (Slack, Telegram, or Discord). Channels are the individual conversational rooms within a provider (a Slack channel, a Telegram chat id, a Discord channel). A Workspace can carry a reply_binding that names the Channel all session gates (ask_user, tool approval, inform) from its sessions forward to. Chat config lives on the Channel itself (config.chats).
How chats, ask_user, and the channel bridge fit together.
Configure channel providers in the console.
Endpoints
| Method | Path | Summary |
|---|---|---|
| GET | /v1/channel_providers |
List channel providers |
| POST | /v1/channel_providers |
Create a channel provider |
| POST | /v1/channel_providers/find |
Find channel providers by predicate |
| GET | /v1/channel_providers/{entity_id} |
Get a channel provider |
| PUT | /v1/channel_providers/{entity_id} |
Replace a channel provider |
| DELETE | /v1/channel_providers/{entity_id} |
Delete a channel provider |
| GET | /v1/channels |
List channels |
| POST | /v1/channels |
Create a channel |
| POST | /v1/channels/find |
Find channels by predicate |
| GET | /v1/channels/{entity_id} |
Get a channel |
| PUT | /v1/channels/{entity_id} |
Replace a channel |
| DELETE | /v1/channels/{entity_id} |
Delete a channel |
| PUT | /v1/workspaces/{workspace_id}/reply_binding |
Set workspace channel association |
| DELETE | /v1/workspaces/{workspace_id}/reply_binding |
Clear workspace channel association |
ChannelProvider object
{
"id": "slack-main",
"provider": "slack",
"config": {
"app_token": "**redacted**",
"bot_token": "**redacted**",
"signing_secret": "**redacted**"
}
}
| Field | Required | Type | Description |
|---|---|---|---|
id |
no | string | Identifier. If omitted, the server assigns a type-prefixed id (e.g. channel-provider-3f9a1c8d). Immutable after creation |
provider |
yes | string | One of "slack", "telegram", "discord" |
config |
yes | ProviderConfig | Platform-specific credentials (see below) |
Token fields are write-only: the API accepts them on POST/PUT but returns redacted placeholders on GET. Raw token values never appear in responses.
Slack config: requires Socket Mode enabled on the Slack app:
| Field | Required | Description |
|---|---|---|
app_token |
yes | App-level token starting with xapp- (generated in Basic Information) |
bot_token |
yes | Bot OAuth token starting with xoxb- |
signing_secret |
no | Webhook signing secret (optional) |
Token format is validated server-side: an app_token not starting xapp- or a bot_token not starting xoxb- returns 422 /errors/validation-error.
Telegram config:
| Field | Required | Description |
|---|---|---|
bot_token |
yes | Bot token in the <id>:<hash> shape generated by @BotFather; must be at least 20 chars and contain a colon |
poll_timeout_seconds |
no | Long-poll timeout per getUpdates call (1-60, default 25) |
Discord config:
| Field | Required | Description |
|---|---|---|
bot_token |
yes | Discord bot token from the Developer Portal; do NOT include the Bot prefix (the adapter adds it) |
enable_dms |
no | Request dm_messages intent for DM channels; default true |
Create a channel provider
POST /v1/channel_providers - returns 201 Created.
curl -X POST https://your-host/v1/channel_providers \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "slack-main",
"provider": "slack",
"config": {
"app_token": "xapp-1-ABC123",
"bot_token": "xoxb-1-DEF456",
"signing_secret": "s3cr3t"
}
}'import httpx
r = httpx.post(
"https://your-host/v1/channel_providers",
headers={"Authorization": f"Bearer {token}"},
json={
"id": "slack-main",
"provider": "slack",
"config": {
"app_token": "xapp-1-ABC123",
"bot_token": "xoxb-1-DEF456",
"signing_secret": "s3cr3t",
},
},
)
assert r.status_code == 201const r = await fetch("/v1/channel_providers", {
method: "POST",
headers: {"Authorization": `Bearer ${token}`, "Content-Type": "application/json"},
body: JSON.stringify({
id: "slack-main",
provider: "slack",
config: {
app_token: "xapp-1-ABC123",
bot_token: "xoxb-1-DEF456",
signing_secret: "s3cr3t"
}
})
})Errors:
422- token format validation failed (bad prefix, wrong shape, or missing required field)409- a provider with thisidalready exists
To create a Telegram provider:
curl -X POST https://your-host/v1/channel_providers \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "telegram-bot",
"provider": "telegram",
"config": {"bot_token": "1234567890:ABCDEFGHIJ1234567890"}
}'import httpx
r = httpx.post(
"https://your-host/v1/channel_providers",
headers={"Authorization": f"Bearer {token}"},
json={
"id": "telegram-bot",
"provider": "telegram",
"config": {"bot_token": "1234567890:ABCDEFGHIJ1234567890"},
},
)
assert r.status_code == 201const r = await fetch("/v1/channel_providers", {
method: "POST",
headers: {"Authorization": `Bearer ${token}`, "Content-Type": "application/json"},
body: JSON.stringify({
id: "telegram-bot",
provider: "telegram",
config: {bot_token: "1234567890:ABCDEFGHIJ1234567890"}
})
})Get a channel provider
GET /v1/channel_providers/{entity_id} - returns 200 OK. Token fields are redacted.
curl https://your-host/v1/channel_providers/slack-main \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.get("https://your-host/v1/channel_providers/slack-main",
headers={"Authorization": f"Bearer {token}"})
got = r.json()
# got["config"]["bot_token"] is redacted; never the raw valueconst r = await fetch("/v1/channel_providers/slack-main", {
headers: {"Authorization": `Bearer ${token}`}
})
const cp = await r.json()Errors: 404 if the id does not exist.
List channel providers
GET /v1/channel_providers - returns an offset or cursor page.
Query parameters: limit (1-200, default 20), offset (default 0), cursor, order_by.
curl "https://your-host/v1/channel_providers?limit=20" \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.get("https://your-host/v1/channel_providers",
headers={"Authorization": f"Bearer {token}"})
providers = r.json()["items"]const r = await fetch("/v1/channel_providers?limit=20", {
headers: {"Authorization": `Bearer ${token}`}
})
const {items} = await r.json()Channel object
{
"id": "ch-general",
"provider_id": "slack-main",
"provider": "slack",
"external_id": "C0123ABCDEF",
"label": "#general",
"config": {
"chats": {
"enabled": false,
"default_agent": null,
"allow_agent_switch": false,
"allowed_agents": [],
"relay_mode": "final"
}
}
}
| Field | Required | Type | Description |
|---|---|---|---|
id |
no | string | Identifier. If omitted, the server assigns a type-prefixed id (e.g. channel-3f9a1c8d). Immutable after creation |
provider_id |
yes | string | Id of the parent ChannelProvider; must exist (FK validated at create time) |
provider |
yes | string | Platform enum: "slack", "telegram", or "discord". Must match the referenced provider's platform |
external_id |
yes | string | Platform-side channel id (Slack channel id, Telegram chat id, Discord channel snowflake) |
label |
no | string | Human-readable label (max 200 chars, default null) |
config |
no | ChannelConfig | Per-room config; provider-discriminated (see below). Defaults to an empty config for the platform |
The pair (provider_id, external_id) must be unique. A duplicate returns 409 /errors/conflict. A non-existent provider_id returns 422, not 409.
Channel config is provider-discriminated. Each variant has a chats block:
| Field | Required | Type | Description |
|---|---|---|---|
config.chats.enabled |
no | boolean | Default false. When true, incoming messages on this room start primer chats |
config.chats.default_agent |
yes when enabled | string | Agent id each new chat begins with; required when chats.enabled=true |
config.chats.allow_agent_switch |
no | boolean | Default false. When false, the /agent command is disabled and allowed_agents is ignored |
config.chats.allowed_agents |
no | list of strings | Only applies when allow_agent_switch=true: restricts /agent to these agent ids; [] means any agent is allowed |
config.chats.relay_mode |
no | string | "final" (default) or "all". Controls which assistant turns are relayed back to the platform |
Create a channel
POST /v1/channels - returns 201 Created.
curl -X POST https://your-host/v1/channels \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "ch-general",
"provider_id": "slack-main",
"provider": "slack",
"external_id": "C0123ABCDEF",
"label": "#general"
}'import httpx
r = httpx.post(
"https://your-host/v1/channels",
headers={"Authorization": f"Bearer {token}"},
json={
"id": "ch-general",
"provider_id": "slack-main",
"provider": "slack",
"external_id": "C0123ABCDEF",
"label": "#general",
},
)
assert r.status_code == 201const r = await fetch("/v1/channels", {
method: "POST",
headers: {"Authorization": `Bearer ${token}`, "Content-Type": "application/json"},
body: JSON.stringify({
id: "ch-general",
provider_id: "slack-main",
provider: "slack",
external_id: "C0123ABCDEF",
label: "#general"
})
})To enable chats on the channel:
curl -X POST https://your-host/v1/channels \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "ch-helpdesk",
"provider_id": "slack-main",
"provider": "slack",
"external_id": "C0456DEFGHI",
"label": "#helpdesk",
"config": {
"chats": {
"enabled": true,
"default_agent": "helpdesk-agent",
"relay_mode": "final"
}
}
}'import httpx
r = httpx.post(
"https://your-host/v1/channels",
headers={"Authorization": f"Bearer {token}"},
json={
"id": "ch-helpdesk",
"provider_id": "slack-main",
"provider": "slack",
"external_id": "C0456DEFGHI",
"label": "#helpdesk",
"config": {
"chats": {
"enabled": True,
"default_agent": "helpdesk-agent",
"relay_mode": "final",
}
},
},
)
assert r.status_code == 201const r = await fetch("/v1/channels", {
method: "POST",
headers: {"Authorization": `Bearer ${token}`, "Content-Type": "application/json"},
body: JSON.stringify({
id: "ch-helpdesk",
provider_id: "slack-main",
provider: "slack",
external_id: "C0456DEFGHI",
label: "#helpdesk",
config: {
chats: {enabled: true, default_agent: "helpdesk-agent", relay_mode: "final"}
}
})
})Errors:
422-provider_iddoes not exist,providermismatches the provider's platform,chats.enabled=truewithoutchats.default_agent, or required field missing409-(provider_id, external_id)already registered
Workspace channel association
A workspace can be linked to a channel so that all session gates
(ask_user, tool approval, inform) from its sessions forward to
that channel. The association is a single nullable field on the
workspace row, not a separate resource.
Set workspace channel association
PUT /v1/workspaces/{workspace_id}/reply_binding - returns 200 OK.
Body:
{"channel_id": "ch-general"}
curl -X PUT https://your-host/v1/workspaces/ws-prod/reply_binding \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_id": "ch-general"}'import httpx
r = httpx.put(
"https://your-host/v1/workspaces/ws-prod/reply_binding",
headers={"Authorization": f"Bearer {token}"},
json={"channel_id": "ch-general"},
)
assert r.status_code == 200await fetch("/v1/workspaces/ws-prod/reply_binding", {
method: "PUT",
headers: {"Authorization": `Bearer ${token}`, "Content-Type": "application/json"},
body: JSON.stringify({channel_id: "ch-general"})
})Errors:
404- workspace does not exist422- the named channel does not exist409- the workspace is terminating and cannot have its association changed
Clear workspace channel association
DELETE /v1/workspaces/{workspace_id}/reply_binding - returns 204 No Content.
curl -X DELETE https://your-host/v1/workspaces/ws-prod/reply_binding \
-H "Authorization: Bearer $TOKEN"import httpx
r = httpx.delete(
"https://your-host/v1/workspaces/ws-prod/reply_binding",
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 204await fetch("/v1/workspaces/ws-prod/reply_binding", {
method: "DELETE",
headers: {"Authorization": `Bearer ${token}`}
})Errors:
404- workspace does not exist
primectl
The same reply-binding and channel-trigger operations are available from the primectl channel command group:
primectl channel binding set <workspace_id> <channel_id> # PUT reply_binding
primectl channel binding clear <workspace_id> # DELETE reply_binding
primectl channel binding get <workspace_id> # read the workspace reply_binding
primectl channel trigger create --provider <provider_id> [--channel <channel_id>] \
--slug <slug> --name <name>
primectl channel sub create <trigger_id> --action <kind> --event-type <type> \
[--command <name>] [--reply-target <target>] --set field=value ...
trigger create posts a channel TriggerConfig (provider-wide when --channel is omitted), and sub create assembles the action config, event_matcher, and reply_target so operators do not hand-write the discriminated config.
Errors note
Channel-provider and channel CRUD errors use the RFC 7807 ProblemDetails envelope with type, title, status, detail, instance, and extensions (which includes request_id and, for 422 errors, an errors array with field paths). The workspace channel-association routes (PUT / DELETE .../reply_binding) instead return a {"detail": {"error": "<code>", "message": "..."}} body for their 409 (workspace terminating) and 422 (channel not found) cases. See the REST API overview for details.