ask
Send one request and receive a normalized reply with a timeout you control.
Install the Thalovant Kotlin SDK for JVM services and Android apps that connect to a hub.
Use the Kotlin SDK when your client or agent runs on the JVM or on Android.
The Maven Central coordinates are com.thalovant:thalovant-sdk. JVM 17 or newer is required, or an Android toolchain that produces Java 17 bytecode. The SDK uses OkHttp, kotlinx-serialization, and kotlinx-coroutines.
Source and releases: thalovant-kotlin-sdk on GitHub.
Add the dependency to your Gradle build. The current version is in Supported Versions.
dependencies { implementation("com.thalovant:thalovant-sdk:0.9.0")}This flow signs in, creates a client identity, and sends one request over WSS.
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() }}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.
// Or use a one-time recovery code instead:The otpCode and recoveryCode values are sent only when provided.
Device login asks your browser to approve the sign-in, so services and apps never handle your password. The SDK prints a short code and the address https://dash.thalovant.com/activate, opens that page when it can, and waits while you approve the request in the dashboard with your normal sign-in, including Google sign-in or MFA. On approval the SDK holds a scoped, revocable API token.
Device login needs SDK 0.1.1 or newer. Approving the request needs a paid workspace plan; a free plan gets HTTP 402.
import com.thalovant.sdk.DeviceLoginOptions
api.loginWithBrowser( DeviceLoginOptions(scopes = listOf("hubs:read"), clientName = "kotlin-demo"),)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.
val api = ThalovantControlPlane(accessToken = System.getenv("THALOVANT_API_TOKEN"))The SDK connects over WSS. Every connection is encrypted and authenticated before anything is sent; JVM 17 and Bouncy Castle provide the cryptography. HTTPS and MQTT remain unsupported.
The SDK replaces any existing authorization query values with the current
identity when opening a WebSocket. Other query parameters and URL fragments are
preserved. Connection failures omit credential-bearing network error messages
and causes. Upgrade if an endpoint saved with old authorization values fails to
connect.
The SDK keeps its keys in ~/.config/thalovant-kotlin/noise by default. Preserve that directory across reconnects and restarts. On Android, provide an app-private directory:
import com.thalovant.sdk.HiveMindNoiseStoreimport java.nio.file.Path
val client = ThalovantClient( identity, noiseStore = HiveMindNoiseStore(Path.of(appPrivateDirectory, "noise")),)The file store requires private POSIX permissions where available and a filesystem supporting hard links, so concurrent processes cannot observe an incomplete key. Use distinct identities for concurrent clients. A hub whose identity has changed fails to connect until you confirm the change with the hub owner and update the saved identity deliberately.
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.
For HTTP 429, inspect ThalovantApiException.statusCode and parse its JSON body. The server’s nested detail includes token_rate_limited for the per-minute limit or token_quota_exceeded for a daily or monthly quota. Honor its retry_after_seconds before retrying; quota responses also identify the exhausted quota. The SDK does not retry automatically. Retain the same explicit idempotency key when retrying one logical hub create. See API token limits.
This release connects to the hub over WSS only. Requesting https or mqtt throws ThalovantUnsupportedProtocolException. Endpoint selection still honors the shared preference order wss, https, mqtt, so identities created for multi-protocol hubs keep working.
Use the Python, Node.js, Go, or Rust SDK when the client needs the HTTPS or MQTT data plane today.
Inspect what an identity supports:
import com.thalovant.sdk.HubProtocol
println(identity.enabledProtocols())println(identity.endpointFor(HubProtocol.WSS))println(identity.mqtt?.endpoint)Raw identity files downloaded from Thalovant work directly. The SDK rejects identity files that other users can read or write on Linux and macOS, so protect them with chmod 600 and keep them out of Git.
val client = ThalovantClient.fromIdentityFile("_identity.json")You can also parse an identity payload you already hold in memory:
import com.thalovant.sdk.ThalovantIdentity
val identity = ThalovantIdentity.fromJson(identityJson)Mutating control-plane commands return durable operations. Poll them with the typed helper:
val operation = api.getOperation("operation-id")println(operation.status)See Operations for lifecycle and retry guidance.
import com.thalovant.sdk.ThalovantEvents
val subscription = client.on(ThalovantEvents.SPEAK) { event -> println(event.text)}// Later, when the listener is no longer needed:subscription.close()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.1.8 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. All three are suspend functions, like ask.
import com.thalovant.sdk.ThalovantClientimport com.thalovant.sdk.ThalovantPolicyDeniedExceptionimport kotlinx.coroutines.runBlocking
fun main() = runBlocking { val client = ThalovantClient.fromIdentityFile("_identity.json") try { val inventory = client.intents(listOf("en-us", "fr-fr")) println("${inventory.source} ${inventory.languages}") for (skill in inventory.skills) { for (intent in skill.intents) { println("${intent.id} ${intent.engine} ${intent.examples("fr-fr")}") } } } catch (denied: ThalovantPolicyDeniedException) { System.err.println("refused ${denied.deniedType}; allowed ${denied.allowed}") throw denied } finally { client.close() }}intents asks ovos.intent.list once per language, then fills in the sentences the listing did not carry, in windows of at most DESCRIBE_BATCH requests so a hub with many 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; phrasesFor(lang) returns all of them, and asJson() writes the same document the other SDKs write. Language tags compare without regard to case or _ and -, so fr-fr and fr_FR are one request.
A hub that refuses a query throws ThalovantPolicyDeniedException at once with deniedType, code, reason, and allowed. With the default IntentInventoryOptions(fallback = true), a refused ovos.intent.list returns names only instead: inventory.source is HubIntentSource.ENGINE_MANIFESTS, inventory.denied names the refused query, and inventory.hasPhrases is false. A hub that accepts the listing and answers it with ok: false throws ThalovantRuntimeException carrying the hub’s own error text, because a failed listing is not an empty hub. describeIntent returns an empty list for the same answer, which is a real one: the hub does not know that registration.
The default inventory also falls back when ovos.intent.list is silent. Disable fallback to propagate that listing timeout. The denied marker names the query that triggered fallback; it does not establish a policy denial. listFallbacks() exposes registered fallback handlers, and inventory includes fallbacksKnown and mayAnswer(lang).
The optional fallback-handler probe takes at most 1.5 seconds across connection, send, and reply wait. A denied, silent, explicitly failed probe or malformed reply envelope leaves discovery unknown. Accepted arrays retain usable handlers and skip invalid rows. Known empty handlers and unknown discovery are different states: language availability is false only when no enabled intent has phrases for that language and a successful probe reports no fallback handlers. A fallback handler’s presence does not guarantee it can answer every language.
Import com.thalovant.sdk.* to bring the runtime extension functions into scope. A conversation keeps one session id and shared context, while each request gets a fresh request id:
import com.thalovant.sdk.*
val conversation = client.conversation(sessionId = "checkout", lang = "en-us")val reply = conversation.query("Tell me a short clean joke.", timeoutMs = 12000)println(reply.text)query collects query/cascade replies with the matching query id until hive.query.complete. Soft intent misses allow later speech; policy denial and query timeout terminate collection and retain any partial speech in a failed reply. Its timeout covers connection, sending, and collection. Requests are never automatically replayed.
Use waitForEvent for one matching event and listen for a Kotlin Flow. The flow starts when collected and buffers at most 64 events; overflow reports an error. Timeout, event count, coroutine cancellation, and transport loss release subscriptions. sendAction and sendCode preserve structured input metadata. connectionInfo, connectWithInfo, healthcheck, and doctor report local authenticated connection state, without asserting that every remote skill is healthy.
connect(timeoutMs) includes queueing and authentication in one budget; its default is 6,000 ms. A queued caller’s timeout or cancellation does not cancel another caller’s connection attempt.
The control-plane client never follows redirects automatically, including when an OkHttp client is supplied. Requests carrying credentials or a JSON body require HTTPS, except explicit localhost, 127.0.0.1, and [::1] HTTP development endpoints. Cookie and authenticator state also require secure transport. Anonymous reads without credentials or a body may use HTTP. URLs containing userinfo are rejected before I/O; configure the final API URL directly.
Device login opens only HTTP(S) verification URLs without userinfo or control characters. Custom interceptors and authenticators remain trusted application code. Ordinary API error messages omit raw bodies and primitive detail values; the explicit error body remains available for controlled inspection.
ask
Send one request and receive a normalized reply with a timeout you control.
sendUtterance
Send speech-like or chat-like input without waiting for the reply.
emit
Send a named event with structured data and context.
getOperation
Poll a durable control-plane command with a typed status.
For cross-language recipes, see SDK Functions.
Since SDK 0.1.3 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.
val group = api.createRuntimeGroup(RuntimeGroupCreatePayload(name = "kiosks"))val hub = api.createHub( HubCreatePayload( name = "joke-garden", spec = buildJsonObject { }, runtimeGroupId = group["id"]!!.jsonPrimitive.content, ),)
val current = api.getHub(hub["id"]!!.jsonPrimitive.content)api.updateHub( current["id"]!!.jsonPrimitive.content, HubUpdatePayload(active = false), etag = current["etag"]!!.jsonPrimitive.content,)updateHub and deleteHub 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 Thalovant API 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 to api.login(...). |
| API access requires a paid plan | Upgrade the workspace before provisioning private resources through the API. |
ThalovantUnsupportedProtocolException | The hub does not expose WSS, or the code requested https or mqtt, which this release does not connect over. |
| A request times out | Pass a larger timeoutMs to ask(...). |
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 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 waitForHubSkillOperation.
Cancelling the wait does not undo the accepted server operation. Polling failures
retain the operation ID in the error; inspect that operation instead of repeating
the write. The wait deadline prevents new polls but does not cancel a request
already in flight.
If an automatic wait fails, HubSkillOperationException.accepted preserves the
full response, including the install/remove state. Pass it to
waitForHubSkillOperation to resume. timedOut distinguishes the polling budget
expiring; coroutine cancellation propagates normally.
Available since 0.5.0.
Request hints carry a recognized language, ordered intent pipeline, and caller location without changing the caller’s context. Empty hints are omitted. The location helper requires a city and omits invalid or zero/zero coordinates. The hub validates language hints against its configured languages.
Replies expose their reported language, ordered speech/audio events, and a
count of dropped media. Embedded skill clips are limited to 4 MiB each and
16 MiB per reply, checked before retention and decoding. Audio does not extend
the reply settlement window. Decoding accepts hexadecimal bytes with ASCII
whitespace between bytes; it never fetches a skill-supplied URL or file path.
The application owns playback. Any play or Play function below belongs to your app.
val location = buildLocation(city = "Montréal", country = "CA")val reply = client.askWithHints("Quel temps fait-il ?", sttLang = "fr-ca", location = location)for (event in reply.mediaEvents) if (event.isAudio) play(event.audioBytes())val examples = intent.examplesWithOptions("en-us", speakable = true)api.updateRuntimeGroupConfig(groupId, delta)// Explicit full replacement:api.replaceRuntimeGroupConfig(groupId, fullConfig)Guarded merging requires the hubs:read and hubs:write scopes and a paid plan.
Safe merging requires an API whose configuration GET returns a valid revision
and whose configuration PUT checks expected_revision. The SDK rereads and
reapplies the original delta only after HTTP 412, with at most three attempts.
Arrays and scalar values replace; objects merge recursively. Personas replace
only when explicitly supplied. Connection failures, redirects, other statuses,
and ambiguous write results are never retried. No unsafe PATCH fallback is used.
Unconditional replacements must still be coordinated with other writers.
Use the explicit replacement operation shown above when a complete replacement is intended, including when working with an older API. Existing code relying on replacement must opt into it when upgrading. Raw intent patterns remain the default; speakable examples remove optional parts, choose alternatives, and substitute caller-supplied slots while retaining complete-phrase priority.
The audio limits use encoded-length upper bounds before decoding, so formatting
whitespace consumes budget too. Like Python’s bytes.fromhex, ASCII whitespace
alone decodes to zero bytes. Bounded malformed clips remain available as event
metadata and fail when decoded; they are never fetched or played automatically.
Distinct audio events may intentionally repeat identical sound content. Only
repeated delivery of the same event object is suppressed where object identity
is available, without counting it as a dropped clip. Rendered example ranking
uses the original pattern’s slot presence even when sample values are supplied.
Available since 0.6.3.
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.
val examples = intent.examplesWithListing("fr-CA", sentence = true)val text = asSentence("what time is it", "en-US")val sample = speakableWithLanguage("play {song}", lang = "en-US")Sentence rendering implies speakable rendering. Explicit slot values override locale samples. Complete phrases rank before prefixes and slot patterns; empty and duplicate rendered examples do not consume the limit. Unlimited raw examples keep registration order. Unknown locales retain bare text and slot names.
Existing example methods remain available. Use ListingRules to provide a
complete custom data snapshot or select bare rendering without locale data.
No network request is needed to load the bundled rules. See the repository README
for custom-rule construction and regex error handling.
The listing data now describes 270 languages, including regional overrides.
Spanish qué hora es becomes Qué hora es?, and French coupe le son
becomes Coupe le son.. Languages without rules, such as tlh, remain bare.
HubSession owns one reusable hub connection. Supply a factory that returns a
connected client and cleans up a failed or cancelled connection attempt. Event
subscriptions survive client replacement. Go and Rust expose a persistent event
stream; the other managed SDKs expose subscription handles. Close the session
when its owner shuts down; close waits for accepted operations and is terminal.
Background connection attempts back off for 10, 20, 40, 80, then 120 seconds. Foreground calls can try immediately. Your application owns probe scheduling: use the reported probe delay (60 seconds while held, 5 seconds while down). The SDK never replays an accepted Ask or Emit after a lost response, because an Ask can trigger an action. A request timeout applies to the underlying operation; waiting for the session and your connection factory are separate budgets.
val session = HubSession(connect = ::connectClient, warm = false)try { val reply = session.ask("What is the weather?")} finally { 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.
OriginPreference gives a preferred address its own short connection budget and
cools it down after a failure. In non-Python SDKs the factory must implement the
address binding on its own transport, retain the public host for TLS/SNI, and
finish failed-attempt cleanup before returning. Transport/platform restrictions
still apply. This helper does not change global DNS or disable TLS validation.
A reply exposes reply.pipelineIds, reply.skillIds and reply.claimed.
Pipeline and skill IDs are nonempty strings in first-seen order, with duplicates removed. A successful reply with only pipeline IDs containing the case-sensitive text fallback reports an unclaimed reply. Any non-fallback stage makes a successful reply claimed. Successful replies from older hubs without stage stamps remain claimed. Failed or unhandled replies are never claimed. Malformed non-string stamps are ignored.
Use this as a hint when deciding whether to continue a conversation. Fallback text and the existing success status are retained. These stamps are not verified peer identity and must not authorize actions.