Reference

API reference.

A versioned HTTP API for interpreting and analyzing PLC projects.

v2026-09-02OpenAPI 3.1.0

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 |

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}/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 | | 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.

Download the OpenAPI 3.1 spec (YAML) — the same file the SDKs use.

Base URL

ServerDescription
https://app.plcs.ai/api/v1Production

Authentication

An API key minted from Settings → API Keys, sent as Authorization: Bearer plck_live_….

Authenticated health check

get/health

Confirms the API is reachable and your credential resolves an organization. Returns the resolved organizationId and actorType.

Responses

StatusDescription
200The credential resolved an organization.
401No valid credential, or the key was revoked.

Response · 200

FieldTypeDescription
status requiredconst "ok"
Always ok
organizationId requiredstring
actorType requiredstring enum
Values: api_key embed_token
json
{
  "status": "ok",
  "organizationId": "string",
  "actorType": "api_key"
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Ask a question about a project

post/projects/{id}/interpret

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

NameInTypeDescription
id requiredpathstringThe project id.
Idempotency-Key requiredheaderstringA unique key (e.g. a UUID) for this write. Retrying with the same key and body replays the original result without double-billing.

Request body required

FieldTypeDescription
promptstringThe question to ask about the project.
conversation_idstringReturned by every response. Pass it back to answer questions or to continue the same line of enquiry with prior turns in context.
answersarray<object>Answers to the questions a prior turn returned.
answers[].idstringThe id of the question being answered.
answers[].selected_indexintegerZero-based index into that question's options.
answers[].free_textstringA free-text answer, when none of the options fit.
modestring enumsync returns a blocking JSON body. stream returns an SSE stream.
Values: sync stream · Default sync
json
{
  "prompt": "What conditions must be true for the main conveyor to start?",
  "mode": "sync"
}

Responses

StatusDescription
200For 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).
400The request was malformed.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
409A 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".
422The 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.
429Per-key rate limit exceeded.

Response · 200

status: "answer"

FieldTypeDescription
status requiredstring enum
Values: answer
conversation_id requiredstring
answer requiredstring
citations requiredarray<object>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.
citations[].location_kindstring enumWhat kind of place path names.
Values: routine data_block definition tag rung
citations[].pathstringThe 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.
citations[].rungintegerThe rung within path, when the read was rung-level.
citations[].stationstringOn a production line, the station this location belongs to.
citations[].project_idstringPresent only when the location is on a line SIBLING rather than the project you asked about.
citations_totalintegerHow 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 requiredobjectToken counts for this call — the sum across every model call the turn made. Informational only — not a bill.
usage.input_tokensinteger
usage.output_tokensinteger

status: "needs_input"

The turn stopped to ask. Answer the questions and call again with the same conversation_id. This is a normal outcome, not an error.

FieldTypeDescription
status requiredstring enum
Values: needs_input
conversation_id requiredstring
questions requiredarray<object>
questions[].idstring
questions[].questionstring
questions[].optionsarray<string>2–4 suggested answers. You may answer with free_text instead.
questions[].recommended_indexintegerThe assistant's own suggestion, or null if it has none. A hint only — it is never applied for you.
questions[].why_it_mattersstringWhat the answer changes, so you can decide how to ask.
usage requiredobjectToken counts for this call — the sum across every model call the turn made. Informational only — not a bill.
usage.input_tokensinteger
usage.output_tokensinteger

status: "unresolved"

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.

FieldTypeDescription
status requiredstring enum
Values: unresolved
conversation_id requiredstring
reason requiredstringWhat went wrong and what to try, in plain language.
usage requiredobjectToken counts for this call — the sum across every model call the turn made. Informational only — not a bill.
usage.input_tokensinteger
usage.output_tokensinteger

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Propose a code change (plan, then author)

post/projects/{id}/generate

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

NameInTypeDescription
id requiredpathstringThe project id.
Idempotency-Key requiredheaderstringA unique key (e.g. a UUID) for this write. Retrying with the same key and body replays the original result without double-billing.

Request body required

