openapi: 3.0.3
info:
  title: naturali.ai — Channel Routes API
  version: 1.0.0
  description: >
    The route table: `(surface, conditions) → action` (design:
    CHANNELS-ROUTING.md §3.3.2/§3.5/§3.6). A route belongs to one channel and
    optionally narrows to one of its adapter-declared surfaces
    (`GET /v1/channel-kinds`); its `match` bag is a set of predicates, ANDed,
    drawn from that same registry.

    On every inbound, after the address's own action (if it has one) and
    before the channel's default, the resolver loads a channel's active
    routes, discards those whose `surface` disagrees and whose `match` does
    not fully evaluate true, and takes the most specific survivor: highest
    count of matched predicates, then `surface` set before `surface` null,
    then highest `priority`, then oldest `created_at`.

    Two active routes may not claim the identical `(surface, match)` cell —
    `409 route_conflict` at write time.
  contact:
    name: naturali.ai
    url: https://naturali.ai
servers:
  - url: '{baseUrl}'
    description: Host of your naturali.ai deployment; every path carries the /v1 prefix.
    variables:
      baseUrl:
        description: Base host URL.
        default: https://api.naturali.ai
tags:
  - name: Channels
    description: Connect and manage a project's messaging surfaces (WhatsApp, Discord).
security:
  - bearerAuth: []
  - oauth2:
      - mcp:access
