Skip to content

.NET SDK

Consystence.Sdk is the official .NET client library for the Consystence API. It targets .NET 10, wraps the HTTP surface behind a set of strongly-typed clients, and is built for dependency injection (DI) first. The same library backs the csy CLI, which supplies its own credential and token-refresh behaviour through the SDK’s extension points.

The library is deliberately thin: it owns HTTP composition (a named HttpClient, auth and tenant message handlers, retry policy, JSON), and it mirrors the server’s wire contracts as SDK-local records so it never has to reference the server’s internal contract assemblies.

The SDK is not yet published to a public feed — during the beta, reference the Consystence.Sdk project from the monorepo (backend/Sdk/Consystence.Sdk). Once published it will install as:

Terminal window
dotnet add package Consystence.Sdk

It depends only on the Microsoft.Extensions.* DI/HTTP/Options packages and Polly (via Microsoft.Extensions.Http.Polly) for transient-fault handling — there is no heavyweight transitive footprint.

Add the SDK to an IServiceCollection with AddConsystenceClient (in the Consystence.Sdk.Extensions namespace). Two overloads are provided.

Configure options inline:

using Consystence.Sdk.Extensions;
builder.Services.AddConsystenceClient(options =>
{
// The server for the current context: an org's cell-direct host for /api routes,
// or the account host for account commands.
options.BaseUrl = "https://acme.consystence.com";
options.TenantCode = "acme";
});

Or bind from configuration. With no argument it binds the Consystence section, validates data annotations, and validates on start:

builder.Services.AddConsystenceClient(); // binds "Consystence"
{
"Consystence": {
"BaseUrl": "https://acme.consystence.com",
"TenantCode": "acme"
}
}

Either overload registers everything needed: the named ConsystenceApi HttpClient, the message handlers, the default credential provider, every typed client as a singleton, and the IConsystenceClient aggregate. Resolve and use it:

var client = serviceProvider.GetRequiredService<IConsystenceClient>();
var sites = await client.Sites.ListAsync(cancellationToken);

ConsystenceClientOptions (section name Consystence):

PropertyDefaultPurpose
BaseUrlhttps://api.consystence.comBase address of the named ConsystenceApi client (all typed clients).
TenantCoderequiredSent on every API request as X-Tenant-Code.
MaxRetryAttempts3Polly transient-error retry count (idempotency-gated — see the HTTP pipeline).
InitialRetryDelay500 msFirst retry delay; backoff doubles each attempt.
HttpTimeout30 sPer-request timeout.
RefreshTokenOverridenullHost-supplied bearer-refresh callback — the only refresh mechanism. On expiry/401 the SDK invokes it with the current refresh token so the host can renew against the token’s issuer (e.g. the CIAM token_endpoint, or a site server’s POST /auth/site/refresh). When null, the SDK never refreshes.

IConsystenceClient exposes the typed clients as properties. Each is also independently injectable.

