openapi: 3.1.0
info:
  title: ProjectLayer API
  version: "1"
  description: |
    The ProjectLayer REST API. All requests are authenticated with a
    Bearer API key (see **Settings → API Keys**) and are scoped to the
    organization that owns the key — a key never sees or modifies data
    belonging to another organization.

    Each key also carries a coarse **scope**: `read` or `write` (or both).
    A `read`-only key can call `GET`/`HEAD`/`OPTIONS` endpoints but gets a
    `403 insufficient_scope` on any `POST`/`PATCH`/`PUT`/`DELETE` endpoint.
    A key created before scopes existed has no scopes recorded, which is
    treated as full (read + write) access.
servers:
  - url: /api/v1
security:
  - bearerApiKey: []
tags:
  - name: Projects
  - name: Tasks
  - name: Comments
  - name: Milestones
  - name: Backlog

paths:
  /projects:
    get:
      tags: [Projects]
      summary: List active projects
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: A page of projects
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Project' }

  /tasks:
    get:
      tags: [Tasks]
      summary: List tasks
      description: >-
        Without any filters, returns tasks across the organization, most
        recently updated first.
      parameters:
        - name: project_id
          in: query
          schema: { type: integer }
          description: Restrict to a single project. An unknown ID returns 404.
        - name: has_plan
          in: query
          schema: { type: boolean }
          description: When true, return only tasks with a Claude build plan.
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: A page of tasks
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/TaskSummary' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Tasks]
      summary: Create a task
      description: Requires the `write` scope.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TaskCreate' }
      responses:
        '201':
          description: The created task
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskDetail' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }

  /tasks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Tasks]
      summary: Get a task
      description: Includes description and full Claude build plan. 404 if not found in your organization.
      responses:
        '200':
          description: The task
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskDetail' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Tasks]
      summary: Update a task (partial)
      description: >-
        Only fields present in the body are applied. Requires the `write`
        scope. Writing `claude_plan` sets the task's build plan (Markdown,
        rendered as sanitized Markdown in the UI), stamps `plan_updated_at`,
        and clears any in-progress or errored planning state; sending `null`
        or an empty string clears the plan.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TaskUpdate' }
      responses:
        '200':
          description: The updated task
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskDetail' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Tasks]
      summary: Delete a task
      description: Requires the `write` scope.
      responses:
        '204': { description: Deleted }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }

  /tasks/{id}/status:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    patch:
      tags: [Tasks]
      summary: Update a task's status
      description: >-
        Intended for automation. `POST` is also accepted for clients that
        cannot send `PATCH`. Requires the `write` scope.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { $ref: '#/components/schemas/TaskStatus' }
      responses:
        '200':
          description: The updated task
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskDetail' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Tasks]
      summary: Update a task's status (POST alias)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { $ref: '#/components/schemas/TaskStatus' }
      responses:
        '200':
          description: The updated task
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskDetail' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }

  /tasks/{taskId}/comments:
    parameters:
      - name: taskId
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Comments]
      summary: List a task's comments
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: A page of comments
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Comment' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Comments]
      summary: Create a comment on a task
      description: >-
        The body is HTML sanitized the same way the web comment form is
        (script/on* attributes etc. are stripped). Requires the `write`
        scope.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string }
      responses:
        '201':
          description: The created comment
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Comment' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }

  /milestones:
    get:
      tags: [Milestones]
      summary: List milestones
      parameters:
        - name: project_id
          in: query
          schema: { type: integer }
          description: Restrict to a single project.
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: A page of milestones
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Milestone' }
    post:
      tags: [Milestones]
      summary: Create a milestone
      description: Requires the `write` scope.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MilestoneCreate' }
      responses:
        '201':
          description: The created milestone
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Milestone' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }

  /projects/{projectId}/milestones:
    parameters:
      - name: projectId
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Milestones]
      summary: List a project's milestones
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: A page of milestones
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Milestone' }
        '404': { $ref: '#/components/responses/NotFound' }

  /milestones/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Milestones]
      summary: Get a milestone
      responses:
        '200':
          description: The milestone
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Milestone' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Milestones]
      summary: Update a milestone (partial)
      description: Requires the `write` scope.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MilestoneUpdate' }
      responses:
        '200':
          description: The updated milestone
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Milestone' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Milestones]
      summary: Delete a milestone
      description: Requires the `write` scope.
      responses:
        '204': { description: Deleted }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }

  /backlog:
    get:
      tags: [Backlog]
      summary: List backlog items
      parameters:
        - name: project_id
          in: query
          schema: { type: integer }
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/BacklogStatus' }
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PerPage'
      responses:
        '200':
          description: A page of backlog items
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Paginated'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/BacklogItemSummary' }
    post:
      tags: [Backlog]
      summary: Create a backlog item
      description: >-
        If an item already exists for the same `source_service` +
        `source_ticket_id`, returns `409 Conflict` with the existing item's
        ID and status instead of creating a duplicate. Requires the `write`
        scope.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BacklogItemCreate' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
                  status: { $ref: '#/components/schemas/BacklogStatus' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: Duplicate ticket — an item already exists }

  /backlog/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [Backlog]
      summary: Get a backlog item
      responses:
        '200':
          description: The backlog item
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BacklogItemDetail' }
        '404': { $ref: '#/components/responses/NotFound' }

