# .NET SDK

> Install the Thalovant .NET SDK for C# services and Unity apps that connect to a hub.

Source: https://docs.thalovant.com/developers/sdks/dotnet/

---
Use the .NET SDK when your client runs on .NET or inside Unity.

The NuGet package name is `Thalovant.Sdk`. It multi-targets `net8.0` and `netstandard2.1`, which covers Unity 2021 and newer. The `net8.0` build has zero external dependencies; the `netstandard2.1` target only depends on the `System.Text.Json` package.

Source and releases: [thalovant-dotnet-sdk on GitHub](https://github.com/thalovant/thalovant-dotnet-sdk).

## Install

Install the package with `dotnet add package Thalovant.Sdk`:

```bash
dotnet add package Thalovant.Sdk
```

Add `--version <version>` when you need a reproducible build; the current
version is in [Supported Versions](/developers/sdks/#supported-versions).

## First Request

This flow signs in, creates a client identity, and sends one request over WSS.

```csharp
using Thalovant;

var api = new ThalovantControlPlane();

await api.LoginAsync("you@example.com", "password");

var result = await api.CreateClientIdentityAsync(
    "hub-id",
    new CreateClientIdentityOptions("dotnet-demo-client"));

using var client = new ThalovantClient(result.Identity);
await client.ConnectAsync();
var reply = await client.AskAsync("Tell me a short clean joke.");
Console.WriteLine(reply.Text);
await client.CloseAsync();
```

`new ThalovantControlPlane()` uses `https://api.thalovant.com` by default.

  Keep `result.Identity` secret. Authenticated API actions require API access on the workspace. Do not log `result.ToJsonObject(includeSecrets: true)`.

## Sign In With MFA

Accounts with multi-factor authentication enabled must include a TOTP code or a one-time recovery code with the login. Without one, the API rejects the sign-in with HTTP 401 and code `mfa_required`, surfaced as `ThalovantApiException.ErrorCode`.

```csharp
await api.LoginAsync("you@example.com", "password", otpCode: "123456");

// Or use a one-time recovery code instead:
await api.LoginAsync("you@example.com", "password", recoveryCode: "abcd-efgh-ijkl");
```

The `otpCode` and `recoveryCode` values are sent only when provided.

## Sign In Without A Password

Device login asks your browser to approve the sign-in, so services and apps never handle your password. The SDK prints a short code and the address `https://dash.thalovant.com/activate`, opens that page when it can, and waits while you approve the request in the dashboard with your normal sign-in, including Google sign-in or MFA. On approval the SDK holds a scoped, revocable API token, returned as a typed `DeviceLoginResult`.

Device login needs SDK 0.1.1 or newer. Approving the request needs a paid workspace plan; a free plan gets HTTP 402.

```csharp
var result = await api.LoginWithBrowserAsync(new DeviceLoginOptions
{
    Scopes = new[] { "hubs:read", "clients:write" },
    ClientName = "my-tool",
});
Console.WriteLine($"{result.TokenId} expires {result.ExpiresAt}");
```

Manage the resulting token on the dashboard's [API Tokens page](/account/api-tokens/).

## Authenticate With An API Token

Pass a stored API token when the process should start authenticated, such as CI jobs and services. Mint one on the [API Tokens page](/account/api-tokens/) or with `LoginWithBrowserAsync`; the page shows scopes, expiry, and last use, and can revoke the token at any time.

```csharp
var api = new ThalovantControlPlane(accessToken: Environment.GetEnvironmentVariable("THALOVANT_API_TOKEN"));
```

## Where The SDK Keeps Its Keys

The SDK connects over WSS. Every connection is encrypted and authenticated before anything is sent. HTTPS and MQTT remain unsupported. The library targets `net8.0` and `netstandard2.1`.

On .NET 8 the default `HiveMindFileNoiseStore` keeps the SDK's keys under `Thalovant/noise` in the local application data directory, with private permissions where the platform can enforce them. Preserve it across restarts.

The `netstandard2.1` build, including Unity, requires an explicitly supplied **existing app-private directory** or an `IHiveMindNoiseStore` implementation backed by platform-protected storage. It refuses to create a default store without APIs that can enforce those permissions:

```csharp
var store = new HiveMindFileNoiseStore(existingAppPrivateDirectory);
using var client = new ThalovantClient(identity, noiseStore: store);
```

The file store serializes key access across processes and writes a key only once the complete value is flushed, so an interrupted write cannot replace a good file. Use distinct identities for concurrent clients, and confirm a changed hub identity with the hub owner before accepting it.

Use fresh request IDs for Ask and fresh query IDs for Query; generated IDs are the default. An overlapping collector with the same ID is rejected on the same client. The namespaces are independent. Do not reuse an ID for a later logical operation, even after cancellation: a delayed reply can still carry it. Reuse a session ID for conversation context.

Intent-manifest fallback retains successful engine replies when another engine is denied or times out. If all engines are unavailable, the first failure is surfaced. Caller cancellation and other errors still propagate.

## Supported Runtime Protocol

`ThalovantClient` connects to the hub over WSS only in this release. Constructing it with `HubProtocol.Https` or `HubProtocol.Mqtt` throws `ThalovantUnsupportedProtocolException`. Endpoint selection still prefers `wss`, then `https`, then `mqtt`, so identities created for multi-protocol hubs keep working.

Use the [Python](/developers/sdks/python/), [Node.js](/developers/sdks/node/), [Go](/developers/sdks/go/), or [Rust](/developers/sdks/rust/) SDK when the client needs the HTTPS or MQTT data plane today.

## Use A Saved Identity

Identities can be built from JSON or loaded from a JSON file. On Linux and macOS the file must not be group- or world-readable; run `chmod 600 <path>` first. The permission check is skipped on Windows and on the `netstandard2.1` Unity build.

```csharp
var identity = ThalovantIdentity.FromFile("/path/to/identity.json");
using var client = new ThalovantClient(identity);
```

The identity document uses the same snake_case fields the API returns, including `access_key`, `password`, `site_id`, and the optional `data_plane_endpoints`, `protocols`, and `mqtt` sections. A `crypto_key` in an older identity file is still parsed and ignored.

## Follow An Operation

Mutating control-plane commands return durable operations. Poll them with the typed helper:

```csharp
var operation = await api.GetOperationAsync("operation-id");
Console.WriteLine(operation.Status);  // Requested, Committed, Applied, Ready, Failed, TimedOut
```

See [Operations](/developers/operations/) for lifecycle and retry guidance.

## Listen For Events

```csharp
var subscription = client.On("speak", e => Console.WriteLine(e.DisplayText));
// Later, when the listener is no longer needed:
subscription.Close();
```

## List What The Hub Can Be Asked

A connected client can list every intent its hub answers, per language, with the sentences a person says to reach each one, over its own session and with no control-plane token. See [What Can My Hub Be Asked?](/concepts/hub-intents/) for what the hub returns, which message types the connection must be allowed to publish, and what a refusal or a failed listing looks like.

Since SDK 0.1.12 the calls are `client.IntentsAsync(languages, options)`, which returns the inventory grouped by skill, `client.ListIntentsAsync(lang, options)`, which returns the registration rows, and `client.DescribeIntentAsync(skillId, intentName, lang, options)`, which returns the definitions behind one intent. Each takes a `CancellationToken` last.

```csharp
try
{
    var inventory = await client.IntentsAsync(new[] { "en-us", "fr-fr" });
    Console.WriteLine($"{inventory.Source} {string.Join(", ", inventory.Languages)}");
    foreach (var skill in inventory.Skills)
    {
        foreach (var intent in skill.Intents)
        {
            Console.WriteLine($"  {intent.Id} [{intent.Engine}]: {string.Join(" | ", intent.Examples("fr-fr"))}");
        }
    }
}
catch (ThalovantPolicyDeniedException denied)
{
    Console.Error.WriteLine($"refused {denied.DeniedType}; allowed {string.Join(", ", denied.Allowed)}");
    throw;
}
```

`IntentsAsync` asks `ovos.intent.list` once per language, then fills in the sentences the listing did not carry, in batches of at most 32 requests so a hub with many intents is never sent the whole burst at once. `IntentInventoryOptions` carries the timeout and the switches: `Describe = false` stops at names, engines, and enabled state. `Examples(lang, limit: 2)` prefers whole sentences to ones with a `{slot}`, then fuller wording up to eight words; `PhrasesFor("fr-FR")` finds `fr-fr` too, and `inventory.ToJsonObject()` writes the same document the other SDKs write. Language tags compare without regard to case or `_` and `-`, so `fr-fr` and `fr_FR` are one request.

A hub that refuses a query throws `ThalovantPolicyDeniedException` at once with `DeniedType`, `Code`, `Reason`, and `Allowed`. With `IntentInventoryOptions.Fallback` on, which is the default, a refused `ovos.intent.list` returns names only instead: `inventory.Source` is `engine-manifests`, `inventory.Denied` names the refused query, and `inventory.HasPhrases` is false. A hub that accepts the listing and answers it with `ok: false` is a different case: since 0.1.13 that throws `ThalovantRuntimeException` carrying the hub's own error text, because a failed listing is not an empty hub. `DescribeIntentAsync` keeps returning an empty list for the same answer, which is a real one: the hub does not know that registration.

The default inventory also falls back when `ovos.intent.list` is silent. Disable fallback to propagate that listing timeout. The `denied` marker names the query that triggered fallback; it does not establish a policy denial. `ListFallbacksAsync()` exposes registered fallback handlers, and inventory includes `FallbacksKnown` and `MayAnswer(lang)`.

The optional fallback-handler probe takes at most 1.5 seconds across connection, send, and reply wait. A denied, silent, explicitly failed probe or malformed reply envelope leaves discovery unknown. Accepted arrays retain usable handlers and skip invalid rows. Known empty handlers and unknown discovery are different states: language availability is false only when no enabled intent has phrases for that language and a successful probe reports no fallback handlers. A fallback handler's presence does not guarantee it can answer every language.

## Runtime Helpers And Deadlines

A conversation shares the connection, session id, language and context. Each request gets a fresh request id:

```csharp
var conversation = client.Conversation(sessionId: "checkout", lang: "en-us");
var reply = await conversation.QueryAsync("Tell me a short clean joke.", TimeSpan.FromSeconds(12));
Console.WriteLine(reply.Text);
```

`QueryAsync` collects query/cascade replies with the matching query id until `hive.query.complete`. Soft intent misses allow later speech; policy denial and query timeout are terminal and retain partial speech in a failed reply. A linked cancellation budget covers connection, send and collection; caller cancellation remains cancellation. Requests are never automatically replayed.

Use `WaitForEventAsync` for one matching event and `ListenAsync` for an `IAsyncEnumerable`. Listening starts when enumerated. The stream buffers at most 64 events and reports overflow; timeout, event count, cancellation and transport loss release subscriptions. `SendActionAsync` and `SendCodeAsync` preserve structured input metadata. `ConnectionInfo`, `ConnectWithInfoAsync`, `HealthcheckAsync` and `DoctorAsync` report local authenticated transport state, without guaranteeing every remote skill is healthy.

`ConnectAsync` includes queueing and authentication in one timeout, with a six-second default. A queued caller's timeout or cancellation does not cancel another caller's connection attempt. An accepted write has an independent 20-second physical deadline. Caller cancellation stops waiting while the write retains ownership; a physical failure or expiry retires the affected connection, with ownership retained until the write actually stops. Supported environments include .NET 8 and 9 on Linux and Windows. Unity device execution has not been verified.

## Control-Plane HTTP Security

The SDK-owned client rejects redirects. Requests carrying credentials or a JSON body require HTTPS, except explicit `localhost`, `127.0.0.1`, and `[::1]` HTTP development endpoints. Default authorization headers, cookies and handler credentials are checked too. Anonymous reads without credentials or a body may use HTTP; URLs containing userinfo are rejected before I/O.

For authenticated custom transport, pass a handler so the SDK can own redirect policy:

```csharp
using Thalovant;
using System.Net.Http;

var handler = new HttpClientHandler();
var api = new ThalovantControlPlane(
    httpMessageHandler: handler,
    accessToken: apiToken);
```

The original four-argument constructor remains binary compatible. An arbitrary supplied `httpClient` cannot expose or enforce its redirect settings, so credential-bearing calls through that path now fail before I/O with migration guidance. The SDK disables redirects on supported HTTP handlers and their recognized inner handlers. Custom handlers remain trusted application code and must not follow redirects themselves. Device login launches only validated HTTP(S) URLs, passing one URL argument to non-Windows browser openers.

## Useful Helpers

  
    Send one request and receive a normalized reply.
  
  
    Observe hub bus events by name with a closeable subscription.
  
  
    Poll a durable control-plane command with a typed status.
  
  
    Discover public hubs before sign-in.
  

For cross-language recipes, see [SDK Functions](/developers/sdk-functions/).

## Provision Hubs

Since SDK 0.1.5 the control plane can create hubs, runtime groups, and skill installs. Browsing the catalog with `ListMarketplaceSkillsAsync()` needs `hubs:read` and works on any plan. `CreateHubAsync`, `CreateRuntimeGroupAsync`, `InstallRuntimeGroupSkillAsync`, `ReleaseHubAsync`, and `ReleaseRuntimeGroupAsync` need `hubs:write` and a paid plan.

```csharp
var group = await api.CreateRuntimeGroupAsync(new CreateRuntimeGroupOptions("kiosks"));
var hub = await api.CreateHubAsync(new CreateHubOptions("joke-garden", new JsonObject())
{
    RuntimeGroupId = (string)group["id"]!,
});

hub = await api.GetHubAsync((string)hub["id"]!);
await api.UpdateHubAsync((string)hub["id"]!, new UpdateHubOptions { Active = false }, (string)hub["etag"]!);
```

`UpdateHubAsync` and `DeleteHubAsync` take the hub's current etag as a required argument. See [Provision Hubs](/developers/provisioning/) for the full flow, the immutable fields, and the error table.

## Common Issues

| Symptom | Check |
| --- | --- |
| `ThalovantApiException` with `Missing Thalovant API access token` | Call `api.LoginAsync(...)` or `api.LoginWithBrowserAsync(...)` before private API actions, or pass `accessToken:` to `ThalovantControlPlane`. |
| HTTP 401 with `ErrorCode` `mfa_required` | Pass `otpCode` or `recoveryCode` to `api.LoginAsync(...)`. |
| API access requires a paid plan | Upgrade the workspace before provisioning private resources through the API. |
| `ThalovantUnsupportedProtocolException` | The hub does not expose WSS, or the code requested `Https` or `Mqtt`, which this release does not connect over. |
| `ThalovantIdentityException` | The identity file is malformed or readable by other users. |
## Manage Skills Through A Hub

The SDK can list, install, update, remove and inspect the history of skills on a
hub's runtime group. **Every hub sharing that runtime group is affected.** A hub
UUID selects the group; it does not create an isolated installation for that hub.
A restricted token must cover all hubs served by the group.

Reads require `hubs:inspect` (`hubs:read` includes it). Writes require `hubs:write`,
an eligible paid plan and ownership. Use `latest` or a concrete released version
for installation and updates. An accepted write returns an `operation_id`;
acceptance does not mean the runtime has finished installing the skill.

| Action | Method |
|---|---|
| List | `ListHubSkillsAsync` |
| History | `ListHubSkillHistoryAsync` |
| Install | `InstallHubSkillAsync` |
| Update | `UpdateHubSkillAsync` |
| Remove | `RemoveHubSkillAsync` |

History returns the API JSON envelope with newest-first `event` and `operation`
entries, preserving nullable actor/version fields. The limit is 1–200.

Use `HubSkillWaitOptions` to opt into operation waiting (120 seconds, polling every
two seconds by default). For cancellation-sensitive calls, first submit without
waiting, retain the accepted response, then call `WaitForHubSkillOperationAsync`.
Cancelling the wait does not undo the accepted server operation. Polling failures
retain the operation ID in the error; inspect that operation instead of repeating
the write. The wait deadline prevents new polls but does not cancel a request
already in flight.

## Request Helpers And Safe Configuration Updates

*Available since `0.5.0`.*

Request hints carry a recognized language, ordered intent pipeline, and caller
location without changing the caller's context. Empty hints are omitted. The
location helper requires a city and omits invalid or zero/zero coordinates.
The hub validates language hints against its configured languages.

Replies expose their reported language, ordered speech/audio events, and a
count of dropped media. Embedded skill clips are limited to 4 MiB each and
16 MiB per reply, checked before retention and decoding. Audio does not extend
the reply settlement window. Decoding accepts hexadecimal bytes with ASCII
whitespace between bytes; it never fetches a skill-supplied URL or file path.
The application owns playback. Any `play` or `Play` function below belongs to your app.

```csharp
var location = ThalovantContext.BuildLocation("Montréal", country: "CA");
var reply = await client.AskWithHintsAsync("Quel temps fait-il ?", sttLang: "fr-ca", location: location);
foreach (var e in reply.MediaEvents) if (e.IsAudio) Play(e.AudioBytes());
var examples = intent.ExamplesWithOptions("en-us", speakable: true);
await api.UpdateRuntimeGroupConfigAsync(groupId, delta);
// Explicit full replacement:
await api.ReplaceRuntimeGroupConfigAsync(groupId, fullConfig);
```

Guarded merging requires the `hubs:read` and `hubs:write` scopes and a paid plan.
Safe merging requires an API whose configuration GET returns a valid `revision`
and whose configuration PUT checks `expected_revision`. The SDK rereads and
reapplies the original delta only after HTTP 412, with at most three attempts.
Arrays and scalar values replace; objects merge recursively. Personas replace
only when explicitly supplied. Connection failures, redirects, other statuses,
and ambiguous write results are never retried. No unsafe PATCH fallback is used.
Unconditional replacements must still be coordinated with other writers.

Use the explicit replacement operation shown above when a complete replacement
is intended, including when working with an older API. Existing code relying on
replacement must opt into it when upgrading. Raw intent patterns remain the
default; speakable examples remove optional parts, choose alternatives, and
substitute caller-supplied slots while retaining complete-phrase priority.

The audio limits use encoded-length upper bounds before decoding, so formatting
whitespace consumes budget too. Like Python's `bytes.fromhex`, ASCII whitespace
alone decodes to zero bytes. Bounded malformed clips remain available as event
metadata and fail when decoded; they are never fetched or played automatically.
Distinct audio events may intentionally repeat identical sound content. Only
repeated delivery of the same event object is suppressed where object identity
is available, without counting it as a dropped clip. Rendered example ranking
uses the original pattern's slot presence even when sample values are supplied.

## Locale-Aware Intent Listings

*Available since `0.6.1`.*

Sentence listings use bundled `thalovant-languages` 0.2.1 data and match Python
0.6.8 with its listing extra. Language selection follows OVOS regional distances,
so a request for `fr-CA` can use a registered `fr-FR` locale.

```csharp
var examples = intent.ExamplesWithListing("fr-CA", sentence: true);
var text = ThalovantContext.AsSentence("what time is it", "en-US");
var sample = ThalovantContext.SpeakableWithLanguage("play {song}", lang: "en-US");
```

Sentence rendering implies speakable rendering. Explicit slot values override
locale samples. Complete phrases rank before prefixes and slot patterns; empty
and duplicate rendered examples do not consume the limit. Unlimited raw examples
keep registration order. Unknown locales retain bare text and slot names.

Existing example methods remain available. Use `ListingRules` to provide a
complete custom data snapshot or select bare rendering without locale data.
No network request is needed to load the bundled rules. See the repository README
for custom-rule construction and regex error handling.

The listing data now describes 270 languages, including regional overrides.
Spanish `qué hora es` becomes `Qué hora es?`, and French `coupe le son`
becomes `Coupe le son.`. Languages without rules, such as `tlh`, remain bare.

## Managed Sessions And Inventory Caches

`HubSession` owns one reusable hub connection. Supply a factory that returns a
connected client and cleans up a failed or cancelled connection attempt. Event
subscriptions survive client replacement. Go and Rust expose a persistent event
stream; the other managed SDKs expose subscription handles. Close the session
when its owner shuts down; close waits for accepted operations and is terminal.

Background connection attempts back off for 10, 20, 40, 80, then 120 seconds.
Foreground calls can try immediately. Your application owns probe scheduling:
use the reported probe delay (60 seconds while held, 5 seconds while down).
The SDK never replays an accepted Ask or Emit after a lost response, because an
Ask can trigger an action. A request timeout applies to the underlying operation;
waiting for the session and your connection factory are separate budgets.

```csharp
await using var session = new HubSession(connectClient, warm: false);
var reply = await session.AskAsync("What is the weather?");
```

`Inventory`, `Skill`, and `Intent` provide a presentable view separate from the
runtime's native intent inventory. Unknown catalogue locales remain unknown;
phrases observed for a language do not prove catalogue support. Examples choose
the closest supported locale. A nonpositive limit returns the raw phrase pool
(Rust uses zero for its unsigned limit). Cache JSON includes explicit intent
language order so serialization cannot change the default example language.

`InventoryCache` is optional, defaults to a one-hour TTL, and returns a miss for
invalid, expired, or unreadable data. Writes use private, unique scratch files
and atomic replacement. POSIX cache files are owner-readable/writable; Windows
uses the user's directory ACLs. Cache keys separate mode, identity path, and the full normalized hub hostname.
Never use inventory caches to store credentials.

`OriginPreference` gives a preferred address its own short connection budget and
cools it down after a failure. In non-Python SDKs the factory must implement the
address binding on its own transport, retain the public host for TLS/SNI, and
finish failed-attempt cleanup before returning. Transport/platform restrictions
still apply. This helper does not change global DNS or disable TLS validation.

## Reply Claims

A reply exposes `reply.PipelineIds`, `reply.SkillIds` and `reply.Claimed`.

Pipeline and skill IDs are nonempty strings in first-seen order, with duplicates removed. A successful reply with only pipeline IDs containing the case-sensitive text `fallback` reports an unclaimed reply. Any non-fallback stage makes a successful reply claimed. Successful replies from older hubs without stage stamps remain claimed. Failed or unhandled replies are never claimed. Malformed non-string stamps are ignored.

Use this as a hint when deciding whether to continue a conversation. Fallback text and the existing success status are retained. These stamps are not verified peer identity and must not authorize actions.
