Control plane
Discover hubs and create client identities.
Use this page when you want to build with the SDK, not memorize every method name.
Start with the quick example, then copy the recipe closest to your task. Each recipe shows the same idea in Python, Node.js, Go, and Rust where the SDKs expose it. The Quick Start, Sign In, and Create A Client Identity recipes also show Kotlin, Swift, and C#; for the full surface of those SDKs, use the Kotlin, Swift, and .NET SDK pages.
All seven managed SDKs expose HubSession and HubSessionPolicy for a reusable
connection, retained subscriptions, and configurable reconnect/probe timing.
Supply a connected-client factory and close the session when its owner exits.
An uncertain Ask or Emit is not replayed automatically: the application decides
whether retrying an action is safe. Failed cleanup blocks a replacement until
that connection has been retired.
Use Inventory, Skill, and Intent for a presentable view of discovered
skills and locale-aware examples. InventoryCache adds optional bounded,
private storage with a default one-hour TTL; unknown catalogue language support
stays unknown. Explicit language order survives JSON serialization. This view
is separate from the native intent-inventory response.
See the managed-session and cache examples for Python, Node, Go, Rust, Kotlin, .NET, and Swift. MCP returns the presentation view within each tool’s identity lease. Embedded C leaves sessions and storage to its caller.
This example signs in, creates a client identity for one hub, connects, sends one request, and prints the reply.
from thalovant import ThalovantClient, ThalovantControlPlane
api = ThalovantControlPlane()
result = api.create_client_identity( "hub-id", name="python-demo-client", preferred_protocols=("wss", "https", "mqtt"),)
with ThalovantClient(result.identity, protocol="wss") as client: reply = client.ask("Tell me a short clean joke.") print(reply.text)import { ThalovantClient, ThalovantControlPlane } from "@thalovant/sdk";
const api = new ThalovantControlPlane();
const result = await api.createClientIdentity("hub-id", { name: "node-demo-client", preferredProtocols: ["wss", "https", "mqtt"],});
const client = new ThalovantClient(result.identity, { protocol: "wss" });try { const reply = await client.ask("Tell me a short clean joke."); console.log(reply.text);} finally { await client.close();}package main
import ( "context" "fmt" "log"
thalovant "github.com/thalovant/thalovant-go-sdk")
func main() { ctx := context.Background() api := thalovant.NewDefaultControlPlane("")
log.Fatal(err) }
result, err := api.CreateClientIdentityForHubID(ctx, "hub-id", thalovant.BootstrapIdentityOptions{ Name: "go-demo-client", PreferredProtocols: []thalovant.HubProtocol{thalovant.ProtocolWSS, thalovant.ProtocolHTTPS, thalovant.ProtocolMQTT}, }) if err != nil { log.Fatal(err) }
client, err := thalovant.NewClientWithOptions(result.Identity, thalovant.ClientOptions{Protocol: thalovant.ProtocolWSS}) if err != nil { log.Fatal(err) } defer client.Close(ctx)
reply, err := client.Ask(ctx, "Tell me a short clean joke.", thalovant.RequestOptions{}) if err != nil { log.Fatal(err) } fmt.Println(reply.Text)}use thalovant::{BootstrapIdentityOptions, Client, ControlPlane, HubProtocol, RequestOptions};
#[tokio::main]async fn main() -> thalovant::Result<()> { let mut api = ControlPlane::default();
let result = api .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(())}import com.thalovant.sdk.CreateClientIdentityOptionsimport com.thalovant.sdk.ThalovantClientimport com.thalovant.sdk.ThalovantControlPlaneimport kotlinx.coroutines.runBlocking
fun main() = runBlocking { val api = ThalovantControlPlane()
val result = api.createClientIdentity( "hub-id", CreateClientIdentityOptions(name = "kotlin-demo-client"), )
val client = ThalovantClient(result.identity) try { val reply = client.ask("Tell me a short clean joke.") println(reply.text) } finally { client.close() }}import ThalovantSDK
let api = ThalovantControlPlane()
let result = try await api.createClientIdentity( hubId: "hub-id", options: CreateClientIdentityOptions(name: "swift-demo-client"))
let client = try ThalovantClient(identity: result.identity)do { try await client.connect() let reply = try await client.ask("Tell me a short clean joke.") print(reply.text) await client.close()} catch { await client.close() throw error}using Thalovant;
var api = new ThalovantControlPlane();
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();The Kotlin, Swift, and C# clients connect over WSS only, so they take no protocol argument here. These examples require the current v3-compatible SDK versions.
Expected result:
Why did the hub keep good logs? It wanted every punchline to be traceable.Control plane
Discover hubs and create client identities.
Identity
Load saved credentials and choose a protocol.
Runtime client
Connect, ask, emit events, and send structured input.
Events and context
Listen for hub events and attach session metadata.
Use this before hub discovery or identity provisioning.
from thalovant import ThalovantControlPlane
api = ThalovantControlPlane()import { ThalovantControlPlane } from "@thalovant/sdk";
const api = new ThalovantControlPlane();import thalovant "github.com/thalovant/thalovant-go-sdk"
api := thalovant.NewDefaultControlPlane("")use thalovant::ControlPlane;
let mut api = ControlPlane::default();Sign in before private control-plane actions such as creating a client identity.
Accounts with multi-factor authentication enabled must include a TOTP code or a one-time recovery code; without one, the API rejects the sign-in with HTTP 401 and code mfa_required. Every SDK sends the MFA fields only when they are set.
# MFA-enabled accounts add a TOTP code or a one-time recovery code:
// MFA-enabled accounts add a TOTP code or a one-time recovery code:if err != nil { log.Fatal(err)}
// MFA-enabled accounts use LoginWithOptions with OTPCode or RecoveryCode: OTPCode: "123456",})if err != nil { log.Fatal(err)}
// MFA-enabled accounts use login_with_options with otp_code or recovery_code:use thalovant::LoginOptions;
api.login_with_options( "password", LoginOptions { otp_code: Some("123456".into()), ..Default::default() },).await?;
// MFA-enabled accounts add a TOTP code or a one-time recovery code:
// MFA-enabled accounts add a TOTP code or a one-time recovery code:try await api.login(email: "[email protected]", password: "password", recoveryCode: "abcd-efgh-ijkl")
// MFA-enabled accounts add a TOTP code or a one-time recovery code:Common failure:
Missing Thalovant API access tokenFix it by signing in before the private call, or by passing an API token to the control-plane client. An HTTP 401 with code mfa_required means the account needs the TOTP or recovery code shown above.
Use device login when the code should never see your password: CLIs, agents, notebooks, and machines you do not fully trust with account credentials.
The SDK prints a line like To sign in, visit https://dash.thalovant.com/activate and enter the code XXXX-XXXX, opens that page in your browser when it can, and polls the API while you approve the sign-in with your normal browser session, including Google sign-in or MFA. On approval the SDK receives a scoped, revocable API token and stores it exactly like a password login.
Approving a device sign-in needs a paid workspace plan; on a free plan the approval is rejected with HTTP 402. Device login needs Python 0.4.22, Node.js 0.2.25, Go v0.3.3, Rust v0.2.20, or Kotlin, Swift, and .NET 0.1.1.
api.login_with_browser()
# Optional: request narrower scopes and label the token in the dashboard.api.login_with_browser(scopes=["hubs:read", "clients:write"], client_name="my-cli")// Prints: To sign in, visit https://dash.thalovant.com/activate and enter the code XXXX-XXXXconst token = await api.loginWithBrowser({ clientName: "my-laptop" });
// api.accessToken is now set, exactly like after api.login(...).token, err := api.LoginWithBrowser(ctx, thalovant.DeviceLoginOptions{ Scopes: []string{"hubs:read", "clients:write"}, // optional ClientName: "my-cli", // optional label in the dashboard})if err != nil { log.Fatal(err)}fmt.Println("signed in, token id:", token["token_id"])use thalovant::DeviceLoginOptions;
let token = api .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"]);import com.thalovant.sdk.DeviceLoginOptions
api.loginWithBrowser( DeviceLoginOptions(scopes = listOf("hubs:read"), clientName = "kotlin-demo"),)let token = try await api.loginWithBrowser(options: DeviceLoginOptions( scopes: ["hubs:read", "clients:write"], clientName: "my-macbook"))print(token.tokenId ?? "", token.scopes)var result = await api.LoginWithBrowserAsync(new DeviceLoginOptions{ Scopes = new[] { "hubs:read", "clients:write" }, ClientName = "my-tool",});Console.WriteLine($"{result.TokenId} expires {result.ExpiresAt}");Every SDK honors the same options: scopes to narrow what the token can do, client_name (native casing) to label the token in the dashboard, open_browser to skip opening the page, prompt to replace the printed line, and a timeout that defaults to 15 minutes.
The approving user manages the resulting token on the dashboard’s API Tokens page: scopes, expiry, last use, and revocation.
Use a stored API token when the process should start authenticated, such as CI jobs, servers, and AI agent configs.
Mint a token on the dashboard’s API Tokens page or through device login, then pass it to the control-plane constructor. Tokens are scoped, revocable, and show their last use on that page; creating one needs a paid workspace plan. The examples read the token from a THALOVANT_API_TOKEN environment variable, the same convention the MCP server uses.
import os
from thalovant import ThalovantControlPlane
api = ThalovantControlPlane(access_token=os.environ["THALOVANT_API_TOKEN"])# Ready immediately; no login call needed.const api = new ThalovantControlPlane("https://api.thalovant.com", { accessToken: process.env.THALOVANT_API_TOKEN,});
// Ready immediately; no login call needed.api := thalovant.NewDefaultControlPlane(os.Getenv("THALOVANT_API_TOKEN"))use thalovant::ControlPlane;
let token = std::env::var("THALOVANT_API_TOKEN").expect("THALOVANT_API_TOKEN is set");let api = ControlPlane::with_access_token(token);val api = ThalovantControlPlane(accessToken = System.getenv("THALOVANT_API_TOKEN"))let api = ThalovantControlPlane(accessToken: ProcessInfo.processInfo.environment["THALOVANT_API_TOKEN"])var api = new ThalovantControlPlane(accessToken: Environment.GetEnvironmentVariable("THALOVANT_API_TOKEN"));Use public hub discovery before you ask a user to choose a hub.
page = api.list_public_hubs(limit=12)
for hub in page["data"]: print(hub["id"], hub["slug"], hub["title"])const page = await api.listPublicHubs({ limit: 12 });
for (const hub of page.data) { console.log(hub.id, hub.slug, hub.title);}page, err := api.ListPublicHubs(ctx, 12, "")if err != nil { log.Fatal(err)}
for _, hub := range page.Data { fmt.Println(hub.ID, hub.Slug, hub.Title)}let page = api.list_public_hubs(Some(12), None).await?;
for hub in page.data { println!("{} {} {}", hub.id, hub.slug, hub.title);}Use this when you have a hub reference from a URL, card, or saved choice.
hub = api.get_public_hub("joke-garden")print(hub["title"], hub["public_ref"])const hub = await api.getPublicHub("joke-garden");console.log(hub.title, hub.publicRef);hub, err := api.GetPublicHub(ctx, "joke-garden")if err != nil { log.Fatal(err)}fmt.Println(hub.Title, hub.PublicRef)let hub = api.get_public_hub("joke-garden").await?;println!("{} {}", hub.title, hub.public_ref);Use this for hubs the signed-in workspace can see.
page = api.list_hubs(limit=25)
for hub in page["data"]: print(hub["id"], hub["title"])const page = await api.listHubs({ limit: 25 });
for (const hub of page.data) { console.log(hub.id, hub.title);}page, err := api.ListHubs(ctx, 25, "", "")if err != nil { log.Fatal(err)}
for _, hub := range page.Data { fmt.Println(hub.ID, hub.Title)}let page = api.list_hubs(Some(25), None, None).await?;
for hub in page.data { println!("{} {}", hub.id, hub.title);}Use this after you already know the hub ID.
hub = api.get_hub("hub-id")print(hub["title"])const hub = await api.getHub("hub-id");console.log(hub.title);hub, err := api.GetHub(ctx, "hub-id")if err != nil { log.Fatal(err)}fmt.Println(hub.Title)let hub = api.get_hub("hub-id").await?;println!("{}", hub.title);Use this when your code should create the hub. Discovery needs hubs:read and works on any plan. The four writes need hubs:write and a paid plan. For the etag rule, the immutable fields, and the error table, see Provision Hubs.
skills = api.list_marketplace_skills()["data"]
group = api.create_runtime_group({"name": "kiosks"})hub = api.create_hub({ "name": "joke-garden", "runtime_group_id": group["id"], "spec": {"protocols": {"wss": {"enabled": True}}},})
api.install_runtime_group_skill(group["id"], "skill-weather")api.release_runtime_group(group["id"], channel="stable")api.release_hub(hub["id"], channel="stable")const catalog = await api.listMarketplaceSkills();
const group = await api.createRuntimeGroup({ name: "kiosks" });const hub = await api.createHub({ name: "joke-garden", runtimeGroupId: group.id as string, spec: { protocols: { wss: { enabled: true } } },});
await api.installRuntimeGroupSkill(group.id as string, "skill-weather");await api.releaseRuntimeGroup(group.id as string, { channel: "stable" });await api.releaseHub(hub.id as string, { channel: "stable" });catalog, err := api.ListMarketplaceSkills(ctx, thalovant.MarketplaceSkillListOptions{})
group, err := api.CreateRuntimeGroup(ctx, map[string]any{"name": "kiosks"})groupID := group["id"].(string)
hub, err := api.CreateHub(ctx, map[string]any{ "name": "joke-garden", "runtime_group_id": groupID, "spec": map[string]any{"protocols": map[string]any{"wss": map[string]any{"enabled": true}}},}, thalovant.HubCreateOptions{})hubID := hub["id"].(string)
_, err = api.InstallRuntimeGroupSkill(ctx, groupID, "skill-weather", thalovant.RuntimeGroupSkillInstallOptions{})_, err = api.ReleaseRuntimeGroup(ctx, groupID, thalovant.ReleaseOptions{Channel: "stable"})_, err = api.ReleaseHub(ctx, hubID, thalovant.ReleaseOptions{Channel: "stable"})let catalog = api.list_marketplace_skills(MarketplaceSkillsOptions::default()).await?;
let group = api.create_runtime_group(json!({"name": "kiosks"})).await?;let group_id = group["id"].as_str().unwrap_or_default().to_string();
let hub = api .create_hub( json!({ "name": "joke-garden", "runtime_group_id": group_id, "spec": {"protocols": {"wss": {"enabled": true}}}, }), None, ) .await?;let hub_id = hub["id"].as_str().unwrap_or_default().to_string();
api.install_runtime_group_skill(&group_id, "skill-weather", SkillInstallOptions::default()).await?;api.release_runtime_group(&group_id, ReleaseOptions { channel: Some("stable".into()), ..Default::default() }).await?;api.release_hub(&hub_id, ReleaseOptions { channel: Some("stable".into()), ..Default::default() }).await?;Kotlin, Swift, and .NET expose the same calls with native names. Their SDK pages show the language-specific form.
Hub update and delete need the hub’s current etag, sent as If-Match. Read the hub first: the etag is a body field, and the API sends no ETag response header.
hub = api.get_hub("hub-id")hub = api.update_hub("hub-id", {"active": False}, etag=hub["etag"])api.delete_hub("hub-id", etag=hub["etag"])let hub = await api.getHub("hub-id");hub = await api.updateHub("hub-id", { active: false }, { etag: hub.etag as string });await api.deleteHub("hub-id", { etag: hub.etag as string });hub, err := api.GetHub(ctx, "hub-id")hub, err = api.UpdateHub(ctx, "hub-id", map[string]any{"active": false}, hub["etag"].(string))err = api.DeleteHub(ctx, "hub-id", hub["etag"].(string))let hub = api.get_hub("hub-id").await?;let etag = hub["etag"].as_str().unwrap_or_default().to_string();
let hub = api.update_hub("hub-id", json!({"active": false}), &etag).await?;let etag = hub["etag"].as_str().unwrap_or_default().to_string();api.delete_hub("hub-id", &etag).await?;A missing or stale etag fails with HTTP 412 and changes nothing. Re-read the hub and retry.
Use the operation ID returned by an asynchronous control-plane command. Stop at ready, failed, or timed_out; Git-only commands stop at committed.
operation = api.get_operation("operation-id")print(operation.status)const operation = await api.getOperation("operation-id");console.log(operation.status);operation, err := api.GetOperation(ctx, "operation-id")let operation = api.get_operation("operation-id").await?;See Operations for lifecycle and retry guidance.
Use these to manage the skill attachments of the runtime group selected by a hub. Every hub sharing the runtime group is affected. Each write returns an operation; wait polls it every 2 seconds for up to 120 seconds by default. The writes need hubs:write and a paid plan; listing needs hubs:inspect, which hubs:read includes. Errors are problem JSON with a root code, such as skill_version_already_installed on HTTP 409 or hub_without_runtime_group on HTTP 404, and the SDK error text reads HTTP <status>: <message> (<code>). They arrive in Python SDK 0.5.15 and Node SDK 0.3.15. For the routes, the app path, and the error table, see Add a Skill to a Hub.
skills = api.list_hub_skills("hub-id") # HubSkillList; rows under .dataprint(skills.source, skills.observed_at)for skill in skills.data: print(skill.skill, skill.installed_version, skill.state, skill.update_available)
api.install_hub_skill("hub-id", "skill-weather", version="latest", wait=True)api.update_hub_skill("hub-id", "skill-weather", version="1.2.0", wait=True)api.remove_hub_skill("hub-id", "skill-weather", wait=True)const skills = await api.listHubSkills("hub-id"); // { data, source, observed_at, ... }console.log(skills.source, skills.observed_at);for (const skill of skills.data) { console.log(skill.skill, skill.installed_version, skill.state, skill.update_available);}
await api.installHubSkill("hub-id", "skill-weather", { version: "latest", wait: true });await api.updateHubSkill("hub-id", "skill-weather", { version: "1.2.0", wait: true });await api.removeHubSkill("hub-id", "skill-weather", { wait: true });The list call returns the route’s envelope, with the rows under data and each row carrying installed_version, latest_version, update_available, and a state of pending, installed, failed, removing, drifted, quarantined, or unmanaged. Each write returns the accepted operation with operation_id, skill, version, previous_version, and state. Go, Rust, Kotlin, Swift and .NET also provide these calls and history in their native naming style; see their SDK pages for wait/resume options.
Create an identity when a browser, service, voice client, device, or agent needs to connect to one hub.
result = api.create_client_identity( "hub-id", name="checkout-kiosk", preferred_protocols=("wss", "https"),)
identity = result.identityconst result = await api.createClientIdentity("hub-id", { name: "checkout-kiosk", preferredProtocols: ["wss", "https"],});
const identity = result.identity;result, err := api.CreateClientIdentityForHubID(ctx, "hub-id", thalovant.BootstrapIdentityOptions{ Name: "checkout-kiosk", PreferredProtocols: []thalovant.HubProtocol{thalovant.ProtocolWSS, thalovant.ProtocolHTTPS},})if err != nil { log.Fatal(err)}
identity := result.Identitylet result = api .create_client_identity_for_hub_id( "hub-id", BootstrapIdentityOptions { name: "checkout-kiosk".into(), preferred_protocols: vec![HubProtocol::Wss, HubProtocol::Https], ..Default::default() }, ) .await?;
let identity = result.identity.clone();val result = api.createClientIdentity( "hub-id", CreateClientIdentityOptions(name = "checkout-kiosk"),)
val identity = result.identitylet result = try await api.createClientIdentity( hubId: "hub-id", options: CreateClientIdentityOptions(name: "checkout-kiosk"))
let identity = result.identityvar result = await api.CreateClientIdentityAsync( "hub-id", new CreateClientIdentityOptions("checkout-kiosk"));
var identity = result.Identity;Every SDK also accepts an active option on creation, and it defaults to true. Pass false to provision the client disabled, so it cannot connect until you activate it with a client update.
Use config or a downloaded identity file when the client already exists.
On Linux and macOS, protect local config.yaml and _identity.json files with owner-only permissions before loading them:
chmod 600 ~/.config/thalovant/config.yamlchmod 600 _identity.jsonfrom thalovant import ThalovantClient, ThalovantIdentity
identity = ThalovantIdentity.from_config(profile="prod")same_identity = ThalovantIdentity.from_file("_identity.json")
client_from_config = ThalovantClient.from_config(profile="prod", protocol="wss")client_from_file = ThalovantClient.from_identity_file("_identity.json")client_from_env = ThalovantClient.from_env()import { ThalovantClient, ThalovantIdentity } from "@thalovant/sdk";
const identity = await ThalovantIdentity.fromConfig({ profile: "prod" });const sameIdentity = await ThalovantIdentity.fromFile("_identity.json");
const clientFromConfig = await ThalovantClient.fromConfig({ profile: "prod", protocol: "wss" });const clientFromFile = await ThalovantClient.fromIdentityFile("_identity.json");const clientFromEnv = ThalovantClient.fromEnv();identity, err := thalovant.IdentityFromConfig("", "prod")if err != nil { log.Fatal(err)}
sameIdentity, err := thalovant.IdentityFromFile("_identity.json")if err != nil { log.Fatal(err)}
clientFromConfig, err := thalovant.NewClientFromConfig("", "prod")clientFromFile, err := thalovant.NewClientFromFile("_identity.json")clientFromEnv, err := thalovant.NewClientFromEnv()use thalovant::{Client, Identity};
let identity = Identity::from_config(Some("prod"))?;let same_identity = Identity::from_file("_identity.json")?;
let client_from_config = Client::from_config(Some("prod"))?;let client_from_file = Client::from_file("_identity.json")?;let client_from_env = Client::from_env()?;Use protocol helpers before forcing WSS, HTTPS, or MQTT.
print(identity.enabled_protocols())print(identity.endpoint_for("wss"))print(identity.endpoint_for("https"))print(identity.endpoint_for("mqtt"))print(identity.supports_protocol("wss"))
if identity.mqtt: print(identity.mqtt.endpoint)console.log(identity.enabledProtocols());console.log(identity.endpointFor("wss"));console.log(identity.endpointFor("https"));console.log(identity.endpointFor("mqtt"));console.log(identity.supportsProtocol("wss"));
if (identity.mqtt) { console.log(identity.mqtt.endpoint);}fmt.Println(identity.EnabledProtocols())fmt.Println(identity.EndpointFor(thalovant.ProtocolWSS))fmt.Println(identity.EndpointFor(thalovant.ProtocolHTTPS))fmt.Println(identity.EndpointFor(thalovant.ProtocolMQTT))fmt.Println(identity.SupportsProtocol(thalovant.ProtocolWSS))
if identity.MQTT != nil { fmt.Println(identity.MQTT.Endpoint)}println!("{:?}", identity.enabled_protocols());println!("{:?}", identity.endpoint_for(HubProtocol::Wss));println!("{:?}", identity.endpoint_for(HubProtocol::Https));println!("{:?}", identity.endpoint_for(HubProtocol::Mqtt));println!("{}", identity.supports_protocol(HubProtocol::Wss));
if let Some(mqtt) = &identity.mqtt { println!("{}", mqtt.endpoint);}Use a runtime client after you have identity material.
Keep the client’s Noise static key and verified server pins in private, persistent storage. Use one identity per independently active client process. A reconnect starts a fresh Noise handshake while preserving this identity and trust; a handshake failure does not authorize erasing a pin. Each SDK page documents its state-store option and platform requirements.
from thalovant import ThalovantClient
client = ThalovantClient(identity, protocol="wss")client.connect()print(client.healthcheck())client.close()import { ThalovantClient } from "@thalovant/sdk";
const client = new ThalovantClient(identity, { protocol: "wss" });await client.connect();console.log(await client.healthcheck());await client.close();client, err := thalovant.NewClientWithOptions(identity, thalovant.ClientOptions{ Protocol: thalovant.ProtocolWSS,})if err != nil { log.Fatal(err)}
if err := client.Connect(ctx); err != nil { log.Fatal(err)}fmt.Println(client.Healthcheck())client.Close(ctx)use thalovant::{Client, HubProtocol};
let client = Client::with_protocol(identity.clone(), HubProtocol::Wss)?;client.connect().await?;println!("{:?}", client.healthcheck().await);client.close().await?;Use a context manager, try/finally, or defer so connections close reliably. In Rust, close the client before propagating a request error: ? immediately returns from the current function and does not await connection cleanup. Concurrent callers join authenticated readiness within their own deadlines. A cancelled queued caller must not close the active connection.
These helpers cover the same workflows in each high-level SDK’s supported transports. Kotlin extension helpers need import com.thalovant.sdk.*.
| Workflow | Python | Node.js | Go | Rust | Kotlin / Swift | .NET |
|---|---|---|---|---|---|---|
| Routed query and cascade replies | query |
query |
Query |
query |
query |
QueryAsync |
| Reuse a conversational session | conversation |
conversation |
Conversation |
conversation |
conversation |
Conversation |
| Wait for a correlated event | wait_for_event |
waitForEvent |
WaitForEvent |
wait_for_event |
waitForEvent |
WaitForEventAsync |
| Observe an event stream | listen |
on |
Listen |
listen |
listen |
ListenAsync |
| Connect and inspect readiness | connect_with_info |
connectWithInfo |
ConnectWithInfo |
connect_with_info |
connectWithInfo |
ConnectWithInfoAsync |
| Ask with language, pipeline and location hints | ask |
ask |
AskWithOptions |
ask_with_hints |
askWithHints |
AskWithHintsAsync |
| Build a request location | build_location |
buildLocation |
BuildLocation |
build_location |
buildLocation |
ThalovantContext.BuildLocation |
| Render a speakable intent pattern | speakable |
speakable |
Speakable |
speakable |
speakable |
ThalovantContext.Speakable |
| Decode one embedded audio event | audio_bytes |
audioBytes |
AudioBytes |
audio_bytes |
audioBytes |
AudioBytes |
| Read answering pipeline IDs | reply.pipeline_ids |
reply.pipelineIds |
reply.PipelineIDs() |
reply.pipeline_ids() |
reply.pipelineIds |
reply.PipelineIds |
| Read answering skill IDs | reply.skill_ids |
reply.skillIds |
reply.SkillIDs() |
reply.skill_ids() |
reply.skillIds |
reply.SkillIds |
| Inspect advisory reply claim | reply.claimed |
reply.claimed |
reply.Claimed() |
reply.claimed() |
reply.claimed |
reply.Claimed |
Kotlin uses a Flow, Swift an AsyncThrowingStream, and .NET an IAsyncEnumerable for listening. Their buffers hold at most 64 events and report overflow explicitly. Cancel or finish the stream to remove its subscription. Swift starts the subscription when listen is called; .NET starts when enumerated. Use each language guide for timeout units and cancellation inputs.
Ask allows bounded delayed speech; Query follows its query ID through cascade replies and waits for completion. A soft intent miss can recover when speech arrives. Hard policy denial and explicit query timeout remain failures, with partial speech preserved. Conversation helpers reuse the session while creating a fresh request ID and merged context per request. A reconnect never automatically replays an application request.
Reply IDs are nonempty strings in first-seen order. A fallback-only successful reply retains its text and success status but reports claimed=false; a successful reply without stage stamps retains legacy claimed behavior. Failed or unhandled replies are never claimed. Claim metadata does not verify peer identity. See reply claims for matching and malformed-stamp behavior.
Embedded C exposes thalovant_ask_event_pipeline_id(), thalovant_ask_event_skill_id() and thalovant_reply_claimed() with caller-owned buffers and ID aggregation. MCP runtime summaries expose pipelineIds, skillIds and claimed through the Node SDK.
Use ask for one request-response interaction.
reply = client.ask("What can this hub do?")
print(reply.text)for item in reply.display_items(): print(item)const reply = await client.ask("What can this hub do?");
console.log(reply.text);for (const item of reply.displayItems()) { console.log(item);}reply, err := client.Ask(ctx, "What can this hub do?", thalovant.RequestOptions{})if err != nil { log.Fatal(err)}
fmt.Println(reply.Text)for _, item := range reply.DisplayItems(600) { fmt.Println(item)}let reply = client .ask("What can this hub do?", RequestOptions::default()) .await?;
println!("{}", reply.text);for item in reply.display_items(Some(600)) { println!("{item:?}");}Expected output shape:
{ "text": "This hub can answer quick joke and trivia requests.", "display_items": []}Use intents when a client should show people what the hub understands, or confirm that a skill registered. The hub answers over the client’s own session with every intent, per language, and the sentences that reach it. No control-plane token is involved. The connection must be allowed to publish ovos.intent.list, and ovos.intent.describe as well when the SDK has to ask for the sentences separately, which a hub that returns them with the listing never makes it do. See What Can My Hub Be Asked?.
from thalovant import ThalovantPolicyDeniedError
try: inventory = client.intents(["en-us", "fr-fr"])except ThalovantPolicyDeniedError as denied: print("refused:", denied.denied_type, "allowed:", denied.allowed) raise
for intent in inventory.intents: print(intent.id, intent.examples("en-us"))Expected output shape:
{ "languages": ["en-us", "fr-fr"], "source": "intent-manifest", "denied": [], "skills": [ { "skill_id": "thalovant-skill-weather.thalovant", "languages": ["en-us", "fr-fr"], "intents": [ { "id": "thalovant-skill-weather.thalovant:current.weather", "skill_id": "thalovant-skill-weather.thalovant", "name": "current.weather", "engine": "padatious", "enabled": true, "phrases": { "en-us": ["what is the weather", "what is the weather in {location}"], "fr-fr": ["quel temps fait-il", "quelle est la météo à {location}"] } } ] } ]}A refused query raises the SDK’s policy error at once, naming the message type, instead of waiting for a timeout. When ovos.intent.list is refused and the fallback is on, which is the default, the result carries names only with source set to engine-manifests. A hub that accepts a query and answers it with ok: false is a different case: a failed listing raises the SDK’s runtime error with the hub’s error text, because a failed listing is not an empty hub, while a failed describe leaves that one intent without sentences and raises nothing. Every published SDK ships this recipe under its own spelling; each SDK page gives a working example, and the SDKs page lists the version each one shipped it in.
The current high-level SDKs use the default engine-manifest fallback when ovos.intent.list times out. In that case, denied names the unanswered query and does not prove a policy refusal. Disable fallback to propagate the listing timeout; silent fallback engines still produce a timeout. See intent discovery.
Use raw events when you already have an event name and a structured payload.
client.emit( "thalovant.client.status", {"state": "ready"}, {"source": "checkout-kiosk"},)await client.emit( "thalovant.client.status", { state: "ready" }, { source: "checkout-kiosk" },);err := client.Emit(ctx, "thalovant.client.status", map[string]any{ "state": "ready",}, map[string]any{ "source": "checkout-kiosk",})if err != nil { log.Fatal(err)}use serde_json::json;
let mut data = serde_json::Map::new();data.insert("state".into(), json!("ready"));
let mut context = serde_json::Map::new();context.insert("source".into(), json!("checkout-kiosk"));
client .emit("thalovant.client.status", data, context) .await?;Use an utterance when the input should behave like speech or chat text from a client.
client.send_utterance( "What is the status?", lang="en-us", session_id="status-session",)await client.sendUtterance("What is the status?", { lang: "en-us", sessionId: "status-session",});err := client.SendUtterance(ctx, "What is the status?", thalovant.RequestOptions{ Lang: "en-us", SessionID: "status-session",})if err != nil { log.Fatal(err)}client .send_utterance( "What is the status?", RequestOptions { lang: Some("en-us".into()), session_id: Some("status-session".into()), ..Default::default() }, ) .await?;Use actions for buttons, menu picks, confirmations, and tool commands.
client.send_action( "/approve invoice-42", title="Approve invoice", session_id="approval-session",)await client.sendAction( "/approve invoice-42", { title: "Approve invoice", sessionId: "approval-session" },);err := client.SendAction(ctx, "/approve invoice-42", thalovant.ActionOptions{ Title: "Approve invoice", SessionID: "approval-session",})if err != nil { log.Fatal(err)}client .send_action( "/approve invoice-42", ActionOptions { title: Some("Approve invoice".into()), session_id: Some("approval-session".into()), ..Default::default() }, ) .await?;Use code input for QR values, barcode scans, serial numbers, short codes, or typed exact values.
client.send_code( "INV-2026-001", kind="invoice", session_id="scan-session",)await client.sendCode("INV-2026-001", { kind: "invoice", sessionId: "scan-session",});err := client.SendCode(ctx, "INV-2026-001", thalovant.CodeOptions{ Kind: "invoice", SessionID: "scan-session",})if err != nil { log.Fatal(err)}client .send_code( "INV-2026-001", CodeOptions { kind: Some("invoice".into()), session_id: Some("scan-session".into()), ..Default::default() }, ) .await?;Use a conversation when related turns should share the same session.
with client.conversation(lang="en-us") as convo: print(convo.ask("Remember that my favorite color is blue.").text) print(convo.ask("What color did I mention?").text)const convo = client.conversation({ lang: "en-us" });
const first = await convo.ask("Remember that my favorite color is blue.");const second = await convo.ask("What color did I mention?");console.log(first.text, second.text);convo := client.Conversation(thalovant.ConversationOptions{Lang: "en-us"})
first, err := convo.Ask(ctx, "Remember that my favorite color is blue.", thalovant.RequestOptions{})if err != nil { log.Fatal(err)}second, err := convo.Ask(ctx, "What color did I mention?", thalovant.RequestOptions{})if err != nil { log.Fatal(err)}fmt.Println(first.Text, second.Text)let convo = client.conversation(ConversationOptions { lang: Some("en-us".into()), ..Default::default()});
let first = convo .ask("Remember that my favorite color is blue.", RequestOptions::default()) .await?;let second = convo .ask("What color did I mention?", RequestOptions::default()) .await?;println!("{} {}", first.text, second.text);Use this when one event proves the request completed.
from thalovant import EVENT_UTTERANCE_HANDLED
event = client.wait_for_event(EVENT_UTTERANCE_HANDLED, timeout=12)print(event.text)import { EVENT_UTTERANCE_HANDLED } from "@thalovant/sdk";
const event = await client.waitForEvent(EVENT_UTTERANCE_HANDLED, { timeoutMs: 12_000,});console.log(event.text);event, err := client.WaitForEvent(ctx, thalovant.EventUtteranceHandled, thalovant.EventOptions{ Timeout: 12 * time.Second,})if err != nil { return err }fmt.Println(event.Name, event.Text())use thalovant::{ListenOptions, EVENT_UTTERANCE_HANDLED};
let event = client.wait_for_event(EVENT_UTTERANCE_HANDLED, ListenOptions::default()).await?;println!("{} {}", event.name, event.text());Use event streams for long-running clients, live status, speech output, and UI updates.
from thalovant import EVENT_SPEAK
for event in client.listen(EVENT_SPEAK, timeout=30, max_events=3): print(event.text)import { EVENT_SPEAK } from "@thalovant/sdk";
const subscription = client.on(EVENT_SPEAK, event => { console.log(event.text);});
// Later, when the listener is no longer needed:subscription.close();events, err := client.Listen(ctx, thalovant.EventSpeak, thalovant.ListenOptions{MaxEvents: 3})if err != nil { return err }defer events.Close()for event := range events.C { fmt.Println(event.Name, event.Text())}if err := events.Err(); err != nil { return err }use thalovant::{ListenOptions, EVENT_SPEAK};
let mut events = client.listen(EVENT_SPEAK, ListenOptions { max_events: Some(3), ..ListenOptions::default()}).await?;while let Some(event) = events.recv().await? { println!("{} {}", event.name, event.text());}Context carries stable user, source, locale, and trace metadata without changing the utterance.
from thalovant import build_client_context
context = build_client_context( user_id="user-42", user_name="Ada", source="checkout-kiosk", platform="kiosk", locale="en-US", metadata={"trace_id": "req-2026-06-10-001"},)
reply = client.ask("Show the next instruction.", context=context)print(reply.text)import { buildClientContext } from "@thalovant/sdk";
const context = buildClientContext({}, { userId: "user-42", userName: "Ada", source: "checkout-kiosk", platform: "kiosk", locale: "en-US", channel: "chat", metadata: { traceId: "req-2026-06-10-001" },});
const reply = await client.ask("Show the next instruction.", { context });console.log(reply.text);requestContext := thalovant.BuildClientContext(nil, thalovant.ClientContextOptions{ UserID: "user-42", UserName: "Ada", Source: "checkout-kiosk", Platform: "kiosk", Locale: "en-US", Channel: "chat", Metadata: map[string]any{"trace_id": "req-2026-06-10-001"},})
reply, err := client.Ask(ctx, "Show the next instruction.", thalovant.RequestOptions{ Context: requestContext,})use serde_json::json;
let mut metadata = serde_json::Map::new();metadata.insert("trace_id".into(), json!("req-2026-06-10-001"));
let context = build_client_context(None, ClientContextOptions { user_id: Some("user-42".into()), user_name: Some("Ada".into()), source: Some("checkout-kiosk".into()), platform: Some("kiosk".into()), locale: Some("en-US".into()), channel: Some("chat".into()), metadata: Some(metadata), ..Default::default()});
let reply = client .ask( "Show the next instruction.", RequestOptions { context: Some(context), ..Default::default() }, ) .await?;Use diagnostics when a client cannot connect, a protocol is missing, or an event never arrives.
print(client.doctor())console.log(await client.healthcheck());fmt.Println(client.Healthcheck())println!("{:?}", client.healthcheck().await);Use the SDK protocol enum or string that matches your language.
| Protocol | Python | Node.js | Go | Rust |
|---|---|---|---|---|
| WSS | "wss" |
"wss" |
thalovant.ProtocolWSS |
HubProtocol::Wss |
| HTTPS | "https" |
"https" |
thalovant.ProtocolHTTPS |
HubProtocol::Https |
| MQTT | "mqtt" |
"mqtt" |
thalovant.ProtocolMQTT |
HubProtocol::Mqtt |
The automatic protocol selectors prefer WSS, then HTTPS, then MQTT when the identity includes broker credentials. Go’s older NewClient(identity) and Rust’s Client::new(identity) constructors select HTTPS directly; use NewClientWithOptions or Client::auto for automatic selection.
list_public_hubs, get_public_hub, list_hubs, or get_hub.create_client_identity for new clients, or load an existing identity from config, file, or environment.ask first because it gives a clear reply.listen, wait_for_event, and build_client_context once the basic request works.intents when the client should list the hub’s intents and sentences per language.Use this table only when you already know the task and need the language-specific name.
| Task | Python | Node.js | Go | Rust |
|---|---|---|---|---|
| Create API client | ThalovantControlPlane() |
new ThalovantControlPlane() |
NewDefaultControlPlane(token) |
ControlPlane::default() |
| Sign in | login(email, password) |
login(email, password) |
Login(ctx, email, password, scope) |
login(email, password, scope) |
| Sign in with MFA | login(..., otp_code=..., recovery_code=...) |
login(..., { otpCode, recoveryCode }) |
LoginWithOptions(ctx, email, password, options) |
login_with_options(email, password, options) |
| Sign in without a password | login_with_browser(...) |
loginWithBrowser(options) |
LoginWithBrowser(ctx, options) |
login_with_browser(options) |
| Use an API token | ThalovantControlPlane(access_token=...) |
new ThalovantControlPlane(url, { accessToken }) |
NewDefaultControlPlane(token) |
ControlPlane::with_access_token(token) |
| List public hubs | list_public_hubs(...) |
listPublicHubs(...) |
ListPublicHubs(ctx, limit, cursor) |
list_public_hubs(limit, cursor) |
| Get public hub | get_public_hub(ref) |
getPublicHub(ref) |
GetPublicHub(ctx, ref) |
get_public_hub(ref) |
| List visible hubs | list_hubs(...) |
listHubs(...) |
ListHubs(ctx, limit, cursor, ownerID) |
list_hubs(limit, cursor, owner_id) |
| Get hub | get_hub(hub_id) |
getHub(hubId) |
GetHub(ctx, hubID) |
get_hub(hub_id) |
| Get operation | get_operation(operation_id) |
getOperation(operationId) |
GetOperation(ctx, operationID) |
get_operation(operation_id) |
| Create identity | create_client_identity(...) |
createClientIdentity(...) |
CreateClientIdentityForHubID(...) |
create_client_identity_for_hub_id(...) |
| List hub skills | list_hub_skills(hub_id) |
listHubSkills(hubId) |
ListHubSkills(ctx, hubID) |
list_hub_skills(hub_id) |
| List hub skill history | list_hub_skill_history(hub_id, limit=50) |
listHubSkillHistory(hubId, { limit: 50 }) |
ListHubSkillHistory(ctx, hubID, 50) |
list_hub_skill_history(hub_id, 50) |
| Install hub skill | install_hub_skill(hub_id, skill, version=, wait=) |
installHubSkill(hubId, skill, { version, wait }) |
InstallHubSkill(ctx, hubID, skill, version, options) |
install_hub_skill(hub_id, skill, version, options) |
| Update hub skill | update_hub_skill(hub_id, skill, version=, wait=) |
updateHubSkill(hubId, skill, { version, wait }) |
UpdateHubSkill(ctx, hubID, skill, version, options) |
update_hub_skill(hub_id, skill, version, options) |
| Remove hub skill | remove_hub_skill(hub_id, skill, wait=) |
removeHubSkill(hubId, skill, { wait }) |
RemoveHubSkill(ctx, hubID, skill, options) |
remove_hub_skill(hub_id, skill, options) |
Hub skill calls and history are available in all seven managed SDKs at the current versions. The provisioning calls have their own naming table on Provision Hubs.
| Task | Python | Node.js | Go | Rust |
|---|---|---|---|---|
| Load identity from config | ThalovantIdentity.from_config(...) |
ThalovantIdentity.fromConfig(...) |
IdentityFromConfig(path, profile) |
Identity::from_config(profile) |
| Load identity file | ThalovantIdentity.from_file(path) |
ThalovantIdentity.fromFile(path) |
IdentityFromFile(path) |
Identity::from_file(path) |
| Load client from file | ThalovantClient.from_identity_file(path) |
ThalovantClient.fromIdentityFile(path) |
NewClientFromFile(path) |
Client::from_file(path) |
| Load client from config | ThalovantClient.from_config(...) |
ThalovantClient.fromConfig(...) |
NewClientFromConfig(path, profile) |
Client::from_config(profile) |
| Load client from env | ThalovantClient.from_env() |
ThalovantClient.fromEnv() |
NewClientFromEnv() |
Client::from_env() |
| Enabled protocols | enabled_protocols() |
enabledProtocols() |
EnabledProtocols() |
enabled_protocols() |
| Endpoint for protocol | endpoint_for(protocol) |
endpointFor(protocol) |
EndpointFor(protocol) |
endpoint_for(protocol) |
| Check protocol | supports_protocol(protocol) |
supportsProtocol(protocol) |
SupportsProtocol(protocol) |
supports_protocol(protocol) |
| MQTT credentials | identity.mqtt |
identity.mqtt |
Identity.MQTT |
identity.mqtt |
| Task | Python | Node.js | Go | Rust |
|---|---|---|---|---|
| Create protocol client | ThalovantClient(identity, protocol="wss") |
new ThalovantClient(identity, { protocol }) |
NewClientWithOptions(identity, ClientOptions{Protocol: ...}) |
Client::with_protocol(identity, protocol) |
| Connect | connect() |
connect() |
Connect(ctx) |
connect() |
| Close | close() |
close() |
Close(ctx) |
close() |
| Health | healthcheck() |
healthcheck() |
Healthcheck() |
healthcheck() |
| Ask | ask(text, ...) |
ask(text, options) |
Ask(ctx, text, options) |
ask(text, options) |
| Emit raw event | emit(event, data, context) |
emit(event, data, context) |
Emit(ctx, event, data, context) |
emit(event, data, context) |
| Send utterance | send_utterance(text, ...) |
sendUtterance(text, options) |
SendUtterance(ctx, text, options) |
send_utterance(text, options) |
| Send action | send_action(payload, ...) |
sendAction(payload, options) |
SendAction(ctx, payload, options) |
send_action(payload, options) |
| Send code | send_code(value, ...) |
sendCode(value, options) |
SendCode(ctx, value, options) |
send_code(value, options) |
| Conversation | conversation(...) |
conversation(options) |
Conversation(options) |
conversation(options) |
| Intent inventory | intents(languages, ...) |
intents(languages) |
Intents(ctx, languages, options) |
intents(languages, options) |
| List intents | list_intents(lang, ...) |
listIntents(lang) |
ListIntents(ctx, lang, options) |
list_intents(lang, options) |
| Describe intent | describe_intent(skill_id, intent_name, lang, ...) |
describeIntent(skillId, intentName, lang) |
DescribeIntent(ctx, skillID, intentName, lang, options) |
describe_intent(skill_id, intent_name, lang, options) |
| Policy refusal | ThalovantPolicyDeniedError |
ThalovantPolicyDeniedError |
PolicyDeniedError |
ThalovantError::PolicyDenied |
The intent rows are published in every SDK. The SDKs page lists the version each one shipped them in, and the Kotlin, Swift, .NET, and embedded C spellings are on their own SDK pages.
| Task | Python | Node.js | Go | Rust |
|---|---|---|---|---|
| Wait for event | wait_for_event(name, ...) |
waitForEvent(name, options) |
WaitForEvent(ctx, name, options) |
wait_for_event(name, options) |
| Stream events | listen(name, ...) |
on(name, handler, options) |
Listen(ctx, name, options) |
listen(name, options) |
| Diagnostics | doctor() |
healthcheck() |
Healthcheck() |
healthcheck() |
| Build context | build_client_context(...) |
buildClientContext(...) |
BuildClientContext(...) |
build_client_context(...) |
| Response text | reply.text |
reply.text |
reply.Text |
reply.text |
| Display items | reply.display_items(...) |
reply.displayItems(...) |
reply.DisplayItems(...) |
reply.display_items(...) |
| Event text | event.text |
event.text |
event.Text() |
event.text() |
Last reviewed: September 13, 2026. Review this page when SDK releases, method signatures, protocol selection, connection lifecycle, or persistent Noise state requirements change.
Hub-addressed skill helpers are now available in all seven managed SDKs, including history and optional operation waiting. They operate on the attached runtime group; every hub sharing it is affected. See each SDK reference for its method names. Embedded C has no provisioning HTTP client.
With the current supported SDK versions,
Python uses sentence=True with the optional listing extra. Node accepts
{ sentence: true } in intent.examples(lang, limit, options).
Go uses IntentExampleOptions{Sentence: true} with ExamplesWithOptions;
Rust uses IntentExampleOptions with examples_with_listing.
Kotlin, Swift and .NET expose examplesWithListing / ExamplesWithListing.
MCP exposes sentence on thalovant_intent_inventory.
These renderers use locale slot samples before caller overrides, prefer complete phrases, and count unique nonempty results toward rendered limits. Raw unlimited examples preserve registration order. The managed SDKs bundle the same data used by Python’s listing extra. See each SDK guide for its call signature and custom-rule options.