openapi: 3.0.3
info:
  title: naturali.ai — Boards API
  version: 1.0.0
  description: >
    Boards, scoped to a project (design: `api/docs/BOARDS.md`). A board is a
    versioned definition of **columns** (`states`) and the **moves** between them
    (`transitions`) — a kanban whose cards are tasks (see the Tasks API). A column
    can run an agent or a tool when a card enters it, repeatedly call a tool until
    a condition is met (`poll`), wait before completing (`delay`), route the card
    onward from the result, or park it for a person to move.

    Each board is backed by a runtime workflow; the naturali record maps the two.
    The definition itself lives on the runtime (the source of truth) and is read from
    there when shaping responses — so a board is a round trip, not a copy.

    Two things this v1 contract deliberately leaves out: guard expressions on
    moves, and approval-gated moves (`requires_approval`). Both are supported by
    the engine underneath and both wait for the shared Approval queue; sending
    either is a 400 rather than a silent drop.
  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: Boards
    description: >-
      Define a project's boards — their columns, moves and per-column
      automation (agents, tools, poll, and delay).
security:
  - bearerAuth: []
paths:
  /v1/projects/{project_id}/boards:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Boards]
      summary: List boards
      description: Lists the boards defined in the project, newest first.
      operationId: listBoards
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of boards.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BoardList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
    post:
      tags: [Boards]
      summary: Create a board
      description: >
        Define a board's columns and moves. Exactly one column must be
        `initial: true`; any number may be `terminal: true` (a card closes when it
        enters one). Every agent or tool a column dispatches must belong to this
        project, and every move a column routes to must be declared in
        `transitions`.
      operationId: createBoard
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BoardCreate'
      responses:
        '201':
          description: Board created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Board'
        '400':
          $ref: '#/components/responses/InvalidBoardDefinition'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/projects/{project_id}/boards/{board_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/BoardId'
    get:
      tags: [Boards]
      summary: Get a board
      description: >
        The board's current definition — the source of truth for which columns
        exist and which moves are legal from each, so a UI renders its columns and
        its buttons from this response.
      operationId: getBoard
      responses:
        '200':
          description: Board details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Board'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
    patch:
      tags: [Boards]
      summary: Update a board
      description: >
        Change the name, description, `payload_schema`, or the definition itself.
        `states` and `transitions` are edited together or not at all — a column
        routes to a move and a move names columns, so validating one against a
        stale copy of the other would accept a definition that cannot route. At
        least one field is required.

        Cards already on the board are not moved. A card sitting in a column the
        new definition drops stays where it is and can only leave through a move
        the new definition declares: the definition is the sole authority at the
        moment a move is fired.
      operationId: updateBoard
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BoardUpdate'
      responses:
        '200':
          description: Board updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Board'
        '400':
          $ref: '#/components/responses/InvalidBoardDefinition'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
    delete:
      tags: [Boards]
      summary: Delete a board
      description: >
        Refused while the board still has open cards (409
        `board_has_open_tasks`) — close or delete them first. Deleting a board
        whose cards are all closed removes those cards and their transition
        history along with it.
      operationId: deleteBoard
      responses:
        '204':
          description: Board deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
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
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Client-supplied key to make this mutating POST idempotent.
      schema:
        type: string
    ProjectId:
      name: project_id
      in: path
      required: true
      description: Project public ID (proj_ prefix).
      schema:
        type: string
        example: proj_V1StGXR8Z5jdHi6B
    BoardId:
      name: board_id
      in: path
      required: true
      description: Board public ID (brd_ prefix).
      schema:
        type: string
        example: brd_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'
    InvalidBoardDefinition:
      description: >
        The request was malformed, or the definition was rejected
        (`invalid_board_definition`, with the upstream validation detail).
      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'
    UpstreamUnavailable:
      description: The upstream runtime could not complete the operation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  schemas:
    BoardDispatch:
      type: object
      description: >
        What a column runs when a card enters it. A tool or poll dispatch runs
        **deterministically**: there is no model in the loop, which is what
        makes them different from an agent that happens to have one tool.
      required: [kind]
      properties:
        kind:
          type: string
          enum: [agent, tool, poll, delay]
          description: >
            What the column does on entry. `agent`/`tool`/`poll` take the
            reference field their kind names below (`agent_id`/`tool_id`); the
            others are rejected. `delay` names no resource at all.
          example: agent
        agent_id:
          type: string
          description: Required when `kind` is `agent` — an agent in this project.
          example: agt_V1StGXR8Z5jdHi6B
          x-naturali-ref: agents
        tool_id:
          type: string
          nullable: true
          description: >
            Required when `kind` is `tool` or `poll` — a tool in this project.
            Null only on a read, and only if the board's internal bookkeeping
            and its stored definition have drifted: the column still
            dispatches a tool, but which tool can no longer be resolved.
            Re-send the definition to repair it.
          example: tool_V1StGXR8Z5jdHi6B
          x-naturali-ref: tools
        input_mapping:
          type: object
          additionalProperties: true
          description: >
            JSON Logic (https://jsonlogic.com) resolving the dispatch input from
            the card, evaluated against `{ task }` — so `{"var":
            "task.payload.theme"}` reads the card's payload. Each value is one
            expression; a plain string, number or boolean is passed through as a
            literal. Not accepted on a `delay` dispatch, which names no resource
            to send it to.
          example:
            theme:
              var: task.payload.theme
        exit_condition:
          type: object
          additionalProperties: true
          description: >
            Required when `kind` is `poll` — JSON Logic stop condition,
            evaluated after every attempt against `{ response, attempt }`
            (`response` is the tool's latest result, `attempt` a 1-based
            count); a truthy result stops polling. The wait between attempts
            is durable and scheduler-driven — it does not hold a request open,
            and a card parked mid-poll survives a platform restart.
          example:
            or:
              - '==': [{ var: response.done }, true]
              - '==': [{ var: response.fatal_error }, true]
        interval:
          type: string
          description: >
            Required when `kind` is `poll` — wait between attempts. Accepts a
            friendly suffix form (`5s`, `30s`, `5m`, `2h`, `500ms`) or ISO 8601
            (e.g. `PT5S`).
          example: 20s
        max_iterations:
          type: integer
          minimum: 1
          maximum: 1000
          description: >
            Only meaningful when `kind` is `poll` — maximum attempts before
            `on_timeout` decides the outcome. Defaults to 10 when omitted.
          example: 60
        on_timeout:
          type: string
          enum: [fail, continue]
          description: >
            Only meaningful when `kind` is `poll` — what happens when
            `max_iterations` is reached without `exit_condition` becoming true.
            `fail` fails the dispatch (routed the same way any failed dispatch
            is, via `on_failure`); `continue` (the default) completes the
            dispatch with the condition unmet, so `on_complete` rules can
            branch on it.
          example: continue
        duration:
          type: string
          description: >
            Required when `kind` is `delay` — how long the column waits before
            completing. Accepts a friendly suffix form (`5s`, `30s`, `5m`,
            `2h`, `500ms`) or ISO 8601 (e.g. `PT5S`). The wait is durable and
            scheduler-driven, the same as a poll's `interval` — it does not
            hold a request open, and a card parked mid-delay survives a
            platform restart.
          example: 5m
        payload_writes:
          type: object
          additionalProperties: true
          description: >
            JSON Logic writing selected fields of the dispatch's own result into
            named `task.payload` keys when the dispatch completes — a
            deterministic, no-model channel for state that must survive more
            than one column. Evaluated against the same `{ task, result }`
            context as `on_complete` (`result` is the tool's/poll's own result
            for a tool/poll column, re-rooted the same way), and applied
            atomically alongside `last_result`. Each write is a raw overwrite
            of its key: a value from an earlier pass through a looping column
            lingers in the payload until the column runs again. Before this,
            carrying a value past one hop meant echoing it through an agent's
            `output_schema` or injecting it into an unrelated tool request
            purely so it would reappear in `last_result` downstream — both are
            unnecessary now.
          example:
            post_text_document_id:
              var: result.object.document_id
    BoardCompletionRule:
      type: object
      description: >
        One routing rule. Rules are evaluated **in order** and the first match
        fires its move, as the `automation` principal, through the same single
        door a person uses. End with `{"when": true, …}` for a catch-all.
      required: [when, transition]
      properties:
        when:
          description: >
            JSON Logic evaluated against `{ task, result }`. `result` is the
            dispatch's output: for an agent column its generation output; for a
            tool column the tool's own result object.

            Routing an agent column on *structured* output (`result.object.…`)
            needs the agent to declare an `output_schema` — set one and every
            field of it is addressable here.
          example:
            '==':
              - var: result.object.approved
              - true
        transition:
          type: string
          description: The move to fire. Must be declared in the board's `transitions`.
          example: approve_text
    BoardOnEnter:
      type: object
      description: >
        A column's automation. Entering the column fires the dispatch; entering
        any other column cancels a dispatch still running from the one being left
        — the card's position is the source of truth.

        When no `on_complete` rule matches, the card stays put reporting
        `automation_status: completed` — a deliberate "done, awaiting routing or a
        human" state rather than a silent stall. A dispatch that fails with no
        `on_failure` declared stays put reporting `automation_status: failed`.

        A column's automation agent must complete on its own: an agent that stops
        to ask its caller to run a tool (a client-executed tool) cannot be resumed
        from a board, so automation agents need server-executed (`http`/`mcp`)
        tools only.
      required: [dispatch]
      properties:
        dispatch:
          $ref: '#/components/schemas/BoardDispatch'
        on_complete:
          type: array
          description: Ordered routing rules; the first match fires its move.
          items:
            $ref: '#/components/schemas/BoardCompletionRule'
        on_failure:
          type: string
          nullable: true
          description: >
            Where a card goes when the dispatch fails terminally. Accepts either a
            declared move, or a column reachable from this one by exactly one
            declared move (which is resolved to that move — reads report the move
            name). Omit to leave a failed card in place for a person.

            Fires for both kinds of column. A failed dispatch never reaches
            `on_complete`, so a catch-all `{"when": true}` rule cannot advance a
            card on failed work, and the failure leaves no `last_result` behind.
          example: needs_human
    BoardState:
      type: object
      description: >
        One column. A column with an `on_enter` works on the card automatically; a
        `kind: human` column parks it until a person fires one of the moves the
        board declares from it, which is exactly the set of buttons a UI renders.
      required: [name]
      properties:
        name:
          type: string
          description: Unique within the board; what `transitions` and a card's `state` name.
          example: review_text
        initial:
          type: boolean
          description: Exactly one column must set this — where new cards land.
          example: false
        terminal:
          type: boolean
          description: 'Entering a terminal column closes the card (`status: closed`).'
          example: false
        kind:
          type: string
          enum: [human]
          nullable: true
          description: >
            `human` marks a parking column: it never dispatches, and declaring
            `on_enter` on it is rejected. Omit for every other column — what a
            column does is defined by its `on_enter`.
          example: human
        stalled_after:
          type: integer
          minimum: 1
          nullable: true
          description: >
            Seconds a card may sit in this column before it counts as parked too
            long. A card here reports `stalled_at` (its `entered_state_at` plus
            this many seconds) and `stalled` once that moment passes; both stay
            put until the card moves.

            It never moves a card and never fails its dispatch — it makes a
            parked card visible to whoever reads the board, which is the point:
            a column that posts to an external service can fail in a way routing
            cannot catch, and `on_failure` only covers a dispatch that failed,
            not one that never came back.

            Omit it on a column where sitting is normal, such as one waiting on
            a person with no deadline.
          example: 86400
        on_enter:
          $ref: '#/components/schemas/BoardOnEnter'
    BoardTransition:
      type: object
      description: >
        One named move. Backward moves are ordinary moves — a card cycling
        `review → draft → review` is the point of a board, not an error. A move
        that is not declared here cannot be fired by anyone; there is no
        free-move escape hatch, so declare an explicit any-column move (listing
        every column in `from`) if a board needs one.
      required: [name, from, to]
      properties:
        name:
          type: string
          description: Unique within the board; the name a caller fires.
          example: approve_text
        from:
          type: array
          minItems: 1
          items:
            type: string
          description: The columns this move is valid from.
          example: [review_text]
        to:
          type: string
          description: The single column it moves the card to.
          example: save_text
    Board:
      type: object
      properties:
        id:
          type: string
          description: Public board ID (brd_ prefix).
          example: brd_V1StGXR8Z5jdHi6B
        project_id:
          type: string
          example: proj_V1StGXR8Z5jdHi6B
        name:
          type: string
          example: Instagram post pipeline
        description:
          type: string
          nullable: true
          example: Theme in, posted video out.
        states:
          type: array
          description: The board's columns, as written.
          items:
            $ref: '#/components/schemas/BoardState'
        transitions:
          type: array
          description: The moves the board allows.
          items:
            $ref: '#/components/schemas/BoardTransition'
        payload_schema:
          type: object
          nullable: true
          additionalProperties: true
          description: JSON Schema every card's payload on this board is validated against.
          example:
            type: object
            properties:
              theme:
                type: string
        created_at:
          type: string
          format: date-time
          example: '2026-07-30T00:00:00.000Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-07-30T00:00:00.000Z'
      required:
        - id
        - project_id
        - name
        - description
        - states
        - transitions
        - payload_schema
        - created_at
        - updated_at
    BoardCreate:
      type: object
      required: [name, states, transitions]
      properties:
        name:
          type: string
          description: >
            Unique within the project — reusing another board's name is rejected
            as an invalid definition.
          example: Instagram post pipeline
        description:
          type: string
          example: Theme in, posted video out.
        states:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/BoardState'
        transitions:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/BoardTransition'
        payload_schema:
          type: object
          additionalProperties: true
          description: Optional JSON Schema validated against every card's payload.
    BoardUpdate:
      type: object
      description: >
        At least one field must be present. `states` and `transitions` must be
        sent together.
      minProperties: 1
      properties:
        name:
          type: string
          example: Instagram pipeline v2
        description:
          type: string
          nullable: true
          description: Send null to clear it.
          example: Updated description.
        states:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/BoardState'
        transitions:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/BoardTransition'
        payload_schema:
          type: object
          nullable: true
          additionalProperties: true
          description: Send null to clear it.
    BoardList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Board'
        next_cursor:
          type: string
          nullable: true
          description: Cursor for the next page, or null at the end.
          example: null
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              example: invalid_board_definition
            message:
              type: string
              example: The board definition was rejected.
            details:
              type: object
              additionalProperties: true
              description: Optional structured context for the error.