FieldTypeDescription
promptstringWhat to generate or change (e.g. "Add a 5-second start-up delay timer").
targetobjectOptional hint for which program/routine to target. Folded into the prompt as guidance; the assistant decides the final placement.
target.programstring
target.routinestring
conversation_idstringReturned by every response. Required to approve or to answer questions.
approvebooleanAuthor 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.
amendmentstringAn optional plain-language tweak applied while executing the approved plan (e.g. "use a latch instead of a seal-in").
answersarray<object>Answers to the questions a prior turn returned.
answers[].idstringThe id of the question being answered.
answers[].selected_indexintegerZero-based index into that question's options.
answers[].free_textstringA free-text answer, when none of the options fit.
modestring enumsync returns a blocking JSON body. stream returns an SSE stream.
Values: sync stream · Default sync
json
{
  "prompt": "Add a 5-second start-up delay timer before the conveyor enables.",
  "target": {
    "program": "MainProgram",
    "routine": "ConveyorControl"
  },
  "mode": "sync"
}

Responses

StatusDescription
200For 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).
400The request was malformed.
401No valid credential, or the key was revoked.
402The 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.
403The key lacks the permission (or project scope) this endpoint needs.
404The project was not found, or approve: true named a conversation with no plan awaiting approval (already approved, or never proposed).
409A 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".
422The 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.
429Per-key rate limit exceeded.

Response · 200

status: "plan"

What the assistant would change. Nothing has been authored yet — call again with approve: true to author it.

FieldTypeDescription
status requiredstring enum
Values: plan
conversation_id requiredstring
plan requiredobject
plan.summarystring
plan.stepsarray<object>
plan.rationalestringThe approach and design choices, in plain language.
plan.assumptionsarray<string>Ambiguities the assistant resolved — worth a sanity check before approving.
plan.affected_tagsarray<string>
plan.risksarray<object>Uncertainty and blast-radius signals. Surface these to a human before approving.
citations requiredarray<object>The locations the assistant read while working out this plan.
citations[].location_kindstring enumWhat kind of place path names.
Values: routine data_block definition tag rung
citations[].pathstringThe 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.
citations[].rungintegerThe rung within path, when the read was rung-level.
citations[].stationstringOn a production line, the station this location belongs to.
citations[].project_idstringPresent only when the location is on a line SIBLING rather than the project you asked about.
citations_totalintegerHow 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 requiredobjectToken counts for this call — the sum across every model call the turn made. Informational only — not a bill.
usage.input_tokensinteger
usage.output_tokensinteger

status: "code"

The authored proposal. This creates NO version — commit it with POST /projects/{id}/versions (code_write) to persist it.

FieldTypeDescription
status requiredstring enum
Values: code
conversation_id requiredstring
explanation requiredstringPlain-language summary of what was authored.
changes requiredarray<object>
changes[].actionstringe.g. CREATE, UPDATE, DELETE, RENAME, DUPLICATE.
changes[].targetstringThe unit's path, e.g. Program:Main/Routine:Feed or PLC_1/FB_Feeder.
changes[].code_typestringST | RLL | Tag | UDT for Rockwell; SCL | DB | LAD | FBD | SFC | Tag | UDT for Siemens; ST | Declaration | LD | Task for CODESYS.
changes[].contentstringThe 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.
changes[].new_namestringThe new name, for a RENAME or the copy of a DUPLICATE.
citations requiredarray<object>The locations the assistant read while authoring these changes.
citations[].location_kindstring enumWhat kind of place path names.
Values: routine data_block definition tag rung
citations[].pathstringThe 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.
citations[].rungintegerThe rung within path, when the read was rung-level.
citations[].stationstringOn a production line, the station this location belongs to.
citations[].project_idstringPresent only when the location is on a line SIBLING rather than the project you asked about.
citations_totalintegerHow 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 requiredobjectToken counts for this call — the sum across every model call the turn made. Informational only — not a bill.
usage.input_tokensinteger
usage.output_tokensinteger

status: "needs_input"

The turn stopped to ask. Answer the questions and call again with the same conversation_id. This is a normal outcome, not an error.

FieldTypeDescription
status requiredstring enum
Values: needs_input
conversation_id requiredstring
questions requiredarray<object>
questions[].idstring
questions[].questionstring
questions[].optionsarray<string>2–4 suggested answers. You may answer with free_text instead.
questions[].recommended_indexintegerThe assistant's own suggestion, or null if it has none. A hint only — it is never applied for you.
questions[].why_it_mattersstringWhat the answer changes, so you can decide how to ask.
usage requiredobjectToken counts for this call — the sum across every model call the turn made. Informational only — not a bill.
usage.input_tokensinteger
usage.output_tokensinteger

status: "unresolved"

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.

FieldTypeDescription
status requiredstring enum
Values: unresolved
conversation_id requiredstring
reason requiredstringWhat went wrong and what to try, in plain language.
usage requiredobjectToken counts for this call — the sum across every model call the turn made. Informational only — not a bill.
usage.input_tokensinteger
usage.output_tokensinteger

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Create a troubleshooting thread

post/projects/{id}/conversations

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

NameInTypeDescription
id requiredpathstringThe project id.
Idempotency-Key requiredheaderstringA unique key (e.g. a UUID) for this write. Retrying with the same key and body replays the original result without double-billing.

Request body

FieldTypeDescription
namestringOptional human label for the thread.
json
{
  "name": "string"
}

Responses

StatusDescription
201The created thread.
400The request was malformed.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
409A request with this Idempotency-Key is still being processed.
422The 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.
429Per-key rate limit exceeded.

Response · 201

FieldTypeDescription
conversationId requiredstring
projectId requiredstring
json
{
  "conversationId": "string",
  "projectId": "string"
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Append a turn to a thread

post/conversations/{cid}/messages

Run one interpret turn inside an existing thread. The thread's prior history is loaded automatically. Requires ai_explain.

Parameters

NameInTypeDescription
cid requiredpathstringThe conversation (thread) id.
Idempotency-Key requiredheaderstringA unique key (e.g. a UUID) for this write. Retrying with the same key and body replays the original result without double-billing.

Request body required

FieldTypeDescription
promptstring
answersarray<object>Answers to the questions the previous turn on this thread returned.
answers[].idstringThe id of the question being answered.
answers[].selected_indexintegerZero-based index into that question's options.
answers[].free_textstringA free-text answer, when none of the options fit.
json
{
  "prompt": "string",
  "answers": [
    {
      "id": "string",
      "selected_index": 0,
      "free_text": "string"
    }
  ]
}

Responses

StatusDescription
200The assistant's answer for this turn.
400The request was malformed.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
409A request with this Idempotency-Key is still being processed.
422This Idempotency-Key was already used with a different body.
429Per-key rate limit exceeded.

Response · 200

status: "answer"

FieldTypeDescription
status requiredstring enum
Values: answer
conversation_id requiredstring
answer requiredstring
citations requiredarray<object>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.
citations[].location_kindstring enumWhat kind of place path names.
Values: routine data_block definition tag rung
citations[].pathstringThe 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.
citations[].rungintegerThe rung within path, when the read was rung-level.
citations[].stationstringOn a production line, the station this location belongs to.
citations[].project_idstringPresent only when the location is on a line SIBLING rather than the project you asked about.
citations_totalintegerHow 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 requiredobjectToken counts for this call — the sum across every model call the turn made. Informational only — not a bill.
usage.input_tokensinteger
usage.output_tokensinteger

status: "needs_input"

The turn stopped to ask. Answer the questions and call again with the same conversation_id. This is a normal outcome, not an error.

FieldTypeDescription
status requiredstring enum
Values: needs_input
conversation_id requiredstring
questions requiredarray<object>
questions[].idstring
questions[].questionstring
questions[].optionsarray<string>2–4 suggested answers. You may answer with free_text instead.
questions[].recommended_indexintegerThe assistant's own suggestion, or null if it has none. A hint only — it is never applied for you.
questions[].why_it_mattersstringWhat the answer changes, so you can decide how to ask.
usage requiredobjectToken counts for this call — the sum across every model call the turn made. Informational only — not a bill.
usage.input_tokensinteger
usage.output_tokensinteger

status: "unresolved"

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.

FieldTypeDescription
status requiredstring enum
Values: unresolved
conversation_id requiredstring
reason requiredstringWhat went wrong and what to try, in plain language.
usage requiredobjectToken counts for this call — the sum across every model call the turn made. Informational only — not a bill.
usage.input_tokensinteger
usage.output_tokensinteger

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Re-run the project's analysis

post/projects/{id}/analyses

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

NameInTypeDescription
id requiredpathstringThe project id.
Idempotency-Key requiredheaderstringA unique key (e.g. a UUID) for this write. Retrying with the same key and body replays the original result without double-billing.

Responses

StatusDescription
202A run is in flight — either newly started, or already going.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
409A request with this Idempotency-Key is still being processed.
422This Idempotency-Key was already used with a different body.
429Per-key rate limit exceeded.

Response · 202

FieldTypeDescription
analysis_id requiredstringRead this run back with GET /analyses/{analysisId}.
project_id requiredstring
version_id requiredstringThe version the run analyses — read the result for this id.
status requiredstring enum
Values: queued running
started requiredbooleanTrue 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).
json
{
  "analysis_id": "string",
  "project_id": "string",
  "version_id": "string",
  "status": "queued",
  "started": true
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Read the project's analysis

get/projects/{id}/analysis

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.
  • completeresults holds the analysis. failed_phases may still be present when some phases failed but the run finished.
  • error — the run failed; failed_phases names the phases that did.

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.

Parameters

NameInTypeDescription
id requiredpathstringThe project id.
version_idquerystringRead 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.

Responses

StatusDescription
200The analysis of the version read (and results when complete).
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
429Per-key rate limit exceeded.

Response · 200

FieldTypeDescription
project_id requiredstring
version_id requiredstringAnalysis is per-version; this is the version the results describe.
is_current_version requiredbooleanWhether 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 requiredstring enum
Values: not_analyzed queued running complete error
analyzed_atstring (date-time)When the run reached a terminal state.
messagestringPresent while queued/running — poll-interval guidance.
resultsobjectThe analysis, present when status is complete.
failed_phasesarray<string>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.
last_analyzed_version_idstringThe 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_atstring (date-time)When that older run completed — what its staleness is judged against.
json
{
  "project_id": "string",
  "version_id": "string",
  "is_current_version": true,
  "status": "not_analyzed",
  "analyzed_at": "2026-05-31T18:15:00.000Z",
  "message": "string",
  "results": null,
  "failed_phases": [
    "string"
  ],
  "last_analyzed_version_id": "string",
  "last_analyzed_at": "2026-05-31T18:15:00.000Z"
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Read one analysis run

get/analyses/{analysisId}

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

NameInTypeDescription
analysisId requiredpathstringThe analysis run id returned by startAnalysis.

Responses

StatusDescription
200The run's status (and results when complete).
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
429Per-key rate limit exceeded.

Response · 200

object

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Export the project to its vendor PLC file

post/projects/{id}/exports/plc

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

NameInTypeDescription
id requiredpathstringThe project id.
Idempotency-Key requiredheaderstringA unique key (e.g. a UUID) for this write. Retrying with the same key and body replays the original result without double-billing.

Responses

StatusDescription
202The export job was started.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
409A request with this Idempotency-Key is still being processed.
422This Idempotency-Key was already used with a different body.
429Per-key rate limit exceeded.

Response · 202

FieldTypeDescription
exportId requiredstring
status requiredstring enum
Values: pending running complete error
json
{
  "exportId": "string",
  "status": "pending"
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Render a PDF report for the project

post/projects/{id}/exports/pdf

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.

Parameters

NameInTypeDescription
id requiredpathstringThe project id.
Idempotency-Key requiredheaderstringA unique key (e.g. a UUID) for this write. Retrying with the same key and body replays the original result without double-billing.

Responses

StatusDescription
202The export job was started.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
409A request with this Idempotency-Key is still being processed.
422This Idempotency-Key was already used with a different body.
429Per-key rate limit exceeded.

Response · 202

FieldTypeDescription
exportId requiredstring
status requiredstring enum
Values: pending running complete error
json
{
  "exportId": "string",
  "status": "pending"
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Poll an export job

get/exports/{exportId}

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

NameInTypeDescription
exportId requiredpathstringThe export job id returned by startPlcExport / startPdfExport.

Responses

StatusDescription
200The export job status (and download_url when complete).
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
429Per-key rate limit exceeded.

Response · 200

FieldTypeDescription
exportId requiredstring
kind requiredstring enum
Values: plc pdf
status requiredstring enum
Values: pending running complete error
download_urlstringPresent only when status is complete. A relative path.
filenamestringSuggested download filename (present when complete).
errorstringShort failure reason (present when status is error).
messagestringPoll guidance (present while pending/running).
json
{
  "exportId": "string",
  "kind": "plc",
  "status": "pending",
  "download_url": "string",
  "filename": "string",
  "error": "string",
  "message": "string"
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Download a completed export artifact

get/exports/{exportId}/download

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

NameInTypeDescription
exportId requiredpathstringThe export job id returned by startPlcExport / startPdfExport.

Responses

StatusDescription
200The export artifact bytes.
400The request was malformed.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
429Per-key rate limit exceeded.

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

List projects

get/projects

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

NameInTypeDescription
team_idquerystringRestrict to a single team.
limitqueryintegerPage size (default 50, max 200).
cursorquerystringOpaque cursor from a previous response's next_cursor.

Responses

StatusDescription
200A page of projects.
400The request was malformed.
401No valid credential, or the key was revoked.
429Per-key rate limit exceeded.

Response · 200

FieldTypeDescription
projects requiredarray<object>
projects[].project_idstring
projects[].identity_idstringThe stable project identity id that project scope is keyed on.
projects[].namestring
projects[].vendorstring enum
Values: allen-bradley siemens codesys null
projects[].industrystringThe 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.
projects[].file_typestringe.g. "l5x", "zip", "scl".
projects[].current_version_idstring
projects[].updated_atstring (date-time)
next_cursor requiredstringPass as ?cursor= to fetch the next page; null on the last page.
json
{
  "projects": [
    {
      "project_id": "string",
      "identity_id": "string",
      "name": "string",
      "vendor": "allen-bradley",
      "industry": "string",
      "file_type": "string",
      "current_version_id": "string",
      "updated_at": "2026-05-31T18:15:00.000Z"
    }
  ],
  "next_cursor": "string"
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Get a project

get/projects/{id}

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

NameInTypeDescription
id requiredpathstringThe project id.

Responses

StatusDescription
200The project metadata.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
429Per-key rate limit exceeded.

Response · 200

FieldTypeDescription
project_id requiredstring
identity_id requiredstring
name requiredstring
display_namestring
vendorstring
industrystringThe 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.
file_typestring
file_size_bytesinteger
original_filenamestring
current_version_id requiredstring
version_numberinteger
analysis_statusstring enumSummary only — poll GET /analyses/{id} for full results. null = never analyzed.
Values: queued running complete error null
created_at requiredstring (date-time)
updated_at requiredstring (date-time)
json
{
  "project_id": "string",
  "identity_id": "string",
  "name": "string",
  "display_name": "string",
  "vendor": "string",
  "industry": "string",
  "file_type": "string",
  "file_size_bytes": 0,
  "original_filename": "string",
  "current_version_id": "string",
  "version_number": 0,
  "analysis_status": "queued",
  "created_at": "2026-05-31T18:15:00.000Z",
  "updated_at": "2026-05-31T18:15:00.000Z"
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Commit a new version (Save project)

post/projects/{id}/versions

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.

Files up to ~4.5 MB may be sent inline as base64 in file.inline; larger files use the large-file flow via file.blob_url. A project-scoped key may only commit to in-scope projects.

Parameters

NameInTypeDescription
id requiredpathstringThe project id.
Idempotency-Key requiredheaderstringA unique key (e.g. a UUID) for this write. Retrying with the same key and body replays the original result without double-billing.

Request body required

FieldTypeDescription
originalFilename requiredstringThe export file name — its extension must match the project's vendor, e.g. "Injection_Molding.L5X", "Reference_Coiler.zip" or "AnalogueClock.export".
namestringOptional display name for the project.
file requiredobjectExactly one of inline or blob_url must be set.
file.inlinestringBase64-encoded file bytes (for files up to ~4.5 MB).
file.blob_urlstring (uri)A blob URL for the large-file flow.
json
{
  "originalFilename": "string",
  "name": "string",
  "file": {
    "inline": "string",
    "blob_url": "https://…"
  }
}

Responses

StatusDescription
200The uploaded bytes matched the current version; no new version was created.
201A new version was committed.
400The request was malformed.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
409A request with this Idempotency-Key is still being processed.
413The inline body exceeded the limit; use the large-file flow.
422This Idempotency-Key was already used with a different body.
429Per-key rate limit exceeded.

Response · 200

FieldTypeDescription
project_id requiredstring
identity_id requiredstring
version_idstringThe new version's id; null when resolution is identical_file.
version_number requiredinteger
resolution requiredstring enumadd_version when a new version was committed; identical_file when the uploaded bytes matched the current version (no-op).
Values: add_version identical_file
vendor requiredstring enum
Values: allen-bradley siemens codesys
analysisstring enumThe 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.
Values: not_analyzed
json
{
  "project_id": "string",
  "identity_id": "string",
  "version_id": "string",
  "version_number": 0,
  "resolution": "add_version",
  "vendor": "allen-bradley",
  "analysis": "not_analyzed"
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Read a project's source

get/projects/{id}/source

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

NameInTypeDescription
id requiredpathstringThe project id.
formatquerystring enum

Responses

StatusDescription
200The requested representation: the parsed model as JSON for parsed, a binary stream for raw. Content-Encoding: gzip when the request advertised gzip.
302The payload lives at a short-lived storage URL given in Location. Follow it to read the bytes; most HTTP clients do so automatically.
400The request was malformed.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
429Per-key rate limit exceeded.

Response · 200

object

json
{}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Latest live tag values

get/projects/{id}/hmi/values

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

NameInTypeDescription
id requiredpathstringThe project id.

Responses

StatusDescription
200The latest snapshot, or an empty live=false body.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
429Per-key rate limit exceeded.

Response · 200

FieldTypeDescription
project_id requiredstring
live requiredbooleanTrue when a DCA session is actively streaming this project.
messagestringPresent when live=false — explains why there are no values.
device_idstring
timestampstring (date-time)
controllerobject
controller.namestring
controller.typestring
controller.statusstring
controller.ipAddressstring
sequenceinteger
tags requiredmap<string, object>Map of tag name → latest value.
json
{
  "project_id": "string",
  "live": true,
  "message": "string",
  "device_id": "string",
  "timestamp": "2026-05-31T18:15:00.000Z",
  "controller": {
    "name": "string",
    "type": "string",
    "status": "string",
    "ipAddress": "string"
  },
  "sequence": 0,
  "tags": {
    "example_permission": null
  }
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Recent value history for a tag

get/projects/{id}/hmi/history

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

NameInTypeDescription
id requiredpathstringThe project id.
tag requiredquerystringThe tag name to fetch history for.
limitqueryintegerMax entries (default 50, max 600).

Responses

StatusDescription
200The tag's recent history.
400The request was malformed.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
429Per-key rate limit exceeded.

Response · 200

FieldTypeDescription
project_id requiredstring
tag requiredstring
count requiredinteger
history requiredarray<object>Most-recent-first history entries.
history[].valueobject
history[].timestampstring (date-time)
history[].sequenceinteger
json
{
  "project_id": "string",
  "tag": "string",
  "count": 0,
  "history": [
    {
      "value": null,
      "timestamp": "2026-05-31T18:15:00.000Z",
      "sequence": 0
    }
  ]
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Request live values for specific tags on demand

post/projects/{id}/hmi/request-tags

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.

Parameters

NameInTypeDescription
id requiredpathstringThe project id.

Request body required

FieldTypeDescription
tags requiredarray<string>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.
json
{
  "tags": [
    "string"
  ]
}

Responses

StatusDescription
200The request was recorded; live reports DCA availability.
400The request was malformed.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
429Per-key rate limit exceeded.
503The endpoint is gated by a server feature flag that is currently off.

Response · 200

FieldTypeDescription
project_id requiredstring
live requiredbooleanTrue 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 requiredarray<string>The tag names actually recorded (de-duped; empties dropped).
json
{
  "project_id": "string",
  "live": true,
  "requested": [
    "string"
  ]
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.

Mint a read-only embed token

post/embed-tokens

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

NameInTypeDescription
Idempotency-Key requiredheaderstringA unique key (e.g. a UUID) for this write. Retrying with the same key and body replays the original result without double-billing.

Request body required

FieldTypeDescription
project_id requiredstringThe project the embed token may access.
json
{
  "project_id": "string"
}

Responses

StatusDescription
201The minted read-only embed token.
400The request was malformed.
401No valid credential, or the key was revoked.
403The key lacks the permission (or project scope) this endpoint needs.
404The resource was not found, or is not visible to this key's org.
409A request with this Idempotency-Key is still being processed.
422This Idempotency-Key was already used with a different body.
429Per-key rate limit exceeded.

Response · 201

FieldTypeDescription
token requiredstringThe signed, read-only embed token to hand to a browser.
expires_at requiredstring (date-time)
permissions requiredmap<string, boolean>The intersected read-only permissions (only ai_explain / hmi_view).
project_id requiredstring
json
{
  "token": "string",
  "expires_at": "2026-05-31T18:15:00.000Z",
  "permissions": {
    "example_permission": true
  },
  "project_id": "string"
}

Every 4xx/5xx uses the standard error envelope and carries a request-id header.