PlcsAi 2.0.0

dotnet add package PlcsAi --version 2.0.0
                    
NuGet\Install-Package PlcsAi -Version 2.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="PlcsAi" Version="2.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="PlcsAi" Version="2.0.0" />
                    
Directory.Packages.props
<PackageReference Include="PlcsAi" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add PlcsAi --version 2.0.0
                    
#r "nuget: PlcsAi, 2.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package PlcsAi@2.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=PlcsAi&version=2.0.0
                    
Install as a Cake Addin
#tool nuget:?package=PlcsAi&version=2.0.0
                    
Install as a Cake Tool

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 in SuggestedAction. 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-Key per write (reused across retries).
  • Retries — retries only on retryable errors, respecting Retry-After.
  • Streaming — parses SSE into StreamEvents over IAsyncEnumerable<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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.0.0 101 9/3/2026
1.2.0 143 6/4/2026
1.1.0 114 6/4/2026
1.0.1 119 6/1/2026
1.0.0 112 6/1/2026