openapi: 3.1.0
info:
  title: Taskeristl API
  version: 0.1.0
  description: |
    Centralized task tracking API for people and agents.
    CRUD over tasks with an owner (the person or system the
    task belongs to), optional assignee, labels, metadata,
    and status (open, in_progress, waiting, deferred, closed).
    Authenticated with a static API key passed in the `X-API-Key` header.

    ## Workflows

    Canonical filter combinations callers compose from primitives.
    Substitute `ME` with the user's identifier (e.g. `armando.arias`).

    | Workflow          | What it answers                                                       | Filters                                                        |
    | ----------------- | --------------------------------------------------------------------- | -------------------------------------------------------------- |
    | `active`          | Open or in-progress tasks assigned to me.                             | `status=open,in_progress&assignee=ME`                          |
    | `mine`            | Everything on my plate (open + in_progress + waiting), assigned to me.| `status=open,in_progress,waiting&assignee=ME`                  |
    | `triage`          | Open tasks I own that nobody is doing yet.                            | `status=open&owner=ME&assignee_is_null=true`                   |
    | `waiting`         | Tasks I own that are blocked on a third party.                        | `status=waiting&owner=ME`                                      |
    | `on_others`       | Open, in-progress, or waiting tasks I own that someone else is doing. | `status=open,in_progress,waiting&owner=ME&assignee_not=ME`     |
    | `closed`          | Everything I've completed.                                            | `status=closed&owner=ME`                                       |
    | `recently_closed` | Closed tasks I own from the last 3 workdays (caller computes cutoff). | `status=closed&owner=ME&closed_after=<ISO>`                    |
    | `deferred`        | Tasks created for me but not mine to do.                              | `status=deferred&owner=ME`                                     |
    | `starred`         | Tasks I own that I've flagged as important.                           | `is_starred=true&owner=ME`                                     |

    The server has no concept of workdays or holidays — for
    `recently_closed`, the caller computes the ISO cutoff and passes it
    as `closed_after`.

servers:
  - url: http://localhost:3000
    description: Local development

security:
  - ApiKeyAuth: []