PropertyInterfaceRoute familyPurpose
SetupISetupClientGET /api/setup/statusServer setup status (mode, version, activation state).
AdminIAdminClient/api/admin/*Org/tenant administration, tenant YAML import/export and restore (RestoreTenantAsync — served on site binaries, unlike import/export), and org re-bootstrap (ReBootstrapTenantAsync).
SitesISiteClient/api/sitesSite CRUD, plus site-wide operational reads: active alarms (ReadAlarmsAsync) and the command-audit ledger (ReadCommandsAsync).
ControllersIControllerClient/api/sites/{siteId}/controllersController create/list/get/update/assign/decommission.
InstancesIInstanceClient/api/sites/{siteId}/instancesDevice-instance create/list/get/update/delete, plus live reads: current tag values (ReadTagsAsync), a historian trend window (ReadTrendAsync), and the instance’s command audit (ReadCommandsAsync).
ElectricalIElectricalClient/api/sites/{siteId}/electricalPower-distribution nodes, edges, and topology.
AccountIAccountClient/api/me, /api/tenants, /api/orgs/* (account host)Account control-plane self-service (account-of-record, tenants, org/member/licence management).
ConfigIConfigClient/api/admin/config, /api/admin/orgs/{slug}/configPlatform/org config store: list/set/unset, audit trail, reload (admin-gated).
AiIAiClient/api/aiAI plane (org-admin-gated): provider health (HealthAsync) and test completions (CompleteAsync).
TrendImportsITrendImportClient/api/historian/import-batchesBulk historian import: SubmitAsync uploads a trend CSV + tag-mapping CSV (a duplicate-file_hash 409 resolves to the existing batch rather than failing); GetStatusAsync polls a batch.

The SDK separates which credential to send from how the HTTP pipeline sends it. This is the canonical surface for SDK auth — see the authentication model for the platform-wide picture.

  • ClientCredential carries a ClientAuthMechanismNone, Bearer, or ApiKey — and, for the API-key case, the key secret.
  • IClientCredentialProvider.GetCredential() returns the active credential per request.
  • The default TokenManagerCredentialProvider reports Bearer whenever the in-memory TokenManager holds a token, otherwise None.

The two mechanisms map directly onto the platform’s MultiAuth schemes:

  • Bearer — a CIAM access token sent as Authorization: Bearer …, backed by TokenManager, with automatic refresh.
  • ApiKey — a tenant API key (csk_ prefix) sent as X-Api-Key. Mutually exclusive with Authorization, and never refreshed — the key is the credential.

To use a non-interactive API key, register your own IClientCredentialProvider after AddConsystenceClient (last registration wins). The SDK ships no static-key provider — the host defines its own wrapper (the CLI does the same with its internal CliCredentialProvider):

builder.Services.AddConsystenceClient(/* … */);
builder.Services.AddSingleton<IClientCredentialProvider>(
_ => new StaticApiKeyProvider(
ClientCredential.ForApiKey("csk_…")));
sealed record StaticApiKeyProvider(ClientCredential Credential) : IClientCredentialProvider
{
public ClientCredential GetCredential() => Credential;
}

API keys are minted via the admin API; see API keys.

The SDK has no login surface and no server-mediated refresh — the platform authenticates with OIDC against the CIAM identity provider (Entra External ID), and token acquisition belongs to the host. A host application (such as the CLI) drives the OIDC flow itself, places the resulting token in TokenManager (which computes ExpiresAt), and sets RefreshTokenOverride so refresh goes to the token’s actual issuer — the IdP token_endpoint for a CIAM token, or the site server’s POST /auth/site/refresh for a site-PIN session. RefreshTokenOverride is the only refresh mechanism: when it is null, the SDK never refreshes. See the authentication model.

All typed clients use the named ConsystenceApi client, whose pipeline is:

flowchart LR
  A[Typed client<br/>e.g. Sites.ListAsync] --> B[TenantHeaderHandler<br/>adds X-Tenant-Code]
  B --> C[AuthenticatedHttpHandler<br/>X-Api-Key XOR Bearer]
  C --> D[Polly retry<br/>transient HTTP errors]
  D --> E[Consystence server]
  • TenantHeaderHandler adds X-Tenant-Code: {TenantCode} to every request that does not already carry it.
  • AuthenticatedHttpHandler asks the credential provider for the active credential. On the API-key path it clears any Authorization header, sets X-Api-Key, and does no refresh or 401 retry. On the Bearer path it attaches Authorization, and — if the token is expired or a 401 comes back and a refresh token exists — refreshes once (guarded by a semaphore) and resends the request.
  • Polly wraps the named client with HandleTransientHttpError and exponential WaitAndRetryAsync backoff governed by MaxRetryAttempts and InitialRetryDelay.
  • Both the Polly retry and the post-401 resend are idempotency-gated: GET/HEAD/OPTIONS/TRACE, PUT, and DELETE requests may always be re-sent; a POST or PATCH is re-sent only when it declares an idempotency contract — a stable Idempotency-Key header the endpoint honours, or an endpoint whose duplicate-submission 409 resolves to the existing resource. Undeclared POST/PATCH requests are never retried, so a transient failure cannot duplicate a side effect.
