openapi: 3.1.0
info:
  title: Qordo API
  version: "1.0"
  # Provenance. This file is hand-maintained and partial; these say so to a
  # machine reader, which `version` alone cannot. Update them in the same PR
  # that adds endpoints. (DEV901634)
  x-document-updated: "2026-08-28"
  x-reconciled-against: qordo-backend@c3ff9f2
  x-generated: false
  x-coverage:
    documented_paths: 41
    documented_operations: 58
    api_paths: 282
    api_operations: 408
    note: >-
      Counted against the commit in x-reconciled-against, over routes whose URI
      begins with `api/`. Do not use `route:list --path=api` — that filter is a
      substring match and pulls in Horizon's dashboard API (`horizon/api/*`),
      which is not part of this API. An endpoint absent here may still exist.
  x-logo:
    url: ./qordo.svg
    altText: Qordo
  description: |
    The Qordo API is a REST interface for the agent-ready task platform. Nearly everything you
    can do in the app — create tasks, comment, search — you can do with a token.

    **Base URL:** `https://api.qordo.ai/api`

    ## About this reference

    This document is written and maintained by hand. It is **not generated from the backend**,
    and it does not cover the whole API.

    As of **28 August 2026** it describes **58 operations across 41 paths**, out of
    **408 operations across 282 paths** the API actually serves — about a seventh of the
    surface, chosen for the endpoints integrations use most.

    What that means for you:

    - **An endpoint missing here may still exist.** Absence from this page is not evidence the
      API cannot do something — recurrence rules and the Trash endpoints, among others, are
      live and undocumented. Check the route before concluding a capability is unavailable.
    - **What is here is accurate.** Documented endpoints are written against real behaviour,
      including status-code and edge-case details a generator would not infer.
    - **Freshness is not automatic.** Nothing verifies this file against the backend on merge.
      The reference was last reconciled against `qordo-backend@c3ff9f2` on 28 August 2026;
      `git log public/openapi.yaml` in `qordo-docs` is the authoritative change history.

    To document another endpoint, edit `public/openapi.yaml` in `qordo-docs` and open a PR —
    the site redeploys on merge to `main`. If you need an endpoint that is not here, read the
    route in the backend rather than assuming it is absent.

    ## Authentication

    Every request is authenticated with a personal access token sent as a Bearer header:

    ```
    Authorization: Bearer qrd_your_token_here
    ```

    Mint a token in **Settings → Profile → API Token**. The token acts as your user — it inherits
    your workspace memberships and permissions. Keep it secret; anyone with it can act as you.

    ## The hierarchy

    Qordo organizes everything in a fixed tree. You resolve down it to find where a task lives:

    ```
    Workspace  →  Space  →  Folder  →  List  →  Task  →  Comments
    ```

    ## Quickstart

    The API speaks in UUIDs, so a typical first integration walks the tree top-down. Your
    workspace UUID comes from `GET /user`, which returns every workspace you belong to.

    1. `GET /user` — your workspaces (use a workspace `id` as `{workspace}` below)
    2. `GET /workspaces/{workspace}/spaces` — pick a space
    3. `GET /workspaces/{workspace}/spaces/{space}/folders` — pick a folder
    4. `GET /workspaces/{workspace}/spaces/{space}/folders/{folder}/lists` — pick a list
    5. `POST /workspaces/{workspace}/tasks` with that `list_id` in the body — create the task

    End to end with `curl` — each response gives you the id for the next call:

    ```bash
    TOKEN="qrd_your_token_here"
    BASE="https://api.qordo.ai/api"
    AUTH="Authorization: Bearer $TOKEN"

    # walk down to a list (copy an id from each response into the next call)
    curl -s "$BASE/user" -H "$AUTH"
    curl -s "$BASE/workspaces/$WORKSPACE/spaces" -H "$AUTH"
    curl -s "$BASE/workspaces/$WORKSPACE/spaces/$SPACE/folders" -H "$AUTH"
    curl -s "$BASE/workspaces/$WORKSPACE/spaces/$SPACE/folders/$FOLDER/lists" -H "$AUTH"

    # create a task in that list — list_id goes in the body
    curl -s -X POST "$BASE/workspaces/$WORKSPACE/tasks" \
      -H "$AUTH" -H "Content-Type: application/json" \
      -d '{"list_id":"'"$LIST"'","title":"My first task","priority":"high"}'
    ```

    ## Conventions

    **Rate limits.** Token requests are limited to **60 requests/minute** per token (`429` when exceeded).

    **Pagination.** Most list endpoints are page-based (`page` / `per_page`); comments use a cursor
    (`next_cursor` / `has_more`).

    **Retrying a write safely.** If a write times out you cannot tell whether it never arrived or
    succeeded with the reply lost — and retrying blind creates a second object. Send an
    `Idempotency-Key` header (any unique string, one per logical operation) and the retry is safe:

    ```bash
    curl -s -X POST "$BASE/workspaces/$WORKSPACE/tasks" \
      -H "$AUTH" -H "Content-Type: application/json" \
      -H "Idempotency-Key: 1b9f4c7e-order-4821-line-3" \
      -d '{"list_id":"'"$LIST"'","title":"My first task"}'
    ```

    The first request does the work. Any later request with the same key returns the **stored
    response** of that first one — same status, same body, nothing created — and carries
    `Idempotent-Replay: true` so you can tell the two apart. Keys last **24 hours** and are private
    to your token.

    Three things to know. Reusing a key with a *different* body is a `422`, not a replay — that is
    a bug in the caller, and answering it with the earlier result would hide it. Retrying while the
    first request is still running is a `409`; wait and retry. And a request that *failed* releases
    its key immediately, so retrying after an error is exactly what you should do.

    Every write accepts the header. It is entirely optional — omit it and nothing changes.

    **Errors.** Failed requests return the matching status code with a JSON body — `401`
    (missing/invalid token), `404` (not found, or not visible to your token), and `422` (validation),
    which includes an `errors` map keyed by field:

    ```json
    { "message": "The title field is required.", "errors": { "title": ["The title field is required."] } }
    ```

    ## MCP Server

    Qordo also ships an **MCP server** (`@qordo-ai/mcp`) that exposes these same operations as tools
    for AI agents (Claude Code, Cursor, VS Code). It's a thin layer over this REST API and uses the
    same `qrd_` token, so an agent respects your permissions exactly as the REST API does.

    **Connecting.** Add Qordo to your MCP client's config:

    ```jsonc
    {
      "mcpServers": {
        "qordo": {
          "command": "npx",
          "args": ["-y", "@qordo-ai/mcp"],
          "env": { "QORDO_TOKEN": "qrd_your_token_here" }
        }
      }
    }
    ```

    Verify with the `get_me` tool — it returns your identity and workspaces (the MCP equivalent of
    `GET /user`).

    **Tool catalog.** All 16 tools map to a REST operation you can also call directly:

    | Tool | Does | REST equivalent |
    |------|------|-----------------|
    | `get_me` | Current user + workspaces | `GET /user` |
    | `get_sidebar` | Full workspace tree | `GET /workspaces/{id}/sidebar` |
    | `list_spaces` | Spaces in a workspace | `GET /workspaces/{id}/spaces` |
    | `list_folders` | Folders in a space | `GET .../spaces/{id}/folders` |
    | `list_lists` | Lists in a folder | `GET .../folders/{id}/lists` |
    | `list_statuses` | Statuses for a list | `GET /lists/{id}/statuses` |
    | `list_members` | Workspace members | `GET /workspaces/{id}/members` |
    | `list_tasks` | Tasks in a list | `GET /workspaces/{id}/tasks` |
    | `get_task` | One task | `GET /workspaces/{id}/tasks/{task}` |
    | `create_task` | Create a task | `POST /workspaces/{id}/tasks` |
    | `update_task` | Update a task | `PATCH /workspaces/{id}/tasks/{task}` |
    | `search_tasks` | Search | `GET /search` |
    | `list_comments` | Task comments | `GET .../tasks/{task}/comments` |
    | `add_comment` | Add a comment | `POST .../tasks/{task}/comments` |
    | `get_task_activity` | Task activity log | `GET .../tasks/{task}/activity` |
    | `get_task_dependencies` | Task dependencies | `GET .../tasks/{task}/dependencies` |

servers:
  - url: https://api.qordo.ai/api

security:
  - bearerAuth: []

tags:
  - name: Navigation
    description: Walk the hierarchy to resolve the IDs you need.
  - name: Tasks
    description: Create, read, update and list tasks.
  - name: Comments
    description: Read, post, edit and resolve comments on a task.
  - name: Custom fields
    description: Read the custom fields in effect for a task and write their values.
  - name: Dependencies
    description: Link tasks as blockers of one another.
  - name: Attachments
    description: Upload, list, download and remove files on a task.
  - name: Search
    description: Full-text search across tasks.
  - name: Email to list
    description: Read, generate and rotate the email address that turns mail into tasks on a list.
  - name: Tags
    description: Maintain a space's tag library and attach tags to tasks, one at a time or in bulk.
  - name: Watchers
    description: Follow tasks, as individuals or whole teams. Separate from assignment.
  - name: Task templates
    description: Save a task as a reusable blueprint, then apply it to create whole task trees.
  - name: Views
    description: Evaluate a saved View server-side into the task rows it shows.