components:
  securitySchemes:
    bearerApiKey:
      type: http
      scheme: bearer
      bearerFormat: pl_live_<random>
      description: >-
        Generate a key from Settings → API Keys. Send it as
        `Authorization: Bearer <key>`.

  parameters:
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
      description: 1-indexed page number.
    PerPage:
      name: per_page
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      description: Items per page, clamped to 1..100.

  responses:
    BadRequest:
      description: Invalid input
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Not found in your organization
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    InsufficientScope:
      description: The API key lacks the scope required for this request
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      properties:
        error:
          oneOf:
            - type: string
            - type: object
              properties:
                code: { type: string, example: insufficient_scope }
                message: { type: string }

    PaginationMeta:
      type: object
      properties:
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer }
        total_pages: { type: integer }

    Paginated:
      type: object
      properties:
        meta: { $ref: '#/components/schemas/PaginationMeta' }

    TaskStatus:
      type: string
      enum: [open, in_progress, done, closed]

    TaskPriority:
      type: string
      enum: [low, medium, high, critical]

    MilestoneStatus:
      type: string
      enum: [open, closed]

    BacklogStatus:
      type: string
      enum: [pending, accepted, rejected, converted]

    Project:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        slug: { type: string }
        prefix: { type: string }
        status: { type: string, example: active }

    TaskSummary:
      type: object
      properties:
        id: { type: integer }
        task_key: { type: string, example: API-42 }
        title: { type: string }
        project_id: { type: integer }
        status: { $ref: '#/components/schemas/TaskStatus' }
        priority: { $ref: '#/components/schemas/TaskPriority' }
        has_plan: { type: boolean }
        plan_updated_at: { type: string, format: date-time, nullable: true }

    TaskDetail:
      allOf:
        - $ref: '#/components/schemas/TaskSummary'
        - type: object
          properties:
            description: { type: string, nullable: true }
            claude_plan: { type: string, nullable: true }

    TaskCreate:
      type: object
      required: [project_id, title]
      properties:
        project_id: { type: integer }
        title: { type: string }
        description: { type: string, nullable: true }
        priority: { $ref: '#/components/schemas/TaskPriority' }
        assignee_id: { type: integer, nullable: true }
        milestone_id: { type: integer, nullable: true }
        claude_plan: { type: string, nullable: true }

    TaskUpdate:
      type: object
      description: All fields optional; only fields present are applied.
      properties:
        title: { type: string }
        description: { type: string, nullable: true }
        priority: { $ref: '#/components/schemas/TaskPriority' }
        status: { $ref: '#/components/schemas/TaskStatus' }
        assignee_id: { type: integer, nullable: true }
        milestone_id: { type: integer, nullable: true }
        claude_plan: { type: string, nullable: true }

    Comment:
      type: object
      properties:
        id: { type: integer }
        task_id: { type: integer }
        author: { type: string }
        author_type: { type: string, enum: [user, claude] }
        body: { type: string }
        created_at: { type: string, format: date-time }

    Milestone:
      type: object
      properties:
        id: { type: integer }
        project_id: { type: integer }
        name: { type: string }
        description: { type: string, nullable: true }
        due_date: { type: string, format: date, nullable: true }
        status: { $ref: '#/components/schemas/MilestoneStatus' }

    MilestoneCreate:
      type: object
      required: [name, project_id]
      properties:
        project_id: { type: integer }
        name: { type: string }
        description: { type: string, nullable: true }
        due_date: { type: string, format: date, nullable: true }
        status: { $ref: '#/components/schemas/MilestoneStatus' }

    MilestoneUpdate:
      type: object
      description: All fields optional; only fields present are applied.
      properties:
        name: { type: string }
        description: { type: string, nullable: true }
        due_date: { type: string, format: date, nullable: true }
        status: { $ref: '#/components/schemas/MilestoneStatus' }

    BacklogItemSummary:
      type: object
      properties:
        id: { type: integer }
        title: { type: string }
        status: { $ref: '#/components/schemas/BacklogStatus' }
        priority: { $ref: '#/components/schemas/TaskPriority' }
        project_id: { type: integer }
        source_service: { type: string }
        source_ticket_id: { type: integer, nullable: true }
        created_at: { type: string, format: date-time }

    BacklogItemDetail:
      allOf:
        - $ref: '#/components/schemas/BacklogItemSummary'
        - type: object
          properties:
            converted_task_id: { type: integer, nullable: true }

    BacklogItemCreate:
      type: object
      required: [project_id, title]
      properties:
        project_id: { type: integer }
        title: { type: string }
        description: { type: string, nullable: true }
        priority: { $ref: '#/components/schemas/TaskPriority' }
        source_service: { type: string, default: supportlayer }
        source_ticket_id: { type: integer, nullable: true }
        source_ticket_url: { type: string, nullable: true }
