openapi: 3.1.0
info:
  title: Inbox Preview for Agents
  version: 0.1.0
  description: |
    Renders HTML emails (or complete raw `.eml` messages) in real email web
    clients — Gmail, Outlook.com, and Yahoo Mail — and captures pixel-accurate
    screenshots. Designed for consumption by AI agents.

    ## Agent Integration

    Three ways to consume this service:

    - **REST API** — documented below
    - **MCP Server** — a hosted Model Context Protocol endpoint at
      `POST /v1/mcp` (Streamable HTTP). Point Claude Code, Claude Desktop,
      Cursor, or any MCP client at `https://api.powerline.ai/v1/mcp` — nothing
      to install.
    - **Skills doc** — LLM-readable Markdown at `GET /v1/skills.md`

    ## MCP Configuration

    ```json
    {
      "mcpServers": {
        "inbox-preview": {
          "type": "http",
          "url": "https://api.powerline.ai/v1/mcp"
        }
      }
    }
    ```

    ### MCP Tools

    | Tool | Maps to |
    |---|---|
    | `list_clients` | `GET /v1/clients` |
    | `submit_preview` | `POST /v1/jobs` |
    | `get_job` | `GET /v1/jobs/{jobId}` |
    | `get_screenshot` | `GET /v1/jobs/{jobId}/screenshots/{filename}` |

    ## Access model

    Submitting a job requires no authentication. The returned `job_id` is an
    unguessable capability: anyone who has it can read the job's status and
    screenshots, and nobody else can. Treat job ids and screenshot URLs as
    secrets if the email content is sensitive.

servers:
  - url: https://api.powerline.ai
    description: Production

# No authentication: submitting is open, and reads are capability-secured by
# the unguessable job id (see "Access model" above).
security: []

