---
title: "Create a route"
method: POST
path: "/v1/projects/{project_id}/channels/{channel_id}/routes"
tags: ["Channels"]
---

# Create a route

`POST /v1/projects/{project_id}/channels/{channel_id}/routes`

## Request

- Base URL: `https://api.naturali.ai`
- URL: `https://api.naturali.ai/v1/projects/{project_id}/channels/{channel_id}/routes`
- Auth: one of:
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://api.naturali.ai/authorize`, token URL `https://api.naturali.ai/token`, scopes: `mcp:access`)

`action` is required; `agent_id` is required when it is `agent`, `text` when it is `message`; `repeat` is only valid alongside `action: message`. `surface`, when present, must be one this channel's kind serves (`GET /v1/channel-kinds`) — otherwise `400 unknown_surface`. `match` keys must be predicates that kind's registry declares — otherwise `400 unknown_predicate`. Writing a route for a surface whose transport mode is off (e.g. a Discord `guild_thread` route before `mention_threads` is enabled) still succeeds, with `unreachable_surface` in the response `warnings`.

## Request body

- ChannelRouteWrite
  - `surface` string, nullable
  - `match` object
  - `priority` integer
  - `action` 'agent' | 'oneshot' | 'message' | 'silence', required: What an inbound message becomes. `agent` is a dialogue: the identity behind the message gets a session, and every later message on it continues the same one. `oneshot` runs the agent once and keeps nothing — no address, no actor, no session, no conversation — so each message is answered on its own. Choose it for work that is filed rather than discussed; choose `agent` when the answer depends on what came before. `message` delivers fixed text without running an agent, and `silence` answers nothing.
  - `agent_id` string
  - `text` string
  - `repeat` union
    - 'every' | 'once'
    - object
      - `after_seconds` integer, required
  - `language` string
  - `config` object, nullable: Conversation config bag — media handling, persona overrides, the Discord allowlist, and how a `oneshot` answers. `discord.reply` picks the delivery: `react` (the default) adds `discord.reaction` — `\u2705` unless named — to the message that triggered the run, `mention` answers beside it addressing whoever asked, `react_or_mention` reacts when the agent writes nothing and answers like `mention` when it writes a line, `thread` opens a thread and answers inside it, and `none` delivers nothing at all. A run that fails delivers nothing in every mode, so a reaction always means the work was done; there is no failure marker. A mention with nothing else in it is ignored unless the action says otherwise, for any action. `discord.use_replied_message: true` takes the text of the message it replies to, so replying to a message with only the mention files that message. `discord.empty_mention_text` answers any other bare mention with that fixed text, addressing the sender, without running the agent. `discord.forward_user_id: true` hands the sender's Discord user id to the agent's tools as the `discord_user_id` tool context, which a tool's `headers` read as `{{context:discord_user_id}}`. It is the id Discord reported for the message's author, never text the model wrote, so a tool can act for whoever sent it. Off unless set, because tool context reaches every tool the agent has. Read only for `oneshot`. A conversational `agent` in a guild *is* its thread — that is what its session is keyed on — so it always opens one.
  - `status` 'active' | 'disabled'

## Response `201`

Route created.

- ChannelRoute: What an inbound message resolves to — a channel's `default`, a route, or an address's own action, one shape on all three. For a Discord channel, `config.discord.allowed_role_ids` / `allowed_user_ids` restrict who may invoke the agent in a server — either list admits, empty or absent lists mean everyone, decided before the agent is invoked so an unlisted member costs nothing.
  - `action` 'agent' | 'oneshot' | 'message' | 'silence' | 'null', nullable, required: What an inbound message becomes; `null` only on an address with no action of its own, which the route table then decides. `agent` is a dialogue: the identity behind the message gets a session, and every later message on it continues the same one. `oneshot` runs the agent once and keeps nothing — no address, no actor, no session, no conversation — so each message is answered on its own. Choose it for work that is filed rather than discussed; choose `agent` when the answer depends on what came before. `message` delivers fixed text without running an agent, and `silence` answers nothing.
  - `agent_id` string, nullable, required: The agent that answers, when `action` is `agent` or `oneshot`.
  - `text` string, nullable, required: The fixed text delivered, when `action` is `message`.
  - `repeat` union, required: How often a `message` fires: `every` (default), `once`, or `{ after_seconds }`. Null unless `action` is `message`.
    - 'every' | 'once'
    - object
      - `after_seconds` integer, required
  - `language` string, nullable, required: Preferred reply language; null lets the agent decide.
  - `config` object, nullable, required: Conversation config bag — media handling, persona overrides, the Discord allowlist, and how a `oneshot` answers. `discord.reply` picks the delivery: `react` (the default) adds `discord.reaction` — `\u2705` unless named — to the message that triggered the run, `mention` answers beside it addressing whoever asked, `react_or_mention` reacts when the agent writes nothing and answers like `mention` when it writes a line, `thread` opens a thread and answers inside it, and `none` delivers nothing at all. A run that fails delivers nothing in every mode, so a reaction always means the work was done; there is no failure marker. A mention with nothing else in it is ignored unless the action says otherwise, for any action. `discord.use_replied_message: true` takes the text of the message it replies to, so replying to a message with only the mention files that message. `discord.empty_mention_text` answers any other bare mention with that fixed text, addressing the sender, without running the agent. `discord.forward_user_id: true` hands the sender's Discord user id to the agent's tools as the `discord_user_id` tool context, which a tool's `headers` read as `{{context:discord_user_id}}`. It is the id Discord reported for the message's author, never text the model wrote, so a tool can act for whoever sent it. Off unless set, because tool context reaches every tool the agent has. Read only for `oneshot`. A conversational `agent` in a guild *is* its thread — that is what its session is keyed on — so it always opens one.
  - `id` string, required
  - `project_id` string, required
  - `channel_id` string, required
  - `surface` string, nullable, required: The surface this route matches, or null for "any" (`GET /v1/channel-kinds`).
  - `match` object, required: The predicate bag, ANDed. `{}` matches every message on this surface.
  - `priority` integer, required: Tiebreak only, after specificity and `surface`.
  - `status` 'active' | 'disabled', required
  - `created_at` string, date-time, required
  - `updated_at` string, date-time, required

## Other responses

- `400`: The request was malformed or failed validation.
- `401`: Missing or invalid credentials.
- `403`: The credential is scoped to a different project, or the caller's role in the project does not carry this action.
- `404`: The resource does not exist (existence is not leaked).
- `409`: The request conflicts with the resource's current state.

---

[API](https://skmtc.dev/naturali/apis/naturali-ai-api.md) · [All operations](https://skmtc.dev/naturali/apis/naturali-ai-api/llms.txt) · [OpenAPI document](https://skmtc.dev/naturali/apis/naturali-ai-api/revisions/e4905b78f422?raw)
