openapi: 3.0.3
info:
  title: naturali.ai — Tasks API
  version: 1.0.0
  description: >
    Tasks — the cards on a board (design: `api/docs/BOARDS.md`). A card lives on
    one board, moves between its columns over time (cycles allowed — that is the
    point), and closes when it enters a terminal column.

    The routes are flat under the project rather than nested under a board: the
    kanban view is `listTasks?board_id=…` grouped by `state`, one call per board,
    and a cross-board view ("every card assigned to me") stays one call too.

    **A card's `state` is read-only.** `PATCH` edits the card; the only thing that
    moves it is `POST …/tasks/{task_id}:transition` — the same operation a person,
    an integration and a column's own automation all go through, which is what
    makes every move atomic, guard-checked and recorded in the card's history.
  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: Tasks
    description: Create, read, move and audit the cards on a project's boards.
security:
  - bearerAuth: []
paths:
  /v1/projects/{project_id}/tasks:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Tasks]
      summary: List tasks
      description: >
        The board query. Filter by `board_id` for one board, add `state` for one
        column, or use `status` / `assignee` across boards.
      operationId: listTasks
      parameters:
        - $ref: '#/components/parameters/BoardIdFilter'
        - $ref: '#/components/parameters/StateFilter'
        - $ref: '#/components/parameters/StatusFilter'
        - $ref: '#/components/parameters/AssigneeFilter'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of cards.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
    post:
      tags: [Tasks]
      summary: Create a task
      description: >
        Put a card on a board. It lands in the board's initial column and that
        column's automation fires — so a board whose first column dispatches an
        agent starts working on this call and keeps going, unattended, until a
        column needs a person.
      operationId: createTask
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskCreate'
      responses:
        '201':
          description: Card created and placed in the board's initial column.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/projects/{project_id}/tasks/{task_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskId'
    get:
      tags: [Tasks]
      summary: Get a task
      description: One card, including its automation status and in-flight dispatch.
      operationId: getTask
      responses:
        '200':
          description: Card details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
    patch:
      tags: [Tasks]
      summary: Update a task
      description: >
        Edit the card's `title`, `assignee` or `payload`. At least one is required.

        `payload` is **shallow-merged** over what is there: keys the request omits
        are preserved. The merged result is validated against the board's
        `payload_schema`. `last_result` is read-only and lives in its own
        field — a payload write can never discard or forge it.

        `state` and `board_id` are rejected — a card moves only through
        `:transition`, and it never changes boards.
      operationId: updateTask
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskUpdate'
      responses:
        '200':
          description: Card updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
    delete:
      tags: [Tasks]
      summary: Delete a task
      description: >
        Removes the card and its transition history. Distinct from closing it: a
        card that reaches a terminal column closes and keeps its audit trail.
      operationId: deleteTask
      responses:
        '204':
          description: Card deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
  '/v1/projects/{project_id}/tasks/{task_id}:transition':
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskId'
    post:
      tags: [Tasks]
      summary: Move a task
      description: >
        Fire a named move on the card — the single path every state change takes.
        The move must be declared on the board and valid from the card's current
        column; the board's definition is what a UI renders its buttons from.

        Naming a move that does not exist, one that is not legal from this column,
        or any move at all on a closed card all answer 409
        `task_transition_conflict`: it is a conflict with the card's state rather
        than a malformed request — the same body succeeds one column earlier.
      operationId: transitionTask
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskTransitionRequest'
      responses:
        '200':
          description: The card after the move.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '502':
          $ref: '#/components/responses/UpstreamUnavailable'
  /v1/projects/{project_id}/tasks/{task_id}/transitions:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskId'
    get:
      tags: [Tasks]
      summary: List the task's moves
      description: >
        The card's append-only history, oldest first: every move it made, what
        kind of principal made it, and what caused it. Returned whole —
        `next_cursor` is always null.
      operationId: listTaskTransitions
      responses:
        '200':
          description: The card's transition history.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskTransitionList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '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
    TaskId:
      name: task_id
      in: path
      required: true
      description: Task public ID (task_ prefix).
      schema:
        type: string
        example: task_V1StGXR8Z5jdHi6B
    BoardIdFilter:
      name: board_id
      in: query
      required: false
      description: Only cards on this board. An unowned board is a 404, not an empty page.
      schema:
        type: string
        example: brd_V1StGXR8Z5jdHi6B
    StateFilter:
      name: state
      in: query
      required: false
      description: Only cards currently in this column — one kanban column.
      schema:
        type: string
        example: await_go_ahead
    StatusFilter:
      name: status
      in: query
      required: false
      description: Only open or only closed cards.
      schema:
        type: string
        enum: [open, closed]
        example: open
    AssigneeFilter:
      name: assignee
      in: query
      required: false
      description: Only cards labelled with this assignee.
      schema:
        type: string
        example: user_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'
    UpstreamUnavailable:
      description: The upstream runtime could not complete the operation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  schemas:
    Task:
      type: object
      properties:
        id:
          type: string
          description: Public task ID (task_ prefix).
          example: task_V1StGXR8Z5jdHi6B
        project_id:
          type: string
          example: proj_V1StGXR8Z5jdHi6B
        board_id:
          type: string
          description: The board this card lives on.
          example: brd_V1StGXR8Z5jdHi6B
          x-naturali-ref: boards
        title:
          type: string
          example: 'Reel: three uses for leftover coffee grounds'
        state:
          type: string
          description: >
            The column the card is in. **Read-only** — moved only through
            `:transition`.
          example: await_go_ahead
        status:
          type: string
          enum: [open, closed]
          description: '`closed` once the card enters a terminal column.'
          example: open
        payload:
          type: object
          additionalProperties: true
          description: >
            The card's working data — entirely caller-owned, and what a
            column's `input_mapping` reads. Store references to artifacts
            (file and document ids), not the artifacts themselves. A
            dispatch's result does **not** land here — see `last_result`.
          example:
            theme: leftover coffee grounds
        last_result:
          type: object
          nullable: true
          additionalProperties: true
          description: >
            The most recent column's dispatch result — read-only, and
            overwritten whole by every dispatch (never merged with the
            previous one). Null until the card's first dispatch settles.

            **Shaped by the kind of column that wrote it.** An agent column
            writes its generation output. A **tool** or **poll** column
            writes the enclosing run's state, which puts the tool's own
            result one level down under `nodes.tool` (or `nodes.poll`) — so a
            later column chains off one with
            `{"var": "task.last_result.nodes.tool.<field>"}`, not
            `last_result.<field>`. (Inside that column's *own*
            `on_complete`, `result.<field>` works as documented; only
            chaining from a *later* column sees the run-state shape.) A
            delay column writes nothing meaningful here — it dispatches no
            tool.
          example:
            finishReason: stop
            text: Three uses for leftover coffee grounds.
        assignee:
          type: string
          nullable: true
          description: Informational label; it does not restrict who may move the card.
          example: user_V1StGXR8Z5jdHi6B
        automation_status:
          type: string
          enum: [running, completed, failed, unrouted]
          nullable: true
          description: >
            The current column's dispatch — what a card's spinner or error badge
            reads. `completed` with the card still in place means the dispatch
            finished and no routing rule matched; `failed` means it failed with no
            `on_failure` declared. Null in a column that dispatches nothing.
          example: running
        active_dispatch:
          type: object
          nullable: true
          additionalProperties: true
          description: >
            The in-flight dispatch, if any. `kind` is always the column's own
            kind — the same `agent` / `tool` / `poll` / `delay` the board was
            written with — never the name of the machinery underneath.

            An agent column reports `{ kind: "agent", id, status }`, where `id`
            is the generation — real provenance, readable through Generations. A
            tool or poll column reports `{ kind: "tool" | "poll", tool_id,
            status }`: the machinery these columns run on is naturali's own and
            is not addressable by a caller, so it is named by the tool it stands
            for rather than by an internal id. A delay column names no tool, so
            it reports `{ kind: "delay", status }` with no `tool_id`.
          example:
            kind: agent
            id: gen_V1StGXR8Z5jdHi6B
            status: in_progress
        stalled_at:
          type: string
          format: date-time
          nullable: true
          description: >
            When the card's current column considers it parked too long —
            `entered_state_at` plus that column's `stalled_after`. Null when the
            column declares no `stalled_after`, and null on a closed card, which
            is finished rather than parked.
          example: '2026-07-31T00:00:00.000Z'
        stalled:
          type: boolean
          description: >
            Whether `stalled_at` has passed. Stays true until the card moves —
            it describes the card, not a one-off notification — and is always
            false when `stalled_at` is null.

            Being stalled never moves a card and never fails its dispatch: the
            column's threshold exists to make a parked card visible to whoever
            polls the board, nothing more.
          example: false
        entered_state_at:
          type: string
          format: date-time
          nullable: true
          description: When the card entered its current column.
          example: '2026-07-30T00:00:00.000Z'
        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
        - board_id
        - title
        - state
        - status
        - payload
        - last_result
        - assignee
        - automation_status
        - active_dispatch
        - stalled_at
        - stalled
        - entered_state_at
        - created_at
        - updated_at
    TaskCreate:
      type: object
      required: [board_id, title]
      properties:
        board_id:
          type: string
          description: The board to put the card on.
          example: brd_V1StGXR8Z5jdHi6B
          x-naturali-ref: boards
        title:
          type: string
          example: 'Reel: three uses for leftover coffee grounds'
        payload:
          type: object
          additionalProperties: true
          description: >
            The card's starting data, validated against the board's
            `payload_schema`.
          example:
            theme: leftover coffee grounds
        assignee:
          type: string
          description: Informational label.
          example: user_V1StGXR8Z5jdHi6B
    TaskUpdate:
      type: object
      description: >
        At least one of `title`, `assignee` or `payload`. `state` and `board_id`
        are rejected.
      minProperties: 1
      properties:
        title:
          type: string
          example: 'Reel: coffee grounds, take two'
        assignee:
          type: string
          nullable: true
          description: Send null to unassign.
          example: user_V1StGXR8Z5jdHi6B
        payload:
          type: object
          additionalProperties: true
          description: >
            Shallow-merged over the current payload; omitted keys are preserved.
            Not nullable — `null` is rejected rather than ignored, since a merge
            has no meaning to give it. Send `{}` to change nothing.
          example:
            approved: true
    TaskTransitionRequest:
      type: object
      required: [transition]
      properties:
        transition:
          type: string
          description: The name of a move the board declares from the card's current column.
          example: approve_text
        note:
          type: string
          nullable: true
          description: Optional reason, recorded on the history entry.
          example: Copy reads well, ship it.
    TaskTransitionRecord:
      type: object
      description: One entry in a card's append-only history.
      properties:
        id:
          type: string
          example: task_tr_V1StGXR8Z5jdHi6B
        task_id:
          type: string
          example: task_V1StGXR8Z5jdHi6B
          x-naturali-ref: tasks
        from_state:
          type: string
          nullable: true
          description: The column left behind; null on the card's initial placement.
          example: review_text
        to_state:
          type: string
          example: save_text
        transition:
          type: string
          nullable: true
          description: The move fired; null on the card's initial placement.
          example: approve_text
        principal_kind:
          type: string
          enum: [user, api_key, automation]
          description: >
            What kind of principal made the move. `automation` is a column's own
            `on_complete` routing or its `on_failure`.

            A move made through this API reports `api_key`: every call naturali
            makes reaches the platform under one service credential, so the record
            cannot distinguish a person from an integration. `user` is reserved for
            when it can.

            There is no `principal_id` counterpart. For a human or API move it
            would identify naturali's own service credential, which says nothing
            about the caller; for an `automation` move what caused the move is
            already reported by `generation_id` / `orchestration_run_id`.
          example: automation
        generation_id:
          type: string
          nullable: true
          description: The agent generation that caused the move, when one did.
          example: gen_V1StGXR8Z5jdHi6B
          x-naturali-ref: generations
        orchestration_run_id:
          type: string
          nullable: true
          description: >
            The run that caused the move, when one did — a tool column's dispatch.
          example: orch_run_V1StGXR8Z5jdHi6B
        note:
          type: string
          nullable: true
          example: Copy reads well, ship it.
        created_at:
          type: string
          format: date-time
          example: '2026-07-30T00:00:00.000Z'
      required:
        - id
        - task_id
        - from_state
        - to_state
        - transition
        - principal_kind
        - generation_id
        - orchestration_run_id
        - note
        - created_at
    TaskList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Task'
        next_cursor:
          type: string
          nullable: true
          description: Cursor for the next page, or null at the end.
          example: null
    TaskTransitionList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/TaskTransitionRecord'
        next_cursor:
          type: string
          nullable: true
          description: Always null — a card's history is returned whole.
          example: null
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              example: task_transition_conflict
            message:
              type: string
              example: The move `approve_text` cannot be applied to this card.
            details:
              type: object
              additionalProperties: true
              description: Optional structured context for the error.