paths:
  /healthz:
    get:
      summary: Health check
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }

  /v1/tasks:
    get:
      summary: List tasks
      description: |
        List tasks ordered by `last_updated_at` descending. Filters AND together.
        See the [Workflows](#tag/Workflows) section for canonical combinations.
      parameters:
        - in: query
          name: status
          description: |
            Filter by lifecycle state. Accepts a single value (`status=open`),
            multiple repeated values (`status=open&status=waiting`), or a
            comma-separated string (`status=open,waiting`).
          schema:
            oneOf:
              - $ref: '#/components/schemas/Status'
              - type: array
                items: { $ref: '#/components/schemas/Status' }
              - type: string
                description: Comma-separated list of statuses.
          style: form
          explode: true
        - in: query
          name: owner
          schema: { type: string }
        - in: query
          name: assignee
          schema: { type: string }
        - in: query
          name: assignee_not
          description: |
            Exclude tasks whose `assignee` equals this value. Tasks with a
            NULL `assignee` are also excluded (standard SQL three-valued
            logic). Useful for the `on_others` workflow.
          schema: { type: string }
        - in: query
          name: assignee_is_null
          description: |
            `true` returns only unassigned tasks; `false` returns only
            assigned tasks. Omit to ignore the assignee state.
          schema: { type: boolean }
        - in: query
          name: customer
          description: Filter to tasks belonging to this customer (exact match).
          schema: { type: string }
        - in: query
          name: label
          description: Filter to tasks whose `labels` array contains this value.
          schema: { type: string }
        - in: query
          name: is_meeting
          description: |
            Filter by the meeting flag. `true` returns only meetings, `false`
            returns only non-meeting tasks. Omit to return both.
          schema: { type: boolean }
        - in: query
          name: is_starred
          description: |
            Filter by the starred flag. `true` returns only starred tasks,
            `false` returns only non-starred tasks. Omit to return both.
          schema: { type: boolean }
        - in: query
          name: closed_after
          description: Inclusive lower bound on `closed_at` (ISO-8601). NULLs excluded.
          schema: { type: string, format: date-time }
        - in: query
          name: closed_before
          description: Exclusive upper bound on `closed_at` (ISO-8601). NULLs excluded.
          schema: { type: string, format: date-time }
        - in: query
          name: updated_after
          description: Inclusive lower bound on `last_updated_at` (ISO-8601).
          schema: { type: string, format: date-time }
        - in: query
          name: updated_before
          description: Exclusive upper bound on `last_updated_at` (ISO-8601).
          schema: { type: string, format: date-time }
        - in: query
          name: checked_after
          description: Inclusive lower bound on `last_checked_at` (ISO-8601). NULLs excluded.
          schema: { type: string, format: date-time }
        - in: query
          name: checked_before
          description: Exclusive upper bound on `last_checked_at` (ISO-8601). NULLs excluded.
          schema: { type: string, format: date-time }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: offset
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: List of tasks
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskList' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      summary: Create a task
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TaskCreate' }
      responses:
        '201':
          description: Task created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/tasks/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer, format: int64, minimum: 1 }
    get:
      summary: Get a task by id
      responses:
        '200':
          description: Task
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      summary: Update a task
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TaskUpdate' }
      responses:
        '200':
          description: Updated task
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      summary: Delete a task
      responses:
        '204':
          description: Deleted
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/tasks/{id}/touch:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer, format: int64, minimum: 1 }
    post:
      summary: Touch a task (bump `last_checked_at`)
      description: |
        Records that an agent or process reviewed this task without changing
        its content. Sets `last_checked_at` to now. Does **not** bump
        `last_updated_at`. Idempotent in effect; calling it twice in a row
        just refreshes the timestamp.
      responses:
        '200':
          description: Updated task
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/tasks/{id}/subtasks:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer, format: int64, minimum: 1 }
    get:
      summary: List a task's subtasks
      description: Subtasks ordered by `seq` ascending.
      responses:
        '200':
          description: List of subtasks
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubtaskList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      summary: Create a subtask
      description: |
        Adds a subtask to the task. `seq` is assigned automatically as the next
        per-parent number (1, 2, 3…); the response includes a `name` like `#43-2`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SubtaskCreate' }
      responses:
        '201':
          description: Subtask created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subtask' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/tasks/{id}/subtasks/{seq}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer, format: int64, minimum: 1 }
      - in: path
        name: seq
        required: true
        description: Per-parent subtask number (the N in `#<task>-<N>`).
        schema: { type: integer, minimum: 1 }
    get:
      summary: Get a subtask
      responses:
        '200':
          description: Subtask
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subtask' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      summary: Update a subtask
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SubtaskUpdate' }
      responses:
        '200':
          description: Updated subtask
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subtask' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      summary: Delete a subtask
      responses:
        '204':
          description: Deleted
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key

  schemas:
    Status:
      type: string
      enum: [open, in_progress, waiting, deferred, closed]
      description: |
        Task lifecycle state.
          - `open` — ready to be worked on; no progress yet.
          - `in_progress` — some work has been started.
          - `waiting` — blocked on action from a third party.
          - `closed` — successfully completed.
          - `deferred` — created for the owner but does not belong to them; no work needs to be done or tracked on it.

    Task:
      type: object
      required: [id, title, owner, status, labels, metadata, is_meeting, is_starred, hours, customer, created_at, last_updated_at, closed_at, last_checked_at]
      properties:
        id:
          type: integer
          format: int64
          readOnly: true
        title:
          type: string
          minLength: 1
          maxLength: 500
        owner:
          type: string
          minLength: 1
          maxLength: 255
          description: |
            Identifies the person or system the task belongs to. Typically the
            local part of an email (e.g. `armando.arias`). Required.
        description:
          type: [string, "null"]
        status:
          $ref: '#/components/schemas/Status'
        assignee:
          type: [string, "null"]
          maxLength: 255
          description: |
            Optional, free-text. Who is currently working on the task. Distinct
            from `owner`, which is who the task belongs to.
        labels:
          type: array
          items: { type: string, maxLength: 100 }
          maxItems: 64
        metadata:
          type: object
          additionalProperties: true
        notes:
          type: [string, "null"]
        is_meeting:
          type: boolean
          default: false
          description: |
            True if this task represents a meeting rather than a unit of
            work. Defaults to false. Use the `is_meeting` query parameter
            on list endpoints to filter meetings in or out.
        is_starred:
          type: boolean
          default: false
          description: |
            True if this task has been starred (flagged as important).
            Defaults to false. Combine with `owner=ME` to drive a personal
            "starred" view (see the `starred` workflow). Use the
            `is_starred` query parameter on list endpoints to filter.
        hours:
          type: [number, "null"]
          minimum: 0
          description: |
            Estimated units of effort to complete the task, in hours.
            Free-form; the API does not interpret it. Null when unknown.
        customer:
          type: [string, "null"]
          maxLength: 255
          description: |
            Free-text customer this task is for. Null when internal or
            unspecified. Use the `customer` query parameter to filter
            (exact match).
        created_at:
          type: string
          format: date-time
          readOnly: true
        last_updated_at:
          type: string
          format: date-time
          readOnly: true
          description: Bumped on any content change. Not bumped by `/touch`.
        closed_at:
          type: [string, "null"]
          format: date-time
          description: |
            Auto-stamped to "now" when `status` transitions to `closed` and
            cleared on any transition away. Writable on create/update for
            backdating: pass an explicit ISO-8601 value to record a
            retroactive close. Must be ≤ now (no future-dating). Cannot be
            set on a task whose effective status is not `closed`, and
            cannot be `null` when status is `closed`.
        last_checked_at:
          type: [string, "null"]
          format: date-time
          description: |
            Bumped to "now" by `POST /v1/tasks/{id}/touch` (or the MCP
            `touch_task` tool). Writable on create/update for backdating
            a review timestamp; must be ≤ now. Unlike `/touch`, setting
            it via `PATCH` also bumps `last_updated_at`.

    TaskCreate:
      type: object
      required: [title, owner]
      additionalProperties: false
      properties:
        title: { type: string, minLength: 1, maxLength: 500 }
        owner: { type: string, minLength: 1, maxLength: 255 }
        description: { type: [string, "null"] }
        status:
          allOf:
            - $ref: '#/components/schemas/Status'
          default: open
        assignee: { type: [string, "null"], maxLength: 255 }
        labels:
          type: array
          items: { type: string, maxLength: 100 }
          maxItems: 64
          default: []
        metadata:
          type: object
          additionalProperties: true
          default: {}
        notes: { type: [string, "null"] }
        is_meeting: { type: boolean, default: false }
        is_starred: { type: boolean, default: false }
        hours: { type: [number, "null"], minimum: 0 }
        customer: { type: [string, "null"], maxLength: 255 }
        closed_at:
          type: [string, "null"]
          format: date-time
          description: |
            Backdate the close. Required to be ≤ now. Only allowed when
            `status` is `closed`; otherwise rejected with 400. If omitted
            and `status=closed`, the server stamps to now (existing
            behaviour).
        last_checked_at:
          type: [string, "null"]
          format: date-time
          description: |
            Backdate the last-checked timestamp. Must be ≤ now. Omit (or
            null) to leave unset on a fresh task.

    TaskUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        title: { type: string, minLength: 1, maxLength: 500 }
        owner: { type: string, minLength: 1, maxLength: 255 }
        description: { type: [string, "null"] }
        status: { $ref: '#/components/schemas/Status' }
        assignee: { type: [string, "null"], maxLength: 255 }
        labels:
          type: array
          items: { type: string, maxLength: 100 }
          maxItems: 64
        metadata:
          type: object
          additionalProperties: true
        notes: { type: [string, "null"] }
        is_meeting: { type: boolean }
        is_starred: { type: boolean }
        hours: { type: [number, "null"], minimum: 0 }
        customer: { type: [string, "null"], maxLength: 255 }
        closed_at:
          type: [string, "null"]
          format: date-time
          description: |
            Backdate or correct the close timestamp. Must be ≤ now (no
            future-dating). Only meaningful when the task's effective
            status (after this PATCH) is `closed`; otherwise rejected.
            `null` is only valid when the effective status is non-closed
            (and is redundant — the server clears it automatically).
        last_checked_at:
          type: [string, "null"]
          format: date-time
          description: |
            Set or clear the last-checked timestamp explicitly. Must be
            ≤ now (no future-dating). Pass `null` to clear. Bumps
            `last_updated_at` (unlike `/touch`).

    TaskList:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Task' }
        pagination:
          type: object
          required: [limit, offset, total]
          properties:
            limit: { type: integer }
            offset: { type: integer }
            total: { type: integer }

    SubtaskStatus:
      type: string
      enum: [open, in_progress, done]
      description: |
        Subtask lifecycle state.
          - `open` — not started.
          - `in_progress` — being worked on.
          - `done` — finished (stamps `closed_at`).

    Subtask:
      type: object
      required: [task_id, seq, name, title, status, created_at, last_updated_at, closed_at]
      properties:
        task_id:
          type: integer
          format: int64
          readOnly: true
          description: Parent task id.
        seq:
          type: integer
          readOnly: true
          description: Per-parent sequence number (the N in `#<task>-<N>`). Stable; not reused after deletion.
        name:
          type: string
          readOnly: true
          description: Display identifier `#<task_id>-<seq>`, e.g. `#43-2`.
        title:
          type: string
          minLength: 1
          maxLength: 500
        status:
          $ref: '#/components/schemas/SubtaskStatus'
        created_at:
          type: string
          format: date-time
          readOnly: true
        last_updated_at:
          type: string
          format: date-time
          readOnly: true
        closed_at:
          type: [string, "null"]
          format: date-time
          description: |
            Auto-stamped to "now" when `status` becomes `done`, cleared on any
            transition away. Writable for backdating: pass an explicit ISO-8601
            value (≤ now) only when status is `done`.

    SubtaskCreate:
      type: object
      required: [title]
      additionalProperties: false
      properties:
        title: { type: string, minLength: 1, maxLength: 500 }
        status:
          allOf:
            - $ref: '#/components/schemas/SubtaskStatus'
          default: open
        closed_at:
          type: [string, "null"]
          format: date-time
          description: Backdate the close. Must be ≤ now. Only allowed when `status` is `done`.

    SubtaskUpdate:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        title: { type: string, minLength: 1, maxLength: 500 }
        status: { $ref: '#/components/schemas/SubtaskStatus' }
        closed_at:
          type: [string, "null"]
          format: date-time
          description: |
            Backdate or correct the close timestamp (≤ now). Only valid when the
            effective status after this PATCH is `done`; `null` only when not `done`.

    SubtaskList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Subtask' }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
            details:
              type: array
              items: { type: object }

  responses:
    BadRequest:
      description: Validation error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
