PlcsAi 2.0.0
dotnet add package PlcsAi --version 2.0.0
NuGet\Install-Package PlcsAi -Version 2.0.0
<PackageReference Include="PlcsAi" Version="2.0.0" />
<PackageVersion Include="PlcsAi" Version="2.0.0" />
<PackageReference Include="PlcsAi" />
paket add PlcsAi --version 2.0.0
#r "nuget: PlcsAi, 2.0.0"
#:package PlcsAi@2.0.0
#addin nuget:?package=PlcsAi&version=2.0.0
#tool nuget:?package=PlcsAi&version=2.0.0
PlcsAi — .NET SDK for the PLCs.ai API
Interpret PLC code from your own tools. Official C# client for the
PLCs.ai API. Targets .NET Standard 2.0+ and .NET 8;
async-first, with IAsyncEnumerable<T> streaming.
Works with all three platform families the API supports: Rockwell
Allen-Bradley (.L5X), Siemens TIA Portal (.zip from the Desktop
Companion App) and CODESYS V3 (.export, covering the OEM toolchains built
on it — WAGO, ABB, Schneider EcoStruxure Machine Expert and others). All three
read the same two ways: GetSourceAsync for the vendor-neutral parsed model,
and DownloadSourceAsync for the vendor file itself.
Install
dotnet add package PlcsAi
Quickstart
using PlcsAi;
using var client = new PlcsClient("plck_live_…"); // or set PLCS_API_KEY
var result = await client.InterpretAsync(
projectId: "prj_…",
prompt: "Why is the filler at line 2 not advancing past Starting?");
if (result is AnswerResponse answer)
{
Console.WriteLine(answer.Answer);
foreach (var c in answer.Citations) // where the answer was read from
Console.WriteLine($" - {c.LocationKind} {c.Path}");
}
Console.WriteLine(result.RequestId); // quote this in a support request
The assistant is interactive
Every AI call returns a TurnResponse — one of a few concrete types. It can stop
and ask rather than guessing, so handling NeedsInputResponse is part of using
the API, not an edge case:
var result = await client.InterpretAsync("prj_…", "Why is the filler stuck?");
if (result is NeedsInputResponse ask)
{
foreach (var q in ask.Questions)
{
Console.WriteLine($"{q.Text} — {q.WhyItMatters}");
// q.RecommendedIndex is the assistant's hint, or null. It is never
// applied for you — 0 is a real index, so test for null, not zero.
}
result = await client.InterpretAsync(
"prj_…",
conversationId: ask.ConversationId, // required with answers
answers: new[] { new Answer("scope", 1) }); // or new Answer("scope", "free text")
}
switch (result)
{
case AnswerResponse a: Console.WriteLine(a.Answer); break;
case UnresolvedResponse u: Console.WriteLine(u.Reason); break; // asked for a *change*
}
result.Status carries the same discriminator as a string if you would rather
switch on that. A status this release does not recognize throws PlcsException.
Citations
AnswerResponse, PlanResponse and CodeResponse carry Citations — the
locations the assistant read while producing them. Provenance, not a
relevance ranking: a citation is recorded where the read happened, so every entry
is a place the turn genuinely looked at. Nothing about how it was chosen is on
the wire.
foreach (var c in answer.Citations)
Console.WriteLine($"{c.LocationKind} {c.Path} {c.Rung} {c.Station}");
Path is the location as the platform spells it (Main/Feed_Conveyor on
Rockwell, PLC_1/DriveStatus on Siemens, FB_Valve.Open on CODESYS), so it can
be handed straight back to GetSourceAsync. On a production line, Station names
the station and ProjectId is set only when the location is on a sibling
project rather than the one you asked about.
Two empties that are not errors: a turn that read nothing citable returns an empty
list, and so does any turn made with a key that lacks code_read — the answer is
unaffected, but a key that may not read your code is not handed the locations it
was read from. When per-kind quotas shorten the list, CitationsTotal reports how
many were read; it is null when Citations is already all of them.
On a streamed turn they arrive on the done event (e.Citations), because a turn
whose answer streams as prose emits no outcome event at all.
Streaming
Prose arrives as token events. A turn that ends on questions, a plan, or a
refusal emits a single outcome event instead; done always carries the final
Status and the whole turn's Usage.
await foreach (var ev in client.InterpretStreamAsync("prj_…", "…"))
{
if (ev.Type == "token") Console.Write(ev.Text);
else if (ev.Type == "outcome") Console.WriteLine($"stopped early: {ev.Outcome!.Kind}");
else if (ev.Type == "done") Console.WriteLine($"\n{ev.Status} usage: {ev.Usage}");
}
The stream names two of these outcomes differently from the blocking body:
Outcome.Kind is questions where Status is needs_input, and changes
where Status is code.
Conversations, analysis, embed tokens
var conv = await client.CreateConversationAsync("prj_…", "Line 2 stoppage");
var msg = await client.SendMessageAsync(conv.ConversationId, "And why now?");
// Answers exactly as InterpretAsync does, including NeedsInputResponse:
if (msg is NeedsInputResponse)
await client.SendMessageAsync(conv.ConversationId, answers: new[] { new Answer("scope", 0) });
// Nothing here produces an analysis as a side effect — you read what the app
// ran, and ask for a fresh one explicitly.
var analysis = await client.GetProjectAnalysisAsync("prj_…");
if (analysis.Status == "not_analyzed" && analysis.LastAnalyzedVersionId != null)
{
// Nothing has run for THIS version and nothing will on its own — but an
// older version still has one. Read it deliberately, and label it:
// IsCurrentVersion is false, so it describes code the project no longer
// contains. Never report it as the project's current state.
var old = await client.GetProjectAnalysisAsync("prj_…", analysis.LastAnalyzedVersionId);
Console.WriteLine($"{old.VersionId} {old.IsCurrentVersion} {analysis.LastAnalyzedAt}");
}
else if (analysis.Status == "complete")
Console.WriteLine(analysis.Results);
// Or block until it settles (returns on not_analyzed too, rather than hanging):
var done = await client.WaitForProjectAnalysisAsync("prj_…");
// Stale or never analyzed? Run it explicitly (billable), then read again:
await client.StartAnalysisAsync("prj_…");
var token = await client.MintEmbedTokenAsync("prj_…"); // read-only, for the iframe
Projects: list, read source, live values
var page = await client.ListProjectsAsync(limit: 50);
foreach (var p in page.Projects) Console.WriteLine($"{p.ProjectId} {p.Name} {p.Vendor} {p.Industry}"); // Industry: null = never analysed
var detail = await client.GetProjectAsync("prj_…"); // metadata + AnalysisStatus
var model = (await client.GetSourceAsync("prj_…")).Parsed; // the vendor-neutral parsed model (needs code_read)
byte[] rawBytes = await client.DownloadSourceAsync("prj_…"); // the vendor file: L5X / ZIP / .export bytes
var values = await client.GetHmiValuesAsync("prj_…"); // needs hmi_view; Live=false when no DCA session
var history = await client.GetHmiHistoryAsync("prj_…", "Motor1.Speed");
Exports (async): PLC file & PDF report
var job = await client.ExportPlcAsync("prj_…"); // or ExportPdfAsync(...)
var done = await client.WaitForExportAsync(job.ExportId);
byte[] artifact = await client.DownloadExportAsync(done.ExportId); // L5X / ZIP / .export / PDF bytes
File.WriteAllBytes(done.Filename!, artifact);
Save a new version (code_write)
var res = await client.CommitVersionAsync("prj_…", File.ReadAllBytes("Conveyor_edited.L5X"), "Conveyor.L5X");
Console.WriteLine($"{res.Resolution} {res.VersionId}"); // add_version, or identical_file (no-op)
Console.WriteLine(res.Analysis); // "not_analyzed" — a commit never analyzes
A commit is an edit, and analysis is expensive and per-version, so the new
version starts out unanalyzed. Graph and search indexing still run, so
InterpretAsync(...) sees the change right away; call StartAnalysisAsync(...)
when you want the analysis refreshed too.
The previous version's analysis is not lost, only superseded:
GetProjectAnalysisAsync(...) then reports LastAnalyzedVersionId, and passing
that as the versionId argument reads it back with IsCurrentVersion == false.
Treat it as a statement about code the project no longer contains — the commit
may have fixed a finding, or introduced one the run never saw.
Propose a change (ai_generate — proposes, never deploys)
Authoring is two turns: the assistant proposes a plan, you approve it, and
only then is code written. Nothing is deployed even then — ApprovePlanAsync
creates no version, so persist the result with CommitVersionAsync(...)
(code_write).
var proposal = await client.GenerateAsync("prj_…", "Add a 5-second start-up delay timer.");
if (proposal is PlanResponse planned)
{
Console.WriteLine(planned.Plan.Summary);
foreach (var step in planned.Plan.Steps) Console.WriteLine($"{step.Target}: {step.Intent}");
// Ambiguities it resolved — sanity-check these before approving.
foreach (var assumption in planned.Plan.Assumptions) Console.WriteLine(assumption);
if (planned.Plan.BlockingRisks.Count > 0) return; // Severity == "block"
var authored = await client.ApprovePlanAsync("prj_…", planned.ConversationId);
if (authored is CodeResponse code)
{
Console.WriteLine(code.Explanation);
foreach (var change in code.Changes)
Console.WriteLine($"{change.Action} {change.Target} ({change.CodeType})\n{change.Content}");
}
}
GenerateAsync can also return NeedsInputResponse (answer and call again with
conversationId + answers) or UnresolvedResponse. ApprovePlanAsync executes
the plan the server recorded when it proposed it, so what gets authored is exactly
what you reviewed; pass amendment: "use a latch instead of a seal-in" to tweak it
on the way through. A plan can be approved once — a second approval is a 404
rather than a second bill.
All three platforms are supported. On a CODESYS project a proposed change's
CodeType is "ST" (the unit's complete Structured Text body),
"Declaration" (its complete declaration), "LD" (a rung-edit script rather
than source) or "Task" (a task-configuration change).
Platform notes
- CODESYS projects are uploaded, not connected. Version-control connectors
(GitHub, GitLab, Bitbucket, Copia, octoplant) link L5X and Siemens ZIP files
only. A CODESYS project is uploaded in the app;
CommitVersionAsync(...)saves new versions of it — there is no sync-from-source path for.export. - Which verbs accept a CODESYS project is published as a capability matrix at
developer.plcs.ai; a verb that doesn't refuses with
vendor_unsupported(422) and names a supported path inSuggestedAction. Every verb currently accepts a CODESYS project. - CODESYS V2/2.3 exports are a different file format and are rejected on upload with a message saying so.
What the client handles for you
- Auth — sends
Authorization: Bearer …on every request. - Idempotency — auto-generates a stable
Idempotency-Keyper write (reused across retries). - Retries — retries only on retryable errors, respecting
Retry-After. - Streaming — parses SSE into
StreamEvents overIAsyncEnumerable<T>. RequestId— surfaced on every result.
Errors
Non-2xx responses throw PlcsApiException with StatusCode, Error,
UserMessage, SuggestedAction, IsRetryable, and RequestId.
Versioning
PlcsClient.PackageVersion // "2.0.0" — this package
PlcsClient.ApiContract // "2026-09-02" — the API contract it was built against
PackageVersion follows SemVer for this package. ApiContract is the date of
the API contract the release targets; the /api/v1 in the URL is a namespace and
does not change when the contract does. The two move independently.
The API contract is not frozen yet: it can change on a new date, and earlier
contracts are not served alongside it. ApiContract tells you which one this
release speaks — if the API has moved past it, upgrade the package.
developer.plcs.ai publishes the current contract.
Upgrading from 1.x
The API contract changed on 2026-08-22 — interpret and generate returned
different shapes before it — and 2.0 follows it. The AI verbs now return a
TurnResponse you pattern match on instead of a single concrete type:
| 1.x | 2.0 |
|---|---|
InterpretResult.Answer |
AnswerResponse.Answer, after matching the type |
.Citations |
.Citations — a List<Citation>, not the 1.x shape |
includeCitations: |
removed — citations are always returned, never requested |
GenerateResult.GeneratedCode / .CodeBlocks |
GenerateAsync returns a PlanResponse; ApprovePlanAsync then returns CodeResponse.Changes |
MessageResult |
TurnResponse, same as interpret |
| — | NeedsInputResponse / UnresolvedResponse are normal outcomes to handle |
The removed types and members are compile errors, so 1.x call sites surface at build time rather than as empty values.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- Microsoft.Bcl.AsyncInterfaces (>= 8.0.0)
- System.Text.Json (>= 8.0.5)
-
net8.0
- System.Text.Json (>= 8.0.5)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.