openapi: 3.1.0
info:
  title: PLCs.ai API
  # The date this contract last changed. Deliberately not a semantic version:
  # there is no way to request an earlier contract, so a semver number would
  # imply a pinning guarantee the API does not offer. See "Versioning" below.
  version: "2026-10-07"
  summary: A versioned HTTP API for interpreting and analyzing PLC projects.
  description: |
    The PLCs.ai API provides plain-language interpretation, troubleshooting,
    and analysis of PLC projects behind a versioned, externally-authenticated
    HTTP surface.

    **Scope.** The API reasons over projects your organization already owns. It
    does not onboard a machine: a project enters the platform through the app or
    a version-source connector, and there is no create-a-project verb here. Once
    a project exists you can read it, ask questions about it, propose changes,
    and save new versions of it.

    **Platform support.** Three readers cover the platforms we read: Rockwell
    Allen-Bradley (`.L5X` from Studio 5000), Siemens TIA Portal (an Openness
    `.zip` export) and CODESYS V3 (`.export`).

    The CODESYS reader covers the OEM toolchains built on CODESYS V3. All of
    them carry the same `vendor` value — `codesys` — and the same `.export`
    format, so there is one API surface for the family rather than one per
    brand:

    | Brand | Supported product line | Export comes from |
    |---|---|---|
    | CODESYS V3 | Any V3 controller | CODESYS V3 |
    | WAGO | PFC100 / PFC200 | e!COCKPIT |
    | ABB | AC500 V3 | Automation Builder |
    | Eaton | XC / XV | XSoft-CODESYS-3 |
    | Bosch Rexroth | ctrlX CORE | ctrlX WORKS |
    | Festo | CPX-E-CEC | CODESYS V3 |
    | Lenze | c300 / p300 / c430 / c520 / c550 | PLC Designer |
    | ifm electronic | ecomatController / ecomatmobile | CODESYS V3 |
    | Phoenix Contact | PLCnext | CODESYS Control for PLCnext |
    | Turck | TX500 HMI/PLC, TBEN-L-PLC | CODESYS V3 |
    | Schneider Electric | Modicon machine controllers | EcoStruxure Machine Expert |

    Coverage is part of the contract, so this table is published rather than
    implied. Where a cell says **422**, that endpoint returns
    `422 vendor_unsupported` with a body naming the supported alternative — it
    never returns an answer produced for a different platform. Every verb
    currently covers all three platforms; the notation stays because a platform
    joins verb by verb.

    | Endpoint | Allen-Bradley | Siemens | CODESYS |
    |---|---|---|---|
    | `GET /projects/{id}` | yes | yes | yes |
    | `GET /projects/{id}/source` | yes | yes | yes |
    | `POST /projects/{id}/versions` | yes | yes | yes |
    | `POST /projects/{id}/uploads` | yes | yes | yes |
    | `POST /projects/{id}/interpret` | yes | yes | yes |
    | `POST /projects/{id}/generate` | yes | yes | yes |
    | `POST /projects/{id}/conversations` | yes | yes | yes |
    | `GET /projects/{id}/analysis` | yes | yes | yes |
    | `GET /projects/{id}/devices` | yes | yes | yes |
    | `POST /projects/{id}/analyses` | yes | yes | yes |
    | `GET /analyses/{analysisId}` | yes | yes | yes |
    | `POST /projects/{id}/exports/plc` | yes | yes | yes |
    | `POST /projects/{id}/exports/pdf` | yes | yes | yes |

    **What a PDF report contains differs by platform**, because the report is
    assembled from what the stored parse can supply on the server. Allen-Bradley
    and CODESYS reports carry the full structural inventory and their drawn logic
    — for Allen-Bradley the project tree, controller, tags, UDTs, AOIs, RLL rungs
    and FBD/SFC sheets; for CODESYS the project overview and import receipt, the
    device and task configuration, the program units with their ST bodies, global
    variables and located I/O, DUTs and interfaces, the HMI screen inventory and
    signal map, the LD networks and the CFC sheets. A **Siemens** report carries
    the aggregated SCL and the pipeline analysis chapters only: its block, tag and
    UDT chapters and its LAD/FBD/GRAPH diagrams are drawn by the in-app export,
    not here. PackML is an Allen-Bradley-only chapter on every lane.

    **`generate` on a CODESYS project** runs the same two-step flow as the other
    two platforms — propose a plan, approve it, then get code — and the proposed
    changes are CODESYS-shaped. A change's `code_type` is `ST` (the complete
    Structured Text body for the unit), `Declaration` (the unit's complete
    declaration, for a new variable, DUT, interface, POU, method or property),
    `LD` (a rung-edit script rather than source, because a CODESYS ladder body is
    edited rung by rung) or `Task` (a task-configuration change, whose `content`
    is a program name or the new task's fields). As on every platform, nothing is
    persisted: commit a proposal with `POST /projects/{id}/versions` (the separate
    `code_write` scope), or make the change in the in-app assistant instead.

    **Versioning.** The `/api/v1` path is a **namespace, not a contract
    version** — it does not change when the contract does. What identifies the
    contract is `info.version` above, which is the **date this contract last
    changed** rather than a semantic version. That is deliberate: there is
    currently no way to request an earlier contract — no version header, no date
    parameter — so a semver number would imply a pinning guarantee that does not
    exist.

    This contract is **not yet frozen.** Until it is, breaking changes land in
    place and move the date; there is no parallel older surface to fall back to.
    Check the date before relying on a response shape, and pin your client
    library rather than the URL. A version header for requesting a specific
    contract date is planned; when it ships, this section will say so.

    **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 the organization's projects and save new versions of them.
  - 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: askProject
      summary: Ask a question about a project
      description: |
        The headline capability: a question in, a grounded answer out.

        The assistant is interactive — when your question is ambiguous it can
        stop and ask rather than guessing. So a successful response is one of
        two shapes, discriminated by `status`:

        - `status: "answer"` — the answer is ready.
        - `status: "needs_input"` — the assistant needs `questions` answered
          first. Relay them to your user (or decide yourself — each carries a
          `recommended_index` hint), then call this endpoint again with the same
          `conversation_id` and an `answers` array. Nothing is guessed on your
          behalf.
        - `status: "unresolved"` — the prompt asked to CHANGE the project rather
          than asking about it. `reason` names the code-proposal endpoint to use
          instead. Returned immediately, before any analysis is billed.

        Two response modes off one endpoint:

        - `mode: "sync"` (default) — a blocking JSON body.
        - `mode: "stream"` — a Server-Sent Events stream (`status` / `token` /
          `outcome` / `done` / `error`). A turn that ends on questions emits
          `outcome` with them instead of prose. See the Streaming guide.

        Requires the `ai_explain` permission. Platform coverage is the
        capability matrix in the API overview; where it says 422, the refusal
        lands before anything is metered.
      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
              answering:
                summary: Answering a prior turn's questions
                value:
                  conversation_id: "v1-interpret-9f2c1e64-..."
                  answers:
                    - id: scope
                      selected_index: 0
      responses:
        "200":
          description: |
            For `mode: "sync"`, the answer or the questions that must be
            answered first. 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/InterpretResponse"
            text/event-stream:
              schema:
                type: string
                description: SSE stream of `status` / `token` / `outcome` / `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/ProjectNotReady"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"

  /projects/{id}/generate:
    post:
      tags: [Generate]
      operationId: proposeCode
      summary: Propose a code change (plan, then author)
      description: |
        Ask the assistant to PRODUCE or MODIFY PLC logic. **Proposes, never
        deploys:** no step here creates a version. To persist a proposal, commit
        it with `POST /projects/{id}/versions` (the separate `code_write` scope).
        Requires the `ai_generate` permission.

        This is a **two-step flow**, because a change should be reviewed before
        it is written:

        1. Send a `prompt`. You get `status: "plan"` — what would change, step by
          step, with any risks. No code is authored yet.
        2. Show the plan to your user. If they approve, call again with the same
          `conversation_id` and `approve: true` (optionally with an `amendment`
          describing a tweak). You get `status: "code"` with the proposed
          `changes`.

        The plan you approve is the one the server recorded in step 1 — it is not
        re-sent by you, so the change that gets authored is exactly the one that
        was reviewed.

        A turn can also return `status: "needs_input"` (answer the `questions`
        and call again, as with `askProject`) or `status: "unresolved"` (nothing
        could be proposed; `reason` says what to do).

        Two response modes off one endpoint:

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

        Platform coverage is the capability matrix in the API overview; where it
        says 422, the refusal lands before anything is metered.
      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
              approving:
                summary: Approving the plan from a prior call
                value:
                  conversation_id: "v1-generate-4b81a0d2-..."
                  approve: true
      responses:
        "200":
          description: |
            For `mode: "sync"`, the plan, the proposed changes, or the questions
            that must be answered first. 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` / `outcome` / `done` / `error` events.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: |
            The project was not found, or `approve: true` named a conversation
            with no plan awaiting approval (already approved, or never proposed).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          $ref: "#/components/responses/ProjectNotReady"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "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`.

        A thread answers each turn exactly as `interpret` does, so it covers the
        same platforms — see the capability matrix in the API overview.
      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/Unprocessable"
        "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"
        "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}/analyses:
    post:
      tags: [Analyses]
      operationId: startAnalysis
      summary: Re-run the project's analysis
      description: |
        Run analysis again on the project's current version, and return
        immediately — the run continues in the background. Read the result with
        `GET /projects/{id}/analysis`.

        This is the only verb on this surface that produces an analysis. A
        project uploaded in the app is analyzed there; a version committed with
        `POST /projects/{id}/versions` is **not**, so this is how a caller gets
        analysis onto a version it authored. It is a deliberate, billable act —
        analysis is expensive and per-version — which is why it is an HTTP
        operation rather than an agent tool.

        Only one run exists per version at a time. If a healthy run is already
        in flight this joins it rather than scheduling a second: the response
        says `started: false` and no additional work (or spend) is incurred.

        The run is not selectable: there is no request body, and every phase the
        project's detected industry calls for is executed. Which standards
        chapters appear therefore varies by project, not by request. Requires
        `analysis_tab`. Covers all three platforms.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: A run is in flight — either newly started, or already going.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AnalysisStarted"
        "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}/analysis:
    get:
      tags: [Analyses]
      operationId: getProjectAnalysis
      summary: Read the project's analysis
      description: |
        Return the analysis the platform already ran for this project — its
        current version by default, or the version `version_id` names. Nothing
        produces one as a side effect: it comes from analyzing the project in
        the app, or from an explicit `POST /projects/{id}/analyses`.

        `status` is one of:

        - `not_analyzed` — no analysis has ever run for this version. It will
          not start on its own; `POST /projects/{id}/analyses` produces one.
          This is a normal state, not an error, and it is the expected answer
          right after `POST /projects/{id}/versions`, which commits a version
          without analyzing it.
        - `queued` / `running` — a run is in flight. The response carries a
          `message` with poll guidance and no results yet. Normal progress.
        - `complete` — `results` holds the analysis. `failed_phases` may still
          be present when some phases failed but the run finished, and
          `skipped_phases` when the run's time budget ran out before some
          phases could start — those produced nothing, but nothing broke.
        - `error` — the run failed; `failed_phases` names the phases that did,
          and `skipped_phases` any that never ran because of it.

        **When the current version is `not_analyzed`, the project's existing
        analysis is still reachable.** The response then carries
        `last_analyzed_version_id` (and `last_analyzed_at`) naming the most
        recent version whose analysis completed — usually the version the last
        commit superseded. Pass that id back as `?version_id=` to read it.

        A version-pinned read serves that version's stored analysis and never
        substitutes it for the current one: the payload keeps naming the version
        it describes and carries `is_current_version: false`. Treat it as a
        statement about superseded code — report which version it describes and
        that the current version has not been analyzed, and remember only
        `POST /projects/{id}/analyses` produces a current one.

        `version_id` resolves strictly inside the project in the path. A version
        belonging to another project — or another organization — returns `404`,
        identical to an id that exists nowhere. A version that exists but was
        never analyzed is a normal `200 not_analyzed`, not a `404`.

        For a cheap freshness check that returns no results blob, read
        `analysis_status` on `GET /projects/{id}` instead. Requires
        `analysis_tab`. Covers all three platforms.

        **`results.components` is not the whole device list.** It is a bounded,
        best-evidence-first working set — the devices the downstream analyses
        focused on. Every device the analysis found is paged from
        `GET /projects/{id}/devices`; on `complete`, the `devices` field here
        carries the count and the link.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: version_id
          in: query
          required: false
          description: |
            Read this version's stored analysis instead of the current
            version's. Must be a version of the project in the path. Omit for
            the current version.
          schema:
            type: string
      responses:
        "200":
          description: The analysis of the version read (and results when complete).
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectAnalysis"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /projects/{id}/devices:
    get:
      tags: [Analyses]
      operationId: getProjectDevices
      summary: Page through the project's device inventory
      description: |
        Every device the analysis found in the project — instruments, drives,
        I/O points, controller links — as the analysis stored them, one page at
        a time. This is the full inventory; `results.components` on
        `GET /projects/{id}/analysis` is a bounded working set selected out of
        it, so a device missing there is usually here.

        `status` is one of:

        - `stored` — `devices` is a page of the version's inventory. `total`
          and `identified` count the whole set matching the filters, not the
          page. Follow `next_cursor` until it is `null`.
        - `absent` — the version was analysed but no inventory is stored for
          it (the run did not produce one, or storing it failed). A normal
          `200` with no rows, not an error.
        - `not_analyzed` — no run has ever touched this version, so there is
          nothing to list. The expected answer right after
          `POST /projects/{id}/versions`; only `POST /projects/{id}/analyses`
          changes it.

        Each row carries `identified`: whether something *named* the device
        (a catalog number, an instrument code, the analysis itself) rather than
        merely finding a wire on it. A row with `identified: false` and a
        typed `type` is a wire whose direction is known and whose device is
        not. Filter with `identified=true` for the named ones.

        The cursor is bound to the version and the filter set it was issued
        for; reusing it with either changed is a `400`. `version_id` resolves
        strictly inside the project in the path, as on the analysis read — a
        version from elsewhere is a `404`, and a pinned read carries
        `is_current_version: false`.

        Requires `analysis_tab`. Read-only, rate-limited on the cheap-read
        tier. Covers all three platforms.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: version_id
          in: query
          required: false
          description: |
            List this version's inventory instead of the current version's.
            Must be a version of the project in the path.
          schema:
            type: string
        - name: limit
          in: query
          required: false
          description: Page size (default 200, max 500).
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 200
        - name: cursor
          in: query
          required: false
          description: Opaque cursor from a previous response's `next_cursor`.
          schema:
            type: string
        - name: type
          in: query
          required: false
          description: Only devices whose `type` equals this (e.g. `vfd`, `digital_input`, `unknown`).
          schema:
            type: string
        - name: module
          in: query
          required: false
          description: Only devices wired to this I/O drop (`location.module`).
          schema:
            type: string
        - name: identified
          in: query
          required: false
          description: "`true` → only identified devices; `false` → only the rest."
          schema:
            type: boolean
      responses:
        "200":
          description: One page of the version's device inventory.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectDevices"
        "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"

  /analyses/{analysisId}:
    get:
      tags: [Analyses]
      operationId: getAnalysis
      summary: Read one analysis run
      description: |
        Return the outcome of a single analysis run, by the `analysis_id`
        `startAnalysis` gave you.

        This stays pinned to the version the run analysed. If a new version is
        committed while the run is in flight, this still reports *your* run,
        while `GET /projects/{id}/analysis` moves on to the new current version.
        `is_current_version` tells you directly whether the version this run
        analysed is still the project's live one — when it is `false`, these
        findings describe code the project no longer contains and must not be
        reported as its current state.

        Not a run history: a version keeps only its most recent run id, so
        starting a second run on the same version makes the older id return
        `404`. Requires `analysis_tab`. Rate-limited on the slow-job tier — an
        analysis takes minutes, so poll no faster than every 5 seconds.
      parameters:
        - $ref: "#/components/parameters/AnalysisId"
      responses:
        "200":
          description: The run's status (and results when complete).
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AnalysisRun"
        "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 (a Rockwell `.L5X`, a Siemens TIA `.zip` or a
        CODESYS `.export`). 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`.

        A CODESYS export is written by patching the `.export` the version was
        uploaded from, so that file must still be in storage. When it is not,
        the job reaches `status: "error"` with a message saying so — the `202`
        only means the job was accepted, so always poll for the terminal state.
      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`.

        Which chapters appear depends on the platform and on whether an analysis
        has completed — see *What a PDF report contains differs by platform* under
        the capability matrix. A report rendered before any analysis run contains
        the structural chapters only.

        `detail` picks how much logic the report carries — the same three tiers
        the in-app export dialog offers:

        - `project` (the default) — project details, structure, tags, data types
          and the analysis chapters. No logic drawings and no source listings.
        - `diagrams` — adds the ladder and FBD/SFC (CODESYS: LD and CFC) drawings.
        - `everything` — adds the routine sections and source listings on top.

        A large plant's drawings run to hundreds of megabytes of print memory:
        `diagrams` and `everything` are refused for such a project (the job ends
        with `error: "pdf_too_large"`) where `project` still prints. Siemens is
        rendered here as one source document, so its report is the same at every
        level. `everything` is exactly what this operation rendered before
        `detail` existed.

        The Components Analysis chapter lists the analysis roster by default.
        Send `{ "full_device_inventory": true }` to list every device in the
        version's stored inventory instead (the same rows
        `GET /projects/{id}/devices` pages) — about 23 devices per page, so a
        large plant adds hundreds of pages. This is the only switch for the
        inventory here: unlike the in-app `everything` preset, no `detail` level
        turns it on. When no inventory is stored for the version the chapter
        falls back to the roster and says so. The body is part of what the
        `Idempotency-Key` is bound to; a body carrying only default values is
        bound exactly as the body-free request.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PdfExportRequest"
      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"
  /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`.

        **It does not analyze.** A commit is an authored edit, and analysis is
        expensive and per-version, so the new version starts out unanalyzed: the
        `201` carries `analysis: "not_analyzed"`, and
        `GET /projects/{id}/analysis` reports the same until someone asks for a
        run with `POST /projects/{id}/analyses`. This matches the app, where the
        editor's Save does not analyze and **Update Analysis** is a separate
        action. Graph and search indexing DO run, so questions asked through
        `interpret` / `conversations` see the committed change immediately.

        The uploaded file must be the same vendor as the project (an L5X stays
        L5X, a Siemens ZIP stays Siemens, a CODESYS `.export` stays `.export`) —
        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.

        The request body is capped at ~4.5 MB, so files up to about 3 MB may be
        sent inline as base64 in `file.inline`. Larger
        files go straight to storage: create an upload with
        `POST /projects/{id}/uploads`, PUT the file to the target it returns, then
        commit here with `file.upload_id`. The API never fetches a file from a
        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/VersionUploadRequest"
      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"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "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}/uploads:
    post:
      tags: [Projects]
      operationId: createProjectUpload
      summary: Create a direct upload for a large file
      description: |
        Returns a single-use PUT target for one project file that is too large to
        send inline. PUT the raw file bytes to `upload.url` with exactly the
        `upload.headers` given — no Authorization header, the URL carries the
        permission — then commit it with `POST /projects/{id}/versions` and
        `file.upload_id`. Requires `code_write`.

        The upload is bound to this API key and this project; another key or
        project cannot use it. The PUT target expires at `expires_at` (15
        minutes); an upload that was PUT can be committed for an hour after it
        was created. Set `content_encoding: gzip` to PUT gzip-compressed bytes —
        project exports compress several-fold; `max_bytes` applies to the bytes
        PUT and to the decompressed file alike. No `Idempotency-Key`: a retry
        creates an independent upload.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateUploadRequest"
      responses:
        "201":
          description: The upload was created.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateUploadResponse"
        "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}/source:
    get:
      tags: [Source]
      operationId: getProjectSource
      summary: Read a project's source
      description: |
        Return the project itself, in one of two representations. Requires
        `code_read`. There is deliberately nothing between them: the API serves
        whole-project reads and reasoning verbs, not fine-grained retrieval.

        - `format=parsed` (default): the vendor-neutral parsed model as JSON —
          the same structure for all three platforms. Served straight from the
          stored artifact, so the response body **is** the parsed model, not an
          envelope around it. There is no size ceiling: the payload is never
          buffered into the response.
        - `format=raw`: the current committed version's vendor file (L5X XML,
          Siemens ZIP or CODESYS `.export`), as a binary download. For a version
          saved in-app (where only the parsed model carries the edit) this is
          regenerated from the parsed model — the same bytes a PLC export
          produces — so it always reflects the latest committed state, not the
          originally uploaded file.

        Both are served by streaming the stored object, or by a `302` to a
        short-lived storage URL where the backend offers one. Follow redirects.

        **Compression.** Stored payloads are gzipped, and serving them that way
        is preferred — send `Accept-Encoding: gzip` and a parsed model arrives
        roughly 14x smaller. The response is then `Content-Encoding: gzip`; any
        client that advertises gzip inflates it transparently. A client that
        does NOT advertise gzip is served the decompressed bytes instead, so a
        plain request always yields directly usable content.
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [parsed, raw]
            default: parsed
      responses:
        "200":
          description: |
            The requested representation: the parsed model as JSON for
            `parsed`, a binary stream for `raw`. `Content-Encoding: gzip` when
            the request advertised gzip.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProjectSource"
            application/octet-stream:
              schema:
                type: string
                format: binary
        "302":
          description: |
            The payload lives at a short-lived storage URL given in `Location`.
            Follow it to read the bytes; most HTTP clients do so automatically.
          headers:
            request-id:
              $ref: "#/components/headers/RequestId"
            Location:
              schema:
                type: string
                format: uri
        "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/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"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "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"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "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 never truncated, and `maxItems` declares the per-request
        ceiling, so a whole-project tag set can be requested in one call;
        unknown names are dropped by the DCA against its discovered tag list.

        With `interval_ms` the call opens an **observation window**: the DCA
        samples the tags at that rate for `duration_sec` (default 180 s) and the
        response carries `window_id` + `sample_interval_ms`. One window per
        project — a standing window is reported, not replaced; with no DCA able
        to serve the project, `live` is false and no window opens.
      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"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "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 run 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
      description: |
        Send `prompt` to ask something. To answer a prior turn's questions, send
        `answers` plus the `conversation_id` they belong to (a `prompt` is not
        needed — the pending question is the context).
      properties:
        prompt:
          type: string
          description: The question to ask about the project.
          minLength: 1
        conversation_id:
          type: string
          description: |
            Returned by every response. Pass it back to answer questions or to
            continue the same line of enquiry with prior turns in context.
        answers:
          type: array
          description: Answers to the `questions` a prior turn returned.
          items:
            $ref: "#/components/schemas/Answer"
        mode:
          type: string
          enum: [sync, stream]
          default: sync
          description: |
            `sync` returns a blocking JSON body. `stream` returns an SSE stream.

    Answer:
      type: object
      description: |
        One answer. Set exactly one of `selected_index` or `free_text`; if both
        are present the selection is used. An entry that answers nothing is
        dropped and the assistant asks again rather than guessing.
      required: [id]
      properties:
        id:
          type: string
          description: The `id` of the question being answered.
        selected_index:
          type: integer
          minimum: 0
          description: Zero-based index into that question's `options`.
        free_text:
          type: string
          description: A free-text answer, when none of the options fit.

    Question:
      type: object
      description: Something the assistant needs to know before it can continue.
      required: [id, question, options, recommended_index, why_it_matters]
      properties:
        id:
          type: string
        question:
          type: string
        options:
          type: array
          items:
            type: string
          description: 2–4 suggested answers. You may answer with `free_text` instead.
        recommended_index:
          type: integer
          nullable: true
          description: |
            The assistant's own suggestion, or null if it has none. A hint only —
            it is never applied for you.
        why_it_matters:
          type: string
          description: What the answer changes, so you can decide how to ask.

    InterpretResponse:
      oneOf:
        - $ref: "#/components/schemas/AnswerResponse"
        - $ref: "#/components/schemas/NeedsInputResponse"
        - $ref: "#/components/schemas/UnresolvedResponse"
      discriminator:
        propertyName: status
        mapping:
          answer: "#/components/schemas/AnswerResponse"
          needs_input: "#/components/schemas/NeedsInputResponse"
          unresolved: "#/components/schemas/UnresolvedResponse"

    AnswerResponse:
      type: object
      required: [status, conversation_id, answer, citations, usage]
      properties:
        status:
          type: string
          enum: [answer]
        conversation_id:
          type: string
        answer:
          type: string
        citations:
          type: array
          description: |
            What the answer was read from — the locations the assistant actually
            looked at while producing it. Empty when the turn read nothing
            citable; never absent.
          items:
            $ref: "#/components/schemas/Citation"
        citations_total:
          type: integer
          description: |
            How many distinct locations the turn read, when the per-kind quotas
            or the cap shortened `citations`. ABSENT when `citations` is the
            complete list — so a shortened list never reads as all of it.
        usage:
          $ref: "#/components/schemas/Usage"

    NeedsInputResponse:
      type: object
      description: |
        The turn stopped to ask. Answer the questions and call again with the same
        `conversation_id`. This is a normal outcome, not an error.
      required: [status, conversation_id, questions, usage]
      properties:
        status:
          type: string
          enum: [needs_input]
        conversation_id:
          type: string
        questions:
          type: array
          items:
            $ref: "#/components/schemas/Question"
        usage:
          $ref: "#/components/schemas/Usage"

    Citation:
      type: object
      description: |
        One location the assistant READ while answering. Provenance, not a
        similarity match: a citation is recorded where the read happened, so a
        location listed here is one the turn genuinely looked at.

        Everything here is a fact about a place in your own project. How the
        assistant chose it — relevance, ranking, internal identifiers — is not
        part of the contract and is never returned.

        Returned empty for a credential that does not hold `code_read` — the
        answer is unaffected, but a key that cannot read the project's code is
        not handed the code locations it was read from.

        Ordered strongest-evidence first: a location the assistant asked for by
        name mid-turn leads one it read while gathering, which leads one that was
        simply in the context it assembled. Per-kind limits keep one kind (a hot
        tag's cross-references, say) from crowding out the rest; when they
        shorten the list, `citations_total` reports how many were read.
      required: [location_kind, path]
      properties:
        location_kind:
          type: string
          enum: [routine, data_block, definition, tag, rung]
          description: What kind of place `path` names.
        path:
          type: string
          description: |
            The location as the platform spells it — `Main/Feed_Conveyor`
            (Rockwell), `PLC_1/DriveStatus` (Siemens), `FB_Valve.Open`
            (CODESYS). Safe to pass back to a read endpoint.
        rung:
          type: integer
          description: The rung within `path`, when the read was rung-level.
        station:
          type: string
          description: On a production line, the station this location belongs to.
        project_id:
          type: string
          description: |
            Present only when the location is on a line SIBLING rather than the
            project you asked about.

    Usage:
      type: object
      description: |
        Token counts for this call — the sum across every model call the turn
        made. Informational only — not a bill.
      required: [input_tokens, output_tokens]
      properties:
        input_tokens:
          type: integer
        output_tokens:
          type: integer

    GenerateRequest:
      type: object
      description: |
        Send `prompt` to get a plan. Send `conversation_id` with `approve: true`
        to author the plan you were shown, or with `answers` to resolve its
        questions.
      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
        conversation_id:
          type: string
          description: Returned by every response. Required to approve or to answer questions.
        approve:
          type: boolean
          description: |
            Author the code for the plan pending on `conversation_id`. The plan
            executed is the one the server recorded when it was proposed, so what
            gets authored is exactly what was reviewed. 404 if no plan is pending.
        amendment:
          type: string
          description: |
            An optional plain-language tweak applied while executing the approved
            plan (e.g. "use a latch instead of a seal-in").
        answers:
          type: array
          description: Answers to the `questions` a prior turn returned.
          items:
            $ref: "#/components/schemas/Answer"
        mode:
          type: string
          enum: [sync, stream]
          default: sync
          description: |
            `sync` returns a blocking JSON body. `stream` returns an SSE stream.

    GenerateResponse:
      oneOf:
        - $ref: "#/components/schemas/PlanResponse"
        - $ref: "#/components/schemas/CodeResponse"
        - $ref: "#/components/schemas/NeedsInputResponse"
        - $ref: "#/components/schemas/UnresolvedResponse"
      discriminator:
        propertyName: status
        mapping:
          plan: "#/components/schemas/PlanResponse"
          code: "#/components/schemas/CodeResponse"
          needs_input: "#/components/schemas/NeedsInputResponse"
          unresolved: "#/components/schemas/UnresolvedResponse"

    PlanResponse:
      type: object
      description: |
        What the assistant would change. Nothing has been authored yet — call
        again with `approve: true` to author it.
      required: [status, conversation_id, plan, citations, usage]
      properties:
        status:
          type: string
          enum: [plan]
        conversation_id:
          type: string
        plan:
          $ref: "#/components/schemas/Plan"
        citations:
          type: array
          description: The locations the assistant read while working out this plan.
          items:
            $ref: "#/components/schemas/Citation"
        citations_total:
          type: integer
          description: |
            How many distinct locations the turn read, when the per-kind quotas
            or the cap shortened `citations`. ABSENT when `citations` is the
            complete list — so a shortened list never reads as all of it.
        usage:
          $ref: "#/components/schemas/Usage"

    Plan:
      type: object
      required: [summary, steps]
      properties:
        summary:
          type: string
        steps:
          type: array
          items:
            type: object
            required: [target, intent]
            properties:
              target:
                type: string
                description: The unit this step changes, e.g. `Program:Main/Routine:Feed`.
              intent:
                type: string
        rationale:
          type: string
          description: The approach and design choices, in plain language.
        assumptions:
          type: array
          items:
            type: string
          description: Ambiguities the assistant resolved — worth a sanity check before approving.
        affected_tags:
          type: array
          items:
            type: string
        risks:
          type: array
          description: Uncertainty and blast-radius signals. Surface these to a human before approving.
          items:
            type: object
            required: [severity, kind, note]
            properties:
              severity:
                type: string
                enum: [info, warn, block]
              kind:
                type: string
              note:
                type: string

    CodeResponse:
      type: object
      description: |
        The authored proposal. This creates NO version — commit it with
        `POST /projects/{id}/versions` (`code_write`) to persist it.
      required: [status, conversation_id, explanation, changes, citations, usage]
      properties:
        status:
          type: string
          enum: [code]
        conversation_id:
          type: string
        explanation:
          type: string
          description: Plain-language summary of what was authored.
        changes:
          type: array
          items:
            $ref: "#/components/schemas/ProposedChange"
        citations:
          type: array
          description: The locations the assistant read while authoring these changes.
          items:
            $ref: "#/components/schemas/Citation"
        citations_total:
          type: integer
          description: |
            How many distinct locations the turn read, when the per-kind quotas
            or the cap shortened `citations`. ABSENT when `citations` is the
            complete list — so a shortened list never reads as all of it.
        usage:
          $ref: "#/components/schemas/Usage"

    ProposedChange:
      type: object
      description: One proposed edit to one unit of the project.
      required: [action, target, code_type, content]
      properties:
        action:
          type: string
          description: e.g. `CREATE`, `UPDATE`, `DELETE`, `RENAME`, `DUPLICATE`.
        target:
          type: string
          description: The unit's path, e.g. `Program:Main/Routine:Feed` or `PLC_1/FB_Feeder`.
        code_type:
          type: string
          description: |
            `ST` | `RLL` | `Tag` | `UDT` for Rockwell; `SCL` | `DB` | `LAD` | `FBD`
            | `SFC` | `Tag` | `UDT` for Siemens; `ST` | `Declaration` | `LD` |
            `Task` for CODESYS.
        content:
          type: string
          description: |
            The proposed source for `target`, with two CODESYS exceptions. On an
            `LD` change it is a rung-edit script (`UPDATE_RUNG` /
            `INSERT_RUNG_AFTER` / `DELETE_RUNG` lines against the numbered rungs
            of the existing body), because a CODESYS ladder body is edited rung
            by rung rather than replaced. On a `Task` change it is the program
            name to schedule, or the new task's fields.
        new_name:
          type: string
          description: The new name, for a RENAME or the copy of a DUPLICATE.

    UnresolvedResponse:
      type: object
      description: |
        The request could not be served as asked. On `proposeCode`: nothing could
        be proposed — usually the request named something that isn't in the
        project, or described no anchorable change. On `askProject`: the prompt
        was a change request, and `reason` names the code-proposal endpoint.
      required: [status, conversation_id, reason, usage]
      properties:
        status:
          type: string
          enum: [unresolved]
        conversation_id:
          type: string
        reason:
          type: string
          description: What went wrong and what to try, in plain language.
        usage:
          $ref: "#/components/schemas/Usage"

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

    MessageRequest:
      type: object
      description: |
        Send `prompt` to add a turn. To answer the questions a prior turn on this
        thread returned, send `answers` instead — the pending question is the
        context, so no prompt is needed.
      properties:
        prompt:
          type: string
          minLength: 1
        answers:
          type: array
          description: Answers to the `questions` the previous turn on this thread returned.
          items:
            $ref: "#/components/schemas/Answer"

    MessageResponse:
      description: |
        The same three shapes `askProject` returns — an answer, the questions
        that must be answered first, or an unresolved turn (the message asked
        for a change rather than a question; `reason` names the capability to
        call instead).
      oneOf:
        - $ref: "#/components/schemas/AnswerResponse"
        - $ref: "#/components/schemas/NeedsInputResponse"
        - $ref: "#/components/schemas/UnresolvedResponse"
      discriminator:
        propertyName: status
        mapping:
          answer: "#/components/schemas/AnswerResponse"
          needs_input: "#/components/schemas/NeedsInputResponse"
          unresolved: "#/components/schemas/UnresolvedResponse"

    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: [analysis_id, project_id, version_id, status, started]
      properties:
        analysis_id:
          type: string
          description: Read this run back with `GET /analyses/{analysisId}`.
        project_id:
          type: string
        version_id:
          type: string
          description: The version the run analyses — read the result for this id.
        status:
          type: string
          enum: [queued, running]
        started:
          type: boolean
          description: |
            True when this call started the run. False when a healthy run was
            already in flight and this call joined it without scheduling a
            second (and without additional spend).

    ProjectAnalysis:
      type: object
      required: [project_id, version_id, is_current_version, status]
      properties:
        project_id:
          type: string
        version_id:
          type: string
          description: |
            Analysis is per-version; this is the version the results describe.
        is_current_version:
          type: boolean
          description: |
            Whether `version_id` is still the project's current version. Always
            present, so a payload can be judged without knowing which call
            produced it.

            `false` means these findings describe code the project no longer
            contains. Analysis findings are specific claims about specific code
            ("missing handshake between Filler and Capper"), so a superseded one
            can be wrong in either direction — the commit that superseded this
            version may already have fixed the finding, or introduced one this
            run never saw. Report which version it describes; never present it
            as the project's current state.
        status:
          type: string
          enum: [not_analyzed, queued, running, complete, error]
        analyzed_at:
          type: string
          format: date-time
          description: When the run reached a terminal state.
        message:
          type: string
          description: Present while `queued`/`running` — poll-interval guidance.
        results:
          description: The analysis, present when `status` is `complete`.
        failed_phases:
          type: array
          items:
            type: string
          description: |
            Names of the analysis phases that failed, if any. Present alongside
            `complete` when the failure was partial and `results` is still
            usable. Names only — failure messages are not published.
        skipped_phases:
          type: array
          items:
            type: string
          description: |
            Names of the analysis phases the run ended without, if any. A
            skipped phase did not run and has no entry in `results`, but did
            not fail. Alongside `complete` it means the run's time budget ran
            out on a large project before these could start — a new run via
            `POST /projects/{id}/analyses` starts with a fresh budget and may
            complete them. Alongside `error` it may mean that, or that a phase
            these depend on failed; the payload does not say which.
        last_analyzed_version_id:
          type: string
          description: |
            The most recent version of this project whose analysis completed.

            Present ONLY alongside `status: not_analyzed` on a read of the
            current version — the state a `POST /projects/{id}/versions` commit
            leaves behind. It means: an analysis exists for an older version.
            Fetch it deliberately with
            `GET /projects/{id}/analysis?version_id=<this>`, and label what you
            report from it — the response will carry
            `is_current_version: false`. Absent when the project has never had
            a completed analysis. Nothing here is a substitute for a current
            analysis; only `POST /projects/{id}/analyses` produces one.
        last_analyzed_at:
          type: string
          format: date-time
          description: |
            When that older run completed — what its staleness is judged
            against.
        devices:
          $ref: "#/components/schemas/AnalysisDevicesPointer"

    AnalysisDevicesPointer:
      type: object
      required: [total, unclassified, href]
      description: |
        Where the full device inventory is, and how big. Present on `complete`
        when the run recorded one. `results.components` is a bounded working
        set selected out of this inventory; the whole of it is paged from
        `href` rather than inlined into the analysis payload.
      properties:
        total:
          type: integer
          description: Every device the run found.
        unclassified:
          type: integer
          description: Of those, the devices nothing could name.
        identified:
          type: integer
          description: |
            Of those, the devices something identified — the same predicate
            as each row's `identified` on the devices read, so it equals that
            read's unfiltered `identified`. Absent on a run stored before the
            total was recorded; never `0` in its place.
        href:
          type: string
          description: |
            The devices read for the version this payload describes — a
            relative path, `version_id` included when the read is pinned.

    ProjectDevice:
      type: object
      required: [id, name, type, scope, identified]
      properties:
        id:
          type: string
          description: Stable within the version's inventory.
        name:
          type: string
          description: |
            The device's name — the contributing tag's own name, or the shared
            stem when several tags were merged into one device (then
            `member_tags` names them).
        type:
          type: string
          description: Device type from the analysis vocabulary, or `unknown`.
        subtype:
          type: string
        category:
          type: string
        description:
          type: string
          description: Carried verbatim from the source, never paraphrased.
        scope:
          type: string
          description: Owning scope — a program name, `controller`, a PLC name, or a POU path.
        location:
          type: object
          required: [module]
          description: Where the device is wired. Absent when it has no I/O binding to read one off.
          properties:
            module:
              type: string
              description: The I/O drop.
            slot:
              type: string
        identified:
          type: boolean
          description: |
            Whether something *named* this device rather than merely finding a
            wire on it. `false` with a typed `type` means the wire's direction
            is known and the device on the end of it is not.
        inhibited:
          type: boolean
          description: The hardware is present in the file and deliberately disabled.
        member_tags:
          type: array
          items:
            type: string
          description: The real symbol names this device absorbed, verbatim.

    ProjectDevices:
      type: object
      required:
        [project_id, version_id, is_current_version, status, total, identified, devices, next_cursor]
      properties:
        project_id:
          type: string
        version_id:
          type: string
          description: The version whose inventory this is.
        is_current_version:
          type: boolean
          description: Whether `version_id` is still the project's current version.
        status:
          type: string
          enum: [stored, absent, not_analyzed]
        vendor:
          type: string
          description: The platform the inventory was built for. Present on `stored`.
        total:
          type: integer
          description: Devices matching the filters — the whole set, not this page.
        identified:
          type: integer
          description: Of `total`, the devices something identified.
        devices:
          type: array
          items:
            $ref: "#/components/schemas/ProjectDevice"
        next_cursor:
          type: string
          nullable: true
          description: Pass as `?cursor=` to fetch the next page; `null` on the last page.

    PdfExportRequest:
      type: object
      properties:
        detail:
          type: string
          enum: [project, diagrams, everything]
          default: project
          description: |
            How much logic the report carries. `project`: structure, tags and
            analysis, no drawings or listings — prints for every project.
            `diagrams`: adds the logic drawings. `everything`: adds the source
            listings as well; the output this operation always rendered before
            this field existed. The two larger levels are refused as
            `pdf_too_large` on a large plant.
        full_device_inventory:
          type: boolean
          default: false
          description: |
            List every device in the version's stored inventory in the
            Components Analysis chapter, instead of the analysis roster. About
            23 devices per page.

    AnalysisRun:
      description: |
        One analysis run — the same payload as `ProjectAnalysis`, plus the run
        id, and pinned to the version that run analysed rather than to the
        project's current version. `is_current_version` says whether that
        version is still the project's; the `last_analyzed_*` pointer is a
        current-version-read field and does not appear here.
      allOf:
        - $ref: "#/components/schemas/ProjectAnalysis"
        - type: object
          required: [analysis_id]
          properties:
            analysis_id:
              type: string

    VersionUploadRequest:
      type: object
      required: [originalFilename, file]
      properties:
        originalFilename:
          type: string
          description: |
            The export file name — its extension must match the project's
            vendor, e.g. "Injection_Molding.L5X", "Reference_Coiler.zip" or
            "AnalogueClock.export".
        name:
          type: string
          description: Optional display name for the project.
        file:
          type: object
          description: Exactly one of `inline` or `upload_id` must be set.
          properties:
            inline:
              type: string
              description: Base64-encoded file bytes (for files up to about 3 MB; the request body is capped at ~4.5 MB).
            upload_id:
              type: string
              description: |
                An upload from `POST /projects/{id}/uploads` that the file was
                PUT to — for files too large to send inline.

    CreateUploadRequest:
      type: object
      properties:
        content_encoding:
          type: string
          enum: [identity, gzip]
          default: identity
          description: How the bytes you PUT are encoded.

    CreateUploadResponse:
      type: object
      required: [upload_id, upload, expires_at, max_bytes, content_encoding]
      properties:
        upload_id:
          type: string
          description: Pass as `file.upload_id` when committing.
        upload:
          type: object
          required: [method, url, headers]
          properties:
            method:
              type: string
              enum: [PUT]
            url:
              type: string
              format: uri
              description: Where to PUT the file bytes. Treat it as a secret until it expires.
            headers:
              type: object
              additionalProperties:
                type: string
              description: Send exactly these headers on the PUT.
        expires_at:
          type: string
          format: date-time
          description: When the PUT target stops accepting the file.
        max_bytes:
          type: integer
          description: Largest file accepted, before and after decompression.
        content_encoding:
          type: string
          enum: [identity, gzip]

    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, codesys]
        analysis:
          type: string
          enum: [not_analyzed]
          description: |
            The new version's analysis state — always `not_analyzed`, because a
            commit does not analyze. Ask for a run with
            `POST /projects/{id}/analyses`. Absent when `resolution` is
            `identical_file`: no new version was written, so the project's
            existing analysis still describes its current one.

    ProjectIndustry:
      type: string
      nullable: true
      description: |
        The industry the project's last analysis run recorded. Three states, and
        the third is not the second: a named industry; `"general"`, meaning the
        project was examined and matched no industry-specific patterns; or
        `null`, meaning it has never been analysed — which says nothing about
        its industry either way.

    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, codesys, null]
        industry:
          $ref: "#/components/schemas/ProjectIndustry"
        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
        industry:
          $ref: "#/components/schemas/ProjectIndustry"
        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 body of `GET /projects/{id}/source?format=parsed`: the
        vendor-neutral parsed model itself, served verbatim from the stored
        artifact — there is no envelope and no `format` / `vendor` wrapper
        around it (read those off `GET /projects/{id}`). Its shape is the
        parser's, and differs by platform; treat it as an opaque object graph
        rather than a fixed schema. (`format=raw` returns a binary stream.)

    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 never truncated — ask for every tag in a project and
            every tag is recorded. `maxItems` is the declared per-request
            ceiling (the per-message transport shape), so a whole-project tag
            set fits in one request; unknown names are dropped by the DCA.
          minItems: 1
          maxItems: 1000
          items:
            type: string
            minLength: 1
        interval_ms:
          type: integer
          minimum: 100
          maximum: 2000
          description: |
            Sample interval in milliseconds. Present, the call opens an
            observation window at this rate instead of a bare request, and the
            response carries `window_id` and `sample_interval_ms`.
        duration_sec:
          type: integer
          minimum: 60
          maximum: 900
          default: 180
          description: |
            How long the observation window runs, in seconds (1–15 minutes).
            Only meaningful with `interval_ms`.

    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; empties dropped).
          items:
            type: string
        request_id:
          type: string
          nullable: true
          description: |
            The lease this call opened on the project's request slot. Each call
            opens its own lease beside any others; it lapses on its own after
            about three minutes. Null only when the request store was unavailable
            and nothing was recorded.
        unsupported_tags:
          type: object
          description: |
            Present only when some requested names were program-scoped Rockwell
            tags (`Program:<Program>.<Tag>`) and the Desktop Companion App build
            serving this project predates the first build that can read them.
            Those names were not recorded — an older build would report them as
            unknown tags — and `min_version` is the build that can serve them.
          required: [names, min_version]
          properties:
            names:
              type: array
              items:
                type: string
            min_version:
              type: string
        window_id:
          type: string
          nullable: true
          description: |
            Present only when `interval_ms` was given: the observation window
            opened (or the project's standing one). Null when no DCA could serve
            the project, so no window opened.
        sample_interval_ms:
          type: integer
          nullable: true
          description: |
            Present only when `interval_ms` was given: the rate the window runs
            at — the requested one, or the standing window's when one already
            stood.

    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.

  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"
    ProjectNotReady:
      description: |
        A request with this Idempotency-Key is still being processed
        (`idempotency_in_progress`), or the project's analysis artifacts are not
        built yet (`project_not_ready`) — the latter clears on its own, so retry
        once the project's analysis reaches "complete".
      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"
    Unprocessable:
      description: |
        The request was well-formed but cannot be carried out. The `error` code
        is one of:
          - `idempotency_conflict` — this Idempotency-Key was already used with
            a different body. Use a fresh key for a new request.
          - `vendor_unsupported` — this endpoint does not cover the project's
            PLC platform (see the capability matrix in the API overview).
            Not retryable; `suggestedAction` names the supported alternative.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    BodyTooLarge:
      description: The inline body exceeded the limit; send the file with `POST /projects/{id}/uploads` and `file.upload_id`.
      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 cannot be billed for this request. Not retryable. The
        `error` code is one of:
          - `subscription_required` — the organization's plan is not active
            (trial ended, design-partner term expired, canceled, unpaid, or AI
            paused for the period); `reason` names which. Read endpoints keep
            working; an owner can subscribe or renew in Settings → Billing.
          - `api_spend_limit_reached` — 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"

