Skip to content
Console

Rust SDK

Use the Rust SDK when your client needs strong types, low overhead, or careful runtime control.

The crate name is thalovant.

Version 0.9.0 requires Rust 1.88 or newer and supports v3 Noise over WSS, HTTPS, and MQTT over TLS. It negotiates XXpsk2 or pinned-peer KKpsk0, with both 25519_ChaChaPoly_SHA256 and 25519_AESGCM_SHA256 available.

Transports expose set_noise_state_dir for private, persistent state and remote_static_key for the authenticated peer key. Preserve state when restarting or changing transports for the same identity. Use distinct identities for concurrent clients; a failed handshake never deletes a saved pin.

WSS, HTTPS, and MQTT share Noise negotiation and reuse the persisted derived PSK for the same hub. The cache is keyed by hub node ID: editing the identity password alone does not replace a cached PSK that the hub still accepts. A rejected or abandoned unfinished handshake discards that derived cache so a later attempt derives the current password.

To force re-derivation, call thalovant::forget_cached_psk(Some(state_dir.as_path()), &node_id)?, or pass None for the default directory. This removes only the derived cache; the client static key and verified server pin remain intact.

Set the directory before connecting. For example, when using WSS:

use std::path::PathBuf;
use thalovant::{Client, RuntimeTransport, WssTransport};
let transport = WssTransport::new(identity.clone());
transport
.set_noise_state_dir(Some(PathBuf::from("/path/to/private/persistent/sdk-state")))
.await;
let client = Client { identity, transport: RuntimeTransport::Wss(transport) };

HTTPS retains cookies and refuses redirects. with_options_and_http_client_builder lets an application add trusted private certificate authorities while preserving those requirements. MQTT requires TLS; use set_tls_configuration for custom trust. Reconnect explicitly after broker loss and wait for the new Noise handshake before sending.

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. Clients sharing a cloned transport share the guard. 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. See the correlation contract.

Install the latest release with cargo add thalovant. Use the current verified version explicitly when you need reproducible builds:

Terminal window

Rust SDK v0.2.20 adds device login through login_with_browser. MQTT TLS uses the operating system’s native trust store; HTTPS and WSS continue to use the SDK’s Rustls transport.

use thalovant::{
BootstrapIdentityOptions, Client, ControlPlane, HubProtocol, RequestOptions,
};
#[tokio::main]
async fn main() -> thalovant::Result<()> {
let mut control = ControlPlane::default();
control.login("[email protected]", "password", None).await?;
let result = control
.create_client_identity_for_hub_id(
"hub-id",
BootstrapIdentityOptions {
name: "rust-demo-client".into(),
preferred_protocols: vec![HubProtocol::Wss, HubProtocol::Https, HubProtocol::Mqtt],
..Default::default()
},
)
.await?;
let client = Client::with_protocol(result.identity.clone(), HubProtocol::Wss)?;
let reply = client
.ask("Tell me a short clean joke.", RequestOptions::default())
.await;
let closed = client.close().await;
let reply = reply?;
closed?;
println!("{}", reply.text);
Ok(())
}

ControlPlane::default() uses https://api.thalovant.com.

Accounts with multi-factor authentication enabled must include a TOTP code or a one-time recovery code with the login. A plain login call is rejected with HTTP 401 and code mfa_required. Use login_with_options; the fields are sent only when set. MFA support needs SDK v0.2.19 or newer.

use thalovant::LoginOptions;
control
.login_with_options(
"password",
LoginOptions {
otp_code: Some("123456".into()),
..Default::default()
},
)
.await?;

Use recovery_code: Some(...) instead of otp_code when the authenticator device is unavailable.

Device login asks your browser to approve the sign-in, so CLIs, services, and agents 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.

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

use thalovant::DeviceLoginOptions;
let token = control
.login_with_browser(DeviceLoginOptions {
scopes: vec!["hubs:read".into(), "clients:write".into()],
client_name: Some("my-tool".into()),
..Default::default()
})
.await?;
println!("signed in, token scopes: {}", token["scopes"]);

Manage the resulting token on the dashboard’s API Tokens page.

Pass a stored API token when the process should start authenticated, such as CI jobs and services. Mint one on the API Tokens page or with login_with_browser; the page shows scopes, expiry, and last use, and can revoke the token at any time.

use thalovant::ControlPlane;
let token = std::env::var("THALOVANT_API_TOKEN").expect("THALOVANT_API_TOKEN is set");
let control = ControlPlane::with_access_token(token);
// Ready for authenticated calls, no login step needed.
let hubs = control.list_hubs(Some(50), None, None).await?;
use thalovant::{Client, RequestOptions};
let client = Client::from_config(Some("prod"))?;
let reply = client
.ask("What can this hub do?", RequestOptions::default())
.await;
let closed = client.close().await;
let reply = reply?;
closed?;
println!("{}", reply.text);

Raw identity files work too:

use thalovant::{Client, RequestOptions};
let client = Client::from_file("_identity.json")?;
let reply = client
.ask("What can this hub do?", RequestOptions::default())
.await;
let closed = client.close().await;
let reply = reply?;
closed?;
println!("{}", reply.text);

