# Python SDK

> Install the Thalovant Python SDK for agents, services, CLIs, notebooks, and test clients.

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

---
Use the Python SDK when your client or agent runs in Python.

The package name is `thalovant`. Python 3.10 or newer is required.

## Install

Install the package with `pip install thalovant`:

```bash
python -m pip install thalovant
```

Pin the version when you need a reproducible build. The current one is in
[Supported Versions](/developers/sdks/#supported-versions).

Since 0.9.0 the package installs two libraries, `aiohttp` and `cryptography`,
and imports neither until the first connection or API call. Extras add the
rest:

- `thalovant[mqtt]` for MQTT.
- `thalovant[yaml]` to read the config file with PyYAML. Without it, a small
  built-in reader handles what a config file holds.
- `thalovant[listing]` for the language rules the intent listing uses.

The SDK is built on asyncio. `AsyncThalovantClient` and
`AsyncThalovantControlPlane` do the work, and `ThalovantClient` and
`ThalovantControlPlane` run the same code on a private thread, so a script
needs no event loop of its own. Code written for 0.8 works unchanged; see
[Upgrading To 0.9](#upgrading-to-09).

## First Request

This flow discovers a public hub, creates a client identity, and sends one request.

```python
from thalovant import ThalovantClient, ThalovantControlPlane

api = ThalovantControlPlane()

hub = api.list_public_hubs(limit=1)["data"][0]
print(hub["title"], hub["slug"])

api.login("you@example.com", "password")

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)
```

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

The same flow in an asyncio application:

```python

from thalovant import AsyncThalovantClient, AsyncThalovantControlPlane

async def main():
    async with AsyncThalovantControlPlane() as api:
        await api.login("you@example.com", "password")
        result = await api.create_client_identity("hub-id", name="python-demo-client")

    async with AsyncThalovantClient(result.identity) as client:
        reply = await client.ask("Tell me a short clean joke.")
        print(reply.text)

asyncio.run(main())
```

  Keep `result.identity` secret. Authenticated API actions require API access on the workspace.

## Voice Request Context and Media

Python `0.7.4` provides convenience helpers for voice clients. Both synchronous and asynchronous `ask()` accept `stt_lang`, `pipeline`, and `location`. `stt_lang` carries the recognizer's language hint; `pipeline` selects the configured intent stages under the request session. `build_location()` constructs a location hint, and `request_context()` merges these options with your existing context.

```python
from thalovant import build_location, speakable

location = build_location(city="Toronto", country="Canada", timezone="America/Toronto")
reply = client.ask("What is the weather?", stt_lang="en-ca", location=location)
print(reply.text, reply.lang)
print(speakable("[please] tell me about {city}", slots={"city": "Toronto"}))
```

`reply.lang` reports the hub's reply language. `reply.media_events` preserves speech and embedded `mycroft.audio.queue` events in arrival order. For an audio event, `event.audio_bytes()` decodes its hexadecimal clip; it does not fetch paths or URLs from the event. Invalid clips raise `ValueError`. Collection limits are 4 MiB per clip and 16 MiB per reply; `reply.dropped_media` counts omitted clips. Keep `reply_settle_seconds` above zero to collect clips arriving with speech.

`HubIntent.examples(speakable=True, slots={...})` and `thalovant intents --speakable` render example phrases. When limiting results, complete source phrases rank before phrases derived from slots. These Python convenience methods are separate from the shared skill-management capabilities supported by all seven managed SDKs.

## Sign In With MFA

Accounts with multi-factor authentication enabled must include a TOTP code or a one-time recovery code with the login. Without one, the API rejects the sign-in with HTTP 401 and code `mfa_required`. Both parameters are sent only when provided. MFA support needs SDK 0.4.21 or newer.

```python
api.login("you@example.com", "password", otp_code="123456")

# Or use a one-time recovery code instead:
api.login("you@example.com", "password", recovery_code="abcd-efgh-ijkl")
```

## Sign In Without A Password

Device login asks your browser to approve the sign-in, so scripts, CLIs, 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 and behaves exactly like after `api.login(...)`.

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

```python
api.login_with_browser()

# Optional: narrow the scopes and label the token in the dashboard.
api.login_with_browser(scopes=["hubs:read", "clients:write"], client_name="my-cli")
```

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

An application that shows the code in its own screen, such as a setup wizard,
runs the device flow one step at a time. `begin_device_login()` returns the
code and the address to show. `poll_device_login()` asks once whether the
person approved it:

```python

from thalovant import ThalovantDeviceLoginPending

async def sign_in(api):
    authorization = await api.begin_device_login(scopes=["hubs:read", "clients:write"])
    show(authorization.verification_uri, authorization.user_code)  # your screen

    interval = authorization.interval
    while True:
        await asyncio.sleep(interval)
        try:
            return await api.poll_device_login(authorization)
        except ThalovantDeviceLoginPending as pending:
            interval = pending.interval  # longer after the API asks to slow down
```

A poll returns the token and stores it on `api`, or raises
`ThalovantDeviceLoginPending`, `ThalovantDeviceLoginExpired` or
`ThalovantDeviceLoginDenied`. The token lasts 365 days and has no refresh
token. When the person removes your application, call
`api.revoke_api_token()`: a token can always revoke itself, whatever its
scopes. The sync `ThalovantControlPlane` has the same methods, called without
`await`. Both need SDK 0.9.0 or newer.

## Authenticate With An API Token

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

```python

from thalovant import ThalovantControlPlane

api = ThalovantControlPlane(access_token=os.environ["THALOVANT_API_TOKEN"])
# Ready immediately; no login call needed.
```

## Use A Saved Identity

Use this when the identity was downloaded from Thalovant, stored in a secret volume, or saved in your protected SDK config.

```python
from thalovant import ThalovantClient

with ThalovantClient.from_config(profile="prod", protocol="wss") as client:
    reply = client.ask("What can this hub do?")
    print(reply.text)
```

```python
from thalovant import ThalovantClient

with ThalovantClient.from_identity_file("_identity.json") as client:
    reply = client.ask("What can this hub do?")
    print(reply.text)
```

Environment variables work too:

```python
from thalovant import ThalovantClient

with ThalovantClient.from_env(protocol="https") as client:
    print(client.ask("Say hello.").text)
```

## Where The SDK Keeps Its Keys

The SDK connects over WSS, HTTPS, or MQTT over TLS. Every connection is encrypted and authenticated before anything is sent.

Pass `noise_state_dir` to `ThalovantClient` or a transport to choose the private, persistent directory where the SDK keeps its keys and the hub identities it has recorded. Keep that directory between restarts, and use a different identity for each client process that runs at the same time.

A damaged saved hub identity raises `ThalovantConnectionError` without replacing the saved state; restore it before retrying. A connection that runs out of time while authenticating over HTTPS raises `ThalovantTimeoutError` after releasing the failed connection.

`connect(timeout=None)` returns only after authenticated readiness within one caller budget. The default is the configured connection timeout plus authentication timeout plus one second, with a minimum of 0.1 seconds. An explicit positive, finite timeout covers the whole connection attempt; the extra readiness allowance from `0.5.7` is removed. Invalid or expired budgets raise a connection error with a `TimeoutError` cause.

`close(timeout=None)` also bounds how long its caller waits. Cleanup retains ownership until it actually finishes. Use `wait_closed(timeout=None)` to observe that cleanup before reusing the client identity; here `None` means an unbounded wait. The async client follows the same rules and preserves cleanup ownership after task cancellation.

`ask(timeout=12)` includes connection, send, and reply collection in one budget. Constructor options `reply_settle_seconds=0.25` and `empty_reply_wait_seconds=5.0` set fixed windows starting on first nonempty speech or first handled/soft-miss without speech. Speech starts settlement without requiring handled. Ask requires the matching request ID and accepts the runtime's session ID. Hard failures freeze partial replies; uncertain application writes are never retried automatically.

Live `on()` subscriptions survive reconnects, and concurrent registration and removal stay consistent with session restoration: a handler is registered once per session, and `subscription.close()` prevents it from returning on later reconnects. A failed registration is not retained for a future reconnect.

`listen` buffers at most 256 events by default; set a positive `max_buffered_events` to change the limit. Overflow raises `ThalovantRuntimeError` and retires the subscription. Its timeout includes connection and registration, and retires a paused subscription when it expires. Close the iterator when finished; the async iterator also retires its listener on cancellation.

TLS certificates are verified by default. For a private CA, configure trust for that CA; `ThalovantClient(self_signed=True)` explicitly opts into a self-signed development endpoint. This disables certificate verification; leave it `False` for normal use. A connection is usable only once it is authenticated. HTTPS keeps its session when reconnecting; after the broker connection is lost, MQTT authenticates again before sending application messages.

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.

## Choose A Protocol

```python
identity = result.identity

print(identity.enabled_protocols())
print(identity.endpoint_for("wss"))
print(identity.endpoint_for("https"))
print(identity.endpoint_for("mqtt"))

for protocol in ("wss", "https", "mqtt"):
    if not identity.supports_protocol(protocol):
        continue
    if protocol == "mqtt" and identity.mqtt is None:
        continue

    with ThalovantClient(identity, protocol=protocol) as client:
        print(protocol, client.ask(f"Reply over {protocol}.").text)
```

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

For broker details, see [MQTT](/developers/mqtt/).

## Add A Conversation

Use a conversation when related turns should share one session.

```python
with ThalovantClient.from_identity_file("_identity.json") as client:
    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)
```

## Add Context

```python
from thalovant import ThalovantClient, 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-09-001"},
)

with ThalovantClient.from_identity_file("_identity.json") as client:
    reply = client.ask("Show the next instruction.", context=context)
    print(reply.text)
```

## Listen For Events

```python
from thalovant import EVENT_SPEAK, ThalovantClient

with ThalovantClient.from_identity_file("_identity.json", protocol="wss") as client:
    for event in client.listen(EVENT_SPEAK, timeout=30, max_events=3):
        print(event.text)
```

The iterator observes matching events during its lifetime. For one correlated request and response, use `ask()`.

## List What The Hub Can Be Asked

Since SDK 0.4.36 a connected client can list every intent its hub answers, per language, with the sentences a person says to reach each one. The inventory is read over the client's own session, so no control-plane token is involved. For what the hub returns and which message types the connection must be allowed to publish, see [What Can My Hub Be Asked?](/concepts/hub-intents/).

```python
from thalovant import ThalovantClient, ThalovantPolicyDeniedError

with ThalovantClient.from_identity_file("_identity.json") as client:
    try:
        inventory = client.intents(["en-us", "fr-fr"])
    except ThalovantPolicyDeniedError as denied:
        print("refused:", denied.denied_type, "allowed:", denied.allowed)
        raise

    print(inventory.source, inventory.languages)
    for skill in inventory.skills:
        print(skill.skill_id)
        for intent in skill.intents:
            print(" ", intent.id, intent.engine, intent.examples("fr-fr"))
```

`intents()` asks `ovos.intent.list` once per language, requesting each row's definition with it, and describes only the sentence-based intents the listing did not already carry. `describe=False` stops at the listing and returns names, engines, and enabled state without sentences. `examples(lang, limit=2)` ranks complete phrases before dangling prefixes and slot patterns, then favors fuller phrases up to eight words. The limit counts returned examples after empty results and duplicates are removed. `phrases_for(lang)` finds the nearest registered language variant: `fr` and `fr-CA` can select `fr-FR`. Unknown languages return no examples.

A hub that refuses a query sends `hive.policy.denied`, which the SDK raises at once as `ThalovantPolicyDeniedError` with `denied_type`, `code`, `reason`, and the `allowed` list. With `fallback=False`, a refused `ovos.intent.list` raises straight away. With the default `fallback=True`, the SDK asks the engines' own manifests instead and returns names only: `inventory.source` is `"engine-manifests"`, `inventory.denied` is `("ovos.intent.list",)`, and `inventory.has_phrases` is false. The error is raised when those queries are refused too, and whenever `ovos.intent.describe` is refused.

Since `0.5.7`, the default fallback also handles an unanswered `ovos.intent.list`. The result still uses `source="engine-manifests"` and `denied=("ovos.intent.list",)`; for this case, `denied` records the unanswered query. With `fallback=False`, the listing timeout raises `ThalovantTimeoutError`. The operation also raises a timeout if the engine manifests never answer.

A hub that accepts the query and answers it with `ok: false` is a different case, and since 0.4.40 the two queries part ways there. A listing that fails raises `ThalovantRuntimeError` carrying the hub's own error text, because a failed listing is not an empty hub. A describe that fails returns no definitions and raises nothing: it means the hub does not know that registration, so the intent keeps its place in the inventory with no sentences.

The two queries are available on their own. `client.list_intents("en-us")` returns the registration rows and `client.describe_intent(skill_id, intent_name, "en-us")` the definitions behind one intent, each with a `timeout` keyword. `AsyncThalovantClient` has all three.

From the command line:

```bash
thalovant --identity _identity.json intents --lang en-us --lang fr-fr
thalovant --identity _identity.json intents --json
```

`--all` prints every sentence instead of two per intent. When fallback supplies names only, the command writes a warning to stderr. That warning can follow an explicit refusal or an unanswered listing; it does not by itself establish a policy denial.

## Async Apps

`AsyncThalovantClient`, `AsyncThalovantControlPlane` and `AsyncHubSession` run
on your event loop and never block it: identity files, key derivation and
certificate loading happen on the loop's executor. An application that already
has an `aiohttp.ClientSession` passes it as `session=`. The SDK uses it as
given and never closes it:

```python
api = AsyncThalovantControlPlane(access_token=token, session=http)
client = AsyncThalovantClient(identity, session=http)
```

`AsyncHubSession` keeps one hub connection up for a long-running application:

```python
from thalovant import AsyncHubSession

session = AsyncHubSession.for_identity(identity, session=http)
session.on_state_change(lambda up: print("hub link", "up" if up else "down"))
session.on("mycroft.volume.set", on_volume)  # follows every reconnect

await session.connect()                      # one attempt; raises if it fails
runner = asyncio.create_task(session.run())  # keeps that link, redials after a drop
reply = await session.ask("What time is it?")

await session.close()
```

`connect()` makes one attempt, so a setup step can report a failure at once.
`run()` keeps the link `connect()` opened and, after a drop, dials again after
10, 20, 40, 80, then every 120 seconds. A new connection is refused until its
hub has admitted it, so `run()` retries a refusal for ten minutes before it
raises `ThalovantHubRefusedError`. Every attempt is logged at DEBUG.

A plain client dials back by itself too when the hub drops its link: after
about five seconds, doubling up to a minute, until the link is back or the
client is closed. A client that only listens gets its events back after a hub
restart. `auto_reconnect=False` turns this off.

## Connect Home Assistant

A Home Assistant integration holds one connection of type `home_assistant` to a
hub. A home skill on the hub sends it `thalovant.home.request` with what the
person said, and the integration answers with `thalovant.home.response`:

```python
from thalovant import AsyncHubSession, AsyncThalovantControlPlane, HomeAnswer, answer_home_requests

api = AsyncThalovantControlPlane(access_token=token, session=http)
result = await api.create_client_identity(
    hub_id, name="Home Assistant", connection_type="home_assistant"
)
await api.wait_for_admission(result)

async def handle(request):
    speech = await ask_home_assistant(request.utterance, request.lang)
    return HomeAnswer(speech=speech, response_type="action_done")

session = AsyncHubSession.for_identity(result.identity, session=http)
stop = answer_home_requests(session, handle)
await session.connect()
runner = asyncio.create_task(session.run())
```

`create_client_identity(..., connection_type=...)` checks that the API made
that kind of connection. If it did not, the SDK deletes what it made and raises
`ThalovantUnsupportedConnectionTypeError`. A plan that does not allow the type
raises `ThalovantPlanError`, and a hub that already has its Home Assistant link
raises `ThalovantAlreadyLinkedError`.

`wait_for_admission()` returns once the hub has admitted the connection, which
takes about ninety seconds. It raises `ThalovantAdmissionTimeoutError` when the
wait runs out; that error is a `ThalovantConnectionError`, so you can keep the
connection and wait again later.

When the API cannot be reached at all, because of DNS, the connection, TLS or a
timeout, any control-plane call raises `ThalovantAPIUnreachableError`. It is
both a `ThalovantAPIError` and a `ThalovantConnectionError`, and has no status
code, so you can tell "try again later" from a refusal.

`answer_home_requests()` answers each request at most once, back along the
route the request came in on, and never after the hub's ten seconds: your
handler gets nine of them, and sending the reply gets what is left. A reply
that could only arrive after the hub gave up is not sent. The speech is sent
as plain text. When your handler raises, runs out of time, or answers outside
the contract, the SDK sends an `error` answer with an error code, and the hub
speaks its own sentence for it in the device's language. `delete_client()`
removes the connection when the person removes the integration.

While waiting for admission, a revoked token raises `ThalovantAuthError`
rather than a failed admission, and a rate limit is waited out for the time
the API names. A long-lived link started with `run()` stops at once with
`ThalovantHubKeyChangedError` if the hub's key is no longer the one the SDK
pinned on first contact: retrying cannot fix that, and the SDK never replaces
the pin by itself.

  Creating a `home_assistant` connection needs an API that knows the type. An
  API that does not raises `ThalovantUnsupportedConnectionTypeError`.

## Useful Helpers

  
    Send one request and receive a normalized reply.
  
  
    Wait for hub events with a timeout.
  
  
    Send a button, menu, or tool action.
  
  
    Send a QR value, serial number, barcode, or typed code.
  
  
    List what the hub can be asked, per language, with the sentences that reach each intent.
  

For the full method list, see [SDK Functions](/developers/sdk-functions/).
Use `api.get_operation(operation_id)` to follow an accepted command; see [Operations](/developers/operations/).

## Provision Hubs

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

```python
group = api.create_runtime_group({"name": "kiosks"})
hub = api.create_hub({"name": "joke-garden", "runtime_group_id": group["id"], "spec": {}})

hub = api.get_hub(hub["id"])
api.update_hub(hub["id"], {"active": False}, etag=hub["etag"])
```

`update_hub` and `delete_hub` require the hub's current etag. See [Provision Hubs](/developers/provisioning/) for the full flow, the immutable fields, and the error table.

From SDK `0.5.15`, `list_hub_skills`, `install_hub_skill`, `update_hub_skill`, and `remove_hub_skill` manage skills on one hub by id. `list_hub_skills` returns a `HubSkillList` whose rows sit under `.data` (`for skill in api.list_hub_skills("hub-id").data:`), the writes return a `HubSkillOperation`, and `wait=True` polls that operation with a 120-second default polling budget. The same actions are on the command line as `thalovant skills list`, `thalovant skills add`, `thalovant skills update`, and `thalovant skills remove`, each with `--hub <hub-id>`. See [Add a Skill to a Hub](/manage/add-a-skill-to-a-hub/).

Malformed skill-list rows are rejected rather than silently omitted. No new status read starts at or after the polling deadline; an HTTP read already in progress keeps its configured request timeout. If a status read fails, the error retains the accepted operation ID so you can resume with `get_operation` without repeating the write. An underlying sanitized API error is preserved as the cause; unexpected exceptions are omitted from the displayed chain.

## Common Issues

| Symptom | Check |
| --- | --- |
| `Missing Thalovant API access token` | Call `api.login(...)` or `api.login_with_browser()` before private API actions, or pass `access_token=` to `ThalovantControlPlane`. |
| HTTP 401 with code `mfa_required` | Pass `otp_code` or `recovery_code` to `api.login(...)`. |
| `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. |
| `ThalovantPolicyDeniedError` | The connection may not publish the message type the error names. Add it to the connection's message types in the dashboard, then reconnect. See [What Can My Hub Be Asked?](/concepts/hub-intents/). |
| `ovos.intent.list failed: ...` | The hub accepted the listing and could not answer it. Check the hub's live state, then ask again. See [What Can My Hub Be Asked?](/concepts/hub-intents/). |

1. Start with WSS.
2. Add HTTPS only for request-response clients.
3. Add MQTT only when the client needs broker-mediated traffic.

## Fallback Handler Discovery

The inventory includes `fallbacks` and `fallbacks_known`. Use `inventory.may_answer("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](/concepts/hub-intents/#fallback-handlers-and-language-availability).

Control-plane redirects are refused. Authenticated API calls and request bodies require HTTPS except explicit loopback development endpoints. See [API credential security](/developers/sdks/#api-credential-security).

## Concurrent Runtime Configuration Updates

`update_runtime_group_config(id, config)` preserves unrelated stored keys by
reading the configuration and its revision, merging your changes, and sending
a conditional `PUT`. A conflicting update returns HTTP 412; the helper rereads
and reapplies your original changes, up to three write attempts. Lists are
replaced, and `personas` is replaced only when you supply it.

This requires an API that supports configuration revisions and conditional
`PUT /v1/runtime-groups/{id}/config`. Older servers fail without a write. Network
failures and other HTTP errors are not retried. A final HTTP failure exposes
`ThalovantAPIError.status_code`. Coordinate any writers using `merge=False`,
which retains unconditional `PATCH` replacement.

```python
control.update_runtime_group_config(runtime_group_id, {"lang": "fr-fr"})
```

## Manage Skills Through A Hub

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

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

| Action | Method |
|---|---|
| List | `list_hub_skills` |
| History | `list_hub_skill_history` |
| Install | `install_hub_skill` |
| Update | `update_hub_skill` |
| Remove | `remove_hub_skill` |

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

Use the existing wait option to follow the accepted operation. A polling failure
retains its operation ID; inspect that operation instead of repeating the write.

Configuration merges snapshot nested config and personas before the first read,
so caller mutation cannot change the payload between conflict retries. The
request context helper also copies an existing session when no pipeline is supplied.

Since 0.9.0 the SDK requires aiohttp 3.11 and cryptography 48.0.1 or newer, so
it installs beside Home Assistant 2026.9, which pins cryptography 48.0.1.
cryptography 50.0.0 fixes a flaw in PKCS#7 decryption, which the SDK never
calls; an application that uses PKCS#7 itself should require 50.0.0. Intel Macs
stay below 49.0.0, because later releases ship no x86_64 macOS wheels.

Before cryptography 50.0.0, the key derivation on a first connection holds
Python's interpreter lock for about a tenth of a second, so an asyncio loop
pauses that long. The key is cached afterwards, so this happens once per
identity and hub.

## Locale-Aware Intent Listings

Install the optional language data when examples should read as sentences:

```sh
python -m pip install 'thalovant[listing]==0.7.6'
```

```python
from thalovant import as_sentence, speakable

print(as_sentence("quelle heure est-il", "fr-CA"))  # Quelle heure est-il?
print(speakable("volume {level} pour cent", lang="fr"))  # volume cinquante pour cent
examples = intent.examples("fr-CA", sentence=True)
```

`sentence=True` implies speakable rendering, capitalization and locale-aware
punctuation. Omitting `lang` uses the first registered locale, including its
slot examples. Explicit `slots` override the language's sample values.
The optional `thalovant-languages` package owns these rules; without it,
sentences remain capitalized and bare and slots retain their names. Unknown
languages receive no guessed punctuation. A dangling prefix remains unpunctuated.

Python 0.7.4 to 0.8.7 suppressed a repeated OVOS warning about the hub's
older nested location format. Since 0.9.0 the SDK does not use the OVOS
libraries, so the warning never appears.

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

## Managed Sessions And Inventory Caches

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

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

```python
from thalovant import HubSession

# connect_client returns a connected client and cleans up failed attempts.
session = HubSession(connect_client, warm=False)
try:
    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.
Existing Python cache files produce a one-time cache miss after this key update.
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. Python uses a serialized, scoped resolver override; avoid blocking unrelated
resolver work inside that scope. TLS validation remains enabled.

`HubSession` wraps synchronous clients. Its `OriginPreference.close()` retries
cleanup of a failed connection attempt; successfully returned clients still
belong to their caller.

## Reply Claims

A reply exposes `reply.pipeline_ids`, `reply.skill_ids` and `reply.claimed`.

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

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

## Upgrading To 0.9

Nothing is required. Every public name, signature and behaviour of 0.8.7 is
still there. What changes:

- The package installs `aiohttp` and `cryptography` and nothing else. If your
  code imported `hivemind-bus-client`, `ovos-bus-client`, `ovos-utils`,
  `requests`, `PyYAML`, `paho-mqtt` or `ovos-spec-tools` without declaring
  it, declare it now. MQTT needs `thalovant[mqtt]`.
- Keys and saved hub identities stay in the same files and the same format.
  Nothing is migrated, and pinned hubs reconnect without a first-contact
  handshake.
- Each sync client owns one private thread for its event loop. Handlers passed
  to `on()` still run off that loop, so a handler can call the client while
  another thread waits in `ask()`.
- `ThalovantControlPlane(session=...)` still takes a requests-style session.
  An `aiohttp.ClientSession` goes to `AsyncThalovantControlPlane` instead.
- The new errors subclass `ThalovantAPIError` or `ThalovantConnectionError`,
  the errors 0.8.7 raised in the same places, so existing `except` clauses
  still catch them.