paths:
  /user:
    get:
      tags: [Navigation]
      summary: Get the current user
      description: Returns the authenticated user and the workspaces they belong to.
      responses:
        "200":
          description: The authenticated user.
          content:
            application/json:
              example:
                id: "b2c1a0d9-4e3f-4a1b-9c8d-7e6f5a4b3c2d"
                name: "Ada Lovelace"
                email: "ada@acme.com"
                workspaces:
                  - id: "1f2e3d4c-5b6a-7980-a1b2-c3d4e5f6a7b8"
                    name: "Acme"
                    pivot: { role_id: "…", is_active: true }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /workspaces/{workspace}/spaces:
    get:
      tags: [Navigation]
      summary: List spaces
      parameters:
        - $ref: "#/components/parameters/workspace"
      responses:
        "200":
          description: Spaces in the workspace.
          content:
            application/json:
              example:
                - id: "3a2b1c0d-…"
                  name: "Engineering"
                  workspace_id: "1f2e3d4c-…"
        "401": { $ref: "#/components/responses/Unauthorized" }

  /workspaces/{workspace}/spaces/{space}/folders:
    get:
      tags: [Navigation]
      summary: List folders
      parameters:
        - $ref: "#/components/parameters/workspace"
        - name: space
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Folders in the space.
          content:
            application/json:
              example:
                - id: "5e6f7a8b-1c2d-3e4f-5a6b-7c8d9e0f1a2b"
                  name: "Q3 Planning"
                  space_id: "3a2b1c0d-…"
        "401": { $ref: "#/components/responses/Unauthorized" }

  /workspaces/{workspace}/spaces/{space}/folders/{folder}/lists:
    get:
      tags: [Navigation]
      summary: List lists
      parameters:
        - $ref: "#/components/parameters/workspace"
        - name: space
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: folder
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Lists in the folder.
          content:
            application/json:
              example:
                - id: "7c8d9e0f-…"
                  name: "Sprint 12"
                  folder_id: "5e6f7a8b-…"
        "401": { $ref: "#/components/responses/Unauthorized" }

  /lists/{list}/statuses:
    get:
      tags: [Navigation]
      summary: List statuses
      description: The statuses available for a list — use a status `id` as `status_id` when creating or updating a task.
      parameters:
        - name: list
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Statuses for the list.
          content:
            application/json:
              example:
                data:
                  - id: "a1b2c3d4-…"
                    name: "To Do"
                    type: "open"
                    color: "#a5b1c2"
                is_customized: false
                source: "space"
        "401": { $ref: "#/components/responses/Unauthorized" }

  /workspaces/{workspace}/members:
    get:
      tags: [Navigation]
      summary: List members
      description: Workspace members — use a member `id` as `assignee_id` when creating or updating a task.
      parameters:
        - $ref: "#/components/parameters/workspace"
      responses:
        "200":
          description: Members of the workspace.
          content:
            application/json:
              example:
                - id: "b2c1a0d9-…"
                  name: "Ada Lovelace"
                  email: "ada@acme.com"
                  role: "Admin"
                  role_slug: "admin"
        "401": { $ref: "#/components/responses/Unauthorized" }

  /workspaces/{workspace}/tasks:
    get:
      tags: [Tasks]
      summary: List tasks
      description: |
        Lists tasks within a scope, with filtering, sorting and grouping.

        **Scope — pick exactly one:** `list_id`, `sprint_id`, `space_id`, or `folder_id`. One is
        required; passing none (or more than one) is a `422`.

        **Filtering.** `filter` is a JSON object (or JSON string) shaped
        `{ "match": "all" | "any", "conditions": [ … ] }`. Each condition is
        `{ "field": …, "op": …, "values": [ … ] }`. Common fields and operators (`op`):

        | Field | Operators | Values |
        |---|---|---|
        | `status_id`, `assignee_id` | `in`, `not_in`, `is_empty` | UUIDs |
        | `priority` | `in`, `not_in`, `is_empty` | `low` `medium` `high` `critical` |
        | `due_date` | `in`, `on`, `before`, `after`, `between`, `is_empty` | presets `overdue` `today` `this_week` `next_week` `no_due_date`, or dates |
        | `title`, `display_id` | `contains`, `not_contains`, `is`, `is_not` | string |
        | `cf_<definition_id>` | vary by field type | — |

        Sorting/grouping don't use `sort_field`/`sort_direction` — use `sorts` (a JSON array, max 3)
        and `group_by`.
      parameters:
        - $ref: "#/components/parameters/workspace"
        - name: list_id
          in: query
          description: "Scope to a list. One of list_id / sprint_id / space_id / folder_id is required."
          schema: { type: string, format: uuid }
        - name: sprint_id
          in: query
          description: "Scope to a sprint."
          schema: { type: string, format: uuid }
        - name: space_id
          in: query
          description: "Scope to all lists in a space."
          schema: { type: string, format: uuid }
        - name: folder_id
          in: query
          description: "Scope to all lists in a folder."
          schema: { type: string, format: uuid }
        - name: filter
          in: query
          description: 'JSON filter object, e.g. {"match":"all","conditions":[{"field":"priority","op":"in","values":["high","critical"]}]}.'
          schema: { type: string }
        - name: sorts
          in: query
          description: 'JSON array of sorts (max 3), e.g. [{"field":"due_date","direction":"asc"}]. Fields: title, status, priority, assignee, due_date, created_at, position, cf_<id>.'
          schema: { type: string }
        - name: group_by
          in: query
          description: "Group the result and populate group_counts."
          schema: { type: string, enum: [status, priority, assignee, list], default: status }
        - name: search
          in: query
          description: "Matches title and display id."
          schema: { type: string, maxLength: 255 }
        - name: per_page
          in: query
          description: "Page size. Omit or 0 returns all matching tasks (unpaginated)."
          schema: { type: integer, minimum: 1, maximum: 100 }
      responses:
        "200":
          description: |
            Matching tasks plus counts. When `per_page > 0` the body also carries `current_page`,
            `last_page` and `per_page`. `group_counts` is keyed by the `group_by` value (`none`
            for null); each task carries `subtask_count`, `attachments_count` and `is_blocked`.
          content:
            application/json:
              example:
                data:
                  - id: "9b1f2e3d-…"
                    title: "Ship SSO required mode"
                    priority: "high"
                    status: { id: "…", name: "In Progress", type: "active", color: "#4b7bec" }
                    subtask_count: 2
                    attachments_count: 1
                    is_blocked: false
                total_count: 1
                completed_count: 0
                group_counts: { "a1b2c3d4-…": 1 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
    post:
      tags: [Tasks]
      summary: Create a task
      description: Creates a task in a list. Note `list_id` goes in the request body, not the path.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [list_id, title]
              properties:
                list_id: { type: string, format: uuid, description: "The list to create the task in." }
                title: { type: string, maxLength: 255 }
                description: { type: [string, "null"] }
                priority: { type: string, enum: [low, medium, high, critical] }
                status_id: { type: string, format: uuid }
                assignee_id: { type: [string, "null"], format: uuid }
                due_date: { type: [string, "null"], format: date-time }
                parent_task_id: { type: [string, "null"], format: uuid }
            example:
              list_id: "7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f"
              title: "Ship SSO required mode"
              description: "Enforce SSO for non-owner members."
              priority: "high"
              due_date: "2026-07-20T00:00:00Z"
      responses:
        "201":
          description: The created task.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
              example:
                id: "9b1f2e3d-4c5b-6a79-80a1-b2c3d4e5f6a7"
                workspace_id: "1f2e3d4c-…"
                list_id: "7c8d9e0f-…"
                parent_task_id: null
                title: "Ship SSO required mode"
                description: "Enforce SSO for non-owner members."
                priority: "high"
                status_id: "a1b2c3d4-…"
                assignee_id: null
                due_date: "2026-07-20T00:00:00Z"
                created_at: "2026-07-15T09:24:11Z"
                updated_at: "2026-07-15T09:24:11Z"
                status: { id: "a1b2c3d4-…", name: "To Do", type: "open", color: "#a5b1c2" }
                assignee: null
                creator: { id: "b2c1a0d9-…", name: "Ada Lovelace", email: "ada@acme.com" }
                custom_field_values: []
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/children:
    get:
      tags: [Tasks]
      summary: List subtasks of several tasks
      description: |
        Returns the direct children of up to **50** parent tasks in one call, keyed by parent id —
        the batch shape a board uses to expand every row at once. Rows are board-shaped (same
        fields as `GET .../tasks`, including `is_blocked`, `subtask_count` and `tags`), so they
        match the rows around them.

        Every requested parent appears as a key, even with no children. Children in lists you
        cannot access are dropped silently rather than failing the batch.

        **Caps.** At most **200** children per parent and **1,000** across the whole call. Parents
        cut by either cap are listed in `truncated`; fetch those one at a time with
        `GET .../tasks/{task}/subtasks`.

        Only one level deep — a child's own children are not included, but each row carries
        `subtask_count` so you can tell where to descend.
      parameters:
        - $ref: "#/components/parameters/workspace"
        - name: parent_ids
          in: query
          required: true
          description: Comma-separated parent task UUIDs. Duplicates and blanks are ignored. Max 50.
          schema: { type: string }
          example: "9b1f2e3d-…,8a7b6c5d-…"
      responses:
        "200":
          description: Children grouped by parent id, plus the parents whose set was cut.
          content:
            application/json:
              example:
                data:
                  "9b1f2e3d-…":
                    - id: "c4d5e6f7-…"
                      parent_task_id: "9b1f2e3d-…"
                      title: "Provision SSO app in the IdP"
                      priority: "medium"
                      status: { id: "…", name: "To Do", type: "not_started", color: "#94a3b8" }
                      subtask_count: 0
                      attachments_count: 0
                      is_blocked: false
                      tags: []
                  "8a7b6c5d-…": []
                truncated: []
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422":
          description: "`parent_ids` missing, or more than 50 parents requested."
          content:
            application/json:
              example:
                message: "At most 50 parent tasks can be requested at once."
                errors: { parent_ids: ["At most 50 parent tasks can be requested at once."] }

  /workspaces/{workspace}/tasks/{task}:
    get:
      tags: [Tasks]
      summary: Get a task
      description: Returns a single task.
      parameters:
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
      responses:
        "200":
          description: The task.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Tasks]
      summary: Update a task
      description: Updates any subset of a task's fields.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, maxLength: 255 }
                description: { type: [string, "null"] }
                priority: { type: string, enum: [low, medium, high, critical] }
                status_id: { type: string, format: uuid }
                assignee_id: { type: [string, "null"], format: uuid }
                due_date: { type: [string, "null"], format: date-time }
            example:
              status_id: "c3d4e5f6-…"
              assignee_id: "b2c1a0d9-…"
      responses:
        "200":
          description: The updated task.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
    delete:
      tags: [Tasks]
      summary: Delete a task
      description: |
        Moves the task to Trash. It is **not** purged: it stays restorable for 30 days,
        after which a scheduled purge destroys it along with its comments and attachments.

        Subtasks go with it, to any depth, and come back with it on restore. A subtask
        that was already in the Trash when the parent was deleted keeps its own batch and
        is **not** revived by restoring the parent.

        The response reports what the delete swept up, so a caller working through a batch
        learns a parent took its children before it makes the next call. Restore with
        `POST /workspaces/{workspace}/trash/task/{id}/restore`.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
      responses:
        "200":
          description: Task moved to Trash.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  display_id: { type: string, example: "DIG917433" }
                  deleted_at: { type: string, format: date-time }
                  trash_expires_at:
                    type: string
                    format: date-time
                    description: When the scheduled purge destroys it — 30 days out.
                  subtasks_deleted:
                    type: integer
                    example: 2
                    description: >-
                      How many subtasks this delete swept up, at any depth. Counts this
                      delete's own sweep only — a subtask already in the Trash is not
                      included, and is not restored along with this parent.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /views/{view}/tasks:
    get:
      tags: [Views]
      summary: Execute a saved View
      description: |
        Evaluates a saved View entirely server-side — from its **persisted** filters, sorts,
        grouping, `show_completed` and `subtask_mode` — and returns exactly the task rows the
        View shows in the UI. Send no `filter`/`sorts`/`group_by`: this endpoint accepts none of
        them, and a request carrying any of the three is a `422` rather than a silent override.

        The View's own `viewable_type`/`viewable_id` decide the scope — a list View returns that
        list's tasks, a folder View that folder's, and so on, exactly as `GET
        /workspaces/{workspace}/tasks` would for the equivalent scope. There is no cross-level
        trickle-down.

        **Location-condition stripping.** A `location` filter condition narrows to specific
        folders/lists, which only means something on a scope spanning more than one of them. On a
        list- or sprint-scoped View — which cannot span more than one — any `location` condition
        in the View's filter is dropped before evaluation, matching what the web UI itself does
        (rather than the condition silently zeroing every row).

        **Pagination default.** Unlike the generic task index, `per_page` here defaults to **50**
        when omitted (max 100) — this endpoint is always paginated, so evaluating a View over a
        very large container never returns an unbounded response by accident.

        **Per-caller cached counts.** `total_count` and `completed_count` are cached for up to 60
        seconds, keyed by the calling token's user — not shared across users, so two people
        executing the same View never see each other's cached numbers.

        The response envelope matches `GET /workspaces/{workspace}/tasks` exactly, including
        `group_pages` and `column_aggregates`, which are always null/empty here (this endpoint
        does not compute a View's column footer aggregates).
      parameters:
        - $ref: "#/components/parameters/view"
        - name: per_page
          in: query
          description: "Page size. Defaults to 50 when omitted (always paginated, unlike the generic task index)."
          schema: { type: integer, minimum: 1, maximum: 100 }
        - name: page
          in: query
          description: "1-based page number."
          schema: { type: integer, minimum: 1 }
      responses:
        "200":
          description: |
            The View's resolved task rows, in the same shape `GET /workspaces/{workspace}/tasks`
            returns.
          content:
            application/json:
              example:
                data:
                  - id: "9b1f2e3d-…"
                    title: "Ship SSO required mode"
                    priority: "high"
                    status: { id: "…", name: "In Progress", type: "active", color: "#4b7bec" }
                    subtask_count: 2
                    attachments_count: 1
                    is_blocked: false
                total_count: 1
                completed_count: 0
                filtered_count: 1
                group_counts: null
                group_pages: null
                column_aggregates: []
                current_page: 1
                last_page: 1
                per_page: 50
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            The caller belongs to the View's workspace but cannot see this particular View — the
            same authorization chain `GET /views/{view}` uses (creator, private-view owner,
            `view_access` grant, or shared + container access). Note this endpoint's 404 case
            below is wider than that sibling endpoint's: a caller with no membership in the
            View's workspace at all gets 404 here, not 403, so a foreign View's existence is
            never confirmed to a token that cannot reach its workspace.
          content:
            application/json:
              example: { message: "This action is unauthorized." }
        "404":
          description: |
            Unknown View id, or a View id belonging to a workspace the caller's token has no
            membership in. This endpoint checks workspace membership up front, before the
            View-level authorization above runs, specifically so a caller outside the View's
            workspace gets the same answer for "wrong workspace" and "does not exist" — 404 either
            way — rather than a 403 that would confirm a foreign View is real.
          content:
            application/json:
              example: { message: "Not Found." }
        "422":
          description: |
            Invalid `per_page`/`page`, or the request carried a `filter`, `sorts` or `group_by`
            query parameter — all three are rejected outright rather than silently ignored.
          content:
            application/json:
              example: { message: "The filter field is prohibited.", errors: { filter: ["The filter field is prohibited."] } }

  /tasks/{task}/move:
    post:
      tags: [Tasks]
      summary: Move a task
      description: Moves a task into another list or sprint. Note this is a flat route — the task is addressed by id alone, without a `workspace` in the path.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/task"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_type, target_id]
              properties:
                target_type: { type: string, enum: [list, sprint], description: "The kind of container to move into." }
                target_id: { type: string, format: uuid, description: "The destination list or sprint id." }
            example:
              target_type: "list"
              target_id: "7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f"
      responses:
        "200":
          description: The moved task.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /tasks/{task}/custom-fields:
    get:
      tags: [Custom fields]
      summary: List a task's custom fields
      description: |
        Returns the custom field **definitions** in effect for this task — resolved down the
        hierarchy (workspace → space → folder → list). Use a definition `id` as
        `custom_field_definition_id` when writing values. This is a flat route (task id only).
        The task's current values live on the task itself, under `custom_field_values`.
      parameters:
        - $ref: "#/components/parameters/task"
      responses:
        "200":
          description: The custom field definitions that apply to the task.
          content:
            application/json:
              example:
                data:
                  - id: "c1d2e3f4-…"
                    workspace_id: "1f2e3d4c-…"
                    scopeable_type: "list"
                    scopeable_id: "7c8d9e0f-…"
                    name: "Story Points"
                    type: "number"
                    options: null
                    is_required: false
                  - id: "d2e3f4a5-…"
                    workspace_id: "1f2e3d4c-…"
                    scopeable_type: "space"
                    scopeable_id: "3a2b1c0d-…"
                    name: "Environment"
                    type: "dropdown"
                    options:
                      - { label: "Staging", color: "#a5b1c2" }
                      - { label: "Production", color: "#eb3b5a" }
                    is_required: false
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /tasks/{task}/custom-field-values:
    put:
      tags: [Custom fields]
      summary: Set a task's custom field values
      description: |
        Upserts one or more custom field values on a task. Each entry references a definition by
        `custom_field_definition_id` (from `GET /tasks/{task}/custom-fields`). The shape of `value`
        depends on the field `type`:

        | Field type | `value` |
        |---|---|
        | `text`, `url`, `dropdown` | string |
        | `number` | number (≥ 0) |
        | `date` | `YYYY-MM-DD` string |
        | `checkbox` | boolean |
        | `multi_select` | array of option-label strings |

        This is a flat route (task id only). `attachment` fields are not set here.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/task"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [values]
              properties:
                values:
                  type: array
                  items:
                    type: object
                    required: [custom_field_definition_id, value]
                    properties:
                      custom_field_definition_id: { type: string, format: uuid }
                      value: { description: "Type-specific — see the table above. Any JSON type, including null." }
            example:
              values:
                - custom_field_definition_id: "c1d2e3f4-…"
                  value: 8
                - custom_field_definition_id: "d2e3f4a5-…"
                  value: "Production"
      responses:
        "200":
          description: The upserted values — one object per field.
          content:
            application/json:
              example:
                - id: "e3f4a5b6-…"
                  workspace_id: "1f2e3d4c-…"
                  task_id: "9b1f2e3d-…"
                  custom_field_definition_id: "c1d2e3f4-…"
                  value_number: 8
                  value_text: null
                  value_date: null
                  value_boolean: null
                  value_json: null
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/{task}/subtasks:
    get:
      tags: [Tasks]
      summary: List a task's subtasks
      description: |
        Returns the direct children of one task — the rows the task page's subtask panel shows.
        Each child carries `status`, `assignee`, `assignees`, `creator`, `custom_field_values`,
        `recurrence`, `attachments_count` and its own `subtask_count` (the tree can go deeper;
        call this again on a child to descend).

        Capped at **200** children; there is no pagination. For the children of many tasks in
        one call, use `GET .../tasks/children?parent_ids=…`.

        `{task}` accepts the UUID or the display id (e.g. `DEV901645`).
      parameters:
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
      responses:
        "200":
          description: The task's direct subtasks.
          content:
            application/json:
              example:
                data:
                  - id: "c4d5e6f7-…"
                    parent_task_id: "9b1f2e3d-…"
                    display_id: "DEV901646"
                    title: "Provision SSO app in the IdP"
                    priority: "medium"
                    status: { id: "…", name: "To Do", type: "not_started", color: "#94a3b8" }
                    assignee: null
                    assignees: []
                    creator: { id: "b2c1a0d9-…", name: "Ada Lovelace", email: "ada@acme.com" }
                    custom_field_values: []
                    recurrence: null
                    attachments_count: 0
                    subtask_count: 0
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspace}/tasks/{task}/comments:
    get:
      tags: [Comments]
      summary: List comments
      description: Cursor-paginated comments on a task.
      parameters:
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
        - name: cursor
          in: query
          schema: { type: string }
      responses:
        "200":
          description: A page of comments.
          content:
            application/json:
              example:
                data:
                  - id: "d4e5f6a7-…"
                    task_id: "9b1f2e3d-…"
                    body: "Looks good — merging."
                    parent_id: null
                    resolved_at: null
                    created_at: "2026-07-15T10:02:44Z"
                    user: { id: "b2c1a0d9-…", name: "Ada Lovelace", email: "ada@acme.com" }
                    assignee: null
                next_cursor: null
                has_more: false
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Comments]
      summary: Add a comment
      description: Posts a comment on a task.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string }
                parent_id: { type: [string, "null"], format: uuid, description: "Reply to another comment." }
                mentions:
                  type: array
                  items: { type: string, format: uuid }
                assignee_id: { type: [string, "null"], format: uuid }
            example:
              body: "Can you confirm the owner break-glass path?"
              assignee_id: "b2c1a0d9-…"
      responses:
        "201":
          description: The created comment.
          content:
            application/json:
              example:
                id: "e5f6a7b8-…"
                task_id: "9b1f2e3d-…"
                body: "Can you confirm the owner break-glass path?"
                parent_id: null
                resolved_at: null
                created_at: "2026-07-15T10:05:12Z"
                user: { id: "b2c1a0d9-…", name: "Ada Lovelace", email: "ada@acme.com" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /search:
    get:
      tags: [Search]
      summary: Search tasks
      description: Full-text search scoped to a workspace. Note `workspace_id` is a query parameter, unlike other routes which take `workspace` in the path.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 1, maxLength: 255 }
        - name: workspace_id
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: type
          in: query
          schema: { type: string, enum: [tasks, comments, attachments] }
        - name: priority
          in: query
          schema: { type: string, enum: [low, medium, high, critical] }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: per_page
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
      responses:
        "200":
          description: Search results.
          content:
            application/json:
              example:
                query: "sso"
                page: 1
                per_page: 20
                tasks:
                  total: 2
                  hits:
                    - id: "9b1f2e3d-…"
                      title: "Ship SSO required mode"
                      priority: "high"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspace}/tasks/{task}/comments/{comment}:
    put:
      tags: [Comments]
      summary: Update a comment
      description: Edits a comment's body (and optionally its mentions/assignee).
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
        - $ref: "#/components/parameters/comment"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string }
                mentions:
                  type: array
                  items: { type: string, format: uuid }
                assignee_id: { type: [string, "null"], format: uuid }
            example:
              body: "Updated — confirmed the owner break-glass path."
      responses:
        "200":
          description: The updated comment.
          content:
            application/json:
              example:
                id: "e5f6a7b8-…"
                task_id: "9b1f2e3d-…"
                body: "Updated — confirmed the owner break-glass path."
                parent_id: null
                assignee_id: "b2c1a0d9-…"
                resolved_at: null
                user: { id: "b2c1a0d9-…", name: "Ada Lovelace", email: "ada@acme.com" }
                assignee: { id: "b2c1a0d9-…", name: "Ada Lovelace", email: "ada@acme.com" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
    delete:
      tags: [Comments]
      summary: Delete a comment
      description: Deletes a comment and any attachments on it.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
        - $ref: "#/components/parameters/comment"
      responses:
        "204": { description: "Comment deleted — no content." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/{task}/comments/{comment}/resolve:
    put:
      tags: [Comments]
      summary: Resolve a comment
      description: Marks an assigned comment as resolved. The comment must have an `assignee_id` and not already be resolved — otherwise `422`.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
        - $ref: "#/components/parameters/comment"
      responses:
        "200":
          description: The resolved comment.
          content:
            application/json:
              example:
                id: "e5f6a7b8-…"
                resolved_at: "2026-07-16T09:12:03Z"
                assignee: { id: "b2c1a0d9-…", name: "Ada Lovelace", email: "ada@acme.com" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/{task}/comments/{comment}/unresolve:
    put:
      tags: [Comments]
      summary: Unresolve a comment
      description: Clears the resolved state of a comment. It must currently be resolved — otherwise `422`.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
        - $ref: "#/components/parameters/comment"
      responses:
        "200":
          description: The comment, now unresolved.
          content:
            application/json:
              example:
                id: "e5f6a7b8-…"
                resolved_at: null
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/{task}/dependencies:
    get:
      tags: [Dependencies]
      summary: List task dependencies
      description: Returns the tasks this task is `blocked_by` and the tasks it is `blocking`. Each entry carries a `dependency_id` — pass it to the delete endpoint to remove the link.
      parameters:
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
      responses:
        "200":
          description: The task's dependency links.
          content:
            application/json:
              example:
                blocked_by:
                  - dependency_id: "f4a5b6c7-…"
                    id: "8a7b6c5d-…"
                    title: "Provision SSO app in the IdP"
                    status: { id: "…", name: "In Progress", type: "active", color: "#4b7bec" }
                blocking: []
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Dependencies]
      summary: Add a dependency
      description: Marks another task as a **blocker** of this task — the blocker must complete first.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [blocker_task_id]
              properties:
                blocker_task_id: { type: string, format: uuid, description: "The task that blocks this one." }
            example:
              blocker_task_id: "8a7b6c5d-4e3f-2a1b-0c9d-8e7f6a5b4c3d"
      responses:
        "201":
          description: The created dependency link.
          content:
            application/json:
              example:
                id: "f4a5b6c7-…"
                workspace_id: "1f2e3d4c-…"
                blocker_task_id: "8a7b6c5d-…"
                blocked_task_id: "9b1f2e3d-…"
                created_by: "b2c1a0d9-…"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            The dependency already exists — or, if you sent an `Idempotency-Key`, a request with
            that key is still in flight. The message distinguishes them.
          content:
            application/json:
              example: { message: "This dependency already exists." }
        "422": { $ref: "#/components/responses/ValidationError" }

  /workspaces/{workspace}/tasks/{task}/dependencies/{dependency}:
    delete:
      tags: [Dependencies]
      summary: Remove a dependency
      description: Deletes a dependency link. Use the `dependency_id` returned by the list endpoint.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
        - name: dependency
          in: path
          required: true
          description: Dependency link UUID (the `dependency_id` from the list endpoint).
          schema: { type: string, format: uuid }
      responses:
        "204": { description: "Dependency removed — no content." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/{task}/activity:
    get:
      tags: [Tasks]
      summary: Get task activity
      description: |
        The task's change history — status changes, reassignments, edits, comments — newest first,
        **cursor-paginated** (30 per page). With no webhooks, polling this (and `/search`) is how you
        detect changes. Follow `next_cursor` until `has_more` is `false`.
      parameters:
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
        - name: cursor
          in: query
          description: "Opaque cursor from a previous response's next_cursor."
          schema: { type: string }
      responses:
        "200":
          description: A page of activity entries.
          content:
            application/json:
              example:
                data:
                  - id: "a9b8c7d6-…"
                    entity_type: "Task"
                    entity_id: "9b1f2e3d-…"
                    action: "updated"
                    actor_id: "b2c1a0d9-…"
                    actor_type: "user"
                    actor_name: "Ada Lovelace"
                    old_value: { status_id: "…", status_name: "To Do" }
                    new_value: { status_id: "…", status_name: "In Progress" }
                    created_at: "2026-07-16T09:20:11Z"
                next_cursor: "eyJjcmVhdGVkX2F0Ijoi…"
                has_more: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /workspaces/{workspace}/tasks/{task}/attachments:
    get:
      tags: [Attachments]
      summary: List attachments
      description: Files attached to a task. Each carries a short-lived signed `preview_url` and `download_url`.
      parameters:
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
        - name: context
          in: query
          description: "Filter by context. Omit for the task's file attachments."
          schema: { type: string, enum: [task, comment, description] }
      responses:
        "200":
          description: The task's attachments.
          content:
            application/json:
              example:
                - id: "c7d8e9f0-…"
                  task_id: "9b1f2e3d-…"
                  file_name: "spec.pdf"
                  mime_type: "application/pdf"
                  file_size: 208913
                  context: "task"
                  preview_url: "https://…r2…/spec.pdf?X-Amz-Expires=3600&…"
                  download_url: "https://…r2…/spec.pdf?response-content-disposition=attachment&…"
                  created_at: "2026-07-15T12:01:44Z"
                  uploadedBy: { id: "b2c1a0d9-…", name: "Ada Lovelace", email: "ada@acme.com" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Attachments]
      summary: Upload an attachment
      description: |
        Uploads a file to a task as `multipart/form-data`. The file field is `file` (one per request,
        up to ~500 MB). Allowed types: jpg, jpeg, png, gif, webp, svg, pdf, doc, docx, xls, xlsx, ppt,
        pptx, csv, txt, zip, rar, mp4, webm, mov.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
                context: { type: string, enum: [task, comment, description], description: "Defaults to task." }
                comment_id: { type: [string, "null"], format: uuid, description: "Attach to a specific comment." }
      responses:
        "201":
          description: The created attachment.
          content:
            application/json:
              example:
                id: "c7d8e9f0-…"
                task_id: "9b1f2e3d-…"
                file_name: "spec.pdf"
                mime_type: "application/pdf"
                file_size: 208913
                context: "task"
                download_url: "https://…r2…/spec.pdf?…"
                uploadedBy: { id: "b2c1a0d9-…", name: "Ada Lovelace", email: "ada@acme.com" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/{task}/attachments/{attachment}:
    get:
      tags: [Attachments]
      summary: Download an attachment
      description: |
        Redirects (`302`) to a short-lived signed URL that downloads the file. Add `?preview=true`
        to stream it inline instead. Most integrations can skip this and use the `download_url` /
        `preview_url` already returned by the list endpoint.
      parameters:
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
        - $ref: "#/components/parameters/attachment"
        - name: preview
          in: query
          description: "Stream the file inline (200) instead of redirecting to a download."
          schema: { type: boolean }
      responses:
        "302": { description: "Redirect to a signed download URL (Location header)." }
        "200": { description: "The file streamed inline (when preview=true)." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Attachments]
      summary: Delete an attachment
      description: Removes a file from a task.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
        - $ref: "#/components/parameters/attachment"
      responses:
        "204": { description: "Attachment deleted — no content." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /lists/{list}/inbound-address:
    get:
      tags: [Email to list]
      summary: Get a list's inbound email address
      description: |
        Returns the List's inbound email address, or `data: null` when it has none. Having no
        address is the ordinary state, not an error.

        **This is a pure read — it never creates an address.** Use `POST` to generate one.
      parameters:
        - $ref: "#/components/parameters/list"
      responses:
        "200":
          description: |
            The address, or `null`.

            `can_reset` is relative to *the caller*, not to the address: the same address reports
            `true` to someone holding `manage` on the space and `false` to everyone else. Don't
            cache it across users.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    oneOf:
                      - $ref: "#/components/schemas/InboundAddress"
                      - type: "null"
              examples:
                none:
                  summary: The list has no address
                  value: { data: null }
                existing:
                  summary: The list has an address
                  value:
                    data:
                      id: "d4e5f6a7-8b9c-4d0e-a1f2-3a4b5c6d7e8f"
                      list_id: "9a8b7c6d-5e4f-4a3b-8c9d-0e1f2a3b4c5d"
                      email_address: "list-5dd884d623ebf14d90aecdcd15887a1343fce5f6@tasks.qordo.ai"
                      can_reset: true
                      created_at: "2026-08-21T20:14:13.000000Z"
                      updated_at: "2026-08-21T20:14:13.000000Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Email to list]
      summary: Generate a list's inbound email address
      description: |
        Generates the List's inbound email address, or hands back the one it already has.

        **Idempotent, and safe to retry after a timeout.** The status code is the only thing that
        distinguishes the two outcomes — `201` when this call created the address, `200` when it
        found an existing one. The bodies are byte-identical, timestamps included, so a client that
        ignores status codes cannot tell them apart.

        The request body is ignored entirely: the address token is always minted server-side, and
        there is no validation failure to handle.

        Any level of access to the List is enough to generate an address — this is not restricted
        to `manage`. Generating one arms a live mail intake, so treat it as a write even though it
        takes no parameters.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/list"
      responses:
        "201":
          description: This call created the address.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/InboundAddress" }
              example:
                data:
                  id: "d4e5f6a7-8b9c-4d0e-a1f2-3a4b5c6d7e8f"
                  list_id: "9a8b7c6d-5e4f-4a3b-8c9d-0e1f2a3b4c5d"
                  email_address: "list-5dd884d623ebf14d90aecdcd15887a1343fce5f6@tasks.qordo.ai"
                  can_reset: true
                  created_at: "2026-08-21T20:14:13.000000Z"
                  updated_at: "2026-08-21T20:14:13.000000Z"
        "200":
          description: The List already had an address; nothing was created or changed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/InboundAddress" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /lists/{list}/inbound-address/reset:
    post:
      tags: [Email to list]
      summary: Rotate a list's inbound email address
      description: |
        Issues a new address for the List and **revokes the old one immediately**.

        Three things to know before calling this:

        - **The revocation is silent.** Mail sent to the old address is accepted from the sending
          provider and then dropped — no bounce, no error, no task. Anyone who saved the old address
          in a forwarding rule or a contact card loses mail without being told.
        - **The address is shared by everyone with access to the List**, not per-member, so a
          rotation changes it under all of them at once.
        - **It always returns `200`, even when it had to create the address.** Unlike `POST
          /inbound-address`, reset does not distinguish creating from replacing, so it gives you no
          signal that you just armed a mail intake on a List that had none.

        Requires `manage` on the List's space. Read `can_reset` on the address first if you want to
        know whether the current user will be allowed.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/list"
      responses:
        "200":
          description: |
            The new address. `id` and `created_at` carry over from the previous address —
            only `email_address` and `updated_at` change.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/InboundAddress" }
              example:
                data:
                  id: "d4e5f6a7-8b9c-4d0e-a1f2-3a4b5c6d7e8f"
                  list_id: "9a8b7c6d-5e4f-4a3b-8c9d-0e1f2a3b4c5d"
                  email_address: "list-5e67aa34219a5b1da0e8d44de74884ba7275373e@tasks.qordo.ai"
                  can_reset: true
                  created_at: "2026-08-21T20:14:13.000000Z"
                  updated_at: "2026-08-21T20:14:23.000000Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/spaces/{space}/tags:
    get:
      tags: [Tags]
      summary: List a space's tags
      description: |
        Returns every tag in the space's library.

        **Tags belong to a space, not to the workspace or a list.** Two spaces can each hold a tag
        called `urgent` and they are different rows with different IDs — so always resolve tags in
        the space of the task you're about to touch.

        The response is a bare array. There is no pagination, and no `page` or `per_page` parameter.
      parameters:
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/space"
        - name: with_counts
          in: query
          description: |
            Adds `tasks_count` to each tag. The count is *not* filtered by what the caller can see,
            so it can be higher than the number of tasks they'd find by searching for the tag.
          schema: { type: boolean }
      responses:
        "200":
          description: The space's tags, unpaginated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Tag" }
              example:
                data:
                  - id: "3c4d5e6f-7a8b-4c9d-0e1f-2a3b4c5d6e7f"
                    workspace_id: "1f2e3d4c-5b6a-7980-a1b2-c3d4e5f6a7b8"
                    space_id: "8e9f0a1b-2c3d-4e5f-6a7b-8c9d0e1f2a3b"
                    name: "urgent"
                    color: "#ef4444"
                    text_color: "#ffffff"
                    created_by: "b2c1a0d9-4e3f-4a1b-9c8d-7e6f5a4b3c2d"
                    created_at: "2026-08-21T20:14:45.000000Z"
                    updated_at: "2026-08-21T20:14:45.000000Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Tags]
      summary: Create a tag
      description: |
        Adds a tag to the space's library.

        Names are unique per space, case-insensitively — `urgent` and `URGENT` collide. The
        constraint is enforced in the database as well as in validation, so you cannot race past it.

        Requires `manage` on the space plus the `tags` edit capability. Note that
        `POST /tasks/{task}/tags` can also create a tag by name and does **not** require either.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/space"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 50, description: "Unique within the space, case-insensitively." }
                color:
                  type: string
                  pattern: "^#[0-9a-fA-F]{6}$"
                  default: "#6b7280"
                  description: "Six-digit hex **with** the leading `#`. Not a palette name — `#f00` and `ef4444` are both rejected."
                text_color:
                  type: [string, "null"]
                  pattern: "^#[0-9a-fA-F]{6}$"
                  description: "Same format. Only settable here and on `PATCH` — the task-attach endpoint ignores it."
            example:
              name: "blocked"
              color: "#ef4444"
              text_color: "#ffffff"
      responses:
        "201":
          description: The created tag.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Tag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: Validation failed, or the name is already taken in this space.
          content:
            application/json:
              example:
                message: "A tag with this name already exists in this space."
                errors:
                  name: ["A tag with this name already exists in this space."]
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tags/{tag}:
    patch:
      tags: [Tags]
      summary: Update a tag
      description: |
        Renames or recolours a tag. The tag stays in its space — there is no way to move one.

        A rename that collides with another tag in the same space is a `422`.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/tag"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 50 }
                color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
                text_color: { type: [string, "null"], pattern: "^#[0-9a-fA-F]{6}$" }
            example:
              color: "#0ea5e9"
      responses:
        "200":
          description: The updated tag.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Tag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
    delete:
      tags: [Tags]
      summary: Delete a tag
      description: |
        Permanently deletes the tag **and removes it from every task that carried it**.

        This is not a soft delete and there is no confirmation step: the tag rows and all of its
        task associations go at once, and the removals are not written to task activity, so
        afterwards there is no record that those tasks were ever tagged. Check `tasks_count` via
        `GET .../tags?with_counts=1` before calling this.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/tag"
      responses:
        "204": { description: "Tag deleted — no content." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/{task}/tags:
    post:
      tags: [Tags]
      summary: Add a tag to a task
      description: |
        Attaches a tag to a task, identified either by `tag_id` or by `name`.

        **`name` is find-or-create.** If no tag in the task's space matches (case-insensitively),
        one is created and stays in the space library permanently. A typo therefore doesn't fail —
        it quietly adds `blocekd` alongside `blocked`. Prefer `tag_id` from `GET .../tags` whenever
        you have it, and treat `name` as an import convenience.

        If you send both, `tag_id` wins and `name` is ignored.

        **Idempotent.** Attaching a tag the task already has returns `201` with the same body and
        changes nothing — no duplicate activity entry, and no `tag_added` automation fires. The
        status code never tells you whether anything actually changed.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tag_id:
                  type: string
                  format: uuid
                  description: "Must already exist in this workspace **and** live in the task's own space."
                name:
                  type: string
                  maxLength: 50
                  description: "Required when `tag_id` is absent. Matched case-insensitively; created if absent."
                color:
                  type: string
                  pattern: "^#[0-9a-fA-F]{6}$"
                  default: "#6b7280"
                  description: "Used **only** when `name` creates a new tag. Ignored otherwise."
            examples:
              byId:
                summary: By id (preferred)
                value: { tag_id: "3c4d5e6f-7a8b-4c9d-0e1f-2a3b4c5d6e7f" }
              byName:
                summary: By name, creating it if it doesn't exist
                value: { name: "blocked", color: "#ef4444" }
      responses:
        "201":
          description: The attached tag. Also returned when the task already had it.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Tag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: |
            Validation failed, the tag isn't in this workspace, the tag belongs to a different
            space, or the task is frozen by an archived sprint. Note that an unknown `tag_id` is a
            `422` here rather than a `404`.
          content:
            application/json:
              examples:
                wrongSpace:
                  summary: Tag lives in another space
                  value:
                    message: "The selected tag does not belong to this task's space."
                    errors:
                      tag_id: ["The selected tag does not belong to this task's space."]
                frozen:
                  summary: Archived sprint — note there is no `errors` key
                  value:
                    message: "This task is in an archived sprint and is read-only. Unarchive the sprint to make changes."
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/{task}/tags/{tag}:
    delete:
      tags: [Tags]
      summary: Remove a tag from a task
      description: |
        Detaches the tag from the task. The tag itself stays in the space library.

        Removing a tag the task doesn't have is a no-op and still returns `204` — the status code
        doesn't distinguish it from a real removal.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
        - $ref: "#/components/parameters/tag"
      responses:
        "204": { description: "Detached, or the task didn't have the tag — no content either way." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: The task is frozen by an archived sprint.
          content:
            application/json:
              example:
                message: "This task is in an archived sprint and is read-only. Unarchive the sprint to make changes."
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/bulk/tags:
    post:
      tags: [Tags]
      summary: Add tags to many tasks
      description: |
        Attaches up to 50 existing tags to up to 200 tasks, or to everything matching a scope via
        `select_all`.

        **Two different failure modes, and the difference matters.** The tag set is validated up
        front: one unresolvable `tag_ids` entry rejects the entire request with a `422` and nothing
        is written. Tasks are best-effort: each one that can't be processed is reported in
        `skipped_reasons` and the rest still succeed.

        Bulk cannot create tags — there is no `name` option here.

        Tags are applied per task by space, so a mixed-space `tag_ids` set is fine: each task
        receives only the tags from its own space. A task sharing no space with any requested tag
        is skipped as `wrong_space`.

        **`updated` counts tasks processed, not tasks changed.** Re-running an identical request
        reports the same `updated` figure while changing nothing.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [tag_ids]
                  properties:
                    tag_ids:
                      type: array
                      minItems: 1
                      maxItems: 50
                      items: { type: string, format: uuid }
                      description: "Every id must exist in this workspace or the whole request fails. De-duplicated server-side."
                - $ref: "#/components/schemas/BulkTaskSelection"
            example:
              task_ids:
                - "5d6e7f8a-9b0c-4d1e-2f3a-4b5c6d7e8f9a"
                - "6e7f8a9b-0c1d-4e2f-3a4b-5c6d7e8f9a0b"
              tag_ids:
                - "3c4d5e6f-7a8b-4c9d-0e1f-2a3b4c5d6e7f"
      responses:
        "200":
          description: |
            The result summary. There is no `207` and no per-task success list — only counts and
            the reasons for whatever was skipped.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkTaskResult" }
              example:
                updated: 2
                skipped: 2
                skipped_reasons:
                  "7f8a9b0c-1d2e-4f3a-4b5c-6d7e8f9a0b1c": "wrong_space"
                  "00000000-0000-4000-8000-000000000000": "not_found"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
    delete:
      tags: [Tags]
      summary: Remove tags from many tasks
      description: |
        The mirror of the `POST`: identical request body, identical response envelope, same
        per-task skip semantics. Detaching tags that were never attached still counts towards
        `updated`.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [tag_ids]
                  properties:
                    tag_ids:
                      type: array
                      minItems: 1
                      maxItems: 50
                      items: { type: string, format: uuid }
                - $ref: "#/components/schemas/BulkTaskSelection"
      responses:
        "200":
          description: The result summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkTaskResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/{task}/watchers:
    get:
      tags: [Watchers]
      summary: List a task's watchers
      description: |
        Returns who follows this task, in two separate arrays: individually added `users`, and
        `teams` following as a unit.

        **A team is one entry, not an expansion.** A five-person team appears as a single object
        with `users_count: 5` and an empty `users` array — no per-member rows exist. Membership is
        resolved live when notifications are sent, so adding someone to the team later makes them a
        follower with no backfill. To know everyone who will actually be notified you must union
        `users` with the current membership of each team.

        The response is a bare object with no `data` wrapper, no pagination and no defined ordering.
      parameters:
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
      responses:
        "200":
          description: The task's individual and team followers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WatcherList" }
              example:
                users:
                  - id: "b2c1a0d9-4e3f-4a1b-9c8d-7e6f5a4b3c2d"
                    name: "Ada Lovelace"
                    email: "ada@acme.com"
                teams:
                  - id: "4d5e6f7a-8b9c-4d0e-1f2a-3b4c5d6e7f8a"
                    workspace_id: "1f2e3d4c-5b6a-7980-a1b2-c3d4e5f6a7b8"
                    name: "Docs QA"
                    alias: "docs-qa"
                    color: "#7c4dff"
                    source: "manual"
                    users_count: 5
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Watchers]
      summary: Add a watcher to a task
      description: |
        Adds one user as a follower.

        **This never touches the task's assignee.** Watching and being assigned are separate
        relations, and a watcher write does not modify the task row at all. Note the reverse is
        *not* true: assigning someone automatically adds them as a watcher.

        **Idempotent, but silently so.** Adding a user who already watches returns `201` again with
        the same body, writes no duplicate activity entry and fires no automation. The status code
        never tells you whether anything changed.

        Only view access to the task is required — and it is checked against *the caller*, not the
        person being added, so any viewer can make someone else follow a task. The target must
        still be an active workspace member who can see the task themselves, or the call is `403`.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id]
              properties:
                user_id:
                  type: string
                  format: uuid
                  description: "A single UUID — not an array. The array form (`user_ids`) exists only on the bulk endpoint."
            example:
              user_id: "b2c1a0d9-4e3f-4a1b-9c8d-7e6f5a4b3c2d"
      responses:
        "201":
          description: Added — or already watching, which is reported identically.
          content:
            application/json:
              example: { message: "Watcher added." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: The caller cannot reach the task, or the user being added cannot see it.
          content:
            application/json:
              example: { message: "This action is unauthorized." }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: |
            `user_id` is missing, or is not an active member of this workspace. Note that an
            unknown user is a `422` here but a `404` on the delete.
          content:
            application/json:
              example:
                message: "The user id field is required."
                errors:
                  user_id: ["The user id field is required."]
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/{task}/watchers/{user}:
    delete:
      tags: [Watchers]
      summary: Remove a watcher from a task
      description: |
        Removes one individual follower.

        **This cannot remove a team-derived follow.** If someone follows only because their team
        follows, this returns `204` and changes nothing — they keep receiving notifications. There
        is no way to exclude one person from a team follow; remove the whole team instead.

        Removing someone who wasn't watching is also `204`, so the status code never distinguishes
        a real removal from a no-op. The removal is silent — nobody is notified.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
        - $ref: "#/components/parameters/user"
      responses:
        "204": { description: "Removed, or they weren't watching — no content either way." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/{task}/watchers/teams:
    post:
      tags: [Watchers]
      summary: Add a team as a follower
      description: |
        Adds a whole team as a follower of the task.

        **The team goes in the request body, not the path.** `POST .../watchers/teams/{team}` is
        not a route and answers `405`; only the `DELETE` carries the team in the path.

        One reference row is stored — members are never expanded into individual watchers, and are
        resolved live at notification time.

        `notified` reports how many people were actually messaged. It excludes the caller and
        silently skips team members who cannot see the task, so it is routinely lower than the
        team's size.

        **`200` versus `201` is the only signal** that the team was already following: the message
        is identical either way.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [team_id]
              properties:
                team_id:
                  type: string
                  format: uuid
                  description: "Must belong to this workspace. The field is `team_id` — not `team` or `id`."
                mute_notifications:
                  type: boolean
                  default: false
                  description: "Add the team without notifying anyone. `notified` then comes back as 0."
            example:
              team_id: "4d5e6f7a-8b9c-4d0e-1f2a-3b4c5d6e7f8a"
              mute_notifications: false
      responses:
        "201":
          description: The team was newly added.
          content:
            application/json:
              example: { message: "Team added as a follower.", notified: 2 }
        "200":
          description: The team was already following. Nothing changed.
          content:
            application/json:
              example: { message: "Team added as a follower.", notified: 0 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/{task}/watchers/teams/{team}:
    delete:
      tags: [Watchers]
      summary: Remove a team follower
      description: |
        Removes the team's follow in one call, which stops notifications for every member covered
        by it. Removing a team that wasn't following is a no-op and still returns `204`.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/task"
        - $ref: "#/components/parameters/team"
      responses:
        "204": { description: "Removed, or the team wasn't following — no content either way." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/tasks/bulk/watchers:
    post:
      tags: [Watchers]
      summary: Add watchers to many tasks
      description: |
        Adds up to 50 users as followers of up to 200 tasks, or of everything matching a scope via
        `select_all`.

        As with bulk tagging, the two halves fail differently: one `user_ids` entry that isn't an
        active workspace member rejects the **whole** request with a `422`, while unusable tasks are
        skipped individually and reported in `skipped_reasons`.

        **Teams cannot be added in bulk** — there is no `team_ids` field.

        `updated` counts tasks processed, not tasks changed. Assignees are untouched here too.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [user_ids]
                  properties:
                    user_ids:
                      type: array
                      minItems: 1
                      maxItems: 50
                      items: { type: string, format: uuid }
                      description: "Each must be an active member of this workspace. De-duplicated server-side."
                - $ref: "#/components/schemas/BulkTaskSelection"
            example:
              task_ids:
                - "5d6e7f8a-9b0c-4d1e-2f3a-4b5c6d7e8f9a"
              user_ids:
                - "b2c1a0d9-4e3f-4a1b-9c8d-7e6f5a4b3c2d"
      responses:
        "200":
          description: The result summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkTaskResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
    delete:
      tags: [Watchers]
      summary: Remove watchers from many tasks
      description: |
        The mirror of the `POST` — same body, same envelope, same per-task skip semantics. Like the
        single-task delete, this cannot remove team-derived follows.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [user_ids]
                  properties:
                    user_ids:
                      type: array
                      minItems: 1
                      maxItems: 50
                      items: { type: string, format: uuid }
                - $ref: "#/components/schemas/BulkTaskSelection"
      responses:
        "200":
          description: The result summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkTaskResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/task-templates:
    get:
      tags: [Task templates]
      summary: List task templates
      description: |
        Lists the templates you can see: every `shared` template in the workspace, plus your own
        `private` ones. Ordered by name.

        Page size is fixed at 30 and **`per_page` is not supported** — sending it changes nothing
        and the response still reports `"per_page": 30`. There is no sort or visibility parameter.
      parameters:
        - $ref: "#/components/parameters/workspace"
        - name: search
          in: query
          description: "Case-insensitive substring match on the template **name** only. It does not look inside the snapshot."
          schema: { type: string, maxLength: 255 }
        - name: created_by
          in: query
          description: "Exact match on creator. An unknown id returns an empty page rather than an error."
          schema: { type: string, format: uuid }
        - name: page
          in: query
          schema: { type: integer, minimum: 1 }
      responses:
        "200":
          description: A page of templates, with the standard paginator envelope.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TaskTemplate" }
                  current_page: { type: integer }
                  last_page: { type: integer }
                  per_page: { type: integer, example: 30 }
                  total: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Task templates]
      summary: Create a task template
      description: |
        Saves an existing task as a reusable blueprint.

        **A template is always a snapshot of a task — there is no create-from-scratch.** Every
        create names a `task_id`, and what gets captured is frozen at that moment; later edits to
        the source task do not flow through.

        **Every include flag defaults to `true`.** Saving the whole shape is the absence of
        choices, so send only the flags you want `false`.

        **Not idempotent.** Two identical requests create two templates — names are not unique and
        there is no duplicate detection or idempotency key. If a request times out, list by
        `search` and check before retrying.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [name, task_id]
                  properties:
                    name: { type: string, maxLength: 255 }
                    task_id:
                      type: string
                      format: uuid
                      description: "The task to snapshot. Must be in this workspace and visible to you."
                    visibility:
                      type: string
                      enum: [shared, private]
                      default: shared
                      description: "`private` templates are visible only to their creator — the workspace owner included."
                - $ref: "#/components/schemas/TemplateIncludeFlags"
            example:
              name: "Bug triage"
              task_id: "5d6e7f8a-9b0c-4d1e-2f3a-4b5c6d7e8f9a"
              visibility: "shared"
              include_comments: false
      responses:
        "201":
          description: |
            The created template. `subtasks_truncated` is `true` when the source subtree was deeper
            than the supported nesting limit and levels had to be dropped — check it rather than
            assuming the whole tree was captured.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TaskTemplate" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/task-templates/import:
    post:
      tags: [Task templates]
      summary: Bulk-import templates from ClickUp
      description: |
        Migration endpoint. Upserts up to 500 templates keyed on `clickup_template_id`, so
        re-running the same batch updates rather than duplicates.

        **Workspace owner only** — this is not governed by the usual permission flags.

        All-or-nothing: if any record fails validation the whole batch is rejected with a per-record
        error report. Note the response reports **counts only** — no ids and no objects come back,
        so you cannot chain straight into `apply` without listing afterwards.

        Two sharp edges: only `snapshot.version` is validated, so a structurally meaningless
        snapshot imports happily and produces an empty task when applied; and the upsert
        **restores soft-deleted rows**, so a template deleted after import comes back if the batch
        is replayed.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                templates:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: object
                    required: [clickup_template_id, name, snapshot]
                    properties:
                      clickup_template_id: { type: string, description: "Upsert key. Must be unique within the batch." }
                      name: { type: string, maxLength: 255 }
                      snapshot:
                        type: object
                        description: "Only `version` is validated, and it must be 1 or 2. Nothing else about the structure is checked, so any other keys pass straight through."
                        additionalProperties: true
                        properties:
                          version: { type: integer, enum: [1, 2] }
                rows:
                  type: array
                  description: "Alias for `templates`. Provide one or the other."
                  items: { type: object }
            example:
              templates:
                - clickup_template_id: "tpl_8891"
                  name: "Bug triage"
                  snapshot: { version: 2, title: "Bug triage" }
      responses:
        "200":
          description: "Import summary. Note this is a `200`, not a `201`, and carries no ids."
          content:
            application/json:
              example:
                created: 1
                updated: 0
                total: 1
                message: "Templates imported."
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/task-templates/recent:
    get:
      tags: [Task templates]
      summary: List recently used templates
      description: |
        The five templates *you* most recently applied, newest first. Per-user, de-duplicated, and
        written only by `apply` — creating or editing a template does not put it here.

        A bare array with no pagination envelope, unlike the index.
      parameters:
        - $ref: "#/components/parameters/workspace"
      responses:
        "200":
          description: Up to five templates, most recently applied first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    maxItems: 5
                    items: { $ref: "#/components/schemas/TaskTemplate" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /workspaces/{workspace}/task-templates/{taskTemplate}:
    get:
      tags: [Task templates]
      summary: Get a task template
      description: |
        Returns the template and its full snapshot.

        A template you cannot see answers `404` rather than `403`, so a not-found result does not
        prove the template doesn't exist. This applies to the workspace owner too — private means
        private.
      parameters:
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/taskTemplate"
      responses:
        "200":
          description: The template, including its snapshot.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TaskTemplate" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Task templates]
      summary: Update or re-snapshot a template
      description: |
        Does one of two very different things depending on whether you send `task_id`.

        **Without `task_id`** it is a metadata-only edit: rename, change visibility, and the stored
        snapshot is left byte-identical.

        **With `task_id` it replaces the snapshot wholesale** from the named task. This is
        destructive — last write wins, with no history, no undo, and no precondition header to
        guard against a concurrent edit. Anything the new snapshot omits is gone from the template
        rather than preserved from before.

        **The trap:** include flags are *not stored on the template*. They apply to the snapshot
        being written, so a re-snapshot that omits them silently re-applies every default of
        `true`. A template originally saved with flags turned off will quietly come back with
        everything included unless you send the same flags again.

        `task_id` must be omitted entirely for a metadata-only update — sending it as `null` or
        `""` is a `422`.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/taskTemplate"
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  properties:
                    name: { type: string, maxLength: 255 }
                    visibility: { type: string, enum: [shared, private] }
                    task_id:
                      type: string
                      format: uuid
                      description: "Present ⇒ destructive re-snapshot. Omit the key entirely for a metadata-only edit."
                - $ref: "#/components/schemas/TemplateIncludeFlags"
            examples:
              rename:
                summary: Metadata only — snapshot untouched
                value: { name: "Bug triage v2" }
              resnapshot:
                summary: Destructive re-snapshot from a task
                value: { task_id: "5d6e7f8a-9b0c-4d1e-2f3a-4b5c6d7e8f9a", include_comments: false }
      responses:
        "200":
          description: The updated template.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TaskTemplate" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
    delete:
      tags: [Task templates]
      summary: Delete a task template
      description: |
        Deletes the template. Applied tasks are unaffected.

        Note this returns **`200` with a body**, not the `204` used elsewhere in this API. A second
        delete of the same id is a `404`.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/taskTemplate"
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              example: { message: "Template deleted." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

  /workspaces/{workspace}/task-templates/{taskTemplate}/apply:
    post:
      tags: [Task templates]
      summary: Apply a template
      description: |
        Materialises the template into a real task tree — subtasks, comments, watchers, tags,
        dependencies and relations, as captured.

        **Destination is `list_id` or `sprint_id`, never both** — sending both is a `422`, and
        there is no folder or space destination. `parent_task_id` makes the applied root a subtask;
        it must live in the same destination container.

        **The response is the root task only.** Despite creating the whole tree, no `subtasks` key
        is returned — list the children separately if you need to verify the hierarchy.

        **Not idempotent.** Applying twice creates two complete, independent task trees. There is
        no idempotency key, so after a timeout you must search the destination before retrying.

        Applying is what populates `recent`.
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
        - $ref: "#/components/parameters/workspace"
        - $ref: "#/components/parameters/taskTemplate"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                list_id: { type: [string, "null"], format: uuid, description: "Required unless `sprint_id` is given." }
                sprint_id: { type: [string, "null"], format: uuid, description: "Required unless `list_id` is given." }
                name:
                  type: [string, "null"]
                  maxLength: 255
                  description: "Overrides the root task's title only. Subtask titles always come from the snapshot."
                parent_task_id:
                  type: [string, "null"]
                  format: uuid
                  description: "Apply beneath an existing task. Must share the destination container."
            example:
              list_id: "9a8b7c6d-5e4f-4a3b-8c9d-0e1f2a3b4c5d"
              name: "Bug triage — August"
      responses:
        "201":
          description: The created root task. Subtasks are created but not returned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Personal access token, prefixed `qrd_`."

  parameters:
    idempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Makes this write safe to retry. Any unique string, one per logical operation.

        The first request runs; later requests with the same key return that first response
        unchanged and carry `Idempotent-Replay: true`. Reusing a key with a different body is a
        `422`; retrying while the first is still in flight is a `409`. Keys expire after 24 hours
        and are scoped to your token.
      schema: { type: string, maxLength: 255 }
      example: "1b9f4c7e-order-4821-line-3"
    workspace:
      name: workspace
      in: path
      required: true
      description: Workspace UUID.
      schema: { type: string, format: uuid }
    task:
      name: task
      in: path
      required: true
      description: Task UUID.
      schema: { type: string, format: uuid }
    view:
      name: view
      in: path
      required: true
      description: View UUID.
      schema: { type: string, format: uuid }
    list:
      name: list
      in: path
      required: true
      description: List UUID.
      schema: { type: string, format: uuid }
    space:
      name: space
      in: path
      required: true
      description: Space UUID.
      schema: { type: string, format: uuid }
    tag:
      name: tag
      in: path
      required: true
      description: Tag UUID.
      schema: { type: string, format: uuid }
    user:
      name: user
      in: path
      required: true
      description: User UUID.
      schema: { type: string, format: uuid }
    team:
      name: team
      in: path
      required: true
      description: Team UUID.
      schema: { type: string, format: uuid }
    taskTemplate:
      name: taskTemplate
      in: path
      required: true
      description: Task template UUID.
      schema: { type: string, format: uuid }
    comment:
      name: comment
      in: path
      required: true
      description: Comment UUID.
      schema: { type: string, format: uuid }
    attachment:
      name: attachment
      in: path
      required: true
      description: Attachment UUID.
      schema: { type: string, format: uuid }

  responses:
    Unauthorized:
      description: Missing or invalid token.
      content:
        application/json:
          example: { message: "Unauthenticated." }
    Forbidden:
      description: |
        Authenticated, but not allowed to do this. Note that a resource you can't reach answers
        `403` rather than `404`, which confirms it exists.
      content:
        application/json:
          example: { message: "This action is unauthorized." }
    IdempotencyConflict:
      description: |
        A request with this `Idempotency-Key` is still being processed. Wait and retry — the
        original is in flight, and running it twice is exactly what the key prevents.
      content:
        application/json:
          example: { message: "A request with this Idempotency-Key is still being processed. Retry shortly." }
    NotFound:
      description: Resource not found (or not visible to your token).
      content:
        application/json:
          example: { message: "Not Found." }
    ValidationError:
      description: The request failed validation.
      content:
        application/json:
          example:
            message: "The title field is required."
            errors:
              title: ["The title field is required."]

  schemas:
    Status:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: "In Progress" }
        type: { type: string, example: "active" }
        color: { type: string, example: "#4b7bec" }
    User:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: "Ada Lovelace" }
        email: { type: string, example: "ada@acme.com" }
    Task:
      type: object
      properties:
        id: { type: string, format: uuid }
        workspace_id: { type: string, format: uuid }
        list_id: { type: string, format: uuid }
        parent_task_id: { type: [string, "null"], format: uuid }
        title: { type: string }
        description: { type: [string, "null"] }
        priority: { type: string, enum: [low, medium, high, critical] }
        status_id: { type: string, format: uuid }
        assignee_id: { type: [string, "null"], format: uuid }
        due_date: { type: [string, "null"], format: date-time }
        start_date: { type: [string, "null"], format: date-time }
        due_time:
          type: [string, "null"]
          pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$'
          description: >-
            Optional time of day on `due_date`, `HH:MM`, **in UTC**. Null means the due date is a
            plain calendar day with no time. This is not the caller's local time and not the
            workspace's — convert to UTC before writing, and out of UTC before displaying.
            `due_date` is the calendar day that same UTC instant falls on, so a time near midnight
            can sit on a different day from the one the user picked locally.
          example: "09:00"
        start_time:
          type: [string, "null"]
          pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$'
          description: Optional time of day on `start_date`, `HH:MM`, in UTC. Same rules as `due_time`.
          example: "01:30"
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        status: { $ref: "#/components/schemas/Status" }
        assignee:
          oneOf:
            - $ref: "#/components/schemas/User"
            - type: "null"
        creator: { $ref: "#/components/schemas/User" }
        custom_field_values:
          type: array
          items: { $ref: "#/components/schemas/CustomFieldValue" }
    CustomFieldValue:
      type: object
      description: >-
        One custom field's value on a task. Which `value_*` key is populated depends on the field's
        type; the rest are null.
      properties:
        id: { type: string, format: uuid }
        custom_field_definition_id: { type: string, format: uuid }
        value_text: { type: [string, "null"] }
        value_number: { type: [number, "null"] }
        value_boolean: { type: [boolean, "null"] }
        value_json: { type: [array, object, "null"] }
        value_date:
          type: [string, "null"]
          format: date
          description: A date field's calendar day, `YYYY-MM-DD`.
          example: "2026-09-09"
        value_time:
          type: [string, "null"]
          pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$'
          description: >-
            Optional time of day on `value_date`, `HH:MM`, **in UTC** — the same contract as
            `due_time` on a task. Null means the value is a plain calendar day. Only meaningful
            when the field definition has `date_include_time` set; a value written while that is
            off is stored but not shown.
          example: "11:00"
    InboundAddress:
      type: object
      description: The email address that turns inbound mail into tasks on a list. One per list, shared by everyone with access to it.
      properties:
        id: { type: string, format: uuid }
        list_id: { type: string, format: uuid }
        email_address:
          type: string
          format: email
          description: "Shaped `list-<40 hex characters>@<your inbound domain>`."
          example: "list-5dd884d623ebf14d90aecdcd15887a1343fce5f6@tasks.qordo.ai"
        can_reset:
          type: boolean
          description: Whether the *calling* user may rotate this address. Caller-relative — do not cache across users.
        created_at: { type: string, format: date-time }
        updated_at:
          type: string
          format: date-time
          description: Only a rotation moves this; reads and repeat generates leave it alone.
    Tag:
      type: object
      description: A label in a space's tag library. Tags are scoped to a space — same name in two spaces means two different tags.
      properties:
        id: { type: string, format: uuid }
        workspace_id: { type: string, format: uuid }
        space_id: { type: string, format: uuid }
        name: { type: string, maxLength: 50, example: "urgent" }
        color: { type: string, pattern: "^#[0-9a-fA-F]{6}$", example: "#ef4444" }
        text_color: { type: [string, "null"], pattern: "^#[0-9a-fA-F]{6}$", example: "#ffffff" }
        created_by: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        tasks_count:
          type: integer
          description: Present only on `GET .../tags?with_counts=1`. Not filtered by the caller's visibility.
    BulkTaskSelection:
      type: object
      description: |
        The task-selection block shared by every bulk endpoint. Provide `task_ids`, or set
        `select_all` together with a scope to act on everything matching it.
      properties:
        task_ids:
          type: array
          minItems: 1
          maxItems: 200
          items: { type: string, format: uuid }
          description: Required unless `select_all` is set.
        select_all:
          type: boolean
          description: Act on every task matching the scope and filter, up to a ceiling of 5000.
        scope: { type: string, enum: [workspace] }
        list_id: { type: string, format: uuid }
        sprint_id: { type: string, format: uuid }
        folder_id: { type: string, format: uuid }
        space_id: { type: string, format: uuid }
        exclude_task_ids:
          type: array
          maxItems: 1000
          items: { type: string, format: uuid }
        exclude_status_types:
          type: array
          maxItems: 10
          items: { type: string, maxLength: 20 }
        subtasks: { type: string, enum: [collapsed, expanded, separate] }
        filter: { description: "Same filter object as `GET /workspaces/{workspace}/tasks`." }
        search: { type: [string, "null"], maxLength: 255 }
    BulkTaskResult:
      type: object
      description: |
        The outcome of a bulk operation. `updated` counts tasks *processed*, not tasks changed — a
        repeat of the same request reports the same number while changing nothing.
      properties:
        updated: { type: integer }
        skipped: { type: integer }
        skipped_reasons:
          type: object
          description: |
            Keyed by task id. Always an object — `{}` when nothing was skipped, never `[]`.
            `not_found` deliberately covers "you can't see it" as well as "it doesn't exist".
          additionalProperties:
            type: string
            enum: [not_found, no_permission, wrong_space, sprint_archived, error]
    TeamWatcher:
      type: object
      description: A team following a task. Members are resolved live at notification time, never expanded into individual watchers.
      properties:
        id: { type: string, format: uuid }
        workspace_id: { type: string, format: uuid }
        name: { type: string, example: "Docs QA" }
        alias: { type: string, example: "docs-qa" }
        color: { type: string, example: "#7c4dff" }
        source: { type: string, example: "manual" }
        users_count:
          type: integer
          description: Current membership size. The accompanying `users` array is always empty — this count is the only membership signal here.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    WatcherList:
      type: object
      description: |
        A task's followers. Bare object — no `data` wrapper, no pagination, no defined ordering.
        Everyone notified is the union of `users` and the live membership of each entry in `teams`.
      properties:
        users:
          type: array
          description: Individually added followers. Carries the full user profile, not just the summary fields shown here.
          items: { $ref: "#/components/schemas/User" }
        teams:
          type: array
          items: { $ref: "#/components/schemas/TeamWatcher" }
    TemplateIncludeFlags:
      type: object
      description: |
        What the snapshot captures. **Every flag defaults to `true`** — send only the ones you want
        off. They are not stored on the template: they describe the snapshot being written, so a
        later re-snapshot that omits them re-applies all the defaults.

        There is no `include_title` — a template always carries the task's title.
      properties:
        include_description: { type: boolean, default: true }
        include_assignee: { type: boolean, default: true }
        include_priority: { type: boolean, default: true }
        include_due_date: { type: boolean, default: true }
        include_status: { type: boolean, default: true }
        include_tags: { type: boolean, default: true }
        include_custom_fields: { type: boolean, default: true }
        include_subtasks: { type: boolean, default: true }
        include_followers: { type: boolean, default: true }
        include_sops: { type: boolean, default: true }
        include_docs: { type: boolean, default: true }
        include_dependencies: { type: boolean, default: true }
        include_relations: { type: boolean, default: true }
        include_comments: { type: boolean, default: true }
        include_attachments: { type: boolean, default: true }
    TaskTemplate:
      type: object
      description: A saved blueprint of a task. The snapshot is frozen at save time — later edits to the source task do not flow through.
      properties:
        id: { type: string, format: uuid }
        workspace_id: { type: string, format: uuid }
        name: { type: string, maxLength: 255, example: "Bug triage" }
        visibility:
          type: string
          enum: [shared, private]
          description: "`private` is visible only to its creator — the workspace owner does not bypass this."
        snapshot:
          type: object
          description: "The captured task. `version` is 2 for anything written today; 1 is still accepted on apply and import."
          additionalProperties: true
          properties:
            version: { type: integer, enum: [1, 2] }
            title: { type: string }
        clickup_template_id:
          type: [string, "null"]
          description: Set only by the bulk import endpoint, which upserts on it.
        created_by: { type: string, format: uuid }
        updated_by: { type: [string, "null"], format: uuid }
        creator: { $ref: "#/components/schemas/User" }
        subtasks_truncated:
          type: boolean
          description: Returned on write. `true` means the source subtree was deeper than the nesting limit and levels were dropped.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
