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: Auth
  - name: Tasks
  - name: Inbox
  - name: Channels
  - name: Telegram
  - name: Slack
  - name: Projects
  - name: Actors
  - name: Events
  - name: Service

paths:
  /auth/email/start:
    post:
      tags: [Auth]
      summary: Start email sign-in (link + 6-digit code)
      security: []
      description: |
        Sends an email with a browser link AND a 6-digit code. The code enables
        agent-led onboarding: the user reads the code to their AI agent, the agent
        calls /auth/email/verify with {email, code}. Always returns 200.
      responses:
        "200": {description: Email queued (if the address is valid)}
  /auth/email/verify:
    post:
      tags: [Auth]
      summary: Finish email sign-in
      security: []
      description: Body is either {token} from the emailed link, or {email, code}. Sets the session cookie and returns session_token (bs_...) for API/agent use.
      responses:
        "200": {description: "session_token, user, workspaces"}
        "401": {description: Invalid or expired link/code}
  /auth/google/start:
    get:
      tags: [Auth]
      summary: Google sign-in URL
      security: []
      parameters:
        - {name: redirect_uri, in: query, schema: {type: string}, description: Must be whitelisted}
      responses:
        "200": {description: "{url, state} — redirect the browser to url"}
  /auth/google/verify:
    post:
      tags: [Auth]
      summary: Finish Google sign-in
      security: []
      description: Body {code, state} from the callback page. Same response as email verify.
      responses:
        "200": {description: "session_token, user, workspaces"}
  /auth/apple/start:
    get:
      tags: [Auth]
      summary: Apple sign-in URL
      security: []
      responses:
        "200": {description: "{url, state}; Apple form_posts to the API callback which sets the cookie and 302s to the app"}
  /auth/passkeys/options:
    post:
      tags: [Auth]
      summary: Passkey registration options (session required)
      responses:
        "200": {description: WebAuthn creation options (resident key required)}
  /auth/passkeys:
    post:
      tags: [Auth]
      summary: Register a passkey
      description: Body {credential, name?} where credential is the JSON result of navigator.credentials.create().
      responses:
        "201": {description: Passkey stored}
    get:
      tags: [Auth]
      summary: List my passkeys
      responses:
        "200": {description: Passkeys}
  /auth/passkeys/login/options:
    post:
      tags: [Auth]
      summary: Passkey sign-in options
      security: []
      responses:
        "200": {description: "{options, sid} — call navigator.credentials.get(options)"}
  /auth/passkeys/login:
    post:
      tags: [Auth]
      summary: Sign in with a passkey
      security: []
      description: Body {sid, credential}. Same response as email verify.
      responses:
        "200": {description: "session_token, user, workspaces"}
  /auth/me:
    get:
      tags: [Auth]
      summary: Current user and their workspaces (session)
      responses:
        "200": {description: "user, workspaces"}
  /auth/logout:
    post:
      tags: [Auth]
      summary: Revoke the session
      responses:
        "200": {description: Signed out}
  /workspaces:
    post:
      tags: [Auth]
      summary: Create a workspace (self-serve signup, session required)
      description: |
        Creates the workspace, the owner actor (linked to the user), an "AI Agent"
        actor operated by the owner, a MAIN project with the approval gate on — and
        returns ONCE a pair of bt_ tokens: agent (works tasks) and owner (approves).
        Session auth for workspace APIs afterwards: cookie/bs_ + X-Baton-Workspace header.
      responses:
        "201": {description: "workspace, actors, tokens (shown once)"}
  /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).
        Reopen: done/rejected/cancelled→new (the author or an admin; timestamps reset,
        the approval gate applies again).
        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; add approve
        buttons with POST /channels/{id}/telegram/webhook) 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.

        **Personal channels.** Add `event_filter.actor_id` (optionally `roles: ["author","assignee"]`,
        default both) and the channel only receives events of tasks where that actor is author or
        assignee — and never their own actions, same rule as the inbox. Notifications about a task
        awaiting approval then carry **one-time action links** (`approve`/`cancel`): `actions` in the
        webhook JSON, Block Kit buttons in Slack, links in email, inline buttons in Telegram.
        (Install the Baton Slack app — `GET /slack/install?workspace={id}`, session auth — and its
        buttons become interactive instead: the press goes back to Baton and Slack vouches for who
        pressed it. A freshly installed Slack channel starts filtered to `task.created` +
        `task.status_changed`; widen it with PATCH. Without the app a `slack` channel is a plain
        Incoming Webhook and gets link buttons.) The link opens a
        confirmation page (`GET /a/{token}` never changes anything — mail clients and messengers
        prefetch links); the action runs on POST from that page, once, as the channel's actor and
        under the usual permission checks. Links expire in 7 days.
      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}
  /channels/{id}/telegram/webhook:
    post:
      tags: [Telegram]
      summary: Turn a telegram channel interactive (admin)
      description: |
        Registers the Bot API webhook so button presses reach Baton, stores the
        generated `secret_token` in the channel config and learns the bot's username.
        From then on every notification about a task awaiting approval carries
        **[✅ Одобрить]** (and **[🔗 Открыть]** when `config.link_template` is set) —
        approve from the phone, no app required.
      parameters:
        - {name: id, in: path, required: true, schema: {type: integer}}
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                url: {type: string, description: "Webhook URL to register; default: BATON_API_URL + /v1/telegram/webhook"}
      responses:
        "200": {description: "Registered — {ok, url, bot_username}"}
        "404": {$ref: "#/components/responses/NotFound"}
        "502": {description: Bot API refused the webhook}
  /me/telegram/link:
    post:
      tags: [Telegram]
      summary: One-time code linking your Telegram account to your actor
      description: |
        Returns a code (valid 15 minutes, single use) and a `t.me` deep link. The
        human sends `/start CODE` to the bot; from then on their presses act as
        this actor — with the usual rights (an agent actor can never approve).
      responses:
        "200": {description: "{code, deep_link, bot_username, expires_in}"}
        "422": {description: Only human actors can link Telegram}
  /telegram/webhook:
    post:
      tags: [Telegram]
      summary: Bot API updates land here (called by Telegram)
      security: []
      description: |
        Authenticated by the `X-Telegram-Bot-Api-Secret-Token` header set during
        setup — not by a Baton token. Handles `callback_query` (approve button:
        resolves the presser via actors.telegram_user_id, then goes through the
        normal transition checks) and `/start CODE` messages (account linking).
        Anything else is acknowledged and ignored.
      responses:
        "200": {description: Update processed}
        "403": {description: Unknown webhook secret}
  /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}/telegram:
    parameters:
      - {name: id, in: path, required: true, schema: {type: integer}}
    post:
      tags: [Telegram]
      summary: Link a Telegram account to an actor (admin)
      description: |
        Manual alternative to the self-serve code flow (`POST /me/telegram/link`),
        for when the numeric Telegram user id is already known — in a private chat
        it is the channel's `chat_id`. One Telegram account maps to one actor per
        workspace; linking it elsewhere releases the previous binding.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [telegram_user_id]
              properties:
                telegram_user_id: {type: string, description: Numeric Telegram user id}
      responses:
        "200": {description: Linked}
        "404": {$ref: "#/components/responses/NotFound"}
    delete:
      tags: [Telegram]
      summary: Unlink an actor's Telegram account (admin)
      responses:
        "200": {description: Unlinked}
        "404": {$ref: "#/components/responses/NotFound"}
  /actors/{id}/slack:
    parameters:
      - {name: id, in: path, required: true, schema: {type: integer}}
    post:
      tags: [Slack]
      summary: Link a Slack account to an actor (admin)
      description: |
        Manual linking for when the Slack user id is already known. Usually not
        needed — a first button press is matched by email automatically. One
        Slack account maps to one actor per workspace; linking it elsewhere
        releases the previous binding.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slack_user_id]
              properties:
                slack_user_id: {type: string, description: "Slack user id like U01ABCDEF"}
      responses:
        "200": {description: Linked}
        "404": {$ref: "#/components/responses/NotFound"}
    delete:
      tags: [Slack]
      summary: Unlink an actor's Slack account (admin)
      responses:
        "200": {description: Unlinked}
        "404": {$ref: "#/components/responses/NotFound"}
  /actors/{id}/slack/dm:
    parameters:
      - {name: id, in: path, required: true, schema: {type: integer}}
    post:
      tags: [Slack]
      summary: Turn on personal Slack messages for an actor (admin)
      description: |
        Same effect as the self-serve `POST /me/slack/dm`, but done by an admin —
        onboarding people who never open Baton themselves. Requires the Baton
        Slack app installed in the workspace and the actor's Slack account linked
        (or matchable by email). Optional `project_ids` narrows the DM feed the
        way a person's own project-scoped token would.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                project_ids: {type: array, items: {type: integer}, description: Limit DMs to these projects}
      responses:
        "201": {description: Personal DM channel created or re-enabled}
        "404": {$ref: "#/components/responses/NotFound"}
        "409": {description: "Slack app not installed, or no Slack account matched"}
    delete:
      tags: [Slack]
      summary: Turn off an actor's personal Slack messages (admin)
      responses:
        "200": {description: Turned off}
        "404": {$ref: "#/components/responses/NotFound"}
  /me/slack/dm:
    post:
      tags: [Slack]
      summary: Turn on personal Slack messages for yourself
      description: |
        The Baton bot DMs you the things that wait on your decision — approve,
        answer a question, accept work. Requires the Baton Slack app installed
        in the workspace; your Slack account is matched by email if not linked
        yet. A project-scoped token narrows the DM feed to its projects.
      responses:
        "201": {description: Personal DM channel created or re-enabled}
        "409": {description: "Slack app not installed, or no Slack account matched"}
    delete:
      tags: [Slack]
      summary: Turn off your personal Slack messages
      responses:
        "200": {description: Turned off}
  /actors/{id}/tokens:
    get:
      tags: [Actors]
      summary: List an actor's tokens (admin)
      description: Metadata only — name, prefix, scopes, timestamps. Token values are never returned.
      parameters:
        - {name: id, in: path, required: true, schema: {type: integer}}
      responses:
        "200": {description: Tokens}
        "404": {$ref: "#/components/responses/NotFound"}
    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}
