Skip to main content

Thalovant Python SDK

Python SDK for connecting apps, services, kiosks, and agents to Thalovant hubs.

The control API is used to discover hubs and provision a client identity. After that, the SDK talks directly to the hub data plane over HTTPS, WSS, or MQTTS.

Thalovant API      -> discover hubs, create client identity
Python SDK         -> connect to the hub data plane
Hub runtime        -> skills, events, replies

The SDK is asyncio at its core. AsyncThalovantClient and AsyncThalovantControlPlane are the implementation; ThalovantClient and ThalovantControlPlane run the same code on a private event-loop thread, so a script, a CLI or a voice satellite can call them without an event loop of its own.

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

What You Need

  • A Thalovant account with API access for authenticated control-plane actions.
  • A hub id or slug.
  • A client identity for that hub. You can create one through the API or use one downloaded from the dashboard.

Install

pip install thalovant

The core needs two libraries: aiohttp and cryptography. The Noise handshake, the HiveMind wire format and the language data are part of the SDK, so installing it does not pull in the OVOS or HiveMind stacks. Neither library is imported until the first connection or API call, which keeps import thalovant quick for a CLI.

Extras:

  • thalovant[mqtt]: the MQTT transport (paho-mqtt).
  • thalovant[yaml]: read the config file with PyYAML. Without it, a small built-in reader handles what a config file holds and names this extra when a file needs more.
  • thalovant[listing]: the per-language rules an intent listing uses to phrase examples (thalovant-languages).

For local SDK development:

pip install -e ".[dev]"

Quick Start

This is the normal first integration flow.

from thalovant import ThalovantClient, ThalovantControlPlane

api = ThalovantControlPlane()

# Public hub discovery does not require auth.
public_hubs = api.list_public_hubs(limit=12)
for hub in public_hubs["data"]:
    print(hub["id"], hub["slug"], hub["title"])

# Auth is required when creating a client identity.
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:
    info = client.connection_info()
    print("connected in", info.connect_ms, "ms")
    reply = client.ask("Tell me a short clean joke.")
    print(reply.text)

The same flow in an asyncio application:

import asyncio

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

Accounts created through Google sign-in have no password. Use the browser device flow instead of login(...):

api.login_with_browser()

This prints a short code and a verification URL, opens your browser to the approval page, and waits for you to approve the request in the dashboard. On approval the SDK stores a scoped, revocable API token, exactly like login(...).

An application that shows the code in its own interface (a setup screen, a config flow) runs the device flow one step at a time instead:

import asyncio

from thalovant import AsyncThalovantControlPlane, ThalovantDeviceLoginPending


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

    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 a slow_down

The sync ThalovantControlPlane has the same two methods.

Each poll asks once. It returns the token (stored on api), or raises ThalovantDeviceLoginPending (poll again after its interval), ThalovantDeviceLoginExpired or ThalovantDeviceLoginDenied. The token lives 365 days and has no refresh token. Keep token.token_id: api.revoke_api_token() revokes the token the client signed in with, which is what an application should do when the person removes it. It is idempotent: a token that is already revoked cannot authenticate its own revoke, so the API answers 401, and that counts as revoked too. Every sign-in sets token_id from its own answer, so a password login() after a device login clears it.

ThalovantControlPlane() uses https://api.thalovant.com by default. Pass a different URL only for local development or a self-hosted control plane.

Keep result secret: result.identity and the raw result.client API response both carry the client credentials. result.as_dict() (the default) redacts every secret — identity and client alike — and is safe to log. result.as_dict(include_secrets=True) returns the real credentials for persisting an identity file and must never be logged; the same rule applies to result.identity.as_dict(include_secrets=True).

Token Auth For CI And Automation

Headless environments (CI jobs, AI agents, cron tasks) should skip login entirely: mint a scoped API token once, then pass it to the constructor.

import os

from thalovant import ThalovantControlPlane

api = ThalovantControlPlane(access_token=os.environ["THALOVANT_API_TOKEN"])

# Ready immediately; no login call needed.
page = api.list_hubs(limit=50)
# CI configuration
export THALOVANT_API_TOKEN="tvpat_..."  # store in your CI secret manager

Tokens come from the dashboard's API Tokens page or from login_with_browser(). Either way the token is durable, scoped, and revocable: grant only the scopes the job needs, and revoke it from the same page when the job is retired. The SDK never reads THALOVANT_API_TOKEN on its own, so pass it explicitly as shown above.

List Your Hubs

Authenticated accounts can list owned or visible hubs:

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

page = api.list_hubs(limit=50)
for hub in page["data"]:
    print(hub["id"], hub["slug"], hub["title"])

Provision Hubs

