ask
Send one request and receive normalized text, speech, and display items.
Use the Node SDK when your client or agent runs in JavaScript or TypeScript.
The package name is @thalovant/sdk. Node.js 20 or newer is required.
Version 0.7.1 uses v3 Noise over WSS, HTTPS, and MQTT over TLS in Node.js. Browser builds support WSS and HTTPS. Both 25519_ChaChaPoly_SHA256 and 25519_AESGCM_SHA256 are supported, with XXpsk2 on first contact and KKpsk0 for a pinned peer.
The optional noiseStateDir works on the client constructor and fromIdentityFile, fromConfig, and fromEnv. In Node.js, preserve that private directory between restarts; its default is the SDK’s XDG configuration directory. In browsers, the same option selects a localStorage namespace, not a filesystem path. Keep the static key and verified server pins together. Use a distinct identity for each concurrent client process.
MQTT requires a TLS endpoint. Setting tls: true upgrades mqtt:// to mqtts:// and ws:// to wss://; unsupported or plaintext effective schemes are refused before broker credentials are sent. MQTT admission, subscription, authentication, and online presence share the connect timeout. HTTPS failure cleanup also stays within its original connect deadline.
const client = await ThalovantClient.fromIdentityFile("identity.json", { protocol: "https", noiseStateDir: "/path/to/private/persistent/sdk-state",});HTTPS keeps the hub’s affinity cookie through reconnects and cleans up its own previous admission before requesting a fresh handshake. Broker reconnects also require a fresh Noise handshake. healthcheck().handshakeComplete stays false until authentication finishes; an open socket or successful HTTP admission alone is insufficient.
connect(timeoutMs?, signal?) uses one deadline for queued cleanup, connection setup and authenticated readiness, with a 6,000 ms default. close(timeoutMs?) bounds its caller’s wait with the same default. If close times out, cleanup still owns the connection. await client.waitForClosed() observes the actual most recent cleanup, without a timeout, and retains its failure. Wait for that cleanup before reusing the identity.
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. See the correlation contract.
Node serializes complete filesystem state transactions across processes, including first-key creation and pin updates. Malformed or unreadable existing keys and pin maps stop the operation without resetting trust. Lock waits are bounded; if a writer crashes and leaves .noise-state.lock, confirm that writer has stopped before removing only the lock file. Keep the key and pin files. This filesystem lock does not coordinate live hub sessions or browser tabs.
Install with npm install @thalovant/[email protected].
import { ThalovantClient, ThalovantControlPlane } from "@thalovant/sdk";
const api = new ThalovantControlPlane();
const publicHubs = await api.listPublicHubs({ limit: 12 });for (const hub of publicHubs.data as Array<{ id: string; slug: string; title: string }>) { console.log(hub.id, hub.slug, hub.title);}
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();}new ThalovantControlPlane() uses https://api.thalovant.com by default.
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. Both options are sent only when provided. MFA support needs SDK 0.2.23 or newer.
// Or use a one-time recovery code instead:Device login asks your browser to approve the sign-in, so scripts, bots, 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 0.2.25 or newer. Approving the request needs a paid workspace plan; a free plan gets HTTP 402.
// 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(...).const page = await api.listHubs({ limit: 50 });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 loginWithBrowser; the page shows scopes, expiry, and last use, and can revoke the token at any time.
const api = new ThalovantControlPlane("https://api.thalovant.com", { accessToken: process.env.THALOVANT_API_TOKEN,});
// Ready immediately; no login call needed.const page = await api.listHubs({ limit: 50 });The SDK also runs in browsers behind a bundler. This needs SDK 0.2.24 or newer.
The control plane, including login, listPublicHubs, createClientIdentity, memory, and analytics, uses the global fetch. ThalovantClient works over the wss and https protocols using the global WebSocket and the same Noise implementation as Node.js.
The package ships a browser map alongside its exports entry, so bundlers such as esbuild, webpack, Vite, and Rollup pick browser-safe modules automatically and never pull ws, mqtt, or node: builtins into web bundles. Bundle it like any other dependency:
esbuild app.js --bundle --platform=browser --outfile=dist/app.js// app.js — runs in the browser after bundlingimport { ThalovantClient, ThalovantControlPlane } from "@thalovant/sdk";
const api = new ThalovantControlPlane();await api.login(email, password);const result = await api.createClientIdentity(hubId, { name: "web-kiosk" });
const client = new ThalovantClient(result.identity, { protocol: "wss" });try { const reply = await client.ask("Hello from the browser."); console.log(reply.text);} finally { await client.close();}Browser caveats:
mqtt protocol stays Node-only. Constructing the MQTT transport in a browser throws ThalovantUnsupportedProtocolError with a clear message; use wss or https instead.ThalovantIdentity.fromFile(), fromConfig(), and defaultConfigPath() throw in browsers; construct ThalovantIdentity from an in-memory object, such as the result of createClientIdentity.localStorage, with the default namespace thalovant:noise. The stored static key, server pins, and cached PSKs are accessible to scripts on that origin. Preserve this state across page loads and use a separate namespace and identity for each independently active tab. The browser queue coordinates only one page; it does not serialize changes across tabs. If browser storage is unavailable or a write fails, newly written state lasts only for that page; a later page load may be refused by a hub that pinned the previous client key. Restore the original state or provision a replacement client identity instead of automatically resetting pins.import { ThalovantClient } from "@thalovant/sdk";
const client = await ThalovantClient.fromConfig({ profile: "prod", protocol: "wss" });try { const reply = await client.ask("What can this hub do?"); console.log(reply.text);} finally { await client.close();}Raw identity files work too:
import { ThalovantClient } from "@thalovant/sdk";
const client = await ThalovantClient.fromIdentityFile("_identity.json");try { const reply = await client.ask("What can this hub do?"); console.log(reply.text);} finally { await client.close();}Environment variables work too:
import { ThalovantClient } from "@thalovant/sdk";
const client = ThalovantClient.fromEnv();const identity = result.identity;
console.log(identity.enabledProtocols());console.log(identity.endpointFor("wss"));console.log(identity.endpointFor("https"));console.log(identity.endpointFor("mqtt"));
for (const protocol of ["wss", "https", "mqtt"] as const) { if (!identity.supportsProtocol(protocol)) continue; if (protocol === "mqtt" && !identity.mqtt) continue;
const client = new ThalovantClient(identity, { protocol }); try { const reply = await client.ask(`Reply over ${protocol}.`); console.log(protocol, reply.text); } finally { await client.close(); }}MQTT requires the identity.mqtt broker credentials returned for that client.
For broker details, see MQTT.
import { buildClientContext } from "@thalovant/sdk";
const context = buildClientContext({}, { userId: "user-42", userName: "Ada", source: "checkout-kiosk", platform: "kiosk", locale: "en-US", channel: "chat",});
const reply = await client.ask("Show the next instruction.", { context });console.log(reply.text);import { EVENT_SPEAK } from "@thalovant/sdk";
const sub = client.on(EVENT_SPEAK, event => { console.log(event.text);});
try { await client.ask("Say the current status.");} finally { sub.close();}Register the listener before sending so it can observe fast replies. ask() also correlates its own response; the subscription observes matching bus events while the request runs.
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.2.38 the calls are client.intents(languages, options), which returns the inventory grouped by skill, client.listIntents(lang, options), which returns the registration rows, and client.describeIntent(skillId, intentName, lang, options), which returns the definitions behind one intent.
import { ThalovantClient, ThalovantPolicyDeniedError } from "@thalovant/sdk";
const client = await ThalovantClient.fromIdentityFile("_identity.json");try { const inventory = await client.intents(["en-us", "fr-fr"]); console.log(inventory.source, inventory.languages); for (const skill of inventory.skills) { for (const intent of skill.intents) { console.log(intent.id, intent.engine, intent.examples("fr-fr")); } }} catch (error) { if (error instanceof ThalovantPolicyDeniedError) { console.error("refused:", error.deniedType, "allowed:", error.allowed); } throw error;} finally { await client.close();}intents() asks ovos.intent.list once per language, then fills in the sentences the listing did not carry. { 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; phrasesFor(lang) returns all of them, and inventory.asObject() is ready for JSON.stringify. Language tags compare without regard to case or _ and -, so fr-fr and fr_FR are one request.
A hub that refuses a query rejects at once with ThalovantPolicyDeniedError, a ThalovantRuntimeError carrying deniedType, code, reason, and allowed. With the default { fallback: true }, a refused or silent ovos.intent.list returns names only instead: inventory.source is "engine-manifests", inventory.denied is ["ovos.intent.list"], and inventory.hasPhrases is false. A hub that accepts the listing and answers it with ok: false rejects with ThalovantRuntimeError carrying the hub’s own error text, because a failed listing is not an empty hub. Since 0.2.39 that rejection replaces the empty list the SDK used to return.
ask
Send one request and receive normalized text, speech, and display items.
waitForEvent
Wait for one matching hub event with a timeout.
sendAction
Send a button, menu, or tool action.
sendCode
Send a barcode, QR value, serial number, or typed exact value.
For the full method list, see SDK Functions.
Use api.getOperation(operationId) to follow an accepted command; see Operations.
Since SDK 0.2.28 the control plane can create hubs, runtime groups, and skill installs. Browsing the catalog with listMarketplaceSkills() needs hubs:read and works on any plan. createHub, createRuntimeGroup, installRuntimeGroupSkill, releaseHub, and releaseRuntimeGroup need hubs:write and a paid plan.
const group = await api.createRuntimeGroup({ name: "kiosks" });let hub = await api.createHub({ name: "joke-garden", runtimeGroupId: group.id as string, spec: {} });
hub = await api.getHub(hub.id as string);await api.updateHub(hub.id as string, { active: false }, { etag: hub.etag as string });updateHub and deleteHub require the hub’s current etag. See Provision Hubs for the full flow, the immutable fields, and the error table.
From SDK 0.3.15, listHubSkills, installHubSkill, updateHubSkill, and removeHubSkill manage skills on one hub by id. listHubSkills resolves with { data, source, observed_at, ... } (for (const skill of (await api.listHubSkills("hub-id")).data)), the writes resolve with a HubSkillOperation, and { wait: true } polls that operation with a 120-second default polling budget (timeoutMs). See Add a Skill to a Hub.
Version 0.5.1 starts no new status read at or after the polling deadline; the polling deadline does not cancel an HTTP request already in flight. If a status read fails, the error retains the accepted operation ID so you can resume with getOperation without repeating the write. An underlying sanitized API error is preserved as the cause; unexpected exceptions are omitted from the displayed chain.
| Symptom | Check |
|---|---|
| Missing access token | Call api.login(...) or api.loginWithBrowser(...) before private API actions, or pass accessToken to ThalovantControlPlane. |
HTTP 401 with code mfa_required |
Pass otpCode or recoveryCode in the login options. |
| 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 mqtt broker credentials, and that the client is not running in a browser bundle. |
Last reviewed: September 13, 2026. Review this page when package versions, runtime deadlines, cancellation, transport support, API credential policy, or Noise state storage changes.
The inventory includes fallbacks and fallbacksKnown. Use inventory.mayAnswer("en-us") for a conservative language check; unknown fallback support may still answer. The probe takes at most 1.5 seconds including connect, send, and wait; a confirmed empty result differs from refusal, failure, silence, or a malformed reply envelope. Accepted arrays retain usable handlers and skip invalid rows. See fallback discovery.
Control-plane redirects are refused. Authenticated API calls and request bodies require HTTPS except explicit loopback development endpoints. See API credential security.
Pass an AbortSignal as signal in the options for ask, query and waitForEvent, or as the second argument to connect. Cancellation stops the caller’s wait; any admitted transport work retains ownership until it finishes or fails safely. Requests are not automatically replayed. Ask uses fixed first-speech and empty-reply windows within its original deadline; Query finishes on its terminal event. See the reply timing and correlation contract.
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 | listHubSkills |
| History | listHubSkillHistory |
| Install | installHubSkill |
| Update | updateHubSkill |
| Remove | removeHubSkill |
History returns the API JSON envelope with newest-first event and operation
entries, preserving nullable actor/version fields. The limit is 1–200.
Use the existing wait option to follow the accepted operation. A polling failure retains its operation ID; inspect that operation instead of repeating the write.
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.
const location = buildLocation({ city: "Montréal", country: "CA", latitude: 45.5, longitude: -73.5 });const reply = await client.ask("Quel temps fait-il ?", { sttLang: "fr-ca", location });for (const event of reply.mediaEvents ?? []) if (event.isAudio) play(event.audioBytes());const sentences = intent.examples("en-us", 2, { speakable: true, slots: { location: "Montréal" } });await api.updateRuntimeGroupConfig(groupId, { tts: { module: "piper" } });// Explicit full replacement:await api.updateRuntimeGroupConfig(groupId, fullConfig, { merge: false });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 reject integers outside JavaScript’s safe range before writing,
so reading and merging cannot silently round an untouched configuration value.
This validation includes supplied personas. Non-finite values and stored
numeric exponents that overflow JavaScript numbers are rejected, preventing
their conversion to null during serialization.
Represent large identifiers as strings or use an SDK with lossless integers.
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.
import { asSentence, speakable } from "@thalovant/sdk";const examples = intent.examples("fr-CA", 2, { sentence: true });const text = asSentence("what time is it", "en-US");const sample = speakable("play {song}", {}, "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.
import { HubSession } from "@thalovant/sdk";
const session = new HubSession(connectClient, { warm: false });try { const reply = await session.ask("What is the weather?");} finally { await session.close();}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. Node browsers use origin-local
storage; do not share that storage namespace across unrelated users.
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.7.1 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.