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: []
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
      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'
        '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'
        '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
      operationId: getChannelRoute
      responses:
        '200':
          description: The route.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChannelRoute'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Channels]
      summary: Replace a route
      description: Full replace, the same validation as create.
      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'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
    delete:
      tags: [Channels]
      summary: Delete a route
      operationId: deleteChannelRoute
      responses:
        '204':
          description: Route deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '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'
        '404':
          $ref: '#/components/responses/NotFound'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: A naturali API key (nat_sk_…) or a session JWT.
  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'
    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
          required: [code, message]
          properties:
            code:
              type: string
              example: unknown_predicate
            message:
              type: string
              example: 'Unknown or invalid predicate `guild_id`.'
            details:
              type: object
              additionalProperties: true
    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, message, silence]
          example: agent
        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: pt-BR
        config:
          type: object
          nullable: true
          additionalProperties: true
        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, message, silence]
          example: agent
        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: pt-BR
        config:
          type: object
          additionalProperties: true
        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