Environment variables work too:

use thalovant::Client;
let client = Client::from_env()?;
let identity = result.identity.clone();
println!("{:?}", identity.enabled_protocols());
println!("{:?}", identity.endpoint_for(HubProtocol::Wss));
println!("{:?}", identity.endpoint_for(HubProtocol::Https));
println!("{:?}", identity.endpoint_for(HubProtocol::Mqtt));
for protocol in [HubProtocol::Wss, HubProtocol::Https, HubProtocol::Mqtt] {
if !identity.supports_protocol(protocol) {
continue;
}
if protocol == HubProtocol::Mqtt && identity.mqtt.is_none() {
continue;
}
let client = Client::with_protocol(identity.clone(), protocol)?;
let reply = client
.ask(&format!("Reply over {protocol:?}."), RequestOptions::default())
.await;
let closed = client.close().await;
let reply = reply?;
closed?;
println!("{protocol:?}: {}", reply.text);
}

MQTT requires the identity.mqtt broker credentials returned for that client.

Client::auto and the file, config, and environment helpers select WSS, then HTTPS, then MQTT. The older Client::new(identity) constructor selects HTTPS; use Client::auto or Client::with_protocol when choosing another transport.

For broker details, see MQTT.

use thalovant::{build_client_context, ClientContextOptions, RequestOptions};
let context = build_client_context(None, ClientContextOptions {
user_id: Some("user-42".into()),
user_name: Some("Ada".into()),
auth_provider: Some("oidc".into()),
source: Some("checkout-kiosk".into()),
platform: Some("kiosk".into()),
locale: Some("en-US".into()),
channel: Some("chat".into()),
..Default::default()
});
let reply = client
.ask(
"Show the next instruction.",
RequestOptions {
context: Some(context),
..Default::default()
},
)
.await?;
use std::time::Duration;
use thalovant::RequestOptions;
use tokio::time::timeout;
let mut events = client.transport.subscribe();
client
.send_utterance("Say the current status.", RequestOptions::default())
.await?;
if let Ok(Ok(event)) = timeout(Duration::from_secs(12), events.recv()).await {
println!("{} {}", event.name, event.text());
}

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? 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.3.0 the calls are client.intents(languages, options).await, which returns the inventory grouped by skill, client.list_intents(lang, options).await, which returns the registration rows, and client.describe_intent(skill_id, intent_name, lang, options).await, which returns the definitions behind one intent.

use thalovant::{Client, IntentInventoryOptions, ThalovantError};
let client = Client::from_file("_identity.json")?;
let result = client
.intents(["en-us", "fr-fr"], IntentInventoryOptions::default())
.await;
let closed = client.close().await;
match result {
Ok(inventory) => {
println!("{:?} {:?}", inventory.source, inventory.languages);
for skill in &inventory.skills {
for intent in &skill.intents {
println!("{} {:?}", intent.id(), intent.examples(Some("fr-fr"), 2));
}
}
}
Err(ThalovantError::PolicyDenied { denied_type, allowed, .. }) => {
eprintln!("refused {denied_type}; allowed {allowed:?}");
}
Err(error) => return Err(error),
}
closed?;

intents asks ovos.intent.list once per language, then fills in the sentences the listing did not carry, in batches of DESCRIBE_BATCH requests so a hub with hundreds of intents is never sent the whole burst at once. IntentInventoryOptions carries the deadline and the switches: describe: false stops at names, engines, and enabled state. examples(lang, limit) prefers whole sentences to ones with a {slot}, then fuller wording up to eight words; phrases_for(lang) returns all of them, and the inventory serialises to the same JSON the other SDKs print. Language tags compare without regard to case or _ and -, so fr-fr and fr_FR are one request.

A hub that refuses a query returns ThalovantError::PolicyDenied at once with denied_type, code, reason, and allowed. With the default fallback: true, a refused or silent ovos.intent.list returns names only instead: source is IntentInventorySource::EngineManifests, denied names the unavailable query without proving a policy refusal, and has_phrases() is false. A hub that accepts the listing and answers it with ok: false is a different case: since 0.3.1 that returns ThalovantError::Runtime carrying the hub’s own error text, because a failed listing is not an empty hub. describe_intent keeps returning an empty list for the same answer, which is a real one: the hub does not know that registration.

ask

Send one request and receive normalized text, speech, and display items.

conversation

Keep related turns in one session.

send_action

Send a structured action from a device, UI, or service command.

send_code

Send a scanned value, serial number, QR value, or typed code.

For the full method list, see SDK Functions. Use control.get_operation(operation_id) to follow an accepted command; see Operations.

Since SDK v0.2.22 the control plane can create hubs, runtime groups, and skill installs. Browsing the catalog with list_marketplace_skills needs hubs:read and works on any plan. create_hub, create_runtime_group, install_runtime_group_skill, release_hub, and release_runtime_group need hubs:write and a paid plan.

let group = api.create_runtime_group(json!({"name": "kiosks"})).await?;
let hub = api
.create_hub(json!({"name": "joke-garden", "runtime_group_id": group["id"], "spec": {}}), None)
.await?;
let hub = api.get_hub(hub["id"].as_str().unwrap_or_default()).await?;
let etag = hub["etag"].as_str().unwrap_or_default();
api.update_hub(hub["id"].as_str().unwrap_or_default(), json!({"active": false}), etag).await?;