var site = await client.Sites.CreateAsync(
new SiteCreateRequest { Code = "CHPP", Name = "Coal Handling Prep Plant" },
cancellationToken);
var all = await client.Sites.ListAsync(cancellationToken);
var one = await client.Sites.GetAsync("CHPP", cancellationToken);

The /api/sites endpoints return the bare SiteInfo record directly (no {site} envelope). See the org and site model for what a site is.

Controllers (the term Consystence uses, not “PLC”) are hand-created per site. The server mints each controller’s canonical ctl_… id — the request carries no id of your own. ControllerConnectionConfig defaults Port to 44818 (EtherNet/IP), and ControllerPlatform spans the supported families (ControlLogix, CompactLogix, Micro800, MicroLogix, PLCnext, Other, plus Vplc for a cloud-hosted virtual PLC).

var controller = await client.Controllers.CreateAsync(site.Id,
new ControllerCreateRequest
{
Name = "Pump House Controller",
Platform = ControllerPlatform.PLCnext,
AssetNumber = "PP-01",
Connection = new ControllerConnectionConfig
{
Protocol = "ethernet-ip",
IpAddress = "10.0.4.10",
},
},
cancellationToken);
// GET returns the device plus its live connection health.
var details = await client.Controllers.GetAsync(site.Id, controller.Id, cancellationToken);
// details.Device.AssetNumber, details.Health.Status

Licence issuance and revocation live in the account console — the cell API no longer mints or revokes licences, and the SDK has no dedicated licence client. Licences are read-only through two surviving paths:

org.Licences
// Cell-direct: org details carry the licences inline.
var org = await client.Admin.GetOrgAsync("acme", cancellationToken);
// Account host: the org's licence pools and allocations.
var licences = await client.Account.GetOrgLicencesAsync(orgId, cancellationToken);
// licences.Pools, licences.Allocations, licences.Assignments

See licensing for the tier and seat-pool model.

The account control-plane client targets the account host (the CLI scopes these to an account context with an account-audienced bearer).

var me = await client.Account.GetMeAsync(cancellationToken); // null if no account-of-record yet
var tenants = await client.Account.ListTenantsAsync(cancellationToken);

GetMeAsync returns null on a 404 (authenticated, but no account row) rather than throwing — distinct from a 401, which throws.

IAccountClient also carries the org/member self-service family (ListOrgsAsync, CreateOrgAsync, GetOrgAsync, GetOrgLicencesAsync, RequestOrgTierAsync, ProvisionUnderOrgAsync, plus invite and member management — mutations are Owner-only server-side). It also exposes an internal operator surface, not intended for integrators.

The SDK includes a source-generated JsonSerializerContext, ConsystenceSdkJsonContext, configured with JsonSerializerDefaults.Web and UseStringEnumConverter = true, so enums serialise as strings bidirectionally. It registers the account, licence, setup, site, and admin contract types for AOT-clean, reflection-free (de)serialisation.

The account client, the admin client’s org/member/domain endpoints (its tenant endpoints predate the context), and the static platform-status client read through the generated JsonTypeInfo (for example ConsystenceSdkJsonContext.Default.AccountMe); the remaining clients currently use a plain Web-defaults JsonSerializerOptions.

The SDK includes guards (ReadJsonOrThrowAsync and EnsureNotHtmlAsync) that detect a non-JSON / HTML response — for example an SPA shell from a path that matched no route, or an endpoint the server’s baked mode does not serve — and surface a clean HttpRequestException naming the request URI, instead of a bare JsonException or a write that silently appears to succeed.