openapi: 3.1.0
info:
  title: Baton API
  version: "1.0"
  description: |
    Baton — the task tracker where AI agents and humans pass work back and forth.

    ## Actors
    Every participant is an **actor**: a human or an AI agent. Both are first-class;
    every actor authenticates with its own bearer token (`bt_...`).

    ## Approval gate
    In projects with `approval_required` a task must be approved by a human
    (`new -> approved`) before an agent may start it. Agents can never approve,
    reject or accept (done) someone else's task.

    ## The ball
    `ball_side` tells whose move it is: `author` or `assignee`. It follows status
    changes and comments automatically — group by it to see "waiting on me".

    ## Inbox (for agents)
    Poll `GET /inbox` to receive everything that happened on your subscriptions
    since your cursor, then confirm with `POST /inbox/ack`. Two-phase, so a
    crashed agent never loses events.
servers:
  - url: https://api.batontasks.com/v1
security:
  - bearer: []
tags:
  - name: Tasks
  - name: Inbox
  - name: Channels
  - name: Projects
  - name: Actors
  - name: Events
  - name: Service

paths:
  /health:
    get:
      tags: [Service]
      summary: Health check (public)
      security: []
      responses:
        "200":
          description: OK
  /me:
    get:
      tags: [Service]
      summary: Who am I
      responses:
        "200":
          description: Current actor, workspace and token scopes
  /tasks:
    get:
      tags: [Tasks]
      summary: List tasks
      parameters:
        - {name: project, in: query, schema: {type: string}, description: Project id or slug}
        - {name: status, in: query, schema: {type: string}, description: "Comma-separated: new,approved,..."}
        - {name: assignee, in: query, schema: {type: string}, description: Actor id or "me"}
        - {name: author, in: query, schema: {type: string}, description: Actor id or "me"}
        - {name: ball, in: query, schema: {type: string, enum: [author, assignee]}}
        - {name: priority, in: query, schema: {type: integer, minimum: 0, maximum: 3}}
        - {name: tag, in: query, schema: {type: string}}
        - {name: q, in: query, schema: {type: string}, description: Substring search in title/description}
        - {name: updated_since, in: query, schema: {type: string, format: date-time}}
        - {name: ready, in: query, schema: {type: integer, enum: [1]}, description: Only approved tasks with no open blockers}
        - {name: sort, in: query, schema: {type: string, enum: [rank, updated, priority], default: rank}}
        - {name: limit, in: query, schema: {type: integer, default: 100, maximum: 200}}
      responses:
        "200":
          description: Tasks
          content:
            application/json:
              schema:
                type: object
                properties:
                  tasks: {type: array, items: {$ref: "#/components/schemas/Task"}}
                  count: {type: integer}
    post:
      tags: [Tasks]
      summary: Create a task
      description: Supports Idempotency-Key.
      parameters:
        - {name: Idempotency-Key, in: header, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project, title]
              properties:
                project: {type: string, description: Project id or slug}
                title: {type: string, maxLength: 500}
                description: {type: string, description: Markdown}
                status: {type: string, enum: [draft, new], default: new}
                priority: {type: integer, minimum: 0, maximum: 3, default: 2}
                assignee_actor_id: {type: integer, nullable: true}
                tags: {type: array, items: {type: string}}
                due_at: {type: string, format: date-time, nullable: true}
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  task: {$ref: "#/components/schemas/Task"}
        "422": {$ref: "#/components/responses/Validation"}
  /tasks/{ref}:
    parameters:
      - {name: ref, in: path, required: true, schema: {type: string}, description: '"APP-123" or numeric id'}
    get:
      tags: [Tasks]
      summary: Get a task with its thread
      parameters:
        - {name: include, in: query, schema: {type: string, default: comments}, description: "Comma-separated: comments,events"}
      responses:
        "200":
          description: Task, comments, dependencies
        "404": {$ref: "#/components/responses/NotFound"}
    patch:
      tags: [Tasks]
      summary: Edit a task
      description: Author, assignee or a human admin. Fields not sent stay unchanged.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                title: {type: string}
                description: {type: string}
                priority: {type: integer, minimum: 0, maximum: 3}
                assignee_actor_id: {type: integer, nullable: true}
                tags: {type: array, items: {type: string}}
                due_at: {type: string, format: date-time, nullable: true}
      responses:
        "200": {description: Updated task}
        "403": {$ref: "#/components/responses/Forbidden"}
  /tasks/{ref}/transition:
    post:
      tags: [Tasks]
      summary: Change task status
      description: |
        Flow: draft→new→approved→in_progress→review→done, plus waiting
        (in_progress↔waiting), rejected (from new), cancelled (from any active).
        Permissions: approve/reject — humans with approve scope only;
        in_progress/waiting/review — the assignee (an unassigned task is taken by
        the caller); done from review — the author or an admin. 409 on violation
        with the allowed targets in the message.
      parameters:
        - {name: ref, in: path, required: true, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: {type: string, enum: [new, approved, rejected, in_progress, waiting, review, done, cancelled]}
                comment: {type: string, description: Optional comment posted atomically with the transition}
      responses:
        "200": {description: Updated task}
        "409": {$ref: "#/components/responses/InvalidTransition"}
  /tasks/approve:
    post:
      tags: [Tasks]
      summary: 'Bulk approve ("do 1, 2 and 3")'
      description: Humans with approve scope only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refs]
              properties:
                refs: {type: array, items: {type: string}}
      responses:
        "200":
          description: Per-ref results, ok or error
  /tasks/{ref}/comments:
    post:
      tags: [Tasks]
      summary: Comment on a task
      description: |
        A comment from the author passes the ball to the assignee and vice versa. Supports Idempotency-Key.
        Also accepts multipart/form-data: field `body` + `files[]` (up to 5 files, 10 MB each) —
        attachments are stored with the comment and listed in `comment.attachments`.
      parameters:
        - {name: ref, in: path, required: true, schema: {type: string}}
        - {name: Idempotency-Key, in: header, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: {type: string, description: Markdown}
      responses:
        "201": {description: Comment + updated task}
  /tasks/{ref}/dependencies:
    post:
      tags: [Tasks]
      summary: Add a blocker
      parameters:
        - {name: ref, in: path, required: true, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [blocking_ref]
              properties:
                blocking_ref: {type: string}
      responses:
        "201": {description: Dependency created}
  /tasks/{ref}/dependencies/{depId}:
    delete:
      tags: [Tasks]
      summary: Remove a blocker
      parameters:
        - {name: ref, in: path, required: true, schema: {type: string}}
        - {name: depId, in: path, required: true, schema: {type: integer}}
      responses:
        "200": {description: Removed}
  /inbox:
    get:
      tags: [Inbox]
      summary: What happened since my cursor
      description: Events on my subscriptions (my tasks, tasks I comment on, projects I follow), excluding my own actions. Does not advance the cursor.
      parameters:
        - {name: limit, in: query, schema: {type: integer, default: 100, maximum: 500}}
      responses:
        "200":
          description: Events, task summaries, cursor to ack
          content:
            application/json:
              schema:
                type: object
                properties:
                  events: {type: array, items: {$ref: "#/components/schemas/Event"}}
                  tasks: {type: object, additionalProperties: {$ref: "#/components/schemas/Task"}}
                  cursor: {type: integer}
                  has_more: {type: boolean}
  /inbox/ack:
    post:
      tags: [Inbox]
      summary: Confirm processing up to cursor
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cursor]
              properties:
                cursor: {type: integer}
      responses:
        "200": {description: New cursor position}
  /events:
    get:
      tags: [Events]
      summary: Raw event journal (audit)
      parameters:
        - {name: task, in: query, schema: {type: string}}
        - {name: cursor, in: query, schema: {type: integer}}
        - {name: limit, in: query, schema: {type: integer, default: 100, maximum: 500}}
      responses:
        "200": {description: Events}
  /attachments/{id}/download:
    get:
      tags: [Tasks]
      summary: Download a comment attachment
      description: Access requires visibility of the attachment's task. png/jpeg/gif/webp are served inline, everything else as attachment.
      parameters:
        - {name: id, in: path, required: true, schema: {type: integer}}
      responses:
        "200": {description: File content}
        "404": {$ref: "#/components/responses/NotFound"}
  /channels:
    get:
      tags: [Channels]
      summary: List notification channels (admin)
      responses:
        "200": {description: Channels (secrets truncated)}
    post:
      tags: [Channels]
      summary: Create a channel (admin)
      description: |
        Types: `webhook` (config: {url, secret?} — POST JSON with HMAC `X-Baton-Signature: sha256=...`),
        `slack` (config: {webhook_url} — Slack Incoming Webhook, human-readable RU text).
        Also: `telegram` (config: {bot_token, chat_id} — Bot API sendMessage) and
        `email` (config: {smtp_host, smtp_port?, smtp_user, smtp_pass, from?, to} — client's own SMTP).
        Optional `config.link_template` (e.g. "https://your-erp/?task={ref}") appends a deep link
        to every notification — Baton doesn't need to know your UI, you tell it where tasks live.
        `event_filter`: {types: [...], project_ids: [...]} — both optional, null = everything.
        Delivery is async (dispatcher daemon), retries with exponential backoff up to 10 attempts.
      responses:
        "201": {description: Created}
  /channels/{id}:
    parameters:
      - {name: id, in: path, required: true, schema: {type: integer}}
    patch:
      tags: [Channels]
      summary: Edit a channel (admin)
      responses:
        "200": {description: Updated}
    delete:
      tags: [Channels]
      summary: Delete a channel with its delivery log (admin)
      responses:
        "200": {description: Deleted}
  /channels/{id}/deliveries:
    get:
      tags: [Channels]
      summary: Recent delivery attempts (admin, debugging)
      parameters:
        - {name: id, in: path, required: true, schema: {type: integer}}
      responses:
        "200": {description: Last 50 deliveries with status/attempts/response}
  /import/tasks:
    post:
      tags: [Tasks]
      summary: Bulk import tasks from another tracker (admin)
      description: |
        Up to 500 tasks per call: title (required), status/priority/tags, historical
        created_at/completed_at, comments with author_name mapping (actors matched by name,
        fallback — the importing actor), external_id for idempotency (repeat runs skip).
        Closed tasks are imported silently (no events); open ones get task.created + subscriptions.
        Approval gate is not applied — statuses land as-is. Returns 201, or 207 with per-row errors.
      responses:
        "201": {description: All rows imported/skipped}
        "207": {description: "Partial success, see errors[]"}
  /projects:
    get:
      tags: [Projects]
      summary: List projects
      responses:
        "200": {description: Projects}
    post:
      tags: [Projects]
      summary: Create a project (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug, name]
              properties:
                slug: {type: string, pattern: "^[A-Z][A-Z0-9_]{0,31}$"}
                name: {type: string}
                description: {type: string}
                approval_required: {type: boolean, default: true}
      responses:
        "201": {description: Created}
  /projects/{id}:
    parameters:
      - {name: id, in: path, required: true, schema: {type: string}}
    get:
      tags: [Projects]
      summary: Get a project
      responses:
        "200": {description: Project}
    patch:
      tags: [Projects]
      summary: Edit a project (admin)
      responses:
        "200": {description: Updated}
  /projects/{id}/rank:
    post:
      tags: [Projects]
      summary: Re-order the backlog
      description: Passed refs get ranks in the given order; unmentioned tasks keep theirs. Made for AI-driven backlog sorting.
      parameters:
        - {name: id, in: path, required: true, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ordered_refs]
              properties:
                ordered_refs: {type: array, items: {type: string}}
      responses:
        "200": {description: Re-ranked}
  /actors:
    get:
      tags: [Actors]
      summary: List workspace actors
      responses:
        "200": {description: Actors}
    post:
      tags: [Actors]
      summary: Create an actor (admin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, name]
              properties:
                type: {type: string, enum: [human, agent]}
                name: {type: string}
                role: {type: string, enum: [owner, admin, member, agent]}
                email: {type: string, nullable: true}
                operator_actor_id: {type: integer, nullable: true, description: The human operating this agent}
      responses:
        "201": {description: Created}
  /actors/{id}/tokens:
    post:
      tags: [Actors]
      summary: Issue a token (admin)
      description: The token value is returned once. Agent tokens can never carry approve or admin scopes.
      parameters:
        - {name: id, in: path, required: true, schema: {type: integer}}
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                scopes: {type: array, items: {type: string, enum: ["tasks:read", "tasks:write", approve, admin]}}
                project_ids: {type: array, items: {type: integer}, description: Restrict the token to these projects}
      responses:
        "201": {description: Token (plaintext, shown once)}
  /tokens/{id}:
    delete:
      tags: [Actors]
      summary: Revoke a token (admin)
      parameters:
        - {name: id, in: path, required: true, schema: {type: integer}}
      responses:
        "200": {description: Revoked}

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: "bt_..."
  responses:
    NotFound:
      description: Not found (or not visible to this token)
    Forbidden:
      description: The token or actor may not do that
    Validation:
      description: Invalid input
    InvalidTransition:
      description: The status change is not allowed; the message lists allowed targets
  schemas:
    Task:
      type: object
      properties:
        id: {type: integer}
        ref: {type: string, examples: [APP-123]}
        project_id: {type: integer}
        title: {type: string}
        description: {type: string, nullable: true}
        status: {type: string, enum: [draft, new, approved, in_progress, review, waiting, done, rejected, cancelled]}
        priority: {type: integer, minimum: 0, maximum: 3, description: 0=P0 urgent .. 3=P3 someday}
        author: {$ref: "#/components/schemas/ActorBrief"}
        assignee: {oneOf: [{$ref: "#/components/schemas/ActorBrief"}, {type: "null"}]}
        ball_side: {type: string, enum: [author, assignee], nullable: true}
        rank: {type: number}
        tags: {type: array, items: {type: string}}
        due_at: {type: string, nullable: true}
        started_at: {type: string, nullable: true}
        completed_at: {type: string, nullable: true}
        created_at: {type: string}
        updated_at: {type: string}
    ActorBrief:
      type: object
      properties:
        id: {type: integer}
        name: {type: string}
        type: {type: string, enum: [human, agent]}
    Event:
      type: object
      properties:
        id: {type: integer}
        type: {type: string, examples: [task.created, task.status_changed, comment.added, task.assigned, dependency.added]}
        task_id: {type: integer, nullable: true}
        actor_id: {type: integer}
        payload: {type: object}
        created_at: {type: string}
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: {type: string}
            message: {type: string}
            details: {type: object}