update_hub and delete_hub take the hub’s current etag as a required argument. See Provision Hubs for the full flow, the immutable fields, and the error table.

Symptom Check
Missing access token Call control.login(...) or control.login_with_browser(...) before private API actions, or use ControlPlane::with_access_token(...).
HTTP 401 with code mfa_required Use control.login_with_options(...) with an otp_code or recovery_code.
API access requires a paid plan Upgrade the workspace before provisioning private resources through the API.
Unsupported protocol Enable that protocol on the hub and create a fresh identity.
MQTT fails immediately Confirm the identity has identity.mqtt broker credentials.

Last reviewed: September 13, 2026. Review this page when package versions, transport support, TLS defaults, or Noise state storage changes.

Fallback Capabilities And Request Deadlines

Section titled “Fallback Capabilities And Request Deadlines”

The intents result remains compatible. Use intents_with_capabilities for fallback handlers and a conservative language check:

use thalovant::IntentInventoryOptions;
let result = client.intents_with_capabilities(["en-us"], IntentInventoryOptions::default()).await;
let closed = client.close().await;
let capabilities = result?;
closed?;
println!("{:?}", capabilities.inventory.source);
println!("{}", capabilities.may_answer("en-us"));

Intent-description windows share one timeout across sending and reply collection. When that budget expires, earlier definitions remain available and no later window is sent. Initial connection, language listings, and the optional fallback probe have separate budgets.

fallbacks_known distinguishes unknown discovery from a confirmed empty list. list_fallbacks(timeout) exposes the optional query directly. Its 1.5-second ceiling includes connection, send, and reply wait. See fallback discovery.

ask_with_options(text, AskOptions) adds reply_settle (250 ms by default) and empty_reply_wait (five seconds) while preserving existing RequestOptions literals. Ask and Query use one deadline across connect, send, and reply collection. A soft intent miss may recover with speech; a policy denial remains a failure with any partial speech retained.

Noise state uses bounded process locks and atomic publication on a filesystem supporting hard links and atomic rename. Preserve private keys and authenticated pins after errors. Connection cancellation and cleanup keep ownership of the retiring generation until its work has stopped.

Control-plane redirects are refused. Authenticated API calls and request bodies require HTTPS except explicit loopback development endpoints. See API credential security.

wait_for_event(name, ListenOptions) waits for one event with a twelve-second default total deadline. listen(name, ListenOptions) returns an EventStream; call recv().await for Result<Option<Event>>. Set timeout, max_events, session_id, request_id, and a fast nonblocking predicate as needed. A matching request ID takes precedence over a rewritten hub session.

Each stream owns a bounded subscription. Overflow and lost authentication return errors. Drop or close() the stream to unsubscribe without closing the shared client. Cancelling one recv future leaves the stream usable; dropping a wait_for_event future releases its subscription.

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 list_hub_skills
History list_hub_skill_history
Install install_hub_skill
Update update_hub_skill
Remove remove_hub_skill

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 wait_for_hub_skill_operation. 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 also cancels an in-flight status request.

Request helpers and safe configuration updates

Section titled “Request helpers and safe configuration updates”

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.

let location = thalovant::build_location(&thalovant::LocationOptions {
city: "Montréal".into(), country: "CA".into(), ..Default::default()
});
let reply = client.ask_with_hints("Quel temps fait-il ?", Default::default(), thalovant::RequestContextOptions {
stt_lang: Some("fr-ca".into()), location, ..Default::default()
}).await?;
for event in reply.media_events() { if event.is_audio() { let bytes = event.audio_bytes()?; /* play bytes */ } }
let examples = intent.examples_with_options(Some("en-us"), 2, true, &Default::default());
api.update_runtime_group_config(group_id, delta, None).await?;
// Explicit full replacement:
api.replace_runtime_group_config(group_id, full_config, None).await?;

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.

Guarded merges preserve native signed and unsigned 64-bit integers. Floating-point values in config, personas, or the stored configuration must be finite and within ±9,007,199,254,740,991. Larger values fail before writing because decoding may have rounded an integer beyond native storage. Use string identifiers for integers that exceed native storage.

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.

use thalovant::{as_sentence, IntentExampleOptions};
let options = IntentExampleOptions { sentence: true, ..Default::default() };
let examples = intent.examples_with_listing(Some("fr-CA"), 2, &options);
let text = as_sentence("what time is it", Some("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.

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 admitted 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 admitted Ask or Emit after a lost response, because an Ask can trigger an action. A request timeout applies to the underlying operation; waiting for session admission and your connection factory are separate budgets.

use thalovant::{HubSession, HubSessionPolicy, AskOptions};
let session = HubSession::new(connect_client, HubSessionPolicy::default())?;
let result = session.ask("What is the weather?", AskOptions::default()).await;
session.close().await?;
let reply = result?;

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 handshake 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.

Version 0.9.0 exposes reply.pipeline_ids(), reply.skill_ids() 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.