openapi: 3.1.0
info:
  title: Collimate API
  version: "dev"
  description: >-
    The public REST API for Collimate — fast live-fork microVM sandboxes for
    agents and RL post-training. Served by the gateway at api.collimate.ai.
    Authentication is a bearer token of the form col_<tier>_<id>_<secret>.
    The `sandboxes` noun is the GA name; POST /v1/sessions is a
    backward-compatible alias of POST /v1/sandboxes.
  contact:
    name: Collimate
    url: https://collimate.ai
servers:
  - url: https://api.collimate.ai
    description: Managed cloud
security:
  - bearerAuth: []
tags:
  - name: health
  - name: templates
  - name: sandboxes
  - name: usage
paths:
  /v1/health:
    get:
      tags: [health]
      summary: Liveness and version
      security: []
      responses:
        "200":
          description: Healthy
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }

  /v1/images:
    get:
      tags: [templates]
      summary: The image catalog the key can address
      description: >-
        Everything the tenant can create sandboxes from — the shared public
        catalog plus templates the tenant baked. `GET /v1/templates` is a
        working alias (same handler, same body); the wire envelope key is
        `templates`.
      responses:
        "200":
          description: Catalog list
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TemplateList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/templates:
    get:
      tags: [templates]
      summary: Templates the tenant can address (alias of /v1/images)
      responses:
        "200":
          description: Template list
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TemplateList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [templates]
      summary: Bake an image into a template (Pro)
      description: >-
        Turn an OCI image into a ready-state template the fleet forks sandboxes
        from. Idempotent — safe to retry.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateTemplateRequest" }
      responses:
        "200":
          description: Bake accepted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateTemplateResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/templates/{id}:
    parameters:
      - $ref: "#/components/parameters/TemplateId"
    get:
      tags: [templates]
      summary: Bake phase and readiness for one template
      responses:
        "200":
          description: Template status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TemplateStatusResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [templates]
      summary: Delete a template the tenant created
      description: >-
        Shared catalog rows are platform-managed and never tenant-deletable
        (403). 409 while the template still backs live sandboxes.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Template still backs live sandboxes (template_in_use)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIError" }

  /v1/templates/{id}/expect:
    parameters:
      - $ref: "#/components/parameters/TemplateId"
    post:
      tags: [templates]
      summary: Declare the width of the next session (Pro)
      description: >-
        Session start — declares how wide the next rollout session will fan out
        so capacity is ready before the burst lands. Clamped to the plan's
        width limit; idempotent. Only valid on templates the tenant created.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [width]
              properties:
                width: { type: integer }
      responses:
        "200":
          description: Declared (response carries the applied expected_width)
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  expected_width: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/templates/{id}/release:
    parameters:
      - $ref: "#/components/parameters/TemplateId"
    post:
      tags: [templates]
      summary: End the session (Pro)
      description: >-
        Drops the width declaration so capacity relaxes to baseline immediately
        instead of decaying. Purely a capacity signal — the template and any
        live sandboxes are untouched. Optional and idempotent.
      responses:
        "200":
          description: Released
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  released: { type: boolean }
                  released_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sandboxes:
    post:
      tags: [sandboxes]
      summary: Create a sandbox (fork a template)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateSandboxRequest" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateSandboxResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [sandboxes]
      summary: List the tenant's sandboxes
      responses:
        "200":
          description: Sandbox list
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SandboxList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sandboxes/{id}:
    parameters:
      - $ref: "#/components/parameters/SandboxId"
    get:
      tags: [sandboxes]
      summary: Get one sandbox
      responses:
        "200":
          description: Sandbox
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SandboxInfo" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [sandboxes]
      summary: Destroy a sandbox
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sandboxes/{id}/exec:
    parameters:
      - $ref: "#/components/parameters/SandboxId"
    post:
      tags: [sandboxes]
      summary: Run a command in a sandbox
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExecRequest" }
      responses:
        "200":
          description: Exec result
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExecResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Busy" }
        "413": { description: Payload too large }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/sandboxes/{id}/fork:
    parameters:
      - $ref: "#/components/parameters/SandboxId"
    post:
      tags: [sandboxes]
      summary: Live-fork a running sandbox into N children
      description: >-
        Fork a running sandbox into `count` copy-on-write children that each keep
        the parent's live process memory. The parent stays running. Fails with
        409 if the sandbox has an in-flight exec.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ForkRequest" }
      responses:
        "200":
          description: Fork fan-out
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ForkResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Busy" }

  /v1/sandboxes/{id}/connect-url:
    parameters:
      - $ref: "#/components/parameters/SandboxId"
    post:
      tags: [sandboxes]
      summary: Mint a scoped connect URL to a guest port (Pro)
      description: >-
        Mint a signed, time-boxed URL to a port inside a running sandbox — the
        connect-from-outside primitive. Pro/Enterprise only; a Demo key gets 403
        demo_tier_limit. The token is HMAC-scoped to (tenant, sandbox, port, exp)
        and revoked when the sandbox is deleted.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConnectUrlRequest" }
      responses:
        "200":
          description: Minted connect URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConnectUrlResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The port is not in the template's exposed ports (port_not_exposed)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIError" }

  /v1/sandboxes/{id}/connect:
    parameters:
      - $ref: "#/components/parameters/SandboxId"
    get:
      tags: [sandboxes]
      summary: WebSocket tunnel to a guest port (the minted connect URL)
      description: >-
        The WebSocket the minted connect-url points at. A WS upgrade whose frames
        are proxied to the guest port; the guest must be listening. Authenticate
        with the scoped `t` token from connect-url or a Bearer col_* key — same
        Pro/Enterprise gate and ownership check. Clients normally open the minted
        url (or pass it to connect_over_cdp / the SDK's port_forward) rather than
        calling this directly.
      security:
        - bearerAuth: []
        - connectToken: []
      parameters:
        - name: port
          in: query
          required: true
          schema: { type: integer }
          description: Guest port to tunnel to (must be an exposed port).
        - name: protocol
          in: query
          required: false
          schema: { type: string, enum: [tcp, cdp], default: tcp }
          description: tcp (raw byte tunnel) or cdp (DevTools-framed relay).
        - name: t
          in: query
          required: false
          schema: { type: string }
          description: Scoped token from connect-url (alternative to a Bearer key).
      responses:
        "101":
          description: Switching protocols (WebSocket upgrade); frames proxy to the guest port.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The port is not in the template's exposed ports (port_not_exposed)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIError" }

  /v1/sandboxes/{id}/suspend:
    parameters:
      - $ref: "#/components/parameters/SandboxId"
    post:
      tags: [sandboxes]
      summary: Suspend a sandbox to disk (stops the meter)
      responses:
        "200":
          description: Suspended
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StateResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sandboxes/{id}/resume:
    parameters:
      - $ref: "#/components/parameters/SandboxId"
    post:
      tags: [sandboxes]
      summary: Resume a suspended sandbox
      responses:
        "200":
          description: Running
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StateResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/usage:
    get:
      tags: [usage]
      summary: Aggregated usage for the tenant
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date-time }
        - name: to
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Usage summary
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UsageSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Authorization: Bearer col_<tier>_<id>_<secret>"
    connectToken:
      type: apiKey
      in: query
      name: t
      description: >-
        Scoped connect token minted by POST /v1/sandboxes/{id}/connect-url;
        HMAC-scoped to (tenant, sandbox, port, exp). Accepted only on
        GET /v1/sandboxes/{id}/connect.

  parameters:
    SandboxId:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: Opaque sandbox id, e.g. sbx_9f2a1c7b3e40.
    TemplateId:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: Template id (from /v1/images or a bake response).

  responses:
    BadRequest:
      description: Malformed request
      content:
        application/json:
          schema: { $ref: "#/components/schemas/APIError" }
    Unauthorized:
      description: Missing or invalid key (fail-closed)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/APIError" }
    Forbidden:
      description: Resource not owned by the tenant
      content:
        application/json:
          schema: { $ref: "#/components/schemas/APIError" }
    NotFound:
      description: Unknown sandbox
      content:
        application/json:
          schema: { $ref: "#/components/schemas/APIError" }
    Busy:
      description: Sandbox has an in-flight exec
      content:
        application/json:
          schema: { $ref: "#/components/schemas/APIError" }
    RateLimited:
      description: Rate or concurrency limit exceeded
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/APIError" }

  schemas:
    Health:
      type: object
      properties:
        status: { type: string, example: ok }
        version: { type: string, example: "dev" }

    TemplateInfo:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        image: { type: string }
        description:
          type: string
          description: What the image is / what's preloaded.
        visibility: { type: string, enum: [public, private] }
        tier:
          type: string
          description: The plan tier the image runs on, when applicable.

    TemplateList:
      type: object
      properties:
        templates:
          type: array
          items: { $ref: "#/components/schemas/TemplateInfo" }

    ReadySpec:
      type: object
      description: The ready-state declaration applied at bake time.
      properties:
        cmd:
          type: string
          description: Shell command run in the guest; ready when it exits 0.
        http:
          type: string
          description: URL polled until 2xx (takes precedence over cmd).
        http_timeout_s: { type: integer }
        ports:
          type: array
          items: { type: integer }
          description: Guest TCP ports exposed as sandbox endpoints.
        env:
          type: object
          additionalProperties: { type: string }
          description: Extra KEY=VALUE pairs injected at bake time.
        network:
          type: boolean
          description: Request a guest NIC for per-rollout egress (Pro).

    CreateTemplateRequest:
      type: object
      required: [name, image, pool]
      properties:
        name: { type: string }
        image:
          type: string
          description: Source OCI image (pin by digest).
        ready: { $ref: "#/components/schemas/ReadySpec" }
        pool:
          type: object
          required: [width]
          properties:
            width:
              type: integer
              description: The fan-out you expect — a hint, not a knob.
            cold_fraction: { type: integer }
        expected_width:
          type: integer
          description: Pre-warm hint for the first rollout's width.

    CreateTemplateResponse:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        image: { type: string }
        phase: { $ref: "#/components/schemas/TemplateBakePhase" }

    TemplateBakePhase:
      type: string
      enum: [Pending, Converting, Distributing, Live, Failed]

    TemplateStatusResponse:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        image: { type: string }
        phase: { $ref: "#/components/schemas/TemplateBakePhase" }
        declared: { type: integer }
        warm: { type: integer }
        frozen: { type: integer }
        converge_state:
          type: string
          enum: [converging, converged]

    SandboxList:
      type: object
      properties:
        sandboxes:
          type: array
          items: { $ref: "#/components/schemas/SandboxInfo" }

    FileSpec:
      type: object
      required: [path, content]
      properties:
        path: { type: string }
        content: { type: string }

    CreateSandboxRequest:
      type: object
      required: [template]
      properties:
        template: { type: string }
        label: { type: string }
        labels:
          type: object
          additionalProperties: { type: string }
        egress:
          type: object
          description: Opaque per-sandbox egress policy.

    CreateSandboxResponse:
      type: object
      properties:
        id: { type: string }
        template: { type: string }
        state: { $ref: "#/components/schemas/SandboxState" }
        fork_time_ms: { type: number }
        create_time_ms: { type: number }
        endpoints:
          type: object
          additionalProperties: { type: string }
          description: >-
            Per-exposed-port connect hint ("<port>": "<gateway connect hint>"),
            not a raw dialable host address — resolve a port into a usable URL
            via POST /v1/sandboxes/{id}/connect-url or the SDK's
            connect_url/cdp_url/port_forward.

    SandboxInfo:
      type: object
      properties:
        id: { type: string }
        template: { type: string }
        state: { $ref: "#/components/schemas/SandboxState" }
        age_secs: { type: number }
        idle_secs: { type: number }
        exec_count: { type: integer }
        endpoints:
          type: object
          additionalProperties: { type: string }
          description: >-
            Per-exposed-port connect hint ("<port>": "<gateway connect hint>"),
            not a raw dialable host address — resolve via
            POST /v1/sandboxes/{id}/connect-url.
        labels:
          type: object
          additionalProperties: { type: string }

    SandboxState:
      type: string
      enum: [running, suspended, deleted]

    ExecRequest:
      type: object
      description: >-
        A superset that supports several ways to express one step. Provide
        whichever fields you need; `files` are materialized before the command.
      properties:
        command:
          type: string
          description: A single shell command string.
        code:
          type: string
          description: Source run via the template interpreter.
        commands:
          type: array
          items:
            type: array
            items: { type: string }
          description: argv arrays run sequentially.
        files:
          type: array
          items: { $ref: "#/components/schemas/FileSpec" }
        timeout_seconds:
          type: integer
          description: Max wall-clock for the exec (<= 3600).
        egress:
          type: object
          description: Opaque per-exec egress policy override.
        seed:
          type: string
          description: 64-hex-char guest CRNG seed for deterministic replay.

    ExecResponse:
      type: object
      properties:
        id: { type: string }
        stdout: { type: string }
        stderr: { type: string }
        exit_code: { type: integer }
        fork_time_ms: { type: number }
        exec_time_ms: { type: number }
        total_time_ms: { type: number }
        seed: { type: string }
        seed_ack:
          type: string
          description: verified | no-ack | off | (mismatch)

    ForkRequest:
      type: object
      properties:
        count:
          type: integer
          default: 1
          description: Children to fork (bounded per request).
        labels:
          type: array
          items: { type: string }

    ForkResponse:
      type: object
      properties:
        parent: { type: string }
        children:
          type: array
          items: { $ref: "#/components/schemas/ForkChild" }

    ForkChild:
      type: object
      properties:
        id: { type: string }
        endpoints:
          type: object
          additionalProperties: { type: string }
          description: >-
            Per-exposed-port connect hint ("<port>": "<gateway connect hint>"),
            not a raw dialable host address — resolve via
            POST /v1/sandboxes/{id}/connect-url.

    ConnectUrlRequest:
      type: object
      required: [port, ttl_seconds]
      properties:
        port:
          type: integer
          description: A guest port from the template's exposed ports.
        ttl_seconds:
          type: integer
          description: URL lifetime in seconds. Clamped down to 3600 (1h).
        protocol:
          type: string
          enum: [tcp, cdp]
          default: tcp
          description: >-
            tcp (default) — a raw byte tunnel for any TCP (HTTP, WebSocket,
            Postgres, a preview or OpenEnv env-server). cdp — a DevTools-framed
            relay a CDP client (connect_over_cdp) consumes directly.

    ConnectUrlResponse:
      type: object
      properties:
        url:
          type: string
          description: >-
            Signed wss:// URL to the guest port
            (…/v1/sandboxes/<id>/connect?port=<p>[&protocol=cdp]&t=<token>).
        expires_at: { type: string, format: date-time }

    StateResponse:
      type: object
      properties:
        id: { type: string }
        state: { $ref: "#/components/schemas/SandboxState" }
        mem_bytes:
          type: integer
          format: int64
          description: On-disk memory image size (suspend only).

    DeleteResponse:
      type: object
      properties:
        deleted: { type: boolean }
        id: { type: string }

    UsageSummary:
      type: object
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        totals:
          type: object
          additionalProperties: { type: number }
          description: >-
            Per-dimension totals: cpu_core_hours, ram_gb_hours, disk_gb_hours,
            snapshot_gb_months.

    APIError:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          description: >-
            Stable machine code — gateway (unauthorized, forbidden,
            demo_tier_limit, insufficient_credits, quota_exceeded, rate_limited,
            not_found, bad_request, template_in_use, port_not_exposed,
            upstream_error)
            or engine (no_session, busy, session_gone, at_capacity, draining,
            session_bringup_failed).
        message:
          type: string
