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

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

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

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

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

security:
  - ApiKeyAuth: []

tags:
  - name: Interpret
    description: Prompt → cited interpretation over the existing assistant.
  - name: Conversations
    description: Stateful multi-turn troubleshooting threads.
  - name: Analyses
    description: Asynchronous analysis jobs (dead code, missing handshakes, cycle-time).
  - name: Projects
    description: Ingest an L5X / Siemens export and resolve a project identity.
  - name: Embed tokens
    description: Mint short-lived, read-only tokens for the embeddable iframe.
  - name: Health
    description: Authenticated connectivity check.

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

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

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

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

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

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

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

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

  /projects:
    post:
      tags: [Projects]
      operationId: ingestProject
      summary: Ingest an L5X / Siemens export
      description: |
        Upload an L5X (Rockwell) or Siemens TIA ZIP export. The file is parsed
        server-side, a `ProjectIdentity` is resolved or created, and analysis +
        indexing are kicked off. Billed identically to a UI upload.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    IngestRequest:
      type: object
      required: [originalFilename, file]
      properties:
        originalFilename:
          type: string
          description: The export file name, e.g. "Conveyor.L5X" or "TIA_Export.zip".
        name:
          type: string
          description: Optional display name for the project.
        file:
          type: object
          description: Exactly one of `inline` or `blob_url` must be set.
          properties:
            inline:
              type: string
              description: Base64-encoded file bytes (for files up to ~4.5 MB).
            blob_url:
              type: string
              format: uri
              description: A blob URL for the large-file flow.

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

    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"
    IdempotencyConflict:
      description: This Idempotency-Key was already used with a different body.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    BodyTooLarge:
      description: The inline body exceeded the limit; use the large-file flow.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: Per-key rate limit exceeded.
      headers:
        request-id:
          $ref: "#/components/headers/RequestId"
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
