openapi: 3.1.0
info:
  title: PLCs.ai API
  version: "1.0.0"
  summary: A stable, versioned HTTP API for interpreting and analyzing PLC projects.
  description: |
    The PLCs.ai API provides plain-language interpretation, troubleshooting,
    and analysis of Allen-Bradley and Siemens projects behind a versioned,
    externally-authenticated HTTP surface.

    **Authentication.** Every request carries an API key as a bearer token:
    `Authorization: Bearer plck_live_…`. A key resolves its organization from
    the credential itself — there is no organization in the URL. Mint, scope,
    and revoke keys from **Settings → API Keys** in the app.

    **Idempotency.** Every write accepts (and requires) an `Idempotency-Key`
    header so a retry never creates a duplicate billable unit.

    **Errors.** Every error uses one envelope with a human `userMessage`, a
    `suggestedAction`, and an `isRetryable` flag. Every response — success or
    error — carries a unique `request-id` header; quote it in support requests.

    **Permissions.** A key carries a set of scopes. Each endpoint documents the
    scope it requires.
  contact:
    name: PLCs.ai Developer Support
    url: https://developer.plcs.ai
  license:
    name: Proprietary
servers:
  - url: https://app.plcs.ai/api/v1
    description: Production

security:
  - ApiKeyAuth: []

tags:
  - name: Interpret
    description: Prompt → cited interpretation over the existing assistant.
  - name: Generate
    description: Prompt → reviewable PLC code proposal (proposes, never deploys).
  - name: Conversations
    description: Stateful multi-turn troubleshooting threads.
  - name: Analyses
    description: Asynchronous analysis jobs (dead code, missing handshakes, cycle-time).
  - name: Projects
    description: Enumerate projects and ingest an L5X / Siemens export.
  - name: Source
    description: Read a project's parsed structure, raw bytes, or aggregated text.
  - name: Exports
    description: Async server-side export to a vendor PLC file or a PDF report.
  - name: HMI
    description: Read live tag values streamed by a Desktop Companion App session.
  - name: Embed tokens
    description: Mint short-lived, read-only tokens for the embeddable iframe.
  - name: Health
    description: Authenticated connectivity check.