paths:
  /v1/jobs:
    post:
      operationId: submitJob
      summary: Submit an email for preview
      description: |
        Accepts email HTML **or** a complete raw RFC 822 message (`.eml`,
        base64-encoded) and queues the exact renders listed in `renders[]` —
        each spec is one client in one environment (viewport width, color
        scheme, images on/blocked). Returns immediately with a job ID for
        polling.

        Provide exactly one of `html` or `eml`. The HTTP body limit is 20MB.
      parameters:
        - name: x-powerline-background
          in: header
          required: false
          schema:
            type: string
          description: |
            If present (any value), the job is queued at background priority —
            interactive jobs will be rendered first. Use for bulk or
            non-time-sensitive work. There is no header to *raise* priority.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/JobRequest"
      responses:
        "202":
          description: Job accepted and queued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobCreated"
        "400":
          description: Validation error (bad field, missing source, malformed or oversized `eml`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "413":
          description: Request body exceeds the 20MB limit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"

  /v1/jobs/{jobId}:
    get:
      operationId: getJob
      summary: Get job status and results
      description: |
        Poll this endpoint until status is `completed` or `failed`.
        Status progression: `queued` → `rendering` → `completed` | `failed`.

        A **completed** job can still contain failed renders: successful
        renders are in `results.renders[]` and any that errored are in
        `results.failed_renders[]` — always check both.
      parameters:
        - name: jobId
          in: path
          required: true
          schema:
            type: string
            pattern: "^j_[A-Za-z0-9_-]{16}$"
          example: j_V1StGXR8Z5jdHi6B
      responses:
        "200":
          description: Job status and results
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobDetail"
        "404":
          description: Unknown (or malformed) job id
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"

  /v1/jobs/{jobId}/events:
    get:
      operationId: streamJobEvents
      summary: Stream renders as they finish (Server-Sent Events)
      description: |
        A `text/event-stream` that pushes each render the moment it completes,
        instead of waiting for the slowest client. Event types:

        - `snapshot` — sent once on connect: current status, progress, and any
          renders that already finished
        - `render` — one render finished; carries the same serialized render
          object as `GET /v1/jobs/{jobId}` plus `failed`, `completed`, `total`
        - `done` — the job finalized; the stream then closes
        - `failed` — the job failed; the stream then closes

        Streams are capped at 10 minutes and send comment keepalives every 20s.
        Polling `GET /v1/jobs/{jobId}` remains the authoritative fallback.
      parameters:
        - name: jobId
          in: path
          required: true
          schema:
            type: string
            pattern: "^j_[A-Za-z0-9_-]{16}$"
      responses:
        "200":
          description: SSE stream of job events
          content:
            text/event-stream:
              schema:
                type: string
        "404":
          description: Unknown (or malformed) job id
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"

  /v1/jobs/{jobId}/screenshots/{filename}:
    get:
      operationId: getScreenshot
      summary: Get a screenshot image
      description: |
        Returns the PNG directly. Body screenshots are named
        `{client}-{width}-{color_scheme}[-noimg].png` (e.g.
        `gmail-600-light.png`, `outlook-1280-dark-noimg.png`).
        When a render includes extra captures, they are served under the same
        name with a suffix: `…-subject.png` (the opened message's subject
        heading) and `…-inbox.png` (the unread inbox row). Use the
        `screenshot_url` values from the job rather than constructing names.
      parameters:
        - name: jobId
          in: path
          required: true
          schema:
            type: string
            pattern: "^j_[A-Za-z0-9_-]{16}$"
        - name: filename
          in: path
          required: true
          schema:
            type: string
          example: gmail-600-light.png
      responses:
        "200":
          description: Screenshot PNG
          content:
            image/png:
              schema:
                type: string
                format: binary
        "404":
          description: Unknown job, unknown file, or malformed name
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"

  /v1/clients:
    get:
      operationId: listClients
      summary: List available email clients
      description: |
        Returns the email clients this deployment can currently render —
        i.e. those with a live render worker right now. Use this (not the
        `ClientId` enum) to decide what to request.
      responses:
        "200":
          description: List of currently renderable clients
          content:
            application/json:
              schema:
                type: object
                properties:
                  clients:
                    type: array
                    items:
                      $ref: "#/components/schemas/EmailClient"

  /v1/health:
    get:
      operationId: healthCheck
      summary: Health check
      responses:
        "200":
          description: Service is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok

  /v1/openapi.yaml:
    get:
      operationId: getOpenApiSpec
      summary: Get this OpenAPI specification
      responses:
        "200":
          description: OpenAPI YAML specification
          content:
            text/yaml:
              schema:
                type: string

  /v1/skills.md:
    get:
      operationId: getSkillsDoc
      summary: Get agent skills documentation
      description: Returns a Markdown document describing how to use this API, intended for LLM agents.
      responses:
        "200":
          description: Skills documentation in Markdown
          content:
            text/markdown:
              schema:
                type: string

components:
  schemas:
    JobRequest:
      type: object
      description: Provide exactly one of `html` or `eml`.
      properties:
        html:
          type: string
          description: Full email HTML to preview
          minLength: 1
          maxLength: 1000000
        eml:
          type: string
          description: |
            A complete raw RFC 822 message (`.eml`), base64-encoded — an
            alternative to `html` that is inserted verbatim (real headers,
            multipart, inline images, attachments). Maximum 10MB decoded.
          minLength: 1
          maxLength: 14000000
        subject:
          type: string
          maxLength: 998
          description: Email subject line
        include_previews:
          type: boolean
          default: false
          description: |
            Also capture how the message appears in the inbox LIST (the unread
            row) and the subject heading above the body, returned as
            `inbox_preview` and `subject_preview` on each render.

            Off by default because they cost more than the body render they
            accompany, and both are best-effort: a client that cannot produce
            one omits it rather than failing the render. Ask for them only when
            you need them.
        renders:
          type: array
          items:
            $ref: "#/components/schemas/RenderSpec"
          minItems: 1
          maxItems: 30
          description: |
            Exactly the renders you want, one spec each — e.g. Outlook light
            and Outlook dark are two entries. Axes compose freely (dark with
            images blocked is a valid render). A spec asking for an axis the
            client doesn't support (e.g. `gmail` + `dark`) is rejected with a
            400 naming the entry — check each client's axes in
            `GET /v1/clients`. Duplicate specs (after defaults are applied)
            are dropped. Omit entirely for the server default: every live
            client at its default width, light, images on.

    RenderSpec:
      type: object
      required: [client]
      description: |
        One render: a client plus the environment it renders in. Omitted axes
        default (client's default width, `light`, images on).
      properties:
        client:
          $ref: "#/components/schemas/ClientId"
        viewport_width:
          type: integer
          minimum: 200
          maximum: 2560
        color_scheme:
          type: string
          enum: [light, dark]
          default: light
          description: |
            The client's dark mode requires client support — check
            `color_schemes` in `GET /v1/clients` (Outlook and Yahoo today).
        images_disabled:
          type: boolean
          default: false
          description: Render with remote images blocked at the browser level.

    JobCreated:
      type: object
      properties:
        include_previews:
          type: boolean
          description: The resolved value this job will use.
        job_id:
          type: string
          example: j_V1StGXR8Z5jdHi6B
        status:
          type: string
          example: queued
        created_at:
          type: string
          format: date-time
        estimated_completion_seconds:
          type: integer
        poll_url:
          type: string
          example: /v1/jobs/j_V1StGXR8Z5jdHi6B
        clients_requested:
          type: array
          description: The distinct clients across the accepted renders.
          items:
            type: string
        renders_accepted:
          type: array
          description: |
            The definitive render list this job will produce — defaults
            applied, duplicates dropped. What appears here is what
            `GET /v1/jobs/{jobId}` will return.
          items:
            $ref: "#/components/schemas/RenderSpec"
        render_count:
          type: integer
        description:
          type: string

    JobDetail:
      type: object
      properties:
        job_id:
          type: string
        status:
          $ref: "#/components/schemas/JobStatus"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          description: Present once the job has finalized.
        progress:
          type: object
          properties:
            total_renders:
              type: integer
            completed_renders:
              type: integer
        estimated_completion_seconds:
          type: integer
          description: Present while the job is still queued or rendering.
        results:
          description: "`null` until the job completes."
          oneOf:
            - type: "null"
            - type: object
              properties:
                renders:
                  type: array
                  description: Successful renders.
                  items:
                    $ref: "#/components/schemas/RenderResult"
                failed_renders:
                  type: array
                  description: |
                    Renders that errored. Present only when at least one render
                    failed — a job can be `completed` with every render here.
                  items:
                    $ref: "#/components/schemas/FailedRender"
        error:
          description: Present only when `status` is `failed`.
          type: object
          properties:
            code:
              type: string
            message:
              type: string
        description:
          type: string

    RenderResult:
      type: object
      properties:
        client:
          $ref: "#/components/schemas/ClientId"
        client_description:
          type: string
          example: "Gmail (Web)"
        viewport_width:
          type: integer
        color_scheme:
          type: string
          enum: [light, dark]
        images_disabled:
          type: boolean
        screenshot_url:
          type: string
          description: Relative URL to the screenshot PNG
          example: /v1/jobs/j_V1StGXR8Z5jdHi6B/screenshots/gmail-600-light.png
        screenshot_dimensions:
          type: object
          description: Actual PNG pixel dimensions (device_scale_factor × the CSS size).
          properties:
            width:
              type: integer
            height:
              type: integer
        device_scale_factor:
          type: number
          description: Device pixel ratio the screenshot was captured at (2 = retina).
        subject_preview:
          $ref: "#/components/schemas/ExtraCapture"
          description: Best-effort capture of the opened message's subject heading.
        inbox_preview:
          $ref: "#/components/schemas/ExtraCapture"
          description: Best-effort capture of the message's (unread) inbox row.
        render_duration_ms:
          type: integer

    ExtraCapture:
      type: object
      properties:
        screenshot_url:
          type: string
        screenshot_dimensions:
          type: object
          properties:
            width:
              type: integer
            height:
              type: integer
        device_scale_factor:
          type: number

    FailedRender:
      type: object
      properties:
        client:
          $ref: "#/components/schemas/ClientId"
        client_description:
          type: string
        viewport_width:
          type: integer
        color_scheme:
          type: string
          enum: [light, dark]
        images_disabled:
          type: boolean
        error:
          type: string
          description: What went wrong with this render.

    EmailClient:
      type: object
      properties:
        id:
          $ref: "#/components/schemas/ClientId"
        name:
          type: string
        tier_description:
          type: string
        engine:
          type: string
        color_schemes:
          type: array
          description: |
            The color schemes this client can honour in a render spec. Every
            client renders `light`; `dark` requires the client to actually
            transform message content (Outlook and Yahoo today).
          items:
            type: string
            enum: [light, dark]
        supports_images_disabled:
          type: boolean
        default_viewport_widths:
          type: array
          items:
            type: integer

    ClientId:
      type: string
      description: |
        All client ids the API recognizes. Which are actually renderable right
        now varies by deployment — consult `GET /v1/clients`.
      enum: [gmail, outlook, yahoo]

    JobStatus:
      type: string
      enum: [queued, rendering, completed, failed]

    ApiError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              example: not_found
            message:
              type: string
            suggestion:
              type: string
            requestId:
              type: string