paths:
  /v1/projects/{project_id}/channels/{channel_id}/routes:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/ChannelId'
    get:
      tags: [Channels]
      summary: List a channel's routes
      description: >
        One channel's routes, newest first, `disabled` ones included — only
        `active` routes take part in resolution. For every route in the project
        in one read, use
        [`GET /v1/projects/{project_id}/channel-routes`](/docs/api/channel-routes/list-project-channel-routes).
      operationId: listChannelRoutes
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of routes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChannelRouteList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [Channels]
      summary: Create a route
      description: >
        `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`.
      operationId: createChannelRoute
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChannelRouteWrite'
      responses:
        '201':
          description: Route created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChannelRoute'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
  /v1/projects/{project_id}/channels/{channel_id}/routes/{route_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/ChannelId'
      - $ref: '#/components/parameters/RouteId'
    get:
      tags: [Channels]
      summary: Get a route
      description: >
        A route id is only meaningful inside its own channel: the same id under
        another `channel_id` is a `404`. A conversation's `route_id` may instead
        be one of the two sentinels for the address's own action and the channel
        default — those name no route and are not readable here.
      operationId: getChannelRoute
      responses:
        '200':
          description: The route.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChannelRoute'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Channels]
      summary: Replace a route
      description: >
        Full replace, the same validation as create: an omitted field takes
        its create default. Where that default would widen a value the route
        sets, omission is `400 replace_omits_fields`, naming the fields in
        `details.fields`, and nothing is written: a set `surface` (the default
        is any surface), a non-empty `match` (every message), a non-empty
        `config` (it carries the Discord allowlist), and `status` on a
        `disabled` route (the default re-activates it). Send the value to keep
        it, or `"surface": null`, `"match": {}`, `"config": null`,
        `"status": "active"` to clear it.
      operationId: updateChannelRoute
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChannelRouteWrite'
      responses:
        '200':
          description: Route replaced.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChannelRoute'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
    delete:
      tags: [Channels]
      summary: Delete a route
      description: >
        Conversations opened under this route keep its id and stay where they
        are; the next inbound resolves to the next most specific route, or the
        channel default, and opens a new conversation there. To stop a route
        from matching while keeping it readable, `PATCH` it to
        `status: disabled` instead.
      operationId: deleteChannelRoute
      responses:
        '204':
          description: Route deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/projects/{project_id}/channel-routes:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Channels]
      summary: Read the whole route table
      description: >
        Every route across every channel in the project, one read — the table
        is small by construction (CHANNELS-ROUTING.md §3.6), so this is
        unpaginated.
      operationId: listProjectChannelRoutes
      responses:
        '200':
          description: Every route in the project.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ChannelRoute'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: A naturali API key (nat_sk_…) or a session JWT.
    oauth2:
      type: oauth2
      description: >-
        A connected app's OAuth access token, issued by this API's authorization
        server (discovery: /.well-known/oauth-authorization-server). Its one scope
        carries every operation, confined to the projects the user chose when
        approving the app.
      flows:
        authorizationCode:
          authorizationUrl: https://api.naturali.ai/authorize
          tokenUrl: https://api.naturali.ai/token
          refreshUrl: https://api.naturali.ai/token
          scopes:
            mcp:access: Every operation this API serves, on the projects the grant covers.
  parameters:
    Limit:
      name: limit
      in: query
      required: false
      description: Maximum items per page — an integer from 1 to 100 (default 20).
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque pagination cursor from a previous response's next_cursor.
      schema:
        type: string
    ProjectId:
      name: project_id
      in: path
      required: true
      description: Project public ID (proj_ prefix).
      schema:
        type: string
        example: proj_V1StGXR8Z5jdHi6B
    ChannelId:
      name: channel_id
      in: path
      required: true
      description: Channel public ID (chan_ prefix).
      schema:
        type: string
        example: chan_V1StGXR8Z5jdHi6B
    RouteId:
      name: route_id
      in: path
      required: true
      description: Route public ID (route_ prefix).
      schema:
        type: string
        example: route_V1StGXR8Z5jdHi6B
  responses:
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    BadRequest:
      description: The request was malformed or failed validation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Forbidden:
      description: >-
        The credential is scoped to a different project, or the caller's role in
        the project does not carry this action.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: The resource does not exist (existence is not leaked).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Conflict:
      description: The request conflicts with the resource's current state.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  schemas:
    ErrorResponse:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          description: >-
            Structured error. Every error response uses this shape, so `code` can
            be read without first checking the type of `error`.
          required:
            - code
            - message
          properties:
            code:
              type: string
              description: >-
                Machine-readable error code: lower_snake for an error naturali
                raises (`access_denied`), UPPER_SNAKE for one the runtime reports
                (`RESOURCE_NOT_FOUND`).
              example: access_denied
            message:
              type: string
              description: Human-readable explanation.
              example: Your role in this project does not carry this action.
            details:
              type: object
              additionalProperties: true
              description: >-
                Structured context for an error naturali raises, such as the
                `resource` and `limit` of a `plan_limit_reached`.
            meta:
              type: object
              description: Structured context for an error the runtime reports.
    ChannelRoute:
      type: object
      properties:
        id:
          type: string
          example: route_V1StGXR8Z5jdHi6B
        project_id:
          type: string
          example: proj_V1StGXR8Z5jdHi6B
        channel_id:
          type: string
          example: chan_V1StGXR8Z5jdHi6B
        surface:
          type: string
          nullable: true
          description: The surface this route matches, or null for "any" (`GET /v1/channel-kinds`).
          example: guild_thread
        match:
          type: object
          additionalProperties: true
          description: The predicate bag, ANDed. `{}` matches every message on this surface.
          example:
            guild_id: '9988776655'
        priority:
          type: integer
          description: Tiebreak only, after specificity and `surface`.
          example: 0
        action:
          type: string
          enum: [agent, oneshot, message, silence]
          example: agent
          description: >
            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:
          type: string
          nullable: true
          example: agent_V1StGXR8Z5jdHi6B
        text:
          type: string
          nullable: true
          example: 'Subscribe at https://example.com/pricing'
        repeat:
          description: Null unless `action` is `message`.
          oneOf:
            - type: string
              enum: [every, once]
            - type: object
              required: [after_seconds]
              properties:
                after_seconds:
                  type: integer
                  minimum: 1
          example: every
        language:
          type: string
          nullable: true
          example: en-US
        config:
          type: object
          nullable: true
          additionalProperties: true
          description: >
            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:
          type: string
          enum: [active, disabled]
          example: active
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
        - id
        - project_id
        - channel_id
        - surface
        - match
        - priority
        - action
        - agent_id
        - text
        - repeat
        - language
        - config
        - status
        - created_at
        - updated_at
    ChannelRouteWrite:
      type: object
      required: [action]
      properties:
        surface:
          type: string
          nullable: true
          example: guild_thread
        match:
          type: object
          additionalProperties: true
          example:
            guild_id: '9988776655'
        priority:
          type: integer
          default: 0
        action:
          type: string
          enum: [agent, oneshot, message, silence]
          example: agent
          description: >
            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:
          type: string
          example: agent_V1StGXR8Z5jdHi6B
        text:
          type: string
          example: 'Subscribe at https://example.com/pricing'
        repeat:
          oneOf:
            - type: string
              enum: [every, once]
            - type: object
              required: [after_seconds]
              properties:
                after_seconds:
                  type: integer
                  minimum: 1
          example: every
        language:
          type: string
          example: en-US
        config:
          type: object
          nullable: true
          additionalProperties: true
          description: >
            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:
          type: string
          enum: [active, disabled]
          default: active
    ChannelRouteList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ChannelRoute'
        next_cursor:
          type: string
          nullable: true
          example: null