paths:
  /health:
    get:
      tags: [Health]
      operationId: getHealth
      summary: Authenticated health check
      description: |
        Confirms the API is reachable and your credential resolves an
        organization. Returns the resolved `organizationId` and `actorType`.
      responses:
        "200":
          description: The credential resolved an organization.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                required: [status, organizationId, actorType]
                properties:
                  status:
                    type: string
                    const: ok
                  organizationId:
                    type: string
                  actorType:
                    type: string
                    enum: [api_key, embed_token]
        "401":
          $ref: "#/components/responses/Unauthorized"

  /projects/{id}/interpret:
    post:
      tags: [Interpret]
      operationId: interpretProject
      summary: Interpret a prompt against a project
      description: |
        The headline capability: a prompt in, a cited interpretation out, over
        the existing assistant. Two response modes off one endpoint:

        - `mode: "sync"` (default) — a blocking JSON body `{ answer, citations, usage }`.
        - `mode: "stream"` — a Server-Sent Events stream (`status` / `token` /
          `citations` / `done` / `error`). See the Streaming guide.

        Requires the `ai_explain` permission.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InterpretRequest"
            examples:
              example:
                summary: Example request
                value:
                  prompt: "What conditions must be true for the main conveyor to start?"
                  mode: sync
                  include_citations: true
      responses:
        "200":
          description: |
            For `mode: "sync"`, the cited interpretation. For `mode: "stream"`,
            an `text/event-stream` of SSE events (see the Streaming guide).
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InterpretResponse"
            text/event-stream:
              schema:
                type: string
                description: SSE stream of `status` / `token` / `citations` / `done` / `error` events.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/IdempotencyInProgress"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimited"

  /projects/{id}/generate:
    post:
      tags: [Generate]
      operationId: generateProjectCode
      summary: Generate or modify PLC code (proposal)
      description: |
        Ask the assistant to PRODUCE or MODIFY PLC logic and get back a
        reviewable proposal. **Proposes, never deploys:** the response is
        generated routine(s) / SCL text with inline insertion directives — it
        creates NO version. To persist a proposal, commit it as a new version
        with `POST /projects/{id}/versions` (requires the separate `code_write`
        scope). Requires the `ai_generate` permission.

        Two response modes off one endpoint:

        - `mode: "sync"` (default) — a blocking JSON body
          `{ generated_code, code_blocks, citations, usage }`.
        - `mode: "stream"` — a Server-Sent Events stream (`status` / `token` /
          `citations` / `done` / `error`). See the Streaming guide.

        `generated_code` is the assistant's full markdown response (prose +
        fenced ` ```ladder ` / ` ```st ` / ` ```scl ` blocks + inline directives
        such as `INSERT_RUNG_AFTER` / `MARK_DELETE`); `code_blocks` is those
        fenced blocks lifted out for programmatic use.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GenerateRequest"
            examples:
              example:
                summary: Example request
                value:
                  prompt: "Add a 5-second start-up delay timer before the conveyor enables."
                  target:
                    program: MainProgram
                    routine: ConveyorControl
                  mode: sync
                  include_citations: true
      responses:
        "200":
          description: |
            For `mode: "sync"`, the generated proposal. For `mode: "stream"`, a
            `text/event-stream` of SSE events (see the Streaming guide).
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerateResponse"
            text/event-stream:
              schema:
                type: string
                description: SSE stream of `status` / `token` / `citations` / `done` / `error` events.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/IdempotencyInProgress"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimited"

  /projects/{id}/conversations:
    post:
      tags: [Conversations]
      operationId: createConversation
      summary: Create a troubleshooting thread
      description: |
        Open a stateful multi-turn thread scoped to a project. Append turns with
        `POST /conversations/{cid}/messages`. Requires `ai_explain`.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: Optional human label for the thread.
      responses:
        "201":
          description: The created thread.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Conversation"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/IdempotencyInProgress"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimited"

  /conversations/{cid}/messages:
    post:
      tags: [Conversations]
      operationId: appendMessage
      summary: Append a turn to a thread
      description: |
        Run one interpret turn inside an existing thread. The thread's prior
        history is loaded automatically. Requires `ai_explain`.
      parameters:
        - $ref: "#/components/parameters/ConversationId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MessageRequest"
      responses:
        "200":
          description: The assistant's answer for this turn.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/IdempotencyInProgress"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimited"

  /projects/{id}/analyses:
    post:
      tags: [Analyses]
      operationId: startAnalysis
      summary: Start an async analysis job
      description: |
        Kick off an analysis run (dead code, missing handshakes, cycle-time) and
        return a job id immediately. Poll `GET /analyses/{analysisId}` for status
        and results. Requires `analysis_tab`.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: The analysis job was started (or joined an in-flight run).
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AnalysisStarted"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/IdempotencyInProgress"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimited"

  /analyses/{analysisId}:
    get:
      tags: [Analyses]
      operationId: getAnalysis
      summary: Poll an analysis job
      description: |
        Return the current status of an analysis job. While running, the
        response carries a `status` of `queued`/`running` plus a `message` with
        poll guidance — never an empty 200. When `complete`, `results` is
        populated; when `error`, `errors` is populated. Requires `analysis_tab`.
      parameters:
        - $ref: "#/components/parameters/AnalysisId"
      responses:
        "200":
          description: The analysis job status (and results when complete).
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AnalysisPoll"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /projects/{id}/exports/plc:
    post:
      tags: [Exports]
      operationId: startPlcExport
      summary: Export the project to its vendor PLC file
      description: |
        Start an async export of the project's current committed version to its
        native vendor format (Rockwell `.L5X` or a Siemens TIA `.zip`). Returns a
        job id immediately; poll `GET /exports/{exportId}` for status, then GET
        the `download_url` when `complete`. Exports the current version as-is (no
        edits). Requires `export_plc`.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: The export job was started.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExportStarted"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/IdempotencyInProgress"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimited"

  /projects/{id}/exports/pdf:
    post:
      tags: [Exports]
      operationId: startPdfExport
      summary: Render a PDF report for the project
      description: |
        Start an async render of a PDF documentation report for the project's
        current committed version. The report is assembled server-side from the
        parsed structure + analysis results (a "report-only" PDF; it does not
        include in-app chat history). Returns a job id immediately; poll
        `GET /exports/{exportId}` then GET the `download_url`. Requires
        `export_pdf`.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: The export job was started.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExportStarted"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/IdempotencyInProgress"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimited"

  /exports/{exportId}:
    get:
      tags: [Exports]
      operationId: getExport
      summary: Poll an export job
      description: |
        Return the current status of an export job. While pending/running the
        response carries a `message` with poll guidance. When `complete`,
        `download_url` (a relative `/exports/{id}/download` path) and `filename`
        are populated; when `error`, `error` carries a short reason. Gated on the
        export's matching permission (`export_plc` / `export_pdf`).
      parameters:
        - $ref: "#/components/parameters/ExportId"
      responses:
        "200":
          description: The export job status (and download_url when complete).
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExportPoll"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /exports/{exportId}/download:
    get:
      tags: [Exports]
      operationId: downloadExport
      summary: Download a completed export artifact
      description: |
        Stream the finished artifact (L5X / Siemens ZIP / PDF) for a `complete`
        export job, with the right `Content-Type` and a `Content-Disposition`
        filename. Gated on the export's matching permission and scope.
      parameters:
        - $ref: "#/components/parameters/ExportId"
      responses:
        "200":
          description: The export artifact bytes.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "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"

  /projects:
    get:
      tags: [Projects]
      operationId: listProjects
      summary: List projects
      description: |
        Enumerate the organization's active projects, most-recently-touched
        first. Requires only a valid key (no extra permission) — an
        `ai_explain`-only operator key still needs to discover which projects
        exist. Results are filtered to the key's project scope. Returns
        identity-level metadata only; read source via `GET /projects/{id}/source`.
      parameters:
        - name: team_id
          in: query
          required: false
          description: Restrict to a single team.
          schema:
            type: string
        - name: limit
          in: query
          required: false
          description: Page size (default 50, max 200).
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: cursor
          in: query
          required: false
          description: Opaque cursor from a previous response's `next_cursor`.
          schema:
            type: string
      responses:
        "200":
          description: A page of projects.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      tags: [Projects]
      operationId: ingestProject
      summary: Ingest an L5X / Siemens export
      description: |
        Upload an L5X (Rockwell) or Siemens TIA ZIP export to create or update
        a project. Billed identically to a UI upload.

        Files up to ~4.5 MB may be sent inline as base64 in `file.inline`. Larger
        files use the large-file flow: upload to the returned blob URL and pass
        `file.blob_url`. Requires `project_upload`.

        A project-scoped key may only add a version to an in-scope identity; it
        can never create a new identity.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IngestRequest"
      responses:
        "201":
          description: The resolved project identity and version.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/BillingConsentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/IdempotencyInProgress"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimited"

  /projects/{id}:
    get:
      tags: [Projects]
      operationId: getProject
      summary: Get a project
      description: |
        Identity + current-version metadata for one project, plus an
        `analysis_status` summary (not the full results blob). Requires only a
        valid key (no extra permission); scoped to the key's project scope.
        Unknown or out-of-scope id → 404.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: The project metadata.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /projects/{id}/versions:
    post:
      tags: [Projects]
      operationId: commitProjectVersion
      summary: Commit a new version (Save project)
      description: |
        Save a new version of an existing project from an updated vendor file —
        the headless equivalent of the platform's "Save project". The new
        version becomes the project's current version (billed identically to a
        UI save). Requires `code_write`.

        The uploaded file must be the same vendor as the project (an L5X stays
        L5X, a Siemens ZIP stays Siemens) — a mismatch returns `400`. Re-sending
        the exact bytes of the current version is a no-op: it returns `200` with
        `resolution: identical_file` and no new version.

        Files up to ~4.5 MB may be sent inline as base64 in `file.inline`; larger
        files use the large-file flow via `file.blob_url`. A project-scoped key
        may only commit to in-scope projects.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IngestRequest"
      responses:
        "201":
          description: A new version was committed.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CommitVersionResponse"
        "200":
          description: The uploaded bytes matched the current version; no new version was created.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CommitVersionResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/IdempotencyInProgress"
        "413":
          $ref: "#/components/responses/BodyTooLarge"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimited"

  /projects/{id}/source:
    get:
      tags: [Source]
      operationId: getProjectSource
      summary: Read a project's source
      description: |
        Return the project's source in one of several representations.
        Requires `code_read`.

        - `format=parsed` (default): the vendor-neutral parsed structure as
          JSON. Projects whose parsed JSON exceeds the 4.5 MB inline ceiling
          return `413`; use `format=raw` for those.
        - `format=raw`: the original uploaded file (L5X XML or Siemens ZIP),
          streamed or 302-redirected as a binary download.
        - `format=scl`: the aggregated Structured-Text view (`{ scl }`).
        - `format=ladder`: the aggregated ladder view (`{ ladder }`).
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [parsed, raw, scl, ladder]
            default: parsed
      responses:
        "200":
          description: |
            The requested representation. JSON for `parsed`/`scl`/`ladder`; a
            binary stream (or 302 redirect) for `raw`.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectSource"
            application/octet-stream:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          description: The parsed structure is too large to return inline; use `format=raw`.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /projects/{id}/hmi/values:
    get:
      tags: [HMI]
      operationId: getHmiValues
      summary: Latest live tag values
      description: |
        The most recent tag snapshot pushed by a Desktop Companion App (DCA)
        session for this project. Requires `hmi_view`. Returns data **only while
        a DCA is actively streaming**; with no live session, `live` is `false`
        and `tags` is empty (this is a normal 200, not an error).
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: The latest snapshot, or an empty `live=false` body.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HmiValues"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /projects/{id}/hmi/history:
    get:
      tags: [HMI]
      operationId: getHmiHistory
      summary: Recent value history for a tag
      description: |
        The most-recent-first value history a DCA session pushed for one tag.
        Requires `hmi_view` and a `tag` query parameter. Empty when no live
        session has streamed that tag.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: tag
          in: query
          required: true
          description: The tag name to fetch history for.
          schema:
            type: string
        - name: limit
          in: query
          required: false
          description: Max entries (default 50, max 600).
          schema:
            type: integer
            minimum: 1
            maximum: 600
            default: 50
      responses:
        "200":
          description: The tag's recent history.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HmiHistory"
        "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"

  /projects/{id}/hmi/request-tags:
    post:
      tags: [HMI]
      operationId: requestHmiTags
      summary: Request live values for specific tags on demand
      description: |
        Ask the Desktop Companion App (DCA) serving this project to read a
        specific set of tags on its next poll — even if the operator never
        pressed "Start streaming" (the DCA only needs: connected + signed in +
        project selected). Requires `hmi_edit`.

        This is a thin request signal, **not** a data read: it records the
        requested tag names and reports whether a DCA is currently able to serve
        them (`live`). Poll `GET /projects/{id}/hmi/values` (or `interpret`) a
        few seconds later to read the delivered values.

        Naturally idempotent — re-requesting the same tags simply extends the
        request window — so **no `Idempotency-Key` is required**. The list is
        de-duped and capped to 25 names; unknown names are dropped by the DCA
        against its discovered tag list.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HmiRequestTagsRequest"
      responses:
        "200":
          description: The request was recorded; `live` reports DCA availability.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HmiRequestTagsResponse"
        "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"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /embed-tokens:
    post:
      tags: [Embed tokens]
      operationId: mintEmbedToken
      summary: Mint a read-only embed token
      description: |
        Mint a short-lived, project-scoped token for the embeddable read-only
        assistant iframe. Regardless of the minting key's scope, the token is
        intersected down to read-only (`ai_explain` + `hmi_view`) — a
        browser-delivered token can never carry a write scope. The minting key
        must itself have at least `ai_explain`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id]
              properties:
                project_id:
                  type: string
                  description: The project the embed token may access.
      responses:
        "201":
          description: The minted read-only embed token.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmbedToken"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/IdempotencyInProgress"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimited"

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: plck_live_
      description: |
        An API key minted from **Settings → API Keys**, sent as
        `Authorization: Bearer plck_live_…`.

  headers:
    RequestId:
      description: A unique id for this response. Quote it in support requests.
      schema:
        type: string
        examples: ["req_8f2a1c9d4e5b6a7c8d9e0f12"]
    RetryAfter:
      description: Seconds to wait before retrying.
      schema:
        type: integer

  parameters:
    ProjectId:
      name: id
      in: path
      required: true
      description: The project id.
      schema:
        type: string
    ConversationId:
      name: cid
      in: path
      required: true
      description: The conversation (thread) id.
      schema:
        type: string
    AnalysisId:
      name: analysisId
      in: path
      required: true
      description: The analysis job id returned by `startAnalysis`.
      schema:
        type: string
    ExportId:
      name: exportId
      in: path
      required: true
      description: The export job id returned by `startPlcExport` / `startPdfExport`.
      schema:
        type: string
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        A unique key (e.g. a UUID) for this write. Retrying with the same key
        and body replays the original result without double-billing.
      schema:
        type: string

  schemas:
    InterpretRequest:
      type: object
      required: [prompt]
      properties:
        prompt:
          type: string
          description: The question to ask about the project.
          minLength: 1
        mode:
          type: string
          enum: [sync, stream]
          default: sync
          description: |
            `sync` returns a blocking JSON body. `stream` returns an SSE stream.
        include_citations:
          type: boolean
          default: true
          description: Whether to include source citations in the response.

    InterpretResponse:
      type: object
      required: [answer, citations, usage]
      properties:
        answer:
          type: string
        citations:
          type: array
          items:
            type: string
          description: Source identifiers backing the answer.
        usage:
          $ref: "#/components/schemas/Usage"

    Usage:
      type: object
      description: |
        Token counts for this call. Informational only — not a bill.
      required: [input_tokens, output_tokens]
      properties:
        input_tokens:
          type: integer
        output_tokens:
          type: integer

    GenerateRequest:
      type: object
      required: [prompt]
      properties:
        prompt:
          type: string
          description: What to generate or change (e.g. "Add a 5-second start-up delay timer").
          minLength: 1
        target:
          type: object
          description: |
            Optional hint for which program/routine to target. Folded into the
            prompt as guidance; the assistant decides the final placement.
          properties:
            program:
              type: string
            routine:
              type: string
        mode:
          type: string
          enum: [sync, stream]
          default: sync
          description: |
            `sync` returns a blocking JSON body. `stream` returns an SSE stream.
        include_citations:
          type: boolean
          default: true
          description: Whether to include source citations in the response.

    GenerateResponse:
      type: object
      required: [generated_code, code_blocks, citations, usage]
      properties:
        generated_code:
          type: string
          description: |
            The assistant's full proposal: prose + fenced code blocks + inline
            insertion directives. The same text the in-app editor parses. This
            is a PROPOSAL — it creates no version.
        code_blocks:
          type: array
          description: Fenced code blocks lifted from `generated_code` for programmatic use.
          items:
            type: object
            required: [language, content]
            properties:
              language:
                type: string
                nullable: true
                description: The fence language tag (e.g. `ladder`, `st`, `scl`), or null.
              content:
                type: string
        citations:
          type: array
          items:
            type: string
          description: Source identifiers backing the proposal.
        usage:
          $ref: "#/components/schemas/Usage"

    Conversation:
      type: object
      required: [conversationId, projectId]
      properties:
        conversationId:
          type: string
        projectId:
          type: string

    MessageRequest:
      type: object
      required: [prompt]
      properties:
        prompt:
          type: string
          minLength: 1
        include_citations:
          type: boolean
          default: true

    MessageResponse:
      type: object
      required: [conversationId, answer, citations, usage]
      properties:
        conversationId:
          type: string
        answer:
          type: string
        citations:
          type: array
          items:
            type: string
        usage:
          $ref: "#/components/schemas/Usage"

    ExportStarted:
      type: object
      required: [exportId, status]
      properties:
        exportId:
          type: string
        status:
          type: string
          enum: [pending, running, complete, error]

    ExportPoll:
      type: object
      required: [exportId, kind, status]
      properties:
        exportId:
          type: string
        kind:
          type: string
          enum: [plc, pdf]
        status:
          type: string
          enum: [pending, running, complete, error]
        download_url:
          type: string
          description: Present only when `status` is `complete`. A relative path.
        filename:
          type: string
          description: Suggested download filename (present when `complete`).
        error:
          type: string
          description: Short failure reason (present when `status` is `error`).
        message:
          type: string
          description: Poll guidance (present while pending/running).

    AnalysisStarted:
      type: object
      required: [analysisId, status]
      properties:
        analysisId:
          type: string
        status:
          type: string
          enum: [queued, running, complete, error]

    AnalysisPoll:
      type: object
      required: [analysisId, status]
      properties:
        analysisId:
          type: string
        status:
          type: string
          enum: [queued, running, complete, error]
        message:
          type: string
          description: Present while `queued`/`running` — poll-interval guidance.
        results:
          description: Present when `status` is `complete`.
        errors:
          description: Present when `status` is `error`.

    IngestRequest:
      type: object
      required: [originalFilename, file]
      properties:
        originalFilename:
          type: string
          description: The export file name, e.g. "Conveyor.L5X" or "TIA_Export.zip".
        name:
          type: string
          description: Optional display name for the project.
        acknowledge_billing:
          type: boolean
          description: |
            Acknowledges the per-project charge for an enterprise per-project
            organization. Only consulted when this upload would create a NEW
            billable project; ignored for every other org type and for
            add-version / identical-file. When a new billable project is created
            without this set to `true`, the request is rejected with
            `402 billing_acknowledgement_required` (carrying the projected cost);
            resend with `true` to confirm. Ignored on `POST /projects/{id}/versions`.
        file:
          type: object
          description: Exactly one of `inline` or `blob_url` must be set.
          properties:
            inline:
              type: string
              description: Base64-encoded file bytes (for files up to ~4.5 MB).
            blob_url:
              type: string
              format: uri
              description: A blob URL for the large-file flow.

    IngestResponse:
      type: object
      required: [identity_id, project_id, vendor, resolution]
      properties:
        identity_id:
          type: string
        display_name:
          type: string
        project_id:
          type: string
        version_id:
          type: string
        version_number:
          type: integer
        resolution:
          type: string
          description: How the identity was resolved (e.g. created / existing / identical_file).
        vendor:
          type: string
          enum: [allen-bradley, siemens]

    CommitVersionResponse:
      type: object
      required: [project_id, identity_id, version_number, resolution, vendor]
      properties:
        project_id:
          type: string
        identity_id:
          type: string
        version_id:
          type: string
          nullable: true
          description: The new version's id; `null` when `resolution` is `identical_file`.
        version_number:
          type: integer
        resolution:
          type: string
          enum: [add_version, identical_file]
          description: |
            `add_version` when a new version was committed; `identical_file` when
            the uploaded bytes matched the current version (no-op).
        vendor:
          type: string
          enum: [allen-bradley, siemens]

    ProjectListItem:
      type: object
      required: [project_id, identity_id, name, current_version_id, updated_at]
      properties:
        project_id:
          type: string
        identity_id:
          type: string
          description: The stable project identity id that project scope is keyed on.
        name:
          type: string
        vendor:
          type: string
          nullable: true
          enum: [allen-bradley, siemens, null]
        file_type:
          type: string
          nullable: true
          description: e.g. "l5x", "zip", "scl".
        current_version_id:
          type: string
          nullable: true
        updated_at:
          type: string
          format: date-time

    ProjectList:
      type: object
      required: [projects, next_cursor]
      properties:
        projects:
          type: array
          items:
            $ref: "#/components/schemas/ProjectListItem"
        next_cursor:
          type: string
          nullable: true
          description: Pass as `?cursor=` to fetch the next page; `null` on the last page.

    ProjectDetail:
      type: object
      required: [project_id, identity_id, name, current_version_id, created_at, updated_at]
      properties:
        project_id:
          type: string
        identity_id:
          type: string
        name:
          type: string
        display_name:
          type: string
          nullable: true
        vendor:
          type: string
          nullable: true
        file_type:
          type: string
          nullable: true
        file_size_bytes:
          type: integer
          nullable: true
        original_filename:
          type: string
          nullable: true
        current_version_id:
          type: string
          nullable: true
        version_number:
          type: integer
          nullable: true
        analysis_status:
          type: string
          nullable: true
          enum: [queued, running, complete, error, null]
          description: Summary only — poll `GET /analyses/{id}` for full results. `null` = never analyzed.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ProjectSource:
      type: object
      description: |
        The JSON form of `GET /projects/{id}/source` for `format` =
        `parsed` / `scl` / `ladder`. (`format=raw` returns a binary stream.)
      required: [format, vendor]
      properties:
        format:
          type: string
          enum: [parsed, scl, ladder]
        vendor:
          type: string
          enum: [allen-bradley, siemens]
        parsed:
          description: Present when `format=parsed` — the vendor-neutral parsed structure.
        scl:
          type: string
          description: Present when `format=scl` — aggregated Structured Text.
        ladder:
          type: array
          items:
            type: object
          description: Present when `format=ladder` — aggregated ladder rungs.

    HmiValues:
      type: object
      required: [project_id, live, tags]
      properties:
        project_id:
          type: string
        live:
          type: boolean
          description: True when a DCA session is actively streaming this project.
        message:
          type: string
          description: Present when `live=false` — explains why there are no values.
        device_id:
          type: string
        timestamp:
          type: string
          format: date-time
        controller:
          type: object
          properties:
            name: { type: string }
            type: { type: string }
            status: { type: string }
            ipAddress: { type: string }
        sequence:
          type: integer
        tags:
          type: object
          additionalProperties: true
          description: Map of tag name → latest value.

    HmiHistory:
      type: object
      required: [project_id, tag, count, history]
      properties:
        project_id:
          type: string
        tag:
          type: string
        count:
          type: integer
        history:
          type: array
          description: Most-recent-first history entries.
          items:
            type: object
            properties:
              value: {}
              timestamp:
                type: string
                format: date-time
              sequence:
                type: integer

    HmiRequestTagsRequest:
      type: object
      required: [tags]
      properties:
        tags:
          type: array
          description: |
            Tag names to request live values for (verbatim project tag names).
            De-duped and capped to 25; unknown names are dropped by the DCA.
          minItems: 1
          maxItems: 25
          items:
            type: string
            minLength: 1

    HmiRequestTagsResponse:
      type: object
      required: [project_id, live, requested]
      properties:
        project_id:
          type: string
        live:
          type: boolean
          description: |
            True when a DCA is currently able to serve this project (connected +
            project selected, or already streaming). When false, the request is
            still recorded but no values arrive until a DCA connects.
        requested:
          type: array
          description: The tag names actually recorded (de-duped, capped to 25).
          items:
            type: string

    EmbedToken:
      type: object
      required: [token, expires_at, permissions, project_id]
      properties:
        token:
          type: string
          description: The signed, read-only embed token to hand to a browser.
        expires_at:
          type: string
          format: date-time
        permissions:
          type: object
          additionalProperties:
            type: boolean
          description: The intersected read-only permissions (only ai_explain / hmi_view).
        project_id:
          type: string

    Error:
      type: object
      description: The standard error envelope for every 4xx/5xx response.
      required: [error, message, isRetryable]
      properties:
        error:
          type: string
          description: A stable machine-readable error code.
        message:
          type: string
          description: A short technical description.
        userMessage:
          type: string
          description: A human-readable explanation safe to surface to an end user.
        suggestedAction:
          type: string
          description: What to do about it.
        isRetryable:
          type: boolean
          description: Whether retrying the same request may succeed.
        request_id:
          type: string
          description: Echo of the `request-id` header.

    BillingConsentError:
      type: object
      description: |
        The error envelope returned for `billing_acknowledgement_required`. It
        is the standard `Error` plus a `billing` block describing the projected
        per-project charge, so an integration can disclose the cost before
        retrying with `acknowledge_billing: true`.
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          properties:
            billing:
              type: object
              properties:
                price_per_project_cents:
                  type: integer
                  description: The per-project rate this create will be billed at, in cents.
                currency:
                  type: string
                  example: usd
                billed_count_after:
                  type: integer
                  description: The org's billable project count after this create settles.

  responses:
    BadRequest:
      description: The request was malformed.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: No valid credential, or the key was revoked.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: The key lacks the permission (or project scope) this endpoint needs.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: The resource was not found, or is not visible to this key's org.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    IdempotencyInProgress:
      description: A request with this Idempotency-Key is still being processed.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    IdempotencyConflict:
      description: This Idempotency-Key was already used with a different body.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    BodyTooLarge:
      description: The inline body exceeded the limit; use the large-file flow.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: Per-key rate limit exceeded.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    ServiceUnavailable:
      description: |
        The endpoint is gated by a server feature flag that is currently off.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    PaymentRequired:
      description: |
        The organization reached its self-set API spend limit for the period and
        chose to pause. An owner/admin can raise the limit or enable overage
        billing in Settings → Spending Controls.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    BillingConsentRequired:
      description: |
        An enterprise per-project organization tried to create a NEW billable
        project over the API. The `error` code is one of:
          - `billing_setup_required` — billing isn't set up yet (e.g. their first
            project); a charge can't be approved with no payment configured. Set
            up billing in the PLCs desktop app first, then retry.
          - `billing_acknowledgement_required` — billing is set up but the charge
            wasn't acknowledged. Disclose the cost from the `billing` block, then
            resend with `acknowledge_billing: true`.
        Only ever returned for enterprise per-project orgs on a new-project
        create — never for other org types, add-version, or identical-file.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/BillingConsentError"