Hubs, runtime groups, and skills can be created and managed from code. These routes need a paid plan and a token with the hubs:write scope ("Create and update your hubs" on the dashboard's API Tokens page).

The scope is checked before the plan, which decides what you actually see. Free-plan API tokens can only be minted with hubs:read, clients:read, and clients:write, so a free-plan API token can never carry hubs:write and never reaches the plan gate: every call below fails with HTTP 403 Insufficient scopes, not HTTP 402. The 402 API access requires a paid plan. shows up only for a dashboard session token, or for an API token that was minted on a paid plan and kept after a downgrade.

api = ThalovantControlPlane(access_token=os.environ["THALOVANT_API_TOKEN"])

# 1. Create a runtime group to run the skills.
group = api.create_runtime_group({"name": "kiosks", "description": "Lobby kiosks"})

# 2. Create a hub attached to it. spec.version is required.
hub = api.create_hub(
    {
        "name": "joke-garden",
        "runtime_group_id": group["id"],
        "spec": {"version": "1", "protocols": {"wss": {"enabled": True}}},
    }
)

# 3. Discover what is installable before installing anything.
for skill in api.list_marketplace_skills()["data"]:
    print(skill["skill_id"], skill["title"], skill["access_tier"])

# 4. Install a skill from the marketplace catalog.
api.install_runtime_group_skill(group["id"], "skill-weather")

# 5. Release: roll the runtime and the hub onto a release channel.
api.release_runtime_group(group["id"], channel="stable")
api.release_hub(hub["id"], channel="stable")

A hub's spec is schema-validated and must carry a non-empty version string; omitting it fails with HTTP 422 Schema validation failed rather than defaulting.

To retry hub creation safely, generate and retain an idempotency_key before the first call and pass that same key with the same payload on every retry. Omitting it generates a new Idempotency-Key for each invocation, so calling again after a timeout can create a second hub. Reusing the original key returns the original hub.

Updating and deleting a hub use optimistic locking. Pass the etag from the hub resource you read; the SDK sends it as If-Match, and the API rejects a stale or missing value with HTTP 412 without changing anything:

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

The get_hub first is mandatory, and it is the body you need: the validator lives only in the hub resource's etag field. The API sends no ETag response header, so there is nothing to read off the response.

name, namespace, and domain are immutable after creation. Sending a different value for one of them fails with HTTP 400 <Field> cannot be changed after hub creation; sending the value the hub already has is accepted and ignored. Patch only the fields you mean to change rather than feeding a whole hub resource back in. The SDK deliberately does not reject these client-side — it cannot know the stored values, and refusing them outright would reject patches the API accepts.

Deleting a hub also deletes its clients and ACLs. Runtime groups have no If-Match requirement, but the API refuses to delete the workspace default group or a group that still has hubs attached (HTTP 409).

The Python helper reads the stored runtime configuration and its revision, then merges your changes and sends a conditional PUT. A concurrent change returns HTTP 412; the helper reads again and reapplies your original changes, up to three write attempts. Unrelated keys and environment settings survive. Lists and explicitly supplied personas are replaced, not appended or merged.

Version 0.6.3 requires API support for configuration revisions and conditional PUT when merging. Older servers fail without a write. Network failures and other HTTP errors are not retried. Inspect ThalovantAPIError.status_code for a final HTTP failure (and code, detail and problem; see Reading An API Error). Pass merge=False only to replace the complete configuration with an unconditional PATCH; that mode requires coordinating concurrent writers.

api.update_runtime_group_config(group["id"], {"lang": "en-us"})
print(api.get_runtime_group_config(group["id"])["config"])

Reading what a hub is running needs the hubs:inspect scope instead (which hubs:read implies, so a free-plan token has it). The answer is not always live, so branch on source:

capabilities = api.get_hub_runtime_capabilities(hub["id"])
if capabilities["source"] == "ovos-runtime":
    print("live:", capabilities["counts"]["total_intents"])
else:
    # ovos-runtime-unavailable / ovos-runtime-timeout: the runtime group's
    # snapshot, i.e. what the group is configured to run, not what is running.
    print("stale:", capabilities["source"])

HTTP 409 comes back only when there is no snapshot to fall back on either — the hub belongs to no runtime group, or that group has no desired and no observed skills. A hub with a configured group returns a stale HTTP 200 far more often than a 409. The route is rate limited per caller and hub; HTTP 429 carries a Retry-After header.

Delete the example hub only after the capability reads are complete:

hub = api.get_hub(hub["id"])
api.delete_hub(hub["id"], etag=hub["etag"])

Discover Skills

The marketplace catalog is readable with the hubs:read scope and, unlike the provisioning routes above, is not paid-gated — a free-plan token can browse the whole catalog before upgrading, and only the install needs a paid plan.

for skill in api.list_marketplace_skills()["data"]:
    print(skill["skill_id"], skill["category"], skill["access_tier"])

Each entry carries what an install needs (skill_id, source_type, source_ref, config_schema, secret_schema) next to presentation fields (title, summary, tags, verified). Admin tokens can additionally pass owner_id= to read another tenant's catalog and include_inactive=True to see retired entries; both are ignored for non-admin callers — a non-admin's owner_id is silently replaced with their own rather than rejected, unlike list_runtime_groups, where the same mistake is a hard HTTP 403. force_refresh=True re-syncs the global catalog from source first, which is slower.

Two group-scoped reads need the hubs:inspect scope and are likewise not paid-gated. The first resolves the catalog against one runtime group, so each entry reports whether it is already desired, whether it was observed running, and whether the tenant plan allows installing it:

view = api.list_runtime_group_marketplace(group["id"])
for entry in view["data"]:
    if entry["installable"] and not entry.get("active"):
        print("available:", entry["skill_id"])

The second answers what the group is actually running right now, rather than what could be installed:

inventory = api.list_runtime_group_inventory(group["id"], refresh=True)
print(inventory["source"], len(inventory["data"]))

Both answer from a cached inventory snapshot by default; pass refresh_inventory=True or refresh=True to force a live read from the runtime operator.

The two routes report source from different vocabularies. A default (non-refreshing) list_runtime_group_marketplace returns runtime-group-cache or runtime-group-cache-empty (or ovos-runtime-operator when the operator's status differed and the API re-synced while serving); ovos-runtime-operator-pending appears there only when you pass refresh_inventory=True. list_runtime_group_inventory returns ovos-runtime-operator, runtime-group-cache, or ovos-runtime-operator-pending, and never runtime-group-cache-empty.

The source on the marketplace route describes the observation, not the listing: its data is the catalog unioned with the group's desired and observed skills, so it stays populated even when the snapshot is empty. Never read an empty-sounding source as an empty data. Only list_runtime_group_inventory returns an empty data when nothing is reporting, and it does so with source="ovos-runtime-operator-pending" rather than failing.

Install Skills

install_runtime_group_skill answers HTTP 200, not 201 — it upserts, so installing a skill that is already present updates that entry in place.

source_type is a free-form string of 1 to 32 characters, not an enum. Only catalog (the default) and git are interpreted specially: catalog resolves the skill against the marketplace and fails with HTTP 404 when it is not there, git requires source_ref to be a valid repository URL. Anything else is stored as sent.

Two different HTTP 402s can come back. API access requires a paid plan. is the plan-level API gate on every provisioning route; This skill requires paid marketplace access for the tenant plan. is a per-skill check on a catalog entry whose access_tier is paid. A paid plan clears the first and can still fail the second, so check installable and purchase_required from list_runtime_group_marketplace first:

view = api.list_runtime_group_marketplace(group["id"])
for entry in view["data"]:
    if entry["installable"]:
        api.install_runtime_group_skill(group["id"], entry["skill_id"])
    elif entry["purchase_required"]:
        print("needs marketplace access:", entry["skill_id"], entry["access_message"])

Skills On One Hub

The hub-skill calls manage the attachments of the hub’s shared runtime group. The group can start with no skills. These calls address the runtime group selected by a hub id (the authenticated hub routes do not take slugs) and every change applies live on that hub, typically within about fifteen seconds and without restarting it.

listing = api.list_hub_skills(hub["id"])
print(listing.source, listing.observed_at)
for skill in listing.data:
    print(skill.skill, skill.installed_version, skill.state, skill.update_available)

# Accepted at once: HTTP 202 with an operation_id, state "installing".
accepted = api.install_hub_skill(hub["id"], "skill-weather")
print(accepted.operation_id, accepted.state, accepted.previous_version)

# Or poll the operation until it converges (default timeout 120 s).
done = api.install_hub_skill(hub["id"], "skill-weather", version="1.2.0", wait=True)
print(done.state)  # "installed"

api.update_hub_skill(hub["id"], "skill-weather", version="latest", wait=True)
api.remove_hub_skill(hub["id"], "skill-weather", wait=True)  # state "removed"

list_hub_skills returns a typed HubSkillList: the envelope says where the reading came from (hub_id, runtime_group_id, observed_at, source, the runtime's phase and message) and data holds one HubSkill row per skill (skill, title, version, installed_version, observed_version, latest_version, update_available, active, state, the runtime's last error, and more). A row's state is pending, installed, failed, removing, drifted, quarantined, or unmanaged; a change in progress shows as pending.

The writes return a typed HubSkillOperation (operation_id, hub_id, runtime_group_id, skill, version, previous_version, state). Installing a skill the hub already carries at another version performs an update. With wait=True a failed or timed_out operation raises ThalovantAPIError carrying the operation's error message and accepted operation ID. The timeout bounds polling: no new operation read starts at or after the deadline, and an expired budget raises ThalovantTimeoutError. An already-running HTTP read keeps its configured request timeout. A failed read raises immediately with the accepted operation ID so you can resume using get_operation; it does not retry the read or replay the accepted write. Malformed skill-list rows raise ThalovantAPIError instead of being silently omitted. The API answers HTTP 409 skill_version_already_installed for the same version, HTTP 404 hub_without_runtime_group when the hub has no runtime group yet (a plain 404 for an unknown hub or a skill that is not installed), and HTTP 422 for an unresolvable "latest" or an invalid version; the problem code is appended to the error message, for example HTTP 409: Skill version already installed. (skill_version_already_installed). Listing needs hubs:inspect (hubs:read implies it); the writes need hubs:write and a paid plan. Hub-restricted tokens are honoured.

The same four commands are on the CLI, authenticated with THALOVANT_API_TOKEN (or --token) against THALOVANT_API_URL (or --api-url), with --json for machine-readable output:

thalovant skills list --hub <hub-id>
thalovant skills add --hub <hub-id> skill-weather --version latest --wait
thalovant skills update --hub <hub-id> skill-weather --version 1.2.0
thalovant skills remove --hub <hub-id> skill-weather

Workspace Analytics

Authenticated accounts can read the same overview used by the dashboard:

overview = api.get_analytics_overview(range="7d", hub_id="hub-id")
print(overview["totals"])

Durable Memory

Private Daily Desk and workspace assistants can manage explicit opt-in memory:

memory = api.create_memory_item(
    {
        "scope": "workspace",
        "kind": "preference",
        "content": "Prefer America/Toronto for scheduling.",
        "tags": ["timezone"],
    }
)
print(memory["id"])

items = api.list_memory_items(scope="workspace", query="timezone")
print(items["data"])

What Can I Ask?

A connected client can ask its hub what it can be asked, over its own session, with no control-plane token:

from thalovant import ThalovantClient

with ThalovantClient.from_identity_file("_identity.json") as client:
    inventory = client.intents(["en-us", "fr-fr"])
    for skill in inventory.skills:
        for intent in skill.intents:
            print(intent.id, intent.examples("fr-fr"))

Each intent carries the sentences a person says to reach it, per language, as the skill wrote them ({location} marks a slot). The hub's connection must be allowed to publish ovos.intent.list; ovos.intent.describe is needed only when the client has to ask for the definitions separately, which a runtime attaching them to the listing never makes it do. A hub that refuses ovos.intent.list raises ThalovantPolicyDeniedError naming the type, or with the default fallback=True lists intent names only from the engines' manifests and marks the result source="engine-manifests", denied=("ovos.intent.list",). From the CLI: thalovant --identity _identity.json intents.

Use An Existing Identity

For local development, store one or more identities in the protected SDK config:

mkdir -p ~/.config/thalovant
chmod 700 ~/.config/thalovant
$EDITOR ~/.config/thalovant/config.yaml
chmod 600 ~/.config/thalovant/config.yaml
profile: prod
profiles:
  prod:
    identity:
      access_key: ...
      password: ...
      site_id: demo-agent
      default_master: https://jokes.thalovant.io
      data_plane_endpoints:
        wss: wss://jokes.thalovant.io/public
        https: https://jokes.thalovant.io/public
        mqtt: mqtts://mqtt.thalovant.com:8883
      mqtt:
        endpoint: mqtts://mqtt.thalovant.com:8883
        username: ...
        password: ...
        topic_prefix: hubs/hub-id/clients/client-id
        tls: true
from thalovant import ThalovantClient

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

SDKs reject config files that are readable or writable by other users on Linux and macOS. Keep this file out of git.

Raw identity files are supported too:

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 are supported too:

export THALOVANT_ACCESS_KEY=...
export THALOVANT_PASSWORD=...
export THALOVANT_CRYPTO_KEY=...
export THALOVANT_SITE_ID=...
export THALOVANT_HUB_HTTPS_HOST=https://hub.example.com
export THALOVANT_HUB_WSS_HOST=wss://hub.example.com
export THALOVANT_HUB_MQTT_HOST=mqtts://mqtt.thalovant.com:8883
export THALOVANT_MQTT_USERNAME=...
export THALOVANT_MQTT_PASSWORD=...
export THALOVANT_MQTT_TOPIC_PREFIX=hivemind/hub-id/client-id
from thalovant import ThalovantClient

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

Save A Provisioned Identity

Only save identities in a secret store or local developer file that is ignored by git.

import json
from pathlib import Path

Path("_identity.json").write_text(
    json.dumps(result.identity.as_dict(include_secrets=True), indent=2),
    encoding="utf-8",
)

Protocols

Hubs may expose one or more public data-plane protocols:

  • wss: secure realtime WebSocket, the default public path and SDK preference.
  • https: request/response HTTP protocol exposed as HTTPS.
  • mqtt: broker-mediated MQTT over TLS. Requires per-client broker credentials.

Noise authentication and persistent identity

From 0.5.5, WSS, HTTPS and MQTT all complete the HiveMind v3 Noise handshake before reporting readiness. The client derives its PSK from the identity password and the hub node ID using Argon2id. Both 25519_ChaChaPoly_SHA256 and 25519_AESGCM_SHA256 are supported: XXpsk2 on first contact, or KKpsk0 when a trusted server key is available. Older non-Noise offers are refused.

From 0.9.0 the handshake and the HiveMind framing are the SDK's own, written on cryptography; hivemind-bus-client and poorman-handshake are no longer installed. The client key and the server pins stay where those libraries kept them, in the same format, so an existing identity keeps its pins and reconnects with KKpsk0 as before. HTTPS preserves the replica-affinity cookie and exchanges ciphertext through the binary endpoints. MQTT uses the identity's broker credentials and topics, then exchanges raw Noise ciphertext. After broker loss, reconnect the transport (or use the client's normal reconnect-on-send behavior).

Keep the client static key and server pins between reconnects and restarts. The default location remains the existing HiveMind identity under the XDG configuration directory. To use a dedicated private directory:

client = ThalovantClient(
    identity,
    protocol="https",
    noise_state_dir="/var/lib/my-agent/thalovant-noise",
)
client.connect()

Changing state directories creates a different client identity unless you migrate the existing key and pins. Authentication failure never deletes a trusted server pin automatically. An intentional server-key replacement requires verifying the new identity before removing the saved pin. Uppercase and lowercase hexadecimal spellings of the same server key are equivalent; reconfirming that key preserves the saved identity file unchanged. Malformed stored pins raise ThalovantConnectionError; restore the verified state instead of deleting it to retry. From 0.5.6, an expired HTTPS Noise handshake raises ThalovantTimeoutError after cleaning up the failed connection.

HTTPS and WSS verify server certificates by default. Configure a trusted CA for private certificates: REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE or SSL_CERT_FILE name a bundle, as they did when HTTPS went through Requests, and WSS honours them too. Proxies named in HTTPS_PROXY, HTTP_PROXY, ALL_PROXY and NO_PROXY apply to both; .netrc is never read. For an explicit development-only exception, construct a transport with self_signed=True and pass it as the client's transport. This opt-in disables certificate verification and should not be used for public hubs.

Inspect what an identity supports:

identity = result.identity

print(identity.enabled_protocols())
print(identity.endpoint_for("wss"))
print(identity.endpoint_for("https"))
print(identity.endpoint_for("mqtt"))
print(identity.mqtt.endpoint if identity.mqtt else None)

Connect with a specific protocol:

from thalovant import ThalovantClient

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

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

From 0.5.8, client.connect(timeout=...) uses one deadline for waiting for a previous attempt, transport setup and authenticated readiness. It raises ThalovantConnectionError if the session is not ready when that budget expires. This replaces 0.5.7's extra, best-effort readiness allowance, which could return without an admitted session. Increase the caller's timeout for slow hubs. Timeout and async connect cancellation start cleanup without waiting beyond the caller's budget. A replacement waits for the retired connect and cleanup to finish, preventing a late attempt from replacing or closing a new session. close(timeout=...) uses its own caller deadline (defaulting to the configured connection budget). If close times out, wait_closed() observes actual retained cleanup; await that before passing the identity to another client instance. From 0.5.11, a refused or unacknowledged HTTP disconnect raises ThalovantConnectionError and retains the session's admission and replica cookie. wait_closed() also reports that failure, and reconnect is blocked. Retry close() on the same client after the endpoint recovers; only a successful disconnect acknowledgment releases the admission. From 0.5.12, the upstream {"error": "Already Disconnected"} reply also confirms cleanup when a prior successful response was lost. This exception applies only to /disconnect. A close that encounters cleanup already owned by a direct transport call reports that cleanup is still in progress; it cannot claim completion for the other call. Intent query deadlines also cover reconnect and send. The optional fallback-skill probe uses at most 1.5 seconds, preserving unknown state when unavailable or explicitly failed, and retains ownership of any timed-out send until it retires.

Use client.connect_with_info() when you need connection telemetry for benchmarks or health dashboards. The returned snapshot includes phase, socket/open time, handshake time, total connect time, and last error.

Use client.query(...) for the direct HiveMind query frame path when the hub supports it. It avoids broad bus fanout and is the preferred request/reply API for low-latency app integrations.

reply = client.query("What time is it in Toronto?")
print(reply.text)

Since 0.5.10, query(timeout=...) includes connection, authenticated readiness, send, and reply collection in one deadline. Direct query and routed cascade replies share the same query ID filter. Intent misses are provisional until hive.query.complete; later speech clears a provisional failure. Completion and hard policy/query-timeout failures stop collection, so later events cannot change the result. A hard failure after speech preserves the partial text with handled=False. An unanswered query still times out. Blocked I/O retains session ownership until cleanup finishes, and an expired connection attempt cannot send a query later. Completion returns immediately without the Ask settlement delay.

ask() also includes connect, send, preparation retries, and settling in its caller deadline. The first nonempty speech starts a fixed settle window (reply_settle_seconds=0.25); a handled event or soft intent miss without speech starts a fixed empty-reply window (empty_reply_wait_seconds=5.0). Later speech switches the empty window to settling and recovers the soft miss. Neither repeated events nor later fragments extend these windows, and both are clipped by the original deadline. A hard policy/query-timeout failure freezes the result immediately. Empty results raise a runtime failure or timeout. Ask requires a matching request ID; ambient or differently correlated replies cannot satisfy the request. Both Ask and Query report the first accepted nonblank runtime session ID, falling back to the requested session when none is reported. Cancelling an async ask, query, event wait, or listener removes its handlers and retires any active connection/write it owns; a queued caller cannot close another caller's session. Transport status checks run outside the waiting caller's thread.

Live on() subscriptions survive reconnects. Call subscription.close() to remove them permanently; concurrent registration and removal are serialized with session restoration.

When the hub or the network drops a WSS or HTTPS link, the client dials back by itself, as 0.8.7's WebSocket library did: about five seconds later (jittered between half and one and a half times that), 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 without calling anything. A call made in the meantime reconnects at once. auto_reconnect=False turns the background redial off. Each attempt is logged at DEBUG on thalovant.client.

wait_for_event(timeout=...) and listen(timeout=...) include connection and subscription setup in the deadline. A listener without a timeout uses the normal connect budget and can then listen indefinitely. Both sync and async listeners accept max_buffered_events=256; it must be a positive integer. Overflow raises ThalovantRuntimeError and retires the subscription. A one-event wait keeps the first correlated match and ignores subsequent events.

Application requests are never automatically replayed after publication starts, including when a local write error leaves the remote outcome uncertain. auto_reconnect and reconnect_attempts apply to connection preparation before publication. This corrects earlier behavior that could emit the same application request twice after a connection error.

MQTT identities include a broker endpoint, username, password, TLS flag, and topic prefix. The broker credentials are scoped to that client and should be treated like a password. Public identities should use mqtts://; the SDK also honors an explicit tls: true flag from the identity.

Use a fresh request ID for each logical Ask and a fresh query ID for each logical Query. One client rejects overlapping collectors with the same ID before dispatch. Ask request IDs and scoped Query IDs are separate namespaces. Cancellation or completion releases the reservation after listeners retire; a new logical operation should still use a new ID to exclude late replies.

Conversations

Use a conversation when several turns should share one session.

from thalovant import ThalovantClient

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)

Events

You can wait for, stream, or subscribe to hub events.

from thalovant import EVENT_SPEAK, ThalovantClient

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

Use timeouts in scripts so they do not wait forever.

Client Context

Context lets skills know which app, device, user, or channel made the request.

from thalovant import ThalovantClient, build_client_context

context = build_client_context(
    user_id="user-42",
    user_name="Ada",
    auth_provider="oidc",
    roles=["member"],
    platform="kiosk",
    source="checkout-kiosk",
    channel="chat",
)

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

Actions And Exact Inputs

Use actions for button payloads and codes for exact typed or scanned values.

with client.conversation(session_id="work-session") as convo:
    convo.send_action('/choose{"id":"42"}', title="Choose item")
    convo.send_code("SN-001-XYZ", kind="qr", label="serial")

Rich Responses

Replies can include text, choices, tables, images, or attachments.

reply = client.ask("Show matching parts.")

for item in reply.display_items(max_text_chars=600):
    if item.kind == "text":
        print(item.text)
    elif item.kind == "choices":
        print([choice["title"] for choice in item.data])

Skill Sounds

A skill that would play a clip on the hub's own speaker sends it to a remote client instead, in order with the speech around it. Keep a settle window: the burst arrives together, and a zero window closes on the first sentence.

client = ThalovantClient.from_identity_file("_identity.json", reply_settle_seconds=0.1)
reply = client.ask("Pull my finger.", stt_lang="en-us")
for event in reply.media_events:
    if event.is_audio:
        play(event.audio_bytes())          # bounded, decoded from hex
    else:
        speak(event.text, event.lang or reply.lang)

Async Apps

AsyncThalovantClient, AsyncThalovantControlPlane and AsyncHubSession run on the caller's event loop and never block it. Reading and writing identity files, deriving the argon2id key and loading the CA bundle happen on the loop's executor; the test suite runs a whole conversation on a debug loop that fails on any blocking call. One exception belongs to the library: before cryptography 50.0.0, argon2id holds the GIL while it runs, so the first connection to a hub pauses every thread, the loop's included, for about a tenth of a second. The key is cached after that, so it happens once per identity and hub; with cryptography 50.0.0 or later it does not happen.

import asyncio
from thalovant import AsyncThalovantClient


async def main():
    async with AsyncThalovantClient.from_config(profile="prod") as client:
        reply = await client.ask("What time is it?")
        print(reply.text)


asyncio.run(main())

An application that already has an aiohttp.ClientSession passes it as session=. The SDK uses it as given and never closes it:

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

Without one, each object opens its own and closes it in close() (or aclose() for the control plane, or at the end of async with).

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

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 straight away. run() keeps the link connect() opened rather than dialling a second one. After a drop it dials again at once, and after a failed attempt it waits on the same ladder as HubSession: 10, 20, 40, 80, then 120 seconds. A new connection is refused until its hub has admitted it, so a refusal is retried for refusal_grace_seconds (600 s by default) before run() raises ThalovantHubRefusedError. A refusal is a close with 1000, 1005 or 1008 during the handshake or within 0.75 s after it, a Noise handshake that does not authenticate (a wrong password), or an upgrade answered 401 or 403. A hub whose Noise key is not the one pinned for it ends run() at once with ThalovantHubKeyChangedError, since retrying cannot change that. A hub that cannot be reached is retried until close(). Every attempt is logged at DEBUG on thalovant.session.

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 was said; the integration answers with thalovant.home.response. The SDK covers each step:

from thalovant import (
    AsyncHubSession,
    AsyncThalovantControlPlane,
    HomeAnswer,
    answer_home_requests,
)

api = AsyncThalovantControlPlane(access_token=token, session=http)
hubs = await api.list_hubs()

result = await api.create_client_identity(
    hub_id, name="Home Assistant", connection_type="home_assistant"
)
await api.wait_for_admission(result)  # about ninety seconds


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=...) sends spec.connection_type and checks that the API created that kind of connection. If the API does not know the type, it raises ThalovantUnsupportedConnectionTypeError. If the API created an ordinary connection instead, it deletes that connection first and then raises the same error. A plan that does not allow the type raises ThalovantPlanError. A hub that already has its Home Assistant link raises ThalovantAlreadyLinkedError, whose client_id names that link.
  • wait_for_admission(result) polls the operation the create returned. It returns once the hub has admitted the connection. It raises ThalovantAdmissionFailedError when the platform reports a failure or the API refuses the wait (keeping its status and body), and ThalovantAdmissionTimeoutError when the wait runs out; the timeout error is a ThalovantConnectionError, so the connection can be kept and the wait tried again later. A revoked token surfaces as ThalovantAuthError and an API out of reach as ThalovantAPIUnreachableError, never as a failed admission. A 429 is waited out for the time the API names.
  • answer_home_requests(session, handler) answers each request at most once, and never after the hub's ten seconds: the handler gets nine of them, and the reply's sending gets what the handler left. A reply that could only arrive after the hub gave up is not sent, and a handler that ignores cancellation does not hold the answer back. The answer goes back along the route the request came in on (client.reply(event, ...)), and its speech is plain text: tags removed, the portable set of character references decoded (numeric, the five XML entities and &nbsp;), white space collapsed. A handler that raises, runs out of time, or answers outside the contract produces an error answer with an error_code; the hub then speaks its own sentence for that code in the device's language.
  • delete_client(client_id) removes the connection; it reads the etag the API needs when you do not pass one. revoke_api_token() revokes the token from a device login, for when the person removes the integration.

The same rules, as language-neutral vectors, are in contracts/conformance/home-link-vectors.json and the three control-plane vector files beside it; every Thalovant SDK runs them.

CLI Diagnostics

thalovant --identity _identity.json doctor

The doctor command checks identity shape, endpoint selection, authentication, handshake, and transport health.

Common Issues

  • Missing Thalovant API access token: call api.login(...) (or api.login_with_browser() for accounts without a password) before private control-plane actions, or pass access_token= to ThalovantControlPlane.
  • API access requires a paid plan: upgrade the workspace before using the SDK control-plane API to provision private resources.
  • Could not reach the Thalovant API.: the request never got an answer (DNS, the connection, TLS, a proxy, or the timeout). It is raised as ThalovantAPIUnreachableError, which is both a ThalovantAPIError and a ThalovantConnectionError and has no status_code; try again later.
  • Unsupported protocol: the hub does not expose that protocol, or the identity was created before that protocol was enabled.
  • MQTT fails immediately: create or download a fresh client identity after MQTT is enabled. MQTT needs the per-client identity.mqtt credentials.
  • A request times out: increase timeout on ask(...) or check doctor().
  • token_rate_limited: the API token exceeded its plan's per-minute request rate (60 requests per minute on the free plan). The response is HTTP 429 with a Retry-After header and a matching retry_after_seconds; wait that long and resend.
  • token_quota_exceeded: the API token used up its plan's daily or monthly call quota. The response names which in quota, alongside limit and used, and carries a Retry-After header and a matching retry_after_seconds pointing at the next UTC day or month. The SDK does not retry either 429 for you, with one exception: wait_for_admission() waits out a 429 for the time it names, since the connection is still on its way, unless that is longer than the wait has left.

Reading An API Error

A refused control-plane request raises ThalovantAPIError. Its message is one line for display and can be shortened, so read what the API said from the error itself:

  • status_code: the HTTP status.
  • code: the machine-readable code, such as platform_image_required or plan_limit, or None.
  • detail: the API's whole sentence, exactly as sent, or None.
  • problem: the whole error body as a dict when it is a JSON object, or None. Every structured field the API sends is here, including ones added after this SDK was released.
from thalovant import ThalovantAPIError

try:
    api.release_runtime_group(group["id"], images={"core": "docker.io/me/ovos-core:dev"})
except ThalovantAPIError as error:
    if error.code == "platform_image_required":
        print(error.detail)
        print(error.problem["allowed_images"])        # per image key
        print(error.problem["allowed_repositories"])  # any tag or digest of these
    elif error.code == "plan_limit":
        print(error.problem["resource"], error.problem["used"], error.problem["limit"])
    else:
        raise

A value the body echoes back from your request (a validation error repeats what it was sent) is only ever in problem, never in the message.

API Shape

  • ThalovantControlPlane()
  • ThalovantControlPlane(access_token=...) to authenticate with an API token instead of logging in
  • ThalovantControlPlane(api_url, access_token=...) for local or self-hosted control planes
  • control.login(email, password, scope=None, otp_code=None, recovery_code=None) (MFA accounts pass a TOTP otp_code or a one-time recovery_code)
  • control.login_with_browser(scopes=None, client_name=None, open_browser=True, prompt=None, timeout=900.0) (browser device-flow sign-in for accounts without a password)
  • control.list_public_hubs(limit=...)
  • control.get_public_hub(hub_ref)
  • control.list_hubs(limit=..., owner_id=...)
  • control.get_hub(hub_id)
  • control.create_hub(payload, idempotency_key=None)
  • control.update_hub(hub_id, payload, etag=...)
  • control.delete_hub(hub_id, etag=...)
  • control.release_hub(hub_id, channel=..., mode=..., version=..., images=..., reason=...) (unless you are a platform administrator, each image must be a catalog pin of the stable or alpha channel, the hub's current, recommended or release-policy image, the platform's default image, or for listener any tag or digest of ghcr.io/thalovant/hivemind-listener; anything else is refused with HTTP 403 platform_image_required)
  • control.set_hub_rating(hub_id, rating)
  • control.clear_hub_rating(hub_id)
  • control.get_hub_runtime_capabilities(hub_id)
  • control.list_runtime_groups(owner_id=...)
  • control.get_runtime_group(runtime_group_id)
  • control.create_runtime_group(payload)
  • control.update_runtime_group(runtime_group_id, payload)
  • control.get_runtime_group_config(runtime_group_id)
  • control.update_runtime_group_config(runtime_group_id, config, *, personas=None, merge=True)
  • control.release_runtime_group(runtime_group_id, channel=..., ...) (the same rule for images, except that core accepts any tag or digest of ghcr.io/thalovant/ovos-core and bus only the images listed above)
  • control.delete_runtime_group(runtime_group_id)
  • control.install_runtime_group_skill(runtime_group_id, skill_id, ...)
  • control.uninstall_runtime_group_skill(runtime_group_id, skill_id)
  • control.list_hub_skills(hub_id)
  • control.list_hub_skill_history(hub_id, limit=50) with integer limit from 1 to 200
  • control.install_hub_skill(hub_id, skill, version="latest", wait=False, timeout=120.0)
  • control.update_hub_skill(hub_id, skill, version=..., wait=False, timeout=120.0)
  • control.remove_hub_skill(hub_id, skill, wait=False, timeout=120.0)
  • control.get_operation(operation_id)
  • control.get_analytics_overview(...)
  • control.list_memory_items(...)
  • control.get_memory_summary(owner_id=...)
  • control.create_memory_item(payload)
  • control.get_memory_item(memory_id)
  • control.update_memory_item(memory_id, payload)
  • control.delete_memory_item(memory_id)
  • control.create_client_identity(hub_id, ...)
  • ThalovantIdentity.from_config(path=None, profile=None)
  • ThalovantIdentity.from_file(path)
  • ThalovantClient.from_config(path=None, profile=None)
  • ThalovantClient.from_identity_file(path)
  • ThalovantClient.from_env()
  • ThalovantClient(identity, protocol="wss")
  • client.connect_with_info()
  • client.connection_info()
  • client.query(text, context=...)
  • client.ask(text, context=...)
  • client.send_utterance(text, context=...)
  • client.send_action(payload, ...)
  • client.send_code(value, ...)
  • client.listen(event_name, ...)
  • client.conversation(...)
  • client.reply(event, msg_type, data=None, context=None) answers an event back along its route (new in 0.9.0)

Async and Home Assistant surface, new in 0.9.0 (every control-plane method below is on ThalovantControlPlane too):

  • AsyncThalovantClient(identity, ..., session=None): the same methods as ThalovantClient, awaited
  • AsyncThalovantControlPlane(api_url=..., access_token=..., session=None): every method of ThalovantControlPlane, awaited; aclose()
  • control.begin_device_login(scopes=None, client_name=None) returns a DeviceAuthorization
  • control.poll_device_login(authorization) returns an ApiToken or raises ThalovantDeviceLoginPending, ThalovantDeviceLoginExpired or ThalovantDeviceLoginDenied
  • control.revoke_api_token(token_id=None)
  • control.get_profile()
  • control.create_client_identity(hub_id, ..., connection_type=None), whose result carries operation, client_id and connection_type
  • control.wait_for_admission(result_or_operation, timeout=..., poll_interval=...)
  • control.list_clients(hub_id=..., ...), control.get_client(client_id), control.delete_client(client_id, etag=None)
  • AsyncHubSession(connect, policy=..., settle_seconds=0.75) and AsyncHubSession.for_identity(identity, session=None, ...): connect(), run(), ask(), emit(), reply(), on(), on_state_change(), close()
  • answer_home_requests(client_or_session, handler, timeout=9.0), HomeRequest, HomeAnswer, home_response(), plain_speech()

Development

pip install -e ".[dev]"
pytest

Control-plane requests reject redirects. Credential-bearing requests require HTTPS; explicit http://localhost, http://127.0.0.1 and http://[::1] endpoints remain available for local development. API URLs must not contain embedded credentials.

Shared-runtime skill management

Hub-addressed skill methods select the runtime group attached to the hub UUID. Every hub sharing that group sees the same skill changes and history. The API requires a restricted token to cover all served hubs. Reads need hubs:inspect (hubs:read implies it); writes need hubs:write, an eligible paid plan and ownership.

The history response contains newest-first event and operation entries, including nullable actor/version fields. Its limit is 1–200 (50 where omitted). An accepted mutation is not proof the skill is ready. Optional waiting polls the operation, with a 120-second default timeout and two-second interval. Polling never repeats an accepted mutation and starts no new read after its deadline; an already-running HTTP request retains its normal request timeout.

Read history with api.list_hub_skill_history(hub_id, limit=50); it returns the API JSON envelope.

When using HubIntent.examples(speakable=True), version 0.6.2 preserves complete source phrases ahead of slot-based phrases before limiting the results.

Configuration merges in 0.6.4 snapshot the caller’s config and personas before the first read, preserving the same payload through conflict retries. Guarded merges require both hubs:read and hubs:write scopes and a paid plan.

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

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. Never use inventory caches to store credentials.

OriginPreference gives a preferred address its own short handshake budget and cools it down after a failure. In non-Python SDKs the factory must implement the address binding on its own transport, retain the public host for TLS/SNI, and finish failed-attempt cleanup before returning. Transport/platform restrictions still apply. Python uses a serialized, scoped resolver override; avoid blocking unrelated resolver work inside that scope. TLS validation remains enabled.

Inbound BUS compatibility

Each BUS frame is delivered once, after protocol processing, as it has been since 0.7.4 (hivemind-bus-client #251/#252). The SDK does not suppress events by Python object identity, so custom transports may reuse message objects. A custom transport keeps working unchanged in 0.9.0: its handlers are called on the thread it calls them from, as before.

Upgrading To 0.9

Nothing is required. Every public name, signature and behaviour of 0.8.7 is still there, and code that worked against 0.8.7 works against 0.9.0 unchanged. What is different:

  • Dependencies. The core installs aiohttp and cryptography and nothing else. hivemind-bus-client, ovos-bus-client, ovos-utils, poorman-handshake, websocket-client, requests, PyYAML, paho-mqtt and ovos-spec-tools are no longer installed with it. MQTT needs thalovant[mqtt]. The config file is read without PyYAML unless thalovant[yaml] is installed. If your own code imported one of those libraries without declaring it, declare it now.
  • Identity state. Keys and pins are read from and written to the same files in the same format. Nothing is migrated and no handshake starts over.
  • Threads. Each sync ThalovantClient and ThalovantControlPlane owns one private event-loop thread. Sync handlers passed to on() still run off that loop, on a thalovant-handlers thread, so a handler can call the client while another thread waits in ask().
  • ThalovantControlPlane(session=...) still accepts a requests-style session and sends through it. An aiohttp.ClientSession belongs to AsyncThalovantControlPlane; passing one to the sync class raises TypeError with that advice.
  • New: the async control plane, AsyncHubSession, device login in single steps, token revocation, connection types with the admission wait, client.reply(), and the Home Assistant link (see above). New errors: ThalovantAuthError, ThalovantPlanError, ThalovantAlreadyLinkedError, ThalovantUnsupportedConnectionTypeError, ThalovantHubRefusedError, ThalovantAdmissionTimeoutError, ThalovantAdmissionFailedError and the three device-login errors, and ThalovantAPIUnreachableError for a control plane that cannot be reached. Each subclasses ThalovantAPIError or ThalovantConnectionError, which is what 0.8.7 raised in the same situations, so existing except clauses still catch them.

Metadata

Release files for thalovant 0.9.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for thalovant 0.9.0
File Size Uploaded
thalovant-0.9.0.tar.gz 508.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for thalovant 0.9.0
File Interpreter ABI Platform
thalovant-0.9.0-py3-none-any.whl Python 3 none any Details

Total release size: 759.8 kB

Release files / thalovant-0.9.0.tar.gz

Download URL thalovant-0.9.0.tar.gz
Size 508.0 kB
Tags Source
SHA-256 checksum
How to use checksums
7174214139337881663ef9565826ece45cb1843711c77526fa8b58aa97d774f6
BLAKE2b-256 checksum
How to use checksums
7888447318d2e71ce7e79f250923e09f56bea39b42cce16f40f754b36a8cd689
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / thalovant-0.9.0-py3-none-any.whl

Download URL thalovant-0.9.0-py3-none-any.whl
Size 251.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d050bda814ae651cbd5ac7737baeee46052a8da407b0611bee09b1c97c8b6bdb
BLAKE2b-256 checksum
How to use checksums
c53972c2f0fad42330fe9fd42b0281cff5f067763acf64d806c4ae6346a2de58
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

0.9.7

2 release files

0.9.6

2 release files

0.9.5

2 release files

0.9.4

2 release files

0.9.3

2 release files

0.9.2

2 release files

0.9.1

2 release files

This release

0.9.0 This release

2 release files

0.8.7

2 release files

0.8.6

2 release files

0.8.5

2 release files

0.8.4

2 release files

0.8.3

2 release files

0.8.2

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.8

2 release files

0.7.7

2 release files

0.7.6

2 release files

0.7.5

2 release files

0.7.4

2 release files

0.7.3

2 release files

0.7.1

2 release files

0.7.0

2 release files

0.6.12

2 release files

0.6.11

2 release files

0.6.10

2 release files

0.6.9

2 release files

0.6.8

2 release files

0.6.7

2 release files

0.6.6

2 release files

0.6.5

2 release files

0.6.4

2 release files

0.6.3

2 release files

0.6.2

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.16

2 release files

0.5.14

2 release files

0.5.13

2 release files

0.5.9

2 release files

0.5.8

2 release files

0.5.7

2 release files

0.5.6

2 release files

0.5.5

2 release files

0.5.4

2 release files

0.5.3

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.29

2 release files

0.4.28

2 release files

0.4.27

2 release files

0.4.26

2 release files

0.4.25

2 release files

0.4.24

2 release files

0.4.23

2 release files

0.4.22

2 release files

0.4.21

2 release files

0.4.20

2 release files

0.4.19

2 release files

0.4.16

2 release files

0.4.15

2 release files

0.4.14

2 release files

0.4.13

2 release files

0.4.12

2 release files

0.4.9

2 release files

0.4.8

2 release files

0.4.7

2 release files

0.4.6

2 release files

0.4.5

2 release files

0.4.4

2 release files

0.4.3

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page