Generating code
Propose, never deploy.
POST /projects/{id}/generate asks the assistant to change PLC logic. It requires ai_generate, creates no version, and takes two calls: a prompt returns a plan, and approving that plan returns the code.
The two-scope safety boundary
ai_generate drafts; code_write persists. A generate-only key can draft a change but can never deploy a machine-bound version. To apply a proposal, save it as a new version via POST /projects/{id}/versions — generate → human review → commit.
Two calls, because a change to running plant logic should be reviewed
Send a prompt and you get status: "plan" — what the assistant intends to do, and nothing written. Send the same conversation_id back with approve: true and you get status: "code".
The plan that executes is the one the server recorded when it was proposed, read from the persisted turn rather than from your request body — so a caller cannot widen what was reviewed between the two calls.
The plan
{
"status": "plan",
"conversation_id": "v1-generate-4c8e…",
"plan": {
"summary": "Add a 5-second start-up delay before the conveyor enables.",
"steps": [{ "target": "ConveyorControl", "intent": "Insert a TON gating the enable." }],
"assumptions": ["The existing enable rung is the right insertion point."],
"affected_tags": ["Conveyor_Enable", "Conveyor_StartDelay"],
"risks": [{ "severity": "warn", "kind": "timing", "note": "Delays restart after an E-Stop reset." }]
},
"citations": [{ "location_kind": "rung", "path": "ConveyorControl", "rung": 12 }],
"usage": { "input_tokens": 3120, "output_tokens": 640 }
}Show risks and assumptions to a human before approving. This is the load-bearing half of “proposes, never deploys”.
The code
changes carries one entry per changed unit — the vendor-neutral projection of what the assistant authored, so you do not parse prose to find it:
{
"status": "code",
"conversation_id": "v1-generate-4c8e…",
"explanation": "Added a 5-second TON gating Conveyor_Enable …",
"changes": [
{
"action": "UPDATE",
"target": "Program:MainProgram/Routine:ConveyorControl",
"code_type": "RLL",
"content": "XIC(Conveyor_Start_PB) TON(Conveyor_StartDelay,?,?) …"
}
],
"citations": [{ "location_kind": "rung", "path": "ConveyorControl", "rung": 12 }],
"usage": { "input_tokens": 4210, "output_tokens": 980 }
}action is CREATE / UPDATE / DELETE / RENAME / DUPLICATE (new_name carries the new name for the last two). code_type is the unit's language: ST / RLL / Tag / UDT on Rockwell, SCL / DB / LAD / FBD / SFC on Siemens, ST / Declaration / LD / Task on CODESYS.
The other two outcomes
Either call can also return needs_input (the assistant needs something answered before it can plan — relay the questions, then call again with answers) or unresolved (nothing could be proposed, usually because the request named something absent from the project). Both are normal, not errors.
An optional target: { program?, routine? } hints at placement, and amendment revises a plan you were shown instead of approving it. Set "mode": "stream" for an SSE stream (see Streaming).
plan = client.generate(
"prj_…",
"Add a 5-second start-up delay timer before the conveyor enables.",
)
if plan.status == "plan":
print(plan.plan.summary)
for r in plan.plan.risks:
print(r.severity, r.note) # show these to a human first
code = client.approve_plan("prj_…", plan.conversation_id)
for ch in code.changes:
print(ch.action, ch.target, ch.code_type)
print(ch.content)var proposed = await client.GenerateAsync(
"prj_…",
"Add a 5-second start-up delay timer before the conveyor enables.");
if (proposed is PlanResponse plan)
{
Console.WriteLine(plan.Plan.Summary);
foreach (var r in plan.Plan.Risks)
Console.WriteLine($"{r.Severity}: {r.Note}"); // show these to a human first
var approved = await client.ApprovePlanAsync("prj_…", plan.ConversationId);
if (approved is CodeResponse code)
foreach (var ch in code.Changes)
Console.WriteLine($"{ch.Action} {ch.Target} ({ch.CodeType})");
}Generate is a write (it runs the model): it requires an Idempotency-Key, and a replay returns the stored proposal without a second model run. It honors the org's API spend cap — a paused org gets 402.