openapi: 3.1.0
info:
  title: Stackon Public API
  version: 1.0.0
  summary: HTTP API for the Stackon agentic development environment.
  description: |
    The Stackon Public API gives you programmatic access to traces, evals,
    eval-gate runs, and the BYO-compute bridge dispatcher. Every agent run
    your team executes — from the dashboard, the Stackon MCP server, or your
    own integration — is captured here.

    ## Authentication

    Every endpoint requires a bearer token in the `Authorization` header:

        Authorization: Bearer ht_<32-random-chars>

    Generate a token at <https://stackon.ai/dashboard/settings/tokens>. Tokens are
    sha256-hashed at rest and never recoverable — copy the plaintext when
    it's first shown to you. Each token holds a set of **scopes** that
    constrain which endpoints it can call. See the security schemes below.

    Bridge daemon endpoints under `/api/v1/bridge/*` use a separate token
    namespace (`hbk_` prefix) minted at pair time — those are not scoped.

    ## Rate limits

    Each token is rate-limited to **60 requests per minute** (fixed window,
    server-side). When you exceed it, you get a `429` with a `Retry-After`
    header in seconds and `X-RateLimit-*` headers on the response.

    ## Errors

    Every error response from `/api/v1/*` follows the same envelope:

    ```json
    { "error": { "code": "rate_limited", "message": "...", "request_id": "..." } }
    ```

    The `request_id` is also returned in the `X-Request-Id` response header
    on success and error. You can pass an `X-Request-Id` header in to
    correlate your logs with ours.

    ## Versioning policy

    `/api/v1` is **stable**. We commit to:

    - Adding new optional fields to responses without a major bump.
    - Adding new endpoints under `/api/v1` without a major bump.
    - Notifying `status: deprecated` endpoints **at least 90 days** before
      removal via the `Deprecation` header (RFC 9745) and the `/changelog`
      page.

    Breaking changes (removed fields, renamed paths, changed error codes)
    ship under `/api/v2` with both versions running in parallel during a
    transition period.

  contact:
    name: Stackon
    url: https://stackon.ai
    email: hello@stackon.ai
  license:
    name: Stackon Terms of Service
    url: https://stackon.ai/terms

servers:
  - url: https://stackon.ai
    description: This deployment

security:
  - BearerAuth: []

tags:
  - name: Traces
    description: Spans, traces, and lifecycle. Lead-pillar surface — every agent run lands here.
  - name: Evals
    description: Run evals against a trace and trigger eval-gate runs from CI.
  - name: Bridge
    description: BYO-compute daemon endpoints (separate `hbk_` token namespace).

paths:
  /api/v1/runs:
    post:
      tags: [Traces]
      summary: Record a one-shot run
      description: |
        Convenience endpoint: record a complete agent run as a trace + a
        single root `agent.run` span in one request. Used by the Stackon MCP
        `record_run` tool.
      security:
        - BearerAuth: [traces:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecordRunRequest'
      responses:
        '201':
          description: Trace created.
          content:
            application/json:
              schema:
                type: object
                required: [trace_id]
                properties:
                  trace_id:
                    type: string
                    format: uuid
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/traces:
    get:
      tags: [Traces]
      summary: List recent traces
      security:
        - BearerAuth: [traces:read]
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        '200':
          description: Page of traces, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  traces:
                    type: array
                    items: { $ref: '#/components/schemas/Trace' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Traces]
      summary: Start a new trace
      security:
        - BearerAuth: [traces:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 200 }
                started_at: { type: string, format: date-time }
                metadata: { type: object, additionalProperties: true }
      responses:
        '201':
          description: Trace started.
          content:
            application/json:
              schema:
                type: object
                required: [trace_id]
                properties:
                  trace_id: { type: string, format: uuid }
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/traces/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Traces]
      summary: Get a trace with its spans
      security:
        - BearerAuth: [traces:read]
      responses:
        '200':
          description: Trace and its spans, oldest-first by start time.
          content:
            application/json:
              schema:
                type: object
                required: [trace, spans]
                properties:
                  trace: { $ref: '#/components/schemas/Trace' }
                  spans:
                    type: array
                    items: { $ref: '#/components/schemas/Span' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/traces/{id}/spans:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Traces]
      summary: Append a span to a trace
      security:
        - BearerAuth: [traces:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 200 }
                kind:
                  type: string
                  enum: [agent, llm, tool, eval, internal]
                  default: internal
                status:
                  type: string
                  enum: [ok, error, cancelled, in_progress]
                  default: ok
                parent_span_id: { type: string, format: uuid }
                started_at: { type: string, format: date-time }
                ended_at: { type: string, format: date-time }
                duration_ms: { type: integer, minimum: 0 }
                cost_usd: { type: number, minimum: 0 }
                tokens_input: { type: integer, minimum: 0 }
                tokens_output: { type: integer, minimum: 0 }
                model: { type: string }
                attributes: { type: object, additionalProperties: true }
                inputs: { type: object, additionalProperties: true }
                outputs: { type: object, additionalProperties: true }
                error_message: { type: string, maxLength: 4000 }
      responses:
        '201':
          description: Span recorded.
          content:
            application/json:
              schema:
                type: object
                required: [span_id]
                properties:
                  span_id: { type: string, format: uuid }
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/traces/{id}/end:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Traces]
      summary: Mark a trace finished and roll up totals
      security:
        - BearerAuth: [traces:write]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                status: { type: string, enum: [ok, error, cancelled], default: ok }
                ended_at: { type: string, format: date-time }
      responses:
        '200':
          description: Trace ended.
          content:
            application/json:
              schema:
                type: object
                properties:
                  trace_id: { type: string, format: uuid }
                  span_count: { type: integer }
                  total_cost_usd: { type: number }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/traces/{id}/evals/run:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Evals]
      summary: Grade a trace against all enabled evals
      description: |
        Runs every enabled eval against the trace. `llm_judge` evals incur
        cost; calls go through the team's budget enforcement.
      security:
        - BearerAuth: [evals:run]
      responses:
        '200':
          description: Eval summary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EvalRunSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/eval-gates/{slug}:
    parameters:
      - name: slug
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Evals]
      summary: Get gate config + latest run
      security:
        - BearerAuth: [traces:read]
      responses:
        '200':
          description: Gate config and the latest run summary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EvalGate' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/eval-gates/{slug}/run:
    parameters:
      - name: slug
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Evals]
      summary: Run a gate synchronously (CI-friendly)
      description: |
        Synchronous gate execution. The HTTP response holds the pass/fail
        result so a CI workflow can branch on `passed` directly. The
        canvas typically completes in well under 60s; budget enforcement
        still gates total cost.

        Status mapping for CI consumers:
        - `status: passed` → check-run success
        - `status: failed` → PR should be blocked
        - `status: error` → configuration / runtime issue
      security:
        - BearerAuth: [evals:run]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                commit_sha: { type: string, maxLength: 80 }
                pr_url: { type: string, maxLength: 500 }
                source: { type: string, enum: [ci, api, manual], default: api }
      responses:
        '200':
          description: Gate run summary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EvalGateRunResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/bridge/pair:
    post:
      tags: [Bridge]
      summary: Daemon claims a one-time pairing code
      description: |
        Unauthenticated — the pairing code (issued at /dashboard/compute) is
        the proof of intent. Returns the bridge identity + the plaintext
        bridge token, shown ONCE. Subsequent bridge endpoints use the
        `hbk_` token in the Authorization header.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, description: 'ABCD-EFGH format' }
                hostname: { type: string, maxLength: 200 }
                platform: { type: string, maxLength: 80 }
                capabilities:
                  type: object
                  additionalProperties: true
                  description: '{ cpu, gpu, models: string[], runtime }'
      responses:
        '201':
          description: Pairing successful.
          content:
            application/json:
              schema:
                type: object
                required: [bridge_id, team_id, name, bridge_token, prefix]
                properties:
                  bridge_id: { type: string, format: uuid }
                  team_id: { type: string, format: uuid }
                  name: { type: string }
                  bridge_token:
                    type: string
                    description: Plaintext token — shown once. Store securely (chmod 600).
                  prefix: { type: string }
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /api/v1/bridge/heartbeat:
    post:
      tags: [Bridge]
      summary: Daemon ping (~30s)
      security:
        - BridgeAuth: []
      responses:
        '200':
          description: Heartbeat accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  server_time: { type: string, format: date-time }

  /api/v1/bridge/jobs/next:
    get:
      tags: [Bridge]
      summary: Poll for next queued job
      security:
        - BridgeAuth: []
      responses:
        '200':
          description: Next job to execute.
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { type: string, format: uuid }
                  kind: { type: string }
                  trace_id: { type: string, format: uuid }
                  payload: { type: object, additionalProperties: true }
                  created_at: { type: string, format: date-time }
        '204':
          description: No work queued.

  /api/v1/bridge/jobs/{id}/result:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Bridge]
      summary: Submit job result
      security:
        - BridgeAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [complete, failed] }
                output_text: { type: string, description: Required when status=complete }
                error_message: { type: string, description: Required when status=failed }
                model: { type: string }
                tokens_input: { type: integer, minimum: 0 }
                tokens_output: { type: integer, minimum: 0 }
                cost_usd: { type: number, minimum: 0 }
                duration_ms: { type: integer, minimum: 0 }
                started_at: { type: string, format: date-time }
      responses:
        '200':
          description: Result accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  trace_id: { type: string, format: uuid }
                  status: { type: string, enum: [complete, failed] }
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: 'ht_<32-random-chars>'
      description: |
        Personal access token — generate at /dashboard/settings/tokens. Each
        token holds a subset of these scopes:

        - `traces:read`  — list/get traces and spans, read gate config
        - `traces:write` — create traces, append spans, end traces, record_run
        - `evals:run`    — trigger evals on a trace, run an eval gate
    BridgeAuth:
      type: http
      scheme: bearer
      bearerFormat: 'hbk_<32-random-chars>'
      description: |
        Bridge daemon token — minted at pair time. Separate namespace from
        `ht_` tokens; no scope vocabulary (single-purpose: job dispatch).

  schemas:
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code:
              type: string
              enum:
                - missing_admin_key
                - missing_authorization
                - invalid_token_format
                - invalid_token
                - token_revoked
                - token_expired
                - missing_scope
                - rate_limited
                - invalid_request
                - not_found
                - forbidden
                - conflict
                - gone
                - internal_error
                - service_unavailable
            message: { type: string }
            request_id: { type: string }

    Trace:
      type: object
      required: [id, name, status, started_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        status: { type: string, enum: [in_progress, ok, error, cancelled] }
        started_at: { type: string, format: date-time }
        ended_at: { type: string, format: date-time, nullable: true }
        duration_ms: { type: integer, nullable: true }
        span_count: { type: integer }
        total_cost_usd: { type: number }
        total_tokens_input: { type: integer }
        total_tokens_output: { type: integer }

    Span:
      type: object
      required: [id, trace_id, name, kind, status, started_at]
      properties:
        id: { type: string, format: uuid }
        trace_id: { type: string, format: uuid }
        parent_span_id: { type: string, format: uuid, nullable: true }
        name: { type: string }
        kind: { type: string, enum: [agent, llm, tool, eval, internal] }
        status: { type: string, enum: [ok, error, cancelled, in_progress] }
        started_at: { type: string, format: date-time }
        ended_at: { type: string, format: date-time, nullable: true }
        duration_ms: { type: integer, nullable: true }
        cost_usd: { type: number }
        tokens_input: { type: integer }
        tokens_output: { type: integer }
        model: { type: string, nullable: true }
        attributes: { type: object, additionalProperties: true }
        inputs: { type: object, additionalProperties: true, nullable: true }
        outputs: { type: object, additionalProperties: true, nullable: true }
        error_message: { type: string, nullable: true }

    RecordRunRequest:
      type: object
      required: [name, output]
      properties:
        name: { type: string, maxLength: 200 }
        output: { type: string }
        user_prompt: { type: string }
        model: { type: string }
        tokens_input: { type: integer, minimum: 0 }
        tokens_output: { type: integer, minimum: 0 }
        cost_usd: { type: number, minimum: 0 }
        duration_ms: { type: integer, minimum: 0 }
        started_at: { type: string, format: date-time }
        metadata: { type: object, additionalProperties: true }

    EvalRunSummary:
      type: object
      properties:
        trace_id: { type: string, format: uuid }
        passed_count: { type: integer }
        failed_count: { type: integer }
        total_evals: { type: integer }
        total_cost_usd: { type: number }

    EvalGate:
      type: object
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        name: { type: string }
        canvas_id: { type: string, format: uuid }
        test_input: { type: string }
        eval_count: { type: integer }
        enabled: { type: boolean }
        created_at: { type: string, format: date-time }
        latest_run:
          nullable: true
          type: object
          properties:
            id: { type: string, format: uuid }
            status: { type: string, enum: [passed, failed, error] }
            passed_count: { type: integer }
            failed_count: { type: integer }
            total_evals: { type: integer }
            trace_id: { type: string, format: uuid, nullable: true }
            completed_at: { type: string, format: date-time, nullable: true }

    EvalGateRunResult:
      type: object
      required: [gate_run_id, gate_slug, status, passed]
      properties:
        gate_run_id: { type: string, format: uuid }
        gate_slug: { type: string }
        status: { type: string, enum: [passed, failed, error] }
        passed: { type: boolean }
        passed_count: { type: integer }
        failed_count: { type: integer }
        total_evals: { type: integer }
        trace_id: { type: string, format: uuid, nullable: true }
        total_cost_usd: { type: number }
        duration_ms: { type: integer }
        error_message: { type: string, nullable: true }
        dashboard_url: { type: string }

  responses:
    InvalidRequest:
      description: Body or parameters failed validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unauthorized:
      description: Token missing, malformed, expired, or revoked.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Forbidden:
      description: Token lacks the required scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NotFound:
      description: Resource does not exist or doesn't belong to the token's team.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Conflict:
      description: State conflict (e.g. job already terminal, gate disabled).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Gone:
      description: Resource is no longer available (e.g. expired pairing code).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    RateLimited:
      description: Token has exceeded its per-minute budget.
      headers:
        Retry-After:
          schema: { type: integer }
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
        X-RateLimit-Reset:
          schema: { type: integer, description: 'Unix timestamp seconds when budget resets' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    ServiceUnavailable:
      description: Server is missing required configuration (e.g. SUPABASE_SECRET_KEY).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
