thalovant

package module
v0.12.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 29, 2026 License: MIT Imports: 40 Imported by: 0

README

Thalovant Go SDK

Go SDK for connecting services, CLIs, devices, 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.

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

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

Use Go 1.26 or newer so the SDK receives supported upstream networking security fixes.

go get github.com/thalovant/thalovant-go-sdk

Quick Start

package main

import (
	"context"
	"fmt"

	thalovant "github.com/thalovant/thalovant-go-sdk"
)

func main() {
	ctx := context.Background()
	control := thalovant.NewDefaultControlPlane("")

	// Public hub discovery does not require auth.
	publicHubs, err := control.ListPublicHubs(ctx, 12, "")
	if err != nil {
		panic(err)
	}
	for _, raw := range publicHubs["data"].([]any) {
		hub := raw.(map[string]any)
		fmt.Println(hub["id"], hub["slug"], hub["title"])
	}

	// Auth is required when creating a client identity.
	if _, err := control.Login(ctx, "you@example.com", "password", ""); err != nil {
		panic(err)
	}

	result, err := control.CreateClientIdentityForHubID(ctx, "hub-id", thalovant.BootstrapIdentityOptions{
		Name:               "go-demo-client",
		PreferredProtocols: []thalovant.HubProtocol{thalovant.ProtocolWSS, thalovant.ProtocolHTTPS, thalovant.ProtocolMQTT},
	})
	if err != nil {
		panic(err)
	}

	client, err := thalovant.NewClientWithOptions(result.Identity, thalovant.ClientOptions{
		Protocol: thalovant.ProtocolWSS,
	})
	if err != nil {
		panic(err)
	}
	defer client.Close(ctx)

	info, err := client.ConnectWithInfo(ctx)
	if err != nil {
		panic(err)
	}
	fmt.Println("connected in", info.ConnectMS, "ms")

	reply, err := client.Ask(ctx, "Tell me a short clean joke.", thalovant.RequestOptions{})
	if err != nil {
		panic(err)
	}
	fmt.Println(reply.Text)
}

NewDefaultControlPlane uses https://api.thalovant.com. Use NewControlPlane only for local development or a self-hosted control plane. Credential-bearing control requests require HTTPS; HTTP is supported only for literal localhost, 127.0.0.1, and [::1] development endpoints. Control requests do not follow redirects, including when using an injected http.Client, so login passwords and bearer credentials remain bound to the chosen endpoint.

Login With MFA

Accounts with multi-factor authentication enabled are rejected with HTTP 401 {"code": "mfa_required"} by a plain Login call. Use LoginWithOptions to pass a TOTP code, or a recovery code when the authenticator is unavailable:

control := thalovant.NewDefaultControlPlane("")

// With a TOTP code from an authenticator app.
_, err := control.LoginWithOptions(ctx, "you@example.com", "password", thalovant.LoginOptions{
	OTPCode: "123456",
})

// Or with a one-time recovery code.
_, err = control.LoginWithOptions(ctx, "you@example.com", "password", thalovant.LoginOptions{
	RecoveryCode: "your-recovery-code",
})

LoginOptions.Scope matches the scope argument of Login. Empty fields are omitted from the request body, so LoginWithOptions with a zero-value LoginOptions behaves exactly like Login without a scope.

Sign In With the Browser (Device Flow)

Accounts without a password (for example Google sign-in) use the device flow. LoginWithBrowser accepts only HTTP(S) verification URLs with a host and no embedded credentials. Browser launch uses direct arguments without a command shell. It prints a verification URL and a short user code, opens the browser on a best-effort basis, and polls until you approve the request:

control := thalovant.NewDefaultControlPlane("")

token, err := control.LoginWithBrowser(ctx, thalovant.DeviceLoginOptions{
	Scopes:     []string{"hubs:read", "clients:write"}, // optional
	ClientName: "my-cli",                               // optional label in the dashboard
})
if err != nil {
	panic(err)
}
fmt.Println("signed in, token id:", token["token_id"])

On approval the returned access_token is a durable scoped API token; it is stored on control.AccessToken exactly like Login, so subsequent control-plane calls are authenticated. The server may expand the echoed scopes during normalization.

Options:

  • OpenBrowser: *bool, defaults to true when nil. Set it to a false pointer on headless hosts; the plain verification URL and code are always shown.
  • Prompt: func(grant map[string]any) replaces the default stdout message. The grant carries verification_uri, user_code, and verification_uri_complete.
  • Timeout: total approval wait, 15 minutes when zero.

Failures are distinct sentinel errors: errors.Is(err, thalovant.ErrDeviceAccessDenied) when the request is denied in the browser, thalovant.ErrDeviceCodeExpired when the code expires unapproved (call LoginWithBrowser again for a new code), and thalovant.ErrTimeout when the wait elapses. Context cancellation is honored between polls. The token's id is kept on control.TokenID, for RevokeAPIToken.

A caller that shows the code on its own screen and polls on its own schedule uses the same flow one step at a time: see Link Home Assistant.

CI: Direct API Token Auth

Non-interactive environments should skip login entirely and construct the control plane with a pre-provisioned API token, such as one issued by LoginWithBrowser on a workstation:

control := thalovant.NewDefaultControlPlane(os.Getenv("THALOVANT_API_TOKEN"))

page, err := control.ListHubs(ctx, 50, "", "")

ControlPlane.AccessToken is an exported field, so an existing instance can also be pointed at a token directly: control.AccessToken = token.

Keep result.Identity secret: it holds the client's data-plane credentials. result.Summary(false) — the default — is safe to log: it redacts the secret fields of the identity and of the raw hub/client maps (the initial_identify access key/password/crypto key/MQTT password, the initial_identify_token, and the echoed spec apiKey/password/cryptoKey). The crypto key is no longer issued, but the redaction still names it so an older stored payload that carries one cannot be logged. result.Summary(true) returns every one of those secrets in the clear and must never be logged or written to an untrusted sink. The redaction covers human-facing formatting only; json.Marshal of the identity itself (for the identity file you persist with chmod 600) still contains the real secrets by design.

List Your Hubs

Authenticated accounts can list owned or visible hubs:

control := thalovant.NewDefaultControlPlane("")
_, _ = control.Login(ctx, "you@example.com", "password", "")

page, err := control.ListHubs(ctx, 50, "", "")
if err != nil {
	panic(err)
}
for _, raw := range page["data"].([]any) {
	hub := raw.(map[string]any)
	fmt.Println(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). A free-plan token fails with HTTP 402 and API access requires a paid plan., and a token without the scope fails with HTTP 403 and Insufficient scopes.

control := thalovant.NewDefaultControlPlane(os.Getenv("THALOVANT_API_TOKEN"))

// 1. Discover what is installable before provisioning anything.
catalog, err := control.ListMarketplaceSkills(ctx, thalovant.MarketplaceSkillListOptions{})
if err != nil {
	panic(err)
}
for _, raw := range catalog["data"].([]any) {
	skill := raw.(map[string]any)
	fmt.Println(skill["skill_id"], skill["title"], skill["access_tier"])
}

// 2. Create a runtime group to run the skills.
group, err := control.CreateRuntimeGroup(ctx, map[string]any{
	"name":        "kiosks",
	"description": "Lobby kiosks",
})
if err != nil {
	panic(err)
}
groupID := group["id"].(string)

// 3. Create a hub attached to it.
hub, err := control.CreateHub(ctx, map[string]any{
	"name":             "joke-garden",
	"runtime_group_id": groupID,
	"spec":             map[string]any{"protocols": map[string]any{"wss": map[string]any{"enabled": true}}},
}, thalovant.HubCreateOptions{})
if err != nil {
	panic(err)
}
hubID := hub["id"].(string)

// 4. Install a skill from the marketplace catalog.
if _, err := control.InstallRuntimeGroupSkill(ctx, groupID, "skill-weather", thalovant.RuntimeGroupSkillInstallOptions{}); err != nil {
	panic(err)
}

// 5. Release: roll the runtime and the hub onto a release channel.
if _, err := control.ReleaseRuntimeGroup(ctx, groupID, thalovant.ReleaseOptions{Channel: "stable"}); err != nil {
	panic(err)
}
if _, err := control.ReleaseHub(ctx, hubID, thalovant.ReleaseOptions{Channel: "stable"}); err != nil {
	panic(err)
}

CreateHub always sends an Idempotency-Key. For safe caller retries, choose one HubCreateOptions.IdempotencyKey before the first attempt and reuse it after a timeout. Leaving it empty generates a fresh key for each call; retrying with empty options can create a second hub.

Updating and deleting a hub use optimistic locking, so etag is a required argument rather than an option. Pass the etag from the hub resource you read; the SDK sends it as If-Match, and the API rejects a stale value with HTTP 412 without changing anything. Empty or whitespace-only values fail locally with ErrAPI before a request is sent:

hub, err := control.GetHub(ctx, hubID)
if err != nil {
	panic(err)
}
etag, ok := hub["etag"].(string)
if !ok || etag == "" {
	panic("hub response has no etag")
}
hub, err = control.UpdateHub(ctx, hubID, map[string]any{"active": false}, etag)
if err != nil {
	panic(err)
}
etag, ok = hub["etag"].(string)
if !ok || etag == "" {
	panic("updated hub response has no etag")
}
if err := control.DeleteHub(ctx, hubID, etag); err != nil {
	panic(err)
}

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

Payload maps take the API's snake_case keys; the camelCase spellings (runtimeGroupId, ownerId, capacityProfile, isLocked, cloneFromDefault) are accepted too and are renamed before the request is sent, so neither spelling is silently dropped.

Runtime configuration is deep-merged using a revision precondition, and Personas is replaced only when set:

_, err = control.UpdateRuntimeGroupConfig(ctx, groupID, map[string]any{"lang": "en-us"}, thalovant.RuntimeGroupConfigOptions{})

config, err := control.GetRuntimeGroupConfig(ctx, groupID)
fmt.Println(config["config"])

Rating a public hub with SetHubRating and ClearHubRating needs the hubs:write scope but, unlike the routes above, no paid plan. Reading what a hub is actually running needs the hubs:inspect scope instead:

capabilities, err := control.GetHubRuntimeCapabilities(ctx, hubID)
fmt.Println(capabilities["counts"].(map[string]any)["total_intents"])

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.

catalog, err := control.ListMarketplaceSkills(ctx, thalovant.MarketplaceSkillListOptions{})
if err != nil {
	panic(err)
}
for _, raw := range catalog["data"].([]any) {
	skill := raw.(map[string]any)
	fmt.Println(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 set OwnerID to read another tenant's catalog and IncludeInactive to see retired entries; both are silently ignored for non-admin callers, which are scoped to their own tenant and to active entries. ForceRefresh 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, err := control.ListRuntimeGroupMarketplace(ctx, groupID, thalovant.RuntimeGroupMarketplaceOptions{})
if err != nil {
	panic(err)
}
for _, raw := range view["data"].([]any) {
	entry := raw.(map[string]any)
	if entry["installable"] == true && entry["active"] != true {
		fmt.Println("available:", entry["skill_id"])
	}
}

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

inventory, err := control.ListRuntimeGroupInventory(ctx, groupID, thalovant.RuntimeGroupInventoryOptions{Refresh: true})
if err != nil {
	panic(err)
}
fmt.Println(inventory["source"], len(inventory["data"].([]any)))

Both answer from a cached inventory snapshot by default; set RefreshInventory or Refresh to force a live read from the runtime operator. When nothing is reporting yet, ListRuntimeGroupInventory returns an empty data list with a pending source rather than failing — GetHubRuntimeCapabilities is the one that answers HTTP 409 in that case.

Workspace Analytics

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

overview, err := control.GetAnalyticsOverview(ctx, thalovant.AnalyticsOverviewOptions{
	Range: "7d",
	HubID: "hub-id",
})
if err != nil {
	panic(err)
}
fmt.Println(overview["totals"])

Durable Memory

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

memory, err := control.CreateMemoryItem(ctx, map[string]any{
	"scope":   "workspace",
	"kind":    "preference",
	"content": "Prefer America/Toronto for scheduling.",
	"tags":    []string{"timezone"},
})
if err != nil {
	panic(err)
}
fmt.Println(memory["id"])

items, err := control.ListMemoryItems(ctx, thalovant.MemoryListOptions{
	Scope: "workspace",
	Query: "timezone",
})
if err != nil {
	panic(err)
}
fmt.Println(items["data"])

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
client, err := thalovant.NewClientFromConfig("", "prod")
if err != nil {
	panic(err)
}
defer client.Close(ctx)

reply, err := client.Ask(ctx, "What can this hub do?", thalovant.RequestOptions{})
if err != nil {
	panic(err)
}
fmt.Println(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:

client, err := thalovant.NewClientFromFile("_identity.json")

Environment variables are supported too:

client, err := thalovant.NewClientFromEnv()
Runtime capabilities and concurrent replies

IntentsWithCapabilities adds optional fallback discovery without changing existing HubIntentInventory literals:

capabilities, err := client.IntentsWithCapabilities(ctx, []string{"en-us"}, thalovant.IntentOptions{})
if err != nil { return err }
fmt.Println(capabilities.Inventory.Source, capabilities.FallbacksKnown)
fmt.Println("may answer:", capabilities.MayAnswer("en-us"))

Fallbacks contains skill IDs and numeric priorities, sorted by priority and skill ID. The optional probe has a 1.5-second budget including connect, send and reply collection. Unsupported, silent, refused, malformed or explicitly failed listings remain unknown; an explicit empty list is known-empty. MayAnswer is a conservative capability hint, not a guarantee that the next request will succeed. Disabled intent phrases do not imply that the runtime can answer them. ListFallbacks(ctx, timeout) exposes the probe directly; nil means unknown.

Use client.SubscribeEvents(capacity) for an independent observer and call its Close method when finished. Its channel closes with ErrEventOverflow if the consumer falls behind. Treat delivered events and their maps as read-only. Transport SubscribeHiveMessages supports independent query/cascade observers. Legacy Events and HiveMessages channels remain available for compatibility; the bounded subscription API reports overflow explicitly.

WaitForEvent(ctx, name, EventOptions) waits for one named event with a default 12-second deadline including connection. Listen returns a filtered subscription:

stream, err := client.Listen(ctx, thalovant.EventSpeak, thalovant.ListenOptions{
    EventOptions: thalovant.EventOptions{Timeout: 30*time.Second, SessionID: "session-id"},
    MaxEvents: 10,
    Capacity: 128,
})
if err != nil { return err }
defer stream.Close()
for event := range stream.C { fmt.Println(event.Text()) }
if err := stream.Err(); err != nil { return err }

Both support Context, RequestID, SessionID, and a Predicate function. Matching request IDs take precedence over the hub-assigned session ID; ID-less legacy events retain session fallback. Listen has no lifetime/count cap when Timeout/MaxEvents are zero, so use a cancellable context or call Close. Buffers default to 256 events and are capped at 65536. Timeout, disconnect and slow-consumer overflow are explicit errors; reaching MaxEvents or calling Close succeeds. Cancellation removes the subscription even if a custom predicate is still pending; predicates should return promptly.

AskWithOptions adds ReplySettle (default 250ms) and EmptyReplyWait (default 5s) alongside embedded RequestOptions. The request deadline bounds connection, send and reply collection. First nonempty speech starts a fixed settlement window; first handled or soft intent-miss without speech starts a fixed empty wait. Later fragments do not reset settlement. Collected speech is returned when the total deadline clips a window, even if an admitted write is still retiring. Policy denial or explicit query timeout freezes the partial reply immediately; soft intent misses can recover. Query waits for hive.query.complete or a hard terminal event. Ask requires the matching request ID and accepts a runtime-replaced session ID; ambient events cannot satisfy it. Existing Ask calls use the same defaults. Cancellation does not replay an application request, and a retiring send retains transport ownership until its cleanup completes.

Connection callers share authenticated readiness. A canceled or timed-out caller cannot race a later connection against its unfinished cleanup. Close uses the client connection timeout by default (6s) and honors an earlier context deadline; ConnectWithInfo includes diagnostic collection in that same deadline, even for custom transports. HTTP cleanup continues after a timed-out caller until its old poll retires, and reconnect waits for that owned cleanup; a timeout means cleanup has not completed, so do not reuse that identity in a separate client. Do not copy a Client or built-in transport after first use.

Noise trust writes use atomic publication and an OS lock shared across processes. An interrupted writer cannot publish a partial static key or lose another hub's pin. A conflicting pin requires explicit verification and ForgetNoisePin. Saved pin values and new pins require exactly 64 hexadecimal characters (32 bytes) and a nonempty node ID. Invalid trust files, including null or empty pin values, fail before any rewrite; diagnose and repair that state explicitly. Hexadecimal case does not change key identity; idempotent checks preserve existing file bytes. Sharing a state directory does not permit simultaneous runtime sessions with the same identity: each active connection needs its own identity.

CI runs race-enabled tests on Linux, macOS and Windows, both minimum/current Go on Linux, reachable vulnerability analysis and a bounded frame-parser fuzz run. Tests use local TLS/Noise peers and cover process crashes, concurrent discovery, reconnect ownership, canceled writes and request correlation.

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

wss, https, and mqtt connections perform the HiveMind v3 Noise handshake (Noise_XXpsk2_25519_ChaChaPoly_SHA256, or KKpsk0 once the hub's static key is pinned). It is the only key exchange a HiveMind-core 5.x hub accepts: there is no pre-shared crypto_key any more, no cleartext path, and a connection that cannot complete the handshake never becomes ready. A WebSocket hub refusal may close with code 1008.

Nothing extra has to be provisioned. The Noise pre-shared key is derived from the identity password with argon2id, salted with the hub's node id, so an identity that can authenticate can already handshake.

Two files persist beside the SDK config file (~/.config/thalovant unless XDG_CONFIG_HOME or %APPDATA% says otherwise), both 0600 on Unix. Windows inherits directory access controls; use an application-private directory accessible only to the intended user. The state filesystem must support atomic rename and hard links (for example ext4, APFS or NTFS); unsupported storage fails without replacing the existing identity. The files are:

  • noise_key — this client's static X25519 key. It has to persist: a hub pins it on first contact, so regenerating it makes the client look like a different peer and the hub refuses it.
  • noise_pins.json — the hub static keys this client has pinned.

Set NoiseStateDir on WSSTransport, HTTPTransport, or MQTTTransport to use another persistent directory. Reuse the same directory and identity when switching transports; do not regenerate a paired client's static key.

A hub pins one client key per connection, so every program that uses one identity has to present the same key. When NoiseStateDir is empty and the identity was read from a file (IdentityFromFile, IdentityFromConfig; see Identity.SourcePath()), the key and pins live in that file's directory. For the usual ~/.config/thalovant files that is the directory above, so nothing moves. For an identity file anywhere else, the first connection copies the key and pins from the old default into its directory -- a copy, never a move, and only when that key has already met the hub being dialled -- so no device gets a new key and is locked out. A directory the process cannot write falls back to the old default.

A hub that pinned another key for the connection refuses this one the moment the XX handshake that showed it ends. That is a *ClientKeyRejectedError (errors.Is(err, thalovant.ErrClientKeyRejected), and still ErrHubRefused): its KeyFolder is where this client's key is, and OtherKeyFolder where another program reading the same identity likely keeps its own. No handshake fixes it: pair again, or point every program that reads the identity at the folder holding the key the hub trusts.

The first connection to a hub trusts the key it presents and records it. A later connection presenting a different key is refused, because the SDK cannot tell a reinstalled hub from another machine answering at the same address. If the hub really was replaced, clear the pin deliberately:

if err := thalovant.ForgetNoisePin("", nodeID); err != nil {
	panic(err)
}

The derivation costs 64 MiB and a few hundred milliseconds. WSS caches the result per hub; HTTP and MQTT derive from the current password on each fresh connection. All transports retain the hub pin after authentication failures.

An unconfirmed HTTP disconnect retains cleanup responsibility. A retry recognizes the hub's exact already-disconnected acknowledgment when its earlier success response was lost; arbitrary refusals and contradictory acknowledgments still fail. Pins and replica affinity remain intact.

HTTP reconnect first resets this transport object's previously admitted peer, so a failed poll can recover even while the hub still retains the old session. An initial connection does not disconnect a peer admitted by another process.

HTTP preserves the hub's replica affinity cookie and posts encrypted frames as Base64 form data with binary=1; encrypted replies arrive through /get_binary_messages. Non-success HTTP status codes and JSON error responses other than the exact idempotent disconnect acknowledgment invalidate the connection. MQTT carries the same Noise frames as raw binary payloads after its initial cleartext HELLO and Noise exchange. TLS remains required on HTTP and MQTT because the access key and broker credentials also need protection. MQTTTransport.TLSConfig can supply private CA roots.

MQTT uses an opaque random broker connection ID; the access key still appears in the protocol-required topic paths. Use a distinct identity per simultaneous client because the hub keys its Noise sessions by identity.

A broker disconnect invalidates MQTT readiness. Call Connect again to resubscribe and negotiate a fresh Noise session; Paho's automatic connection resumption is disabled because it would retain stale encryption counters. Concurrent sends serialize complete encrypted messages and their chunks. Passing encrypt=false to SendHiveMessage cannot bypass Noise.

For an explicit transport and persistent identity state:

transport := thalovant.NewHTTPTransport(identity)
transport.NoiseStateDir = "/var/lib/my-agent/thalovant"
if err := transport.Connect(ctx); err != nil {
    return err
}
defer transport.Disconnect(ctx)
// Connect returns only after the Noise exchange and encrypted HELLO succeed.
err := transport.EmitBus(ctx, "ovos.intent.list", thalovant.Data{},
    thalovant.Context{"request_id": thalovant.NewSessionID()})

HTTPTransport.RemoteStaticKey() and MQTTTransport.RemoteStaticKey() expose the authenticated hub key, as WSS already does. An interrupted or tampered session must reconnect before sending again.

Inspect what an identity supports:

identity := result.Identity

fmt.Println(identity.EnabledProtocols())
fmt.Println(identity.EndpointFor(thalovant.ProtocolWSS))
fmt.Println(identity.EndpointFor(thalovant.ProtocolHTTPS))
fmt.Println(identity.EndpointFor(thalovant.ProtocolMQTT))
if identity.MQTT != nil {
	fmt.Println(identity.MQTT.Endpoint)
}

Connect with a specific protocol:

for _, protocol := range []thalovant.HubProtocol{
	thalovant.ProtocolWSS,
	thalovant.ProtocolHTTPS,
	thalovant.ProtocolMQTT,
} {
	if !identity.SupportsProtocol(protocol) {
		continue
	}
	if protocol == thalovant.ProtocolMQTT && identity.MQTT == nil {
		continue
	}

	client, err := thalovant.NewClientWithOptions(identity, thalovant.ClientOptions{Protocol: protocol})
	if err != nil {
		panic(err)
	}
	reply, err := client.Ask(ctx, fmt.Sprintf("Reply over %s.", protocol), thalovant.RequestOptions{})
	_ = client.Close(ctx)
	if err != nil {
		panic(err)
	}
	fmt.Println(protocol, reply.Text)
}

Use client.ConnectWithInfo(ctx) 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(ctx, ...) 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, err := client.Query(ctx, "What time is it in Toronto?", thalovant.QueryOptions{})

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.

Conversations

Use a conversation when related turns should share one session.

conversation := client.Conversation(thalovant.ConversationOptions{Lang: "en-us"})

first, err := conversation.Ask(ctx, "Remember that my favorite color is blue.", thalovant.RequestOptions{})
if err != nil {
	panic(err)
}
second, err := conversation.Ask(ctx, "What color did I mention?", thalovant.RequestOptions{})
if err != nil {
	panic(err)
}

fmt.Println(first.Text)
fmt.Println(second.Text)

Client Context

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

requestContext := thalovant.BuildClientContext(nil, thalovant.ClientContextOptions{
	UserID:       "user-42",
	UserName:     "Ada",
	AuthProvider: "oidc",
	Roles:        []string{"member"},
	Platform:     "kiosk",
	Source:       "checkout-kiosk",
	Channel:      "chat",
})

reply, err := client.Ask(ctx, "Show the next instruction.", thalovant.RequestOptions{
	Context: requestContext,
})

Actions And Exact Inputs

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

conversation := client.Conversation(thalovant.ConversationOptions{SessionID: "work-session"})

_ = conversation.SendAction(ctx, `/choose{"id":"42"}`, thalovant.ActionOptions{Title: "Choose item"})
_ = conversation.SendCode(ctx, "SN-001-XYZ", thalovant.CodeOptions{Kind: "qr", Label: "serial"})

Rich Responses

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

items := reply.DisplayItems(600)
for _, item := range items {
	if item.Kind == "text" {
		fmt.Println(item.Text)
	}
}

When A Hub Refuses Or Cannot Answer

The hub answers a refusal the instant it makes one, so an ask ends there rather than running to its deadline. Three different things arrive as hive.policy.denied, and each needs something different said to whoever is waiting; a fourth is not a refusal at all.

var (
	denied     *thalovant.PolicyDeniedError
	unanswered *thalovant.UnansweredError
)

reply, err := client.Ask(ctx, "what is the weather", thalovant.AskOptions{})
switch {
case err == nil:
	fmt.Println(reply.Text)

case errors.As(err, &denied):
	switch denied.Code {
	case thalovant.PolicyCodeQuotaExceeded:
		// A spent allowance, not a policy. denied.Quota has the numbers:
		// "you have used 50 of 50 daily questions; they come back in 10h".
		q := denied.Quota
		fmt.Printf("%d of %d %s questions used, back in %ds\n", q.Used, q.Limit, q.Period, q.ResetAfter)
	case thalovant.PolicyCodeBackendUnavailable:
		// The hub could not reach its own assistant. Nothing to change here.
		fmt.Println("the hub is not able to answer right now")
	default:
		// An allow-list refusal: denied.Allowed is what this connection may
		// publish, and denied.DeniedType is what it may not.
		fmt.Println("ask whoever manages this connection to allow", denied.DeniedType)
	}

case errors.As(err, &unanswered):
	// Not a refusal and not a fault: the hub understood and has no skill for
	// it. unanswered.Said is what the person said.
	fmt.Printf("nothing here answers %q\n", unanswered.Said)

case errors.Is(err, thalovant.ErrTimeout):
	fmt.Println("the hub did not answer in time")
}

Both new errors wrap ErrRuntime, so errors.Is(err, thalovant.ErrRuntime) still holds for anything that was a runtime failure before.

A denial the hub could not correlate (it builds them with source and destination context only, so they carry no request id) is taken by an ask only when it names the type that ask sent and that ask is the only utterance the client has out. A second ask, a query, or a fire-and-forget SendUtterance within the last ten seconds all make it ambiguous, and a wrong guess would end a question the hub never refused.

What A Hub Can Be Asked

A connected client can ask its hub what can be said, over its own session and with no control-plane token. The hub runtime keeps an intent manifest: every intent each skill registered, per language, and for template intents the sentences the skill's locale files wrote, {slot} placeholders included.

inventory, err := client.Intents(ctx, []string{"en-us", "fr-fr"})
if err != nil {
	var denied *thalovant.PolicyDeniedError
	if errors.As(err, &denied) {
		// This connection may not publish denied.DeniedType; denied.Allowed
		// lists what it may.
	}
	panic(err)
}
for _, skill := range inventory.Skills {
	fmt.Println(skill.SkillID, skill.Languages())
	for _, intent := range skill.Intents {
		fmt.Println("  ", intent.ID(), intent.Engine, intent.Examples("en-us", 2))
	}
}

Each HubIntent carries Phrases keyed by language, PhrasesFor(lang) — tags compare case-insensitively with _ and - folded, so fr_FR finds fr-fr — and Examples(lang, limit), which prefers whole sentences to ones with a slot, shorter first. Engine is padatious for a template intent and adapt for a keyword one.

inventory.Source is intent-manifest when the sentences came from the manifest. A refused or silent ovos.intent.list query uses the engines' own manifests instead: the result then carries names only, Source is engine-manifests, the legacy Denied field names ovos.intent.list for either case (silence does not prove a policy refusal), and HasPhrases() is false. IntentOptions tunes the call — Timeout bounds each query the hub is sent (5 seconds when zero), Fallback set to a false pointer returns the original refusal or timeout instead of falling back, and Describe set to a false pointer skips the per-intent describes and returns names and engines only:

no := false
inventory, err := client.Intents(ctx, nil, thalovant.IntentOptions{
	Timeout:  3 * time.Second,
	Fallback: &no,
})

A nil or empty language list asks for en-us; tags are trimmed, and a language repeated under another spelling (en-us, en-US, en_us) is asked once, under the first spelling given. Built-in transports give concurrent Ask, Query, and inventory calls independent subscriptions. Custom transports should implement EventSubscriber and HiveMessageSubscriber for this behavior; legacy custom implementations sharing one channel must serialize reply collectors. The two underlying queries are exposed too:

// ovos.intent.list: one row per registration in one language.
rows, err := client.ListIntents(ctx, "en-us")

// ovos.intent.describe: the registrations behind one intent, sentences included.
definitions, err := client.DescribeIntent(ctx, rows[0].SkillID, rows[0].IntentName, "en-us")
fmt.Println(definitions[0].Samples)

The connection must be allowed to publish ovos.intent.list; ovos.intent.describe is needed only when the sentences are asked for, which is the default, so Describe set to a false pointer needs the listing alone. A hub that answers a listing {"ok": false} has failed the query rather than refused the type: Intents and ListIntents return an error wrapping ErrRuntime carrying the hub's own text, and the engines' manifests are not asked instead — a listing that failed is not a hub with no intents. The same answer to a describe is a real one, meaning the hub does not know that registration, so that intent simply carries no sentences.

When the runtime does not attach definitions to the listing, each intent is described individually; those requests go out thalovant.DescribeBatch (32) at a time so a hub with many intents cannot burst more replies than the transport's channel holds. An intent the hub does not describe in time simply carries no sentences.

json.Marshal(inventory) produces the same snake_case shape as the Python SDK's as_dict(), so the output can be handed to a satellite, an installer or an agent as is.

Common Issues

  • missing access token: call control.Login(...) or control.LoginWithBrowser(...) before private control-plane actions, or pass an access token to NewControlPlane.
  • HTTP 401 with "code": "mfa_required": the account has MFA enabled; use control.LoginWithOptions(...) with an OTPCode or RecoveryCode.
  • The account has no password (Google sign-in): use control.LoginWithBrowser(...), or mint a durable token once and pass it to NewDefaultControlPlane in CI.
  • API access requires a paid plan: upgrade the workspace before using the SDK control-plane API to provision private resources. Hub ratings and the marketplace catalog are readable without one.
  • HTTP 412 with "ETag mismatch": the etag passed to UpdateHub or DeleteHub is stale or empty. Re-read the hub with GetHub and retry with the etag it returns; nothing was changed.
  • 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.
  • the hub refused "ovos.intent.list": the connection's allow-list does not include the intent manifest queries. Connections the control plane provisions for SDK clients allow ovos.intent.list, ovos.intent.describe (needed only when definitions are asked for) and the two engine manifest reads by default; for an older client identity, allow them in the dashboard's connection settings or create a fresh identity. The error is a *thalovant.PolicyDeniedError (errors.As) that carries the refused type and the allowed list; by default client.Intents falls back to the engines' manifests and returns names only.
  • ovos.intent.list failed: ...: the hub accepted the query and could not answer it — the text after the colon is the hub's own. This is an error wrapping ErrRuntime, not a *PolicyDeniedError, and no fallback is attempted: the hub's intents are unknown, not absent. Retry, or check the runtime's logs.
  • A request times out: set RequestOptions{Timeout: ...}.
  • HTTP 429 with "code": "token_rate_limited": the API token exceeded its plan's per-minute request rate (60 requests per minute on the free plan). The response carries a Retry-After header and a matching retry_after_seconds; wait that long and resend.
  • HTTP 429 with "code": "token_quota_exceeded": the API token exhausted its plan's daily or monthly call quota. The body names which in quota (daily or monthly) alongside limit, used, and retry_after_seconds; Retry-After points at the next UTC day or month boundary.

Both 429s apply to token-authenticated control-plane calls and are returned as a *APIError wrapping ErrAPI, with the status and selected error message fields in its message and the body's code, quota, limit, used and retry_after_seconds in its Problem (see Reading An API Error). The SDK does not retry automatically and does not expose the HTTP headers. When inspecting a direct API response, honor its authoritative Retry-After value before resending. Check the dashboard for per-plan limits and reset times. See also https://docs.thalovant.com/developers/sdks/go/.

Reading An API Error

A refused control-plane request returns a *thalovant.APIError, which matches errors.Is(err, thalovant.ErrAPI). Its message is one line for display and can be shortened, so read what the API said from the error itself:

  • StatusCode: the HTTP status.
  • Code: the machine-readable code, such as platform_image_required or plan_limit, or "".
  • ProblemDetail: the API's whole sentence, exactly as sent, or "". Detail is the shortened line Error() prints.
  • Problem: the whole error body as a map[string]any when it is a JSON object, or nil. Every structured field the API sends is here, including ones added after this SDK was released. Numbers are float64, as anywhere encoding/json decodes into a map.
_, err := control.ReleaseRuntimeGroup(ctx, groupID, thalovant.ReleaseOptions{
	Images: map[string]string{"core": "docker.io/me/ovos-core:dev"},
})
var apiErr *thalovant.APIError
if errors.As(err, &apiErr) {
	switch apiErr.Code {
	case "platform_image_required":
		fmt.Println(apiErr.ProblemDetail)
		fmt.Println(apiErr.Problem["allowed_images"])       // per image key
		fmt.Println(apiErr.Problem["allowed_repositories"]) // any tag or digest of these
	case "plan_limit":
		fmt.Println(apiErr.Problem["resource"], apiErr.Problem["used"], apiErr.Problem["limit"])
	}
}

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

A Home Assistant integration (or any home controller) links to a hub in four steps: sign in on the device, create a connection of kind home_assistant, wait for the hub to admit it, then answer the hub's requests.

Sign in one step at a time

BeginDeviceLogin asks for a code and returns without printing or opening anything. Show the person VerificationURI and UserCode, or open VerificationURIComplete, which carries the code. Then call PollDeviceLogin every Interval until it stops saying "pending":

control := thalovant.NewDefaultControlPlane("")
grant, err := control.BeginDeviceLoginWithOptions(ctx, thalovant.DeviceLoginOptions{
	Scopes:     thalovant.HomeAssistantScopes(),
	ClientName: "Home Assistant (kitchen)",
	ClientID:   thalovant.HomeAssistantClientID,
})
if err != nil {
	return err
}
fmt.Printf("Visit %s and enter %s\n", grant.VerificationURI, grant.UserCode)

for {
	token, err := control.PollDeviceLogin(ctx, grant)
	var pending *thalovant.DeviceLoginPendingError
	switch {
	case err == nil:
		fmt.Println("signed in until", token.ExpiresAt)
		return nil
	case errors.As(err, &pending):
		time.Sleep(pending.Interval)
	default:
		return err // ErrDeviceCodeExpired, ErrDeviceAccessDenied, or an *APIError
	}
}

HomeAssistantScopes() is hubs:read, clients:read and clients:write, which is also everything a Free plan can approve. ClientID signs in as a registered app: the approval screen shows the platform's own name for it as verified, with ClientName as the device's label beside it, and approving the app again replaces the token it already holds. An id the API does not know is refused with a 400 unknown_client. BeginDeviceLogin(ctx, scopes, name) is the same call without one.

The person approving can read what they are approving: control.DescribeDeviceLogin(ctx, userCode), signed in as them, returns the scopes, ClientName, ClientID, DeviceName and ClientVerified, which is true only for a registered app. A code that is unknown, expired or already answered is a 404. A slow_down answer adds five seconds to grant.Interval for good, so keep polling with the same grant. On approval the token is kept on control.AccessToken and its id on control.TokenID; control.RevokeAPIToken(ctx, "") revokes it (a token may always revoke itself) and forgets it. Revoking it is idempotent: a token already revoked or expired cannot authenticate its own revoke, so the API's 401 counts as revoked too, and revoking again sends nothing. Every sign-in sets TokenID from its own answer, so a later password sign-in clears it rather than leave the id of a token the control plane no longer holds. The device code and the token are redacted when a DeviceAuthorization or an APIToken is printed, and never appear in an error.

Create the connection and wait for it
result, err := control.CreateClientIdentity(ctx, hub, thalovant.BootstrapIdentityOptions{
	Name:           "Home Assistant (Kitchen hub)",
	ConnectionType: thalovant.ConnectionTypeHomeAssistant,
})
var apiErr *thalovant.APIError
errors.As(err, &apiErr) // every refusal below carries one
switch {
case errors.Is(err, thalovant.ErrAlreadyLinked):
	return fmt.Errorf("this hub is already linked by %s", apiErr.LinkedClientID())
case errors.Is(err, thalovant.ErrUnsupportedConnectionType):
	return err // the API cannot make this kind of connection yet
case errors.Is(err, thalovant.ErrPlan):
	return err // the plan does not allow it; apiErr.Problem has the numbers
case errors.Is(err, thalovant.ErrAuth):
	return err // sign in again
case err != nil:
	return err
}
// Keep result.Identity: it is what the link connects with.
err = control.WaitForAdmission(ctx, result.Operation, thalovant.AdmissionOptions{})

The API has to say the connection is of the kind asked for. When it makes an ordinary connection instead, CreateClientIdentity deletes it and returns an *UnsupportedConnectionTypeError. ErrAuth, ErrPlan and ErrAlreadyLinked match the *APIError that every control-plane call returns, so the same checks work anywhere.

A hub admits a new connection about ninety seconds after it is created. WaitForAdmission follows the operation until it is ready:

  • a failed operation is an *AdmissionFailedError with the operation's ErrorCode;
  • a 5xx is ridden out, and so is a 429 (a Free plan allows 60 requests a minute): the next read waits what the API asks, APIError.RetryAfter, read from the body's retry_after_seconds, then Retry-After, then RateLimit-Reset;
  • a 401 or 403 is the *APIError itself (errors.Is(err, thalovant.ErrAuth) for a revoked token or a missing scope): the token, not the connection, is the trouble;
  • any other refusal of the wait is an *AdmissionFailedError whose Err is the *APIError, with the status, code and detail the API answered;
  • an API out of reach is returned as it is (errors.Is(err, thalovant.ErrAPIUnreachable)): it says nothing about the hub.

Running out of time (180 seconds by default) is an *AdmissionTimeoutError, which matches both ErrConnection and ErrTimeout, because the connection may still be admitted later; a 429 asking for longer than the time left is that timeout at once, and no single read runs past it. An operation link on another origin -- scheme, host and port, with the default port spelled out -- is never fetched. control.DeleteClient(ctx, clientID, "") removes a connection: it reads the etag when you pass none, retries once if the connection changed, and treats one that is already gone as deleted.

Answer the hub

The hub sends thalovant.home.request and expects one thalovant.home.response within 10 seconds. AnswerHomeRequests registers a handler on a HubSession, answers each request on a goroutine of its own, and sends the answer as a reply, so it goes back the way the request came. session.Run keeps the link up:

session, err := thalovant.NewHubSession(func(ctx context.Context) (thalovant.HubSessionClient, error) {
	client, err := thalovant.NewClientWithOptions(result.Identity, thalovant.ClientOptions{})
	if err != nil {
		return nil, err
	}
	if err := client.Connect(ctx); err != nil {
		_ = client.Close(context.Background())
		return nil, err
	}
	return client, nil
}, thalovant.DefaultHubSessionPolicy())
if err != nil {
	return err
}
defer session.Close(context.Background())

stop := thalovant.AnswerHomeRequests(session, func(ctx context.Context, request thalovant.HomeRequest) (thalovant.HomeAnswer, error) {
	// askAssist is your call into Home Assistant's conversation agent.
	speech, err := askAssist(ctx, request.Utterance, request.Lang, request.ConversationID)
	if err != nil {
		return thalovant.HomeAnswer{}, err
	}
	return thalovant.HomeAnswer{Speech: speech, ResponseType: thalovant.HomeActionDone}, nil
}, thalovant.HomeAnswerOptions{})
defer stop()

return session.Run(ctx)

The hub gives a request 10 seconds from its arrival, and the handler and the reply share them. The handler runs on its own goroutine with 9 seconds (or what is left of the 10, when less), under a context that ends when its time is up; the reply gets what the handler left. The answer goes out at the deadline whether or not the handler has returned, so a handler that ignores its context cannot hold it back. A reply is never started after the 10 seconds, and one still waiting to be sent then -- queued behind another frame -- is withdrawn: the hub has already given up on it. Withdrawing it leaves the link as it was. A frame already being written is always finished, since half of one would break the Noise stream.

Every request gets an answer while there is time: an error or a panic is answered as failed_to_handle, running out of time as timeout, and an answer outside the contract's response types and error codes as unknown. In those cases the speech is empty and the hub says its own sentence, in the device's language. Speech is sent as plain text, the same way in every SDK: real tags, comments and processing instructions are removed (so "5 < 6 and 7 > 3" stays whole), numeric references, the five XML entities and &nbsp; are decoded and nothing else, and every run of Unicode white space becomes one space.

Run asks a LinkSupervisor after every attempt. It retries a failed attempt after 10, 20, 40, 80, then 120 seconds, and dials again the moment a link drops. A hub turns away a connection it has not admitted yet, so a refusal is "not yet" for 10 minutes (WithRefusalGrace) before Run returns an error matching ErrHubRefused. A hub whose Noise key is not the pinned one ends Run at once with ErrHubKeyChanged: retrying cannot change it, and the pin is never replaced for you (see ForgetNoisePin). So does a hub that refuses this client's own key, ErrClientKeyRejected (see "Transport Security").

A refusal is any of these:

  • a handshake answer that does not authenticate under the connection's password, which is how a wrong password shows;
  • a WebSocket upgrade answered 401 or 403;
  • a close with no status, 1000, 1005 or 1008 during the handshake or within 750 ms after it (WithSettle), which is how a hub that does not know the client's key answers, as long as the hub has sent nothing that decrypted under the new session's keys: a hub refuses a key before it says anything;
  • over HTTP, a request answered 401 or 403 before the hub has sent anything.

Right as an XX handshake ends, such a refusal is ErrClientKeyRejected: the hub pinned another key for this connection. Any other close is a drop. On every transport -- WSS, HTTP and MQTT -- a KK handshake that fails that way is followed at once, inside the same connect, by one XX handshake, because only XX tells a changed password (a refusal) from a changed hub key; the pin is still checked when XX completes, so the retry is no downgrade.

session.On(eventType, handler) registers a handler for any other event type; it keeps working across reconnects. session.OnStateChange reports the link going up and down, and session.Reply or client.Reply answers any event back along its route (ReplyContext is the routing rule on its own). A bare Client has no On; use client.Listen(ctx, thalovant.HomeRequestEvent, ...) and thalovant.AnswerHomeRequest for each event.

API Shape

  • NewDefaultControlPlane(accessToken)
  • NewControlPlane(apiURL, accessToken) for local or self-hosted control planes
  • control.Login(ctx, email, password, scope)
  • control.LoginWithOptions(ctx, email, password, LoginOptions{Scope: ..., OTPCode: ..., RecoveryCode: ...})
  • control.LoginWithBrowser(ctx, DeviceLoginOptions{Scopes: ..., ClientName: ..., ClientID: ..., OpenBrowser: ..., Prompt: ..., Timeout: ...})
  • control.BeginDeviceLogin(ctx, scopes, clientName), control.BeginDeviceLoginWithOptions(ctx, DeviceLoginOptions{...}) and control.PollDeviceLogin(ctx, grant)
  • control.DescribeDeviceLogin(ctx, userCode) -> *DeviceLoginRequest; HomeAssistantClientID
  • control.RevokeAPIToken(ctx, tokenID)
  • control.ListPublicHubs(ctx, limit, cursor)
  • control.GetPublicHub(ctx, hubRef)
  • control.ListHubs(ctx, limit, cursor, ownerID)
  • control.GetHub(ctx, hubID)
  • control.CreateHub(ctx, payload, HubCreateOptions{IdempotencyKey: ...})
  • control.UpdateHub(ctx, hubID, payload, etag)
  • control.DeleteHub(ctx, hubID, etag)
  • control.ReleaseHub(ctx, hubID, ReleaseOptions{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, whose *APIError lists the images allowed instead (see Reading An API Error)
  • control.SetHubRating(ctx, hubID, rating)
  • control.ClearHubRating(ctx, hubID)
  • control.GetHubRuntimeCapabilities(ctx, hubID)
  • control.ListRuntimeGroups(ctx, ownerID)
  • control.GetRuntimeGroup(ctx, runtimeGroupID)
  • control.CreateRuntimeGroup(ctx, payload)
  • control.UpdateRuntimeGroup(ctx, runtimeGroupID, payload)
  • control.GetRuntimeGroupConfig(ctx, runtimeGroupID)
  • control.UpdateRuntimeGroupConfig(ctx, runtimeGroupID, config, RuntimeGroupConfigOptions{Personas: ...})
  • control.ReleaseRuntimeGroup(ctx, runtimeGroupID, ReleaseOptions{...}) — the same rule for Images, except that core accepts any tag or digest of ghcr.io/thalovant/ovos-core and bus only the images the platform releases for it
  • control.DeleteRuntimeGroup(ctx, runtimeGroupID)
  • control.InstallRuntimeGroupSkill(ctx, runtimeGroupID, skillID, RuntimeGroupSkillInstallOptions{MarketplaceSkillID: ..., SourceType: ..., SourceRef: ..., VersionPin: ..., Active: ...})
  • control.UninstallRuntimeGroupSkill(ctx, runtimeGroupID, skillID)
  • control.ListMarketplaceSkills(ctx, MarketplaceSkillListOptions{OwnerID: ..., IncludeInactive: ..., ForceRefresh: ...})
  • control.ListRuntimeGroupMarketplace(ctx, runtimeGroupID, RuntimeGroupMarketplaceOptions{RefreshInventory: ...})
  • control.ListRuntimeGroupInventory(ctx, runtimeGroupID, RuntimeGroupInventoryOptions{Refresh: ...})
  • control.GetOperation(ctx, operationID)
  • control.GetAnalyticsOverview(ctx, options)
  • control.ListMemoryItems(ctx, options)
  • control.GetMemorySummary(ctx, ownerID)
  • control.CreateMemoryItem(ctx, payload)
  • control.GetMemoryItem(ctx, memoryID)
  • control.UpdateMemoryItem(ctx, memoryID, payload)
  • control.DeleteMemoryItem(ctx, memoryID)
  • control.CreateClientIdentityForHubID(ctx, hubID, options)
  • control.CreateClientIdentity(ctx, hub, BootstrapIdentityOptions{Name: ..., ConnectionType: ...})
  • control.WaitForAdmission(ctx, result.Operation, AdmissionOptions{Timeout: ..., PollInterval: ...})
  • control.GetClient(ctx, clientID)
  • control.DeleteClient(ctx, clientID, etag)
  • IdentityFromConfig(path, profile)
  • IdentityFromFile(path); identity.SourcePath()
  • NewClientFromConfig(path, profile)
  • NewClientFromFile(path)
  • NewClientFromEnv()
  • NewClientWithOptions(identity, ClientOptions{Protocol: ...})
  • client.ConnectWithInfo(ctx)
  • client.ConnectionInfo()
  • client.Query(ctx, text, options)
  • client.Ask(ctx, text, options)
  • client.SendUtterance(ctx, text, options)
  • client.SendAction(ctx, payload, options)
  • client.SendCode(ctx, value, options)
  • client.Conversation(options)
  • client.Intents(ctx, languages, IntentOptions{Timeout: ..., Describe: ..., Fallback: ...})
  • client.ListIntents(ctx, lang, IntentOptions{Timeout: ..., IncludeDefinitions: ...})
  • client.DescribeIntent(ctx, skillID, intentName, lang, IntentOptions{Timeout: ...})
  • client.Reply(ctx, event, msgType, data, context) and ReplyContext(context)
  • NewHubSession(connect, policy, WithSettle(...), WithRefusalGrace(...))
  • session.Run(ctx), session.Connect(ctx), session.On(eventType, handler), session.OnStateChange(notify), session.Reply(...)
  • AnswerHomeRequests(session, handler, HomeAnswerOptions{Timeout: ..., HubTimeout: ..., OnReplyError: ...}) and AnswerHomeRequest(ctx, replier, event, handler, options)
  • PlainSpeech(text), DecodeReferences(text), StripSSML(text)
  • NewLinkSupervisor(policy, refusalGrace).After(outcome, now); LinkClientKeyRejected, ErrClientKeyRejected and *ClientKeyRejectedError{KeyFolder, OtherKeyFolder}

Development

go test ./...

Concurrent Ask calls on one client must use distinct request IDs; concurrent Query calls must use distinct query IDs. An active duplicate fails locally with ErrRuntime before publication. Ask and Query use separate namespaces. Reservations end when their collectors are disposed; existing transport ownership still prevents reuse while an admitted write retires. Use a fresh ID for each later logical operation, including after cancellation; delayed remote replies can outlive a disposed collector. Reuse is appropriate only when an application deliberately correlates the same operation.

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. Callers must supply a limit from 1 to 200; pass 50 for the server default. 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.

Methods: ListHubSkills / ListHubSkillHistory / InstallHubSkill / UpdateHubSkill / RemoveHubSkill / WaitForHubSkillOperation. Responses preserve API JSON fields. Use HubSkillWaitOptions to opt into waiting. For cancellation-sensitive work, submit without waiting, retain the complete accepted response (including operation_id and state), then pass that response to the wait helper separately. Cancelling waiting does not undo the server operation. After a polling failure, inspect/resume that operation instead of submitting the write again.

Request helpers and safe configuration updates (0.7.0)

AskOptions and Reply gain fields in this release. Use keyed struct literals when upgrading code that constructed these types positionally.

Request hints carry a recognized language, ordered intent pipeline, and caller location without changing the caller's context. Empty hints are omitted. The location helper requires a city and omits invalid or zero/zero coordinates. The hub validates language hints against its configured languages.

Replies expose their reported language, ordered speech/audio events, and a count of dropped media. Embedded skill clips are limited to 4 MiB each and 16 MiB per reply, checked before retention and decoding. Audio does not extend the reply settlement window. Decoding accepts hexadecimal bytes with ASCII whitespace between bytes; it never fetches a skill-supplied URL or file path. The application owns playback (the play/Play function in this example).

location := thalovant.BuildLocation(thalovant.LocationOptions{City: "Montréal", Country: "CA"})
reply, err := client.AskWithOptions(ctx, "Quel temps fait-il ?", thalovant.AskOptions{
    STTLang: "fr-ca", Location: location,
})
// Check err before reading reply. AudioBytes returns ([]byte, error).
examples := intent.ExamplesWithOptions("en-us", 2, thalovant.IntentExampleOptions{Speakable: true})
delta := map[string]any{"lang": "en-us"}
_, err = control.UpdateRuntimeGroupConfig(ctx, groupID, delta, thalovant.RuntimeGroupConfigOptions{})
// Explicit full replacement:
fullConfig := map[string]any{"lang": "en-us"}
_, err = control.ReplaceRuntimeGroupConfig(ctx, groupID, fullConfig, thalovant.RuntimeGroupConfigOptions{})

Guarded merging requires the hubs:read and hubs:write scopes and a paid plan. Safe merging requires an API whose configuration GET returns a valid revision and whose configuration PUT checks expected_revision. The SDK rereads and reapplies the original delta only after HTTP 412, with at most three attempts. Arrays and scalar values replace; objects merge recursively. Personas replace only when explicitly supplied. Connection failures, redirects, other statuses, and ambiguous write results are never retried. No unsafe PATCH fallback is used. Unconditional replacements must still be coordinated with other writers.

Use the explicit replacement operation shown above when a complete replacement is intended, including when working with an older API. Existing code relying on replacement must opt into it when upgrading. Raw intent patterns remain the default; speakable examples remove optional parts, choose alternatives, and substitute caller-supplied slots while retaining complete-phrase priority.

The audio limits use encoded-length upper bounds before decoding, so formatting whitespace consumes budget too. Like Python's bytes.fromhex, ASCII whitespace alone decodes to zero bytes. Bounded malformed clips remain available as event metadata and fail when decoded; they are never fetched or played automatically. Distinct audio events may intentionally repeat identical sound content. Only repeated delivery of the same event object is suppressed where object identity is available, without counting it as a dropped clip. Rendered example ranking uses the original pattern's slot presence even when sample values are supplied.

Locale-aware intent listings (0.8.1)

intent.ExamplesWithOptions("fr-CA", 2, thalovant.IntentExampleOptions{Sentence: true}) returns capitalized sentences using the closest registered locale. Sentence mode also renders patterns. SpeakableWithLanguage(pattern, slots, lang) fills slots from the bundled thalovant-languages 0.2.1 data, then applies explicit overrides. AsSentence("quelle heure est-il", "fr-CA") returns "Quelle heure est-il?". The original two-argument Speakable remains available without locale defaults.

Examples rank complete phrases before prefixes and slot patterns, then prefer fuller wording up to eight words. Empty and duplicate rendered phrases do not consume the limit. Raw unlimited examples preserve registration order. If no language is supplied, the selected registration's locale is retained. OVOS-compatible distance matching uses versioned langcodes 3.5.1 CLDR tables, including Portuguese norm-region behavior; distances above ten do not match.

DefaultListing() returns the bundled immutable rules. NewListingRules(&data) accepts a complete ListingData tree and snapshots it. Set IntentExampleOptions.Listing or call the returned rules' methods to use it. NewListingRules(nil) produces bare rendering with slot names and no guessed punctuation. Unknown languages behave the same way. Invalid custom patterns return a constructor error. Regex matching has a 100ms per-pattern deadline and a 65536-entry backtracking stack bound. Asks returns matching errors; sentence rendering leaves the line unpunctuated on those errors. The rules are safe to share between goroutines and perform no runtime file or network access.

Generated data retains its source licenses in LICENSE-languages and LICENSE-langcodes.

Regenerate data and reference cases with python scripts/sync-listing-data.py in the public-package environment specified at the top of that script.

The SDK code, CLDR matching tables and bundled thalovant-languages data retain their upstream MIT license notices. Both data notices ship with the SDK.

Language data refresh

The bundled listing data follows thalovant-languages 0.2.1: 270 languages (290 base and regional entries), with regional rules resolved through the public package loader. Sentence marks and trailing words now match Python 0.6.8; for example Spanish qué hora es becomes Qué hora es?, while French coupe le son remains a complete sentence. Undescribed languages such as tlh still render bare. The reference fixtures cover 4,652 listing cases and 990 OVOS language-selection cases.

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. Go also takes a handler per event type with session.On. 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. Either run session.Run(ctx) in a goroutine, which keeps the link up by that policy (see Answer the hub), or schedule probes yourself with 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.

session, err := thalovant.NewHubSession(connectClient, thalovant.DefaultHubSessionPolicy())
if err != nil { return err }
defer session.Close(context.Background())
reply, err := session.Ask(ctx, "What is the weather?", thalovant.AskOptions{})

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. This helper does not change global DNS or disable TLS validation.

Reply claims

Replies expose advisory claim status and first-seen, unique pipeline and skill IDs. A failed or unhandled reply is not claimed; a successful fallback-only reply is not claimed. Successful replies without stage stamps retain legacy behavior and are claimed. Only nonempty string stamps are used; malformed metadata is ignored. Claim status does not authenticate a peer or suppress reply text. See the public SDK guide for native member names.

Documentation

Index

Constants

View Source
const (
	ConnectionTypeVoiceSatellite = "voice_satellite"
	ConnectionTypeWebChat        = "web_chat"
	ConnectionTypeDeveloper      = "developer"
	ConnectionTypeEmbedded       = "embedded"
	// ConnectionTypeHomeAssistant is a hub's Home Assistant link. A hub holds
	// at most one: a second is refused with ErrAlreadyLinked.
	ConnectionTypeHomeAssistant = "home_assistant"
)

The kinds of connection the API knows, sent as spec.connection_type. The kind decides what a connection may send and receive; the API may learn more of them, so the field is a plain string.

View Source
const (
	// DefaultAdmissionTimeout bounds WaitForAdmission. A hub admits a new
	// connection about ninety seconds after it is created.
	DefaultAdmissionTimeout = 180 * time.Second
	// DefaultOperationPollInterval is how often WaitForAdmission reads the
	// operation.
	DefaultOperationPollInterval = 2 * time.Second
)
View Source
const (
	EventRecognizerLoopUtterance = "recognizer_loop:utterance"
	EventSpeak                   = "speak"
	EventOvosUtteranceSpeak      = "ovos.utterance.speak"
	EventUtteranceHandled        = "ovos.utterance.handled"
	// EventIntentUnmatched is the current OVOS bus event fired when an utterance
	// matches no intent. EventIntentFailure is the legacy Mycroft name for the
	// same signal; both are kept so old and new runtimes are recognised.
	EventIntentUnmatched = "ovos.intent.unmatched"
	EventIntentFailure   = "complete_intent_failure"
	EventPolicyDenied    = "hive.policy.denied"
	EventQueryTimeout    = "hive.query.timeout"
	DefaultUserAgent     = userAgent
	// The hub runtime's intent manifest (OVOS-INTENT-4 section 10) and the
	// engines' own manifests, read by Client.Intents, Client.ListIntents and
	// Client.DescribeIntent. See intents.go.
	EventIntentList             = "ovos.intent.list"
	EventIntentListResponse     = "ovos.intent.list.response"
	EventIntentDescribe         = "ovos.intent.describe"
	EventIntentDescribeResponse = "ovos.intent.describe.response"
	EventAdaptManifestGet       = "intent.service.adapt.manifest.get"
	EventAdaptManifest          = "intent.service.adapt.manifest"
	EventPadatiousManifestGet   = "intent.service.padatious.manifest.get"
	EventPadatiousManifest      = "intent.service.padatious.manifest"
)
View Source
const (
	DefaultControlAPIURL    = "https://api.thalovant.com"
	DefaultControlUserAgent = userAgent

	// DefaultDeviceLoginTimeout bounds how long LoginWithBrowser waits for the
	// user to approve the sign-in request in the browser.
	DefaultDeviceLoginTimeout = 15 * time.Minute
)
View Source
const (
	// PolicyCodeACL is an allow-list refusal: ask whoever manages the
	// connection to allow the type; Allowed lists what it may send.
	PolicyCodeACL = "acl_disallowed_type"
	// PolicyCodeQuotaExceeded is a spent allowance: wait, or raise the
	// limit; Quota carries the numbers.
	PolicyCodeQuotaExceeded = "intent_quota_exceeded"
	// PolicyCodeBackendUnavailable is a hub whose agent bus is down, which
	// nothing the caller does will fix.
	PolicyCodeBackendUnavailable = "backend_unavailable"
)

The hub's codes for the three kinds of refusal that arrive as hive.policy.denied, each needing something different said about it.

View Source
const (
	// HomeRequestEvent is what a hub sends a Home Assistant link.
	HomeRequestEvent = "thalovant.home.request"
	// HomeResponseEvent is the one answer every request gets.
	HomeResponseEvent = "thalovant.home.response"
	// HomeRequestTimeout is how long the hub waits for an answer before it
	// treats the silence as a timeout.
	HomeRequestTimeout = 10 * time.Second
	// DefaultHomeHandlerTimeout is how long a handler has by default: a
	// second inside the hub's bound, so the SDK's own timeout answer still
	// lands before the hub gives up.
	DefaultHomeHandlerTimeout = HomeRequestTimeout - time.Second
)
View Source
const (
	HomeActionDone  = "action_done"
	HomeQueryAnswer = "query_answer"
	HomeError       = "error"
)

The response types a home answer may carry.

View Source
const (
	HomeErrorNoIntentMatch    = "no_intent_match"
	HomeErrorNoValidTargets   = "no_valid_targets"
	HomeErrorFailedToHandle   = "failed_to_handle"
	HomeErrorUnknown          = "unknown"
	HomeErrorTimeout          = "timeout"
	HomeErrorAgentUnavailable = "agent_unavailable"
)

The error codes an error answer may name.

View Source
const (
	// IntentSourceManifest marks an inventory read from the hub runtime's
	// intent manifest: sentences per language.
	IntentSourceManifest = "intent-manifest"
	// IntentSourceEngines marks the names-only fallback read from the
	// engines' own manifests; the inventory's Denied then names the query
	// the hub refused.
	IntentSourceEngines = "engine-manifests"
	// DefaultIntentTimeout bounds each intent query when
	// IntentOptions.Timeout is zero.
	DefaultIntentTimeout = 5 * time.Second
	// DescribeBatch is how many describes go out together. A hub with 69
	// intents in two languages is 138 requests and, with every reply
	// delivered twice, 276 inbound events -- more than a transport's reply
	// channel holds, and a burst the hub never asked for. Batching also
	// bounds the deadline: a hub answering nothing fails after one batch
	// rather than holding every request open.
	DescribeBatch = 32
)
View Source
const DefaultConfigFilename = "config.yaml"
View Source
const DefaultDashboardURL = "https://dash.thalovant.com"

DefaultDashboardURL is where a person approves the request.

View Source
const DefaultHubRefusalGrace = 600 * time.Second

DefaultHubRefusalGrace is how long Run treats a hub refusing the credentials as "not admitted yet" before returning the refusal. A new connection is refused until its hub admits it, about ninety seconds.

View Source
const DefaultHubSettle = 750 * time.Millisecond

DefaultHubSettle is how long a link opened by Connect or Run must stay up before it counts. A hub that does not know a client's static key says so only by closing right after the handshake.

View Source
const EventAudioQueue = "mycroft.audio.queue"
View Source
const EventFallbackList = "ovos.skills.fallback.list"
View Source
const EventFallbackListResponse = "ovos.skills.fallback.list.response"
View Source
const HomeAssistantClientID = "thalovant-home-assistant"

HomeAssistantClientID is the registered app id Home Assistant signs in as: DeviceLoginOptions.ClientID of its device login. The approval screen then shows the platform's own name for the app as verified, and approving it again replaces the token the last approval gave it instead of counting a second one against the plan.

View Source
const HubSource = "hub"
View Source
const InventoryCacheTTL = time.Hour
View Source
const InventoryCacheVersion = 1
View Source
const MaxAudioClipBytes = 4 * 1024 * 1024
View Source
const MaxReplyMediaBytes = 16 * 1024 * 1024
View Source
const NoiseKeyFilename = "noise_key"

NoiseKeyFilename is the static X25519 private key used for every v3 handshake, hex encoded. It must persist: regenerating it on each start makes every connection look like a new peer and defeats pinning in both directions.

View Source
const NoisePinsFilename = "noise_pins.json"

NoisePinsFilename records the server static keys this client has pinned, as a JSON object keyed by the server node id.

View Source
const NoisePskFilename = "noise_psks.json"

NoisePskFilename caches derived pre-shared keys, as a JSON object keyed by the server node id.

The derivation is argon2id at 64 MiB and depends only on the password and the hub's node id, both constant for the life of the pairing, so it is the same answer every time. The in-memory cache on a transport only helps that one object; this survives reconnects, other transports in the same process, and restarts.

Only the key is stored. A fingerprint of the password would make rotation cheap to detect, but it would also put a fast hash of the password in the same file as the key it protects -- and a fast hash is exactly the offline oracle argon2id exists to deny. A rotated password is noticed when the handshake rejects the stale key, and ForgetCachedPSK drops it.

View Source
const Version = "0.12.0"

Version is the module release this package was built from, and the single source of truth for every user agent the SDK sends. The VERSION file at the repository root is the release pipeline's copy of the same number; TestVersionMatchesVersionFile keeps the two in step.

Never hard-code a version inside a user-agent literal anywhere else: TestNoSourceFileHardCodesAUserAgentVersion rejects it.

Variables

View Source
var (
	ErrIdentity   = errors.New("thalovant identity error")
	ErrConnection = errors.New("thalovant connection error")
	ErrTimeout    = errors.New("thalovant timeout")
	ErrRuntime    = errors.New("thalovant runtime error")
	ErrAPI        = errors.New("thalovant api error")
	ErrProtocol   = errors.New("thalovant unsupported protocol")

	// ErrDeviceAccessDenied reports that the browser device sign-in request
	// was denied by the user.
	ErrDeviceAccessDenied = errors.New("thalovant device sign-in denied")
	// ErrDeviceCodeExpired reports that the device sign-in code expired
	// before it was approved.
	ErrDeviceCodeExpired = errors.New("thalovant device sign-in code expired")
	// ErrDeviceLoginPending reports that nobody has approved a device
	// sign-in yet. PollDeviceLogin returns it as a *DeviceLoginPendingError,
	// whose Interval says when to ask again.
	ErrDeviceLoginPending = errors.New("thalovant device sign-in pending")

	// ErrAuth matches an *APIError that signing in again is the way out of:
	// HTTP 401 (a token unknown, expired or revoked), 423 (a locked account),
	// or 403 whose detail is "Insufficient scopes".
	ErrAuth = errors.New("thalovant authentication refused")
	// ErrPlan matches an *APIError the account's plan refused: HTTP 402, or
	// 403 with code "plan_limit". Problem carries the plan's numbers.
	ErrPlan = errors.New("thalovant plan refused")
	// ErrAlreadyLinked matches an *APIError saying the hub already holds the
	// one link of its kind: HTTP 409 with code "home_assistant_already_linked".
	// LinkedClientID names the connection that holds it.
	ErrAlreadyLinked = errors.New("thalovant hub already linked")
	// ErrUnsupportedConnectionType matches an *UnsupportedConnectionTypeError:
	// the API could not make a connection of the kind asked for.
	ErrUnsupportedConnectionType = errors.New("thalovant unsupported connection type")
	// ErrHubRefused reports that a hub turned the connection's credentials
	// away. It always travels with ErrConnection. A new connection is refused
	// until its hub admits it, so a HubSession's Run treats it as "not yet"
	// for a grace period before returning it.
	ErrHubRefused = errors.New("thalovant hub refused the credentials")
	// ErrHubKeyChanged reports that a hub's Noise static key is not the one
	// pinned for it: the hub was replaced or reinstalled, or another machine
	// answers at its address. It always travels with ErrConnection. It is not
	// a refusal, and retrying cannot change it; the pin is never replaced
	// automatically (see ForgetNoisePin).
	ErrHubKeyChanged = errors.New("thalovant hub key changed")
	// ErrClientKeyRejected reports that a hub refused this client's own Noise
	// static key: it pinned a different one for the connection. It is a
	// refusal -- errors.Is(err, ErrHubRefused) holds too, and it travels with
	// ErrConnection -- but no handshake can recover from it, so a HubSession's
	// Run returns it at once. errors.As reaches the *ClientKeyRejectedError,
	// which names the key folders.
	ErrClientKeyRejected = fmt.Errorf("%w: the hub refused this client's Noise key", ErrHubRefused)
	// ErrAPIUnreachable reports a control-plane request that never got an
	// answer: DNS, the connection, TLS, a proxy. It always travels with ErrAPI
	// and ErrConnection, and it says nothing about what the API would have
	// answered.
	ErrAPIUnreachable = errors.New("thalovant api unreachable")
)
View Source
var BinaryPayloadKinds = map[int]string{
	1: "raw_audio",
	2: "numpy_image",
	3: "file",
	4: "stt_transcribe",
	5: "stt_handle",
	6: "tts_audio",
}

BinaryPayloadKinds name the payload types a BINARY frame can carry, by their wire number.

A hub answers speak:synth by rendering the utterance and sending one of these back, so a client with no synthesiser of its own can still speak; a file arrives the same way. The wire numbers the type, this names it.

View Source
var ConversationSessionFields = []string{
	"converse_handlers",
	"active_handlers",
	"active_skills",
	"context",
	"utterance_states",
	"response_mode",
}

ConversationSessionFields are the session fields a client carries from one turn of a conversation to the next.

A hub keeps nothing for a named session: OVOS-SESSION-2 §2.2 makes the orchestrator stateless for those, so the carrier a client sends is the whole snapshot and whatever the last turn activated is discarded the moment it ends. Without converse_handlers the converse pipeline has no skill to poll and every follow-up reaches the fallback instead of the skill that just answered.

An allow-list, not a deny-list. Deliberately absent: the caller's own per-turn settings (lang, pipeline, site_id), because a client that decides the language per utterance would otherwise be pinned to whichever one the conversation opened in; and the live device flags, which describe a moment that has passed by the time the next turn is sent.

View Source
var DefaultNativeScopes = []string{"hubs:read", "clients:read", "clients:write"}

DefaultNativeScopes are the three a phone needs; also the three a free plan may mint.

View Source
var DefaultProtocolPreference = []HubProtocol{ProtocolWSS, ProtocolHTTPS, ProtocolMQTT}
View Source
var ErrEventOverflow = errors.New("runtime event subscription overflow")

ErrEventOverflow means a subscriber did not keep up. The subscription is closed instead of silently losing replies or blocking the Noise reader.

View Source
var HiveKinds = []string{"broadcast", "propagate", "escalate", "intercom", "rendezvous"}

HiveKinds are the hive's own frame kinds, which a client may subscribe to.

query and cascade are deliberately absent: they are this client's own request/response traffic and Ask already owns them, so subscribing to one would quietly compete for the same replies.

Functions

func Alive added in v0.9.0

func Alive(client HubSessionClient) bool

func AnswerHomeRequests added in v0.11.0

func AnswerHomeRequests(link HomeLink, handler HomeHandler, opts HomeAnswerOptions) (stop func())

AnswerHomeRequests answers every thalovant.home.request that reaches link, each on a goroutine of its own so a slow one does not hold up the next, and returns a function that stops it. Stopping cancels the contexts of the answers still running; those send nothing.

With a HubSession, requests keep arriving across reconnects while the session's Run keeps the link up:

session, _ := thalovant.NewHubSession(connect, thalovant.DefaultHubSessionPolicy())
stop := thalovant.AnswerHomeRequests(session, handler, thalovant.HomeAnswerOptions{})
defer stop()
go session.Run(ctx)

func AsSentence added in v0.8.0

func AsSentence(text, lang string) string

AsSentence uses the bundled canonical locale data.

func BinaryKindName added in v0.10.0

func BinaryKindName(wireNumber int) string

BinaryKindName names a payload type. One nobody has named still arrives, under its number, rather than being dropped.

func BuildLocation added in v0.7.0

func BuildLocation(opts LocationOptions) map[string]any

func CarryConversation added in v0.10.0

func CarryConversation(previous, session map[string]any) map[string]any

CarryConversation fills the conversation fields of session from the hub's last reply.

This turn's own values win: a field the caller set is never overwritten, only one it left out is taken from the turn before.

func ChallengeFor added in v0.9.2

func ChallengeFor(verifier string) string

ChallengeFor returns the S256 challenge for a verifier.

func ClosestLanguage added in v0.8.0

func ClosestLanguage(target string, available []string) (string, bool)

func CommonAffix added in v0.9.0

func CommonAffix(names []string) (kind, token string)

func CompareNames added in v0.9.0

func CompareNames(left, right string) int

CompareNames gives a deterministic natural order without integer overflow.

func DecodeReferences added in v0.11.0

func DecodeReferences(text string) string

DecodeReferences decodes the portable set of character references once, left to right: numeric references (&#72;, &#x48;, &#X48;) and the five XML entities plus &nbsp;. A numeric reference to no character -- 0, a surrogate, anything past U+10FFFF -- and every other named reference (&eacute;, &copy;) are left as written, since the libraries SDKs would otherwise use disagree about them.

func DefaultConfigPath added in v0.2.11

func DefaultConfigPath() (string, error)

func EncodeHiveBinaryFrame added in v0.2.5

func EncodeHiveBinaryFrame(message HiveMessage) ([]byte, error)

func EndpointFromDomain added in v0.2.1

func EndpointFromDomain(domain string, protocol HubProtocol) string

func EventMatchesContext

func EventMatchesContext(event Event, expected Context) bool

func ForgetCachedPSK added in v0.4.2

func ForgetCachedPSK(dir, nodeID string) error

ForgetCachedPSK drops a stored key. The handshake calls this when the hub rejects one, which is how a rotated password is noticed: the next attempt derives again from the current one.

func ForgetNoisePin added in v0.4.0

func ForgetNoisePin(dir, nodeID string) error

ForgetNoisePin drops a pinned server key. Use it when a server was deliberately reinstalled or replaced; a pin that stops matching on its own is a failure to investigate, not one to clear.

func FriendlyTitle added in v0.9.0

func FriendlyTitle(id string) string

func HomeAssistantScopes added in v0.11.0

func HomeAssistantScopes() []string

HomeAssistantScopes are the scopes a Home Assistant link signs in with: enough to find the account's hubs and to create, read and delete the one connection it holds. They are also everything a Free plan can approve. Each call returns a fresh slice.

func HomeErrorCodes added in v0.11.0

func HomeErrorCodes() []string

HomeErrorCodes lists every error code the contract allows, in its order. Each call returns a fresh slice.

func HomeResponseTypes added in v0.11.0

func HomeResponseTypes() []string

HomeResponseTypes lists every response type the contract allows, in its order. Each call returns a fresh slice.

func HubDisplayName added in v0.9.2

func HubDisplayName(hub map[string]any) string

HubDisplayName returns what to call a hub on a screen somebody is reading.

Every control-plane read in this SDK returns raw JSON, so each caller picks its own fields -- and on 2026-09-15 a phone offered somebody a list of rooms called "ops-copilot", "daily-desk", "news-stream". Those are slugs. The app was not careless: it read name and preferred it over slug, and on that deployment name holds the slug. The name a person was shown when the hub was made lives in spec.catalog.title.

One place to get that wrong is better than one per app.

func HubHostname added in v0.9.0

func HubHostname(master string) string

func Humanize added in v0.9.0

func Humanize(name string) string

func IdentityHost added in v0.9.0

func IdentityHost(identityPath string) string

func InventoryCacheKey added in v0.9.0

func InventoryCacheKey(mode, identityPath string) string

func IsThalovantURL added in v0.9.2

func IsThalovantURL(raw string) bool

IsThalovantURL reports whether a URL belongs to Thalovant, for a caller that wants to show where it is about to send somebody. Scheme and host only: a display check, not an authorization one.

func LanguagesPresent added in v0.9.0

func LanguagesPresent(i Inventory) []string

func LoadCachedPSK added in v0.4.2

func LoadCachedPSK(dir, nodeID string) []byte

LoadCachedPSK returns the stored pre-shared key for a hub, or nil when there is none.

func LoadNoisePin added in v0.4.0

func LoadNoisePin(dir, nodeID string) (string, error)

LoadNoisePin returns the pinned server static key for a node id, or "" when this client has not seen that server before.

func LoadOrCreateNoiseKey added in v0.4.0

func LoadOrCreateNoiseKey(dir string) (noise.DHKey, error)

LoadOrCreateNoiseKey returns this client's persistent static X25519 keypair, generating and storing one on first use.

On Unix the key file is created 0600 and rejected if group/world-accessible. Windows inherits the protected state directory's access controls.

func NewRequestID

func NewRequestID() string

func NewSessionID

func NewSessionID() string

func NewVerifier added in v0.9.2

func NewVerifier() (string, error)

NewVerifier returns a PKCE verifier: 64 random bytes, base64url, no padding.

func NoiseStateDir added in v0.4.0

func NoiseStateDir() (string, error)

NoiseStateDir is the directory holding the static key and the pin file. It sits beside the SDK config file, so XDG_CONFIG_HOME and the Windows APPDATA location are honored the same way.

func PlainSpeech added in v0.11.0

func PlainSpeech(text string) string

PlainSpeech is speech a device can say as it is, made in this order, the same in every SDK (home-link-vectors.json):

  1. markup removed, with StripSSML: only real tags, comments and processing instructions, so "5 < 6 and 7 > 3" stays whole;
  2. character references decoded once, left to right, with DecodeReferences: numeric ones, the five XML entities and &nbsp;, nothing else;
  3. every run of Unicode White_Space collapsed to one space, and the ends trimmed of it.

Nothing comes from html.UnescapeString: its table of named references is not the one other SDKs decode.

func RequestIDFromContext

func RequestIDFromContext(context Context) string

func RichMediaFromData

func RichMediaFromData(data Data) map[string]any

func SameLanguage added in v0.3.13

func SameLanguage(a, b string) bool

SameLanguage reports whether two language tags name the same language: "fr-fr" and "fr_FR" do.

func SaveCachedPSK added in v0.4.2

func SaveCachedPSK(dir, nodeID string, psk []byte) error

SaveCachedPSK records a derived key so the next connection to this hub skips argon2id. The cache is an optimisation, so callers treat a failure here as non-fatal.

func SaveNoisePin added in v0.4.0

func SaveNoisePin(dir, nodeID, publicKey string) error

SaveNoisePin records the server static key for a node id on first contact.

func SessionIDFromContext

func SessionIDFromContext(context Context) string

func Speakable added in v0.7.0

func Speakable(pattern string, slots map[string]string) string

Speakable renders one sentence, removing optional parts without inventing slot values.

func SpeakableWithLanguage added in v0.8.0

func SpeakableWithLanguage(pattern string, slots map[string]string, lang string) string

SpeakableWithLanguage uses bundled locale examples without changing the original two-argument Speakable function's calling convention.

func StripAffix added in v0.9.0

func StripAffix(name, kind, token string) string

func StripSSML

func StripSSML(text string) string

StripSSML removes SSML and XML markup from display text: tags, comments and processing instructions. Only real markup goes, so "5 < 6 and 7 > 3" survives whole, and an unclosed "<b" is text. Entities are left as they are; PlainSpeech decodes the portable set.

func UsualForm added in v0.10.3

func UsualForm(tag string) (string, bool)

ClosestLanguage returns the nearest OVOS-compatible registration (maximum distance ten). Equal distances preserve the caller's registration order. UsualForm is the form a language is usually written in, when that differs from tag: "en-CA" and "en-AT" both to "en-us", "fr-BE" to "fr-fr", "pt-AO" to "pt-br", from CLDR's likely subtags. The second return is false when there is nothing different to try, so a caller can tell "already the usual form" from "no idea".

Listing and asking do not agree about languages, and this closes the gap. A hub matches an utterance to the closest language it knows, so a phone set to "en-CA" is understood by skills registered under "en-US"; its manifest is keyed by exact tag, so the same hub lists nothing for "en-CA".

Lower case, because that is how skills register and how the manifest is keyed: an exact lookup with BCP47's "en-US" finds nothing.

Types

type APIError added in v0.7.0

type APIError struct {
	// StatusCode is the HTTP status the API answered with.
	StatusCode int
	// Detail is the single line Error() prints: the body's own message
	// fields joined, whitespace-collapsed and cut at 256 runes, or a stand-in
	// such as "(no response body)" or "(server error response omitted)". It
	// never carries a value the body echoed back from the request.
	Detail string
	// Code is the body's machine-readable code, such as
	// "platform_image_required" or "plan_limit", exactly as sent; "" when the
	// body has none. It is read from the body's "code" member, or from inside
	// a "detail" member that is itself an object (FastAPI's own envelope), and
	// a code that is not a string or is only whitespace is no code.
	Code string
	// ProblemDetail is the API's whole sentence, exactly as sent: never
	// trimmed, collapsed or shortened, unlike Detail. It is read the same way
	// as Code, from the body's "detail" member; "" when the body has none.
	ProblemDetail string
	// Problem is the whole error body decoded, when it is a JSON object: the
	// Problem+JSON document every API refusal is. A structured field is
	// reachable here without a new SDK release: refused_images,
	// allowed_images and allowed_repositories on platform_image_required;
	// resource, limit, used and plan on plan_limit. Numbers are float64, as
	// everywhere encoding/json decodes into map[string]any. nil for a body
	// that is empty, not JSON, or JSON that is not an object. It can hold
	// values the body echoed back from the request, which is why Error()
	// never prints it.
	Problem map[string]any
	// RetryAfter is how long the API asked the caller to wait before trying
	// again, when it said; 0 otherwise. It is read from the body's
	// retry_after_seconds (at the top, or inside a detail object), else from
	// the Retry-After header in seconds, else from RateLimit-Reset: the API's
	// own rate limiter answers a 429 in plain text with only that header.
	RetryAfter time.Duration
}

APIError is a control-plane request the API answered with an error status. It preserves the HTTP status while continuing to match ErrAPI.

Error() prints one bounded line for display, and that line can be shortened, so it is never where to read what the API said. That rides beside it: Code to branch on, ProblemDetail for the API's whole sentence, and Problem for every structured field of the body.

var apiErr *thalovant.APIError
if errors.As(err, &apiErr) && apiErr.Code == "platform_image_required" {
	fmt.Println(apiErr.ProblemDetail)
	fmt.Println(apiErr.Problem["allowed_images"])
}

An APIError built as a literal with only StatusCode and Detail reads and prints exactly as it always did, with the other fields empty.

func (*APIError) Error added in v0.7.0

func (e *APIError) Error() string

func (*APIError) GoString added in v0.12.0

func (e *APIError) GoString() string

GoString keeps %#v from printing Problem, which can hold values the body echoed back from the request: a validation error's input is the request as sent. It shows the status, the code and the display line, which never carries an echoed value; read Problem itself when you need it.

func (*APIError) Is added in v0.11.0

func (e *APIError) Is(target error) bool

Is reports whether the refusal is one a caller can branch on: ErrAuth, ErrPlan or ErrAlreadyLinked. They are read from the status and the body, so every control-plane call answers them the same way, and the error stays an *APIError with every field it carried:

var apiErr *thalovant.APIError
switch {
case errors.Is(err, thalovant.ErrAlreadyLinked) && errors.As(err, &apiErr):
	fmt.Println("linked by", apiErr.LinkedClientID())
case errors.Is(err, thalovant.ErrPlan):
	fmt.Println("upgrade the plan")
case errors.Is(err, thalovant.ErrAuth):
	fmt.Println("sign in again")
}

func (*APIError) LinkedClientID added in v0.11.0

func (e *APIError) LinkedClientID() string

LinkedClientID is the connection that already holds a hub's link, named by an ErrAlreadyLinked refusal; "" when the answer names none. It is read from the body's client_id (or existing_client_id, or connection_id), at the top or inside a detail that is itself an object.

func (*APIError) Unwrap added in v0.7.0

func (e *APIError) Unwrap() error

type APIToken added in v0.11.0

type APIToken struct {
	AccessToken string
	TokenType   string
	Scopes      []string
	// ExpiresAt is when the token stops working; zero when the API did not say.
	ExpiresAt time.Time
	// TokenID names the token for RevokeAPIToken; "" when the API did not say.
	TokenID string
}

APIToken is an API token a device sign-in minted: the credential and what it may do. There is no refresh token; a device-login token lives 365 days. Keep TokenID to revoke it with RevokeAPIToken. String() redacts AccessToken.

func (APIToken) GoString added in v0.11.0

func (t APIToken) GoString() string

GoString keeps %#v from printing the access token.

func (APIToken) String added in v0.11.0

func (t APIToken) String() string

String renders the token with its access token redacted.

type ActionOptions

type ActionOptions struct {
	Title     string
	Lang      string
	Context   Context
	SessionID string
	RequestID string
}

type AdmissionFailedError added in v0.11.0

type AdmissionFailedError struct {
	// OperationID is the operation that was followed.
	OperationID string
	// Status is the operation's final status, when it reached one.
	Status OperationStatus
	// ErrorCode is the operation's own code, such as "gitops_push_rejected";
	// "" when it had none, and always "" when the API refused the wait (its
	// code is on the *APIError in Err).
	ErrorCode string
	// ErrorMessage is the operation's own explanation, when it gave one.
	ErrorMessage string
	// Err is the API's refusal of the wait, when that is what ended it.
	Err error
}

AdmissionFailedError reports that the hub could not admit a new connection: the operation carrying it ended failed or timed_out on the platform, or the API refused the wait itself (for any reason but authentication, which WaitForAdmission returns as the *APIError it is). It matches ErrConnection; when the API refused the wait, errors.As also reaches its *APIError, with the status, code and detail it answered.

func (*AdmissionFailedError) Error added in v0.11.0

func (e *AdmissionFailedError) Error() string

func (*AdmissionFailedError) Unwrap added in v0.11.0

func (e *AdmissionFailedError) Unwrap() []error

Unwrap makes an AdmissionFailedError match ErrConnection, and whatever the API answered when that is what ended the wait.

type AdmissionOptions added in v0.11.0

type AdmissionOptions struct {
	// Timeout is how long to wait; DefaultAdmissionTimeout when zero.
	Timeout time.Duration
	// PollInterval is how often to read the operation;
	// DefaultOperationPollInterval when zero.
	PollInterval time.Duration
}

AdmissionOptions bounds WaitForAdmission. Zero values take the defaults.

type AdmissionTimeoutError added in v0.11.0

type AdmissionTimeoutError struct {
	// Wait is how long the wait lasted.
	Wait time.Duration
	// OperationID is the operation that was followed.
	OperationID string
}

AdmissionTimeoutError reports that a new connection was not admitted by its hub within the wait. It is a connection error and a timeout at once -- errors.Is matches both ErrConnection and ErrTimeout, and Timeout reports true -- because the connection may still be admitted after it.

func (*AdmissionTimeoutError) Error added in v0.11.0

func (e *AdmissionTimeoutError) Error() string

func (*AdmissionTimeoutError) Timeout added in v0.11.0

func (e *AdmissionTimeoutError) Timeout() bool

Timeout reports true, the way a net.Error that timed out does.

func (*AdmissionTimeoutError) Unwrap added in v0.11.0

func (e *AdmissionTimeoutError) Unwrap() []error

Unwrap makes an AdmissionTimeoutError match ErrConnection and ErrTimeout.

type AnalyticsOverviewOptions added in v0.2.13

type AnalyticsOverviewOptions struct {
	Range     string
	Bucket    string
	HubID     string
	ClientID  string
	Country   string
	Message   string
	Utterance string
	Intent    string
	TimeStart string
	TimeEnd   string
	Weekday   *int
	Hour      *int
}

type AskOptions added in v0.5.0

type AskOptions struct {
	STTLang  string
	Pipeline []string
	Location map[string]any
	RequestOptions
	ReplySettle    time.Duration
	EmptyReplyWait time.Duration
}

AskOptions extends RequestOptions without changing existing keyed or unkeyed RequestOptions literals. Zero settlement values use the family defaults.

type BootstrapIdentityOptions added in v0.2.2

type BootstrapIdentityOptions struct {
	Name               string
	SiteID             string
	Spec               map[string]any
	OwnerID            string
	Active             *bool
	PreferredProtocols []HubProtocol
	IdempotencyKey     string
	// ConnectionType is the kind of connection to create, such as
	// ConnectionTypeHomeAssistant, sent as spec.connection_type; "" leaves the
	// API's default. The API must say the connection is of that kind: when it
	// does not, CreateClientIdentity deletes what it made and returns an
	// *UnsupportedConnectionTypeError.
	ConnectionType string
}

type BootstrapIdentityResult added in v0.2.2

type BootstrapIdentityResult struct {
	Identity Identity
	Hub      map[string]any
	Client   map[string]any
	Endpoint *SelectedHubEndpoint
	// Operation tracks the hub admitting the new connection, about ninety
	// seconds; WaitForAdmission follows it. nil when the API sent none.
	Operation *OperationResource
}

func (BootstrapIdentityResult) ClientID added in v0.11.0

func (r BootstrapIdentityResult) ClientID() string

ClientID is the id of the connection that was created; "" when the answer carried none.

func (BootstrapIdentityResult) ConnectionType added in v0.11.0

func (r BootstrapIdentityResult) ConnectionType() string

ConnectionType is the kind of connection the API says it created, from the answer's spec.connection_type; "" when it named none.

func (BootstrapIdentityResult) SelectedProtocol added in v0.2.2

func (r BootstrapIdentityResult) SelectedProtocol() HubProtocol

func (BootstrapIdentityResult) Summary added in v0.2.2

func (r BootstrapIdentityResult) Summary(includeSecrets bool) map[string]any

type Client

type Client struct {
	Identity       Identity
	Transport      RuntimeTransport
	ConnectTimeout time.Duration
	// contains filtered or unexported fields
}

func NewClient

func NewClient(identity Identity) *Client

func NewClientFromConfig added in v0.2.11

func NewClientFromConfig(path string, profile string) (*Client, error)

func NewClientFromEnv

func NewClientFromEnv() (*Client, error)

func NewClientFromFile

func NewClientFromFile(path string) (*Client, error)

func NewClientWithOptions added in v0.2.2

func NewClientWithOptions(identity Identity, opts ClientOptions) (*Client, error)

func (*Client) Ask

func (c *Client) Ask(ctx context.Context, text string, opts RequestOptions) (Reply, error)

func (*Client) AskWithOptions added in v0.5.0

func (c *Client) AskWithOptions(ctx context.Context, text string, opts AskOptions) (Reply, error)

func (*Client) Broadcast added in v0.10.0

func (c *Client) Broadcast(ctx context.Context, eventType string, data Data, eventContext Context) error

Broadcast sends an event down to every child of this hub. Admin only.

A hub requires admin standing and the can_broadcast grant, and a client that sends one without them is not answered with an error -- it is disconnected for misbehaviour. Nothing here can check first: a hub's HELLO carries its public key, peer name and node id, and nothing about what this client may do, so a refusal arrives as a closed connection on the next read.

func (*Client) Close

func (c *Client) Close(ctx context.Context) error

func (*Client) ClosedRefused added in v0.11.0

func (c *Client) ClosedRefused() bool

ClosedRefused reports whether the hub closed this client's last connection the way it refuses credentials: a close with no status, 1000, 1005 or 1008. It is only a verdict on the credentials when the close came right after the handshake -- a hub that does not know a client's static key says so only by closing then, and a hub shutting down later closes with 1000 too -- which is how HubSession's settle window reads it. It is false for a transport that cannot tell.

func (*Client) Connect

func (c *Client) Connect(ctx context.Context) error

func (*Client) ConnectWithInfo added in v0.2.14

func (c *Client) ConnectWithInfo(ctx context.Context) (TransportConnectionInfo, error)

ConnectWithInfo includes diagnostic collection in the connection deadline. A timed-out custom getter retains operation ownership until it returns.

func (*Client) ConnectionInfo added in v0.2.14

func (c *Client) ConnectionInfo() TransportConnectionInfo

func (*Client) Conversation

func (c *Client) Conversation(opts ConversationOptions) Conversation

func (*Client) DescribeIntent added in v0.3.13

func (c *Client) DescribeIntent(ctx context.Context, skillID, intentName, lang string, opts ...IntentOptions) ([]IntentDefinition, error)

DescribeIntent returns every registration behind one intent in one language, keyword ones first, sentences included for a template intent. An empty lang asks for "en-us". A registration the hub does not know yields an empty list, not an error: ok: false is a real answer here, unlike on the listing, and means the intent has no sentences. Built-in transports support concurrent collectors through independent subscriptions. Legacy custom transports with one shared event channel must serialize collectors.

func (*Client) Emit

func (c *Client) Emit(ctx context.Context, eventType string, data Data, eventContext Context) error

func (*Client) Escalate added in v0.10.0

func (c *Client) Escalate(ctx context.Context, eventType string, data Data, eventContext Context) error

Escalate sends an event up to the parent node.

func (*Client) Healthcheck

func (c *Client) Healthcheck() TransportHealth

func (*Client) Intents added in v0.3.13

func (c *Client) Intents(ctx context.Context, languages []string, opts ...IntentOptions) (HubIntentInventory, error)

Intents returns everything the hub can be asked, per language, grouped by skill.

It reads the runtime's intent manifest over this session, so no control-plane credential is involved. Each intent carries the sentences a person says to reach it, as the skill wrote them, "{slot}" placeholders included. A nil or empty languages asks for "en-us"; tags are trimmed and a language repeated under another spelling ("en-US", "en_us") is asked once, under the first spelling given.

The hub's queries are correlated by request id like Ask; a reply delivered more than once is taken once. Unless the runtime attached definitions to the listing, every template registration is described, DescribeBatch of them in flight at a time, and one the hub does not describe in time carries no sentences.

A hub that refuses ovos.intent.list is asked for the engines' own manifests instead, unless IntentOptions.Fallback is false: the result then carries names only, Source set to IntentSourceEngines and Denied naming the refused query. A hub refusing those too, or any refusal with the fallback off, returns a *PolicyDeniedError; a hub that stays silent returns an error wrapping ErrTimeout. A hub that answers the listing ok: false has failed the query rather than refused the type: that returns an error wrapping ErrRuntime, and the engines are not asked instead.

Built-in transports give each call its own bounded event subscription. Hubs that omit request IDs still require one same-type query at a time.

func (*Client) IntentsWithCapabilities added in v0.5.0

func (c *Client) IntentsWithCapabilities(ctx context.Context, languages []string, opts ...IntentOptions) (HubIntentCapabilities, error)

IntentsWithCapabilities adds the optional fallback-handler probe, bounded to 1.5 seconds. Existing Intents remains available for callers that only need the manifest and its established return type.

func (*Client) ListFallbacks added in v0.5.0

func (c *Client) ListFallbacks(ctx context.Context, timeout time.Duration) ([]HubFallback, error)

ListFallbacks returns nil for unsupported, refused, silent or malformed discovery; a non-nil empty slice means the hub reported no handlers. Caller cancellation and transport errors still propagate.

func (*Client) ListIntents added in v0.3.13

func (c *Client) ListIntents(ctx context.Context, lang string, opts ...IntentOptions) ([]IntentRegistration, error)

ListIntents returns the hub's intent manifest for one language, one row per registration. An empty lang asks for "en-us". With IntentOptions.IncludeDefinitions the runtime is asked to attach each row's definition; a runtime that honours it fills IntentRegistration.Definition. A hub that answers ok: false returns an error wrapping ErrRuntime carrying the hub's own text: a listing that failed is not a hub with no intents. Built-in transports give each call its own bounded event subscription. Hubs that omit request IDs still require one same-type query at a time.

func (*Client) Listen added in v0.5.0

func (c *Client) Listen(ctx context.Context, eventName string, options ListenOptions) (*Subscription[Event], error)

Listen connects and returns an independent filtered stream. Range over C and inspect Err afterward; reaching MaxEvents or calling Close is successful. Timeout, caller cancellation, disconnect and overflow close the stream with an explicit error. Legacy custom transports need EventSubscriber for isolation.

func (*Client) ListenBinary added in v0.10.0

func (c *Client) ListenBinary(ctx context.Context, options ListenOptions) (*Subscription[ThalovantBinary], error)

ListenBinary connects and returns an independent stream of binary frames: rendered speech, and files.

This is what a hub sends back for speak:synth -- the audio itself, so a client with no synthesiser can still speak -- and how it hands over a file. Delivered as a stream and not on a reply, because a binary frame carries no request id: it cannot be attributed to one Ask. Its Utterance is the only thread back to a turn.

func (*Client) ListenHive added in v0.10.0

func (c *Client) ListenHive(ctx context.Context, kind string, options ListenOptions) (*Subscription[HiveMessage], error)

ListenHive connects and returns an independent stream of one hive frame kind.

A hub relays more than this client's conversation: broadcast is aimed down at every child, propagate walks the whole hive, escalate goes up to the parent, intercom is addressed node to node, and rendezvous is the mailbox peers use to find each other through NAT. See HiveKinds.

Range over C and inspect Err afterward; call Close when done.

func (*Client) Propagate added in v0.10.0

func (c *Client) Propagate(ctx context.Context, eventType string, data Data, eventContext Context) error

Propagate sends an event across the hive; every node sees it once.

func (*Client) Query added in v0.2.15

func (c *Client) Query(ctx context.Context, text string, opts QueryOptions) (Reply, error)

func (*Client) Reply added in v0.11.0

func (c *Client) Reply(ctx context.Context, event Event, msgType string, data Data, eventContext Context) error

Reply answers an event the hub sent, back along the route it came (OVOS-MSG-1 §5.2). The reply carries a deep copy of the event's context as the hub sent it -- its session, its request id, everything a skill waiting on the answer matches -- with the routing turned round by ReplyContext. eventContext entries, when given, are laid over the copy before the turn. Like Emit, a reply is never replayed.

func (*Client) SendAction

func (c *Client) SendAction(ctx context.Context, payload string, opts ActionOptions) error

func (*Client) SendCode

func (c *Client) SendCode(ctx context.Context, value string, opts CodeOptions) error

func (*Client) SendUtterance

func (c *Client) SendUtterance(ctx context.Context, text string, opts RequestOptions) error

func (*Client) SubscribeEvents added in v0.5.0

func (c *Client) SubscribeEvents(capacity int) *Subscription[Event]

func (*Client) WaitForEvent added in v0.5.0

func (c *Client) WaitForEvent(ctx context.Context, eventName string, options EventOptions) (Event, error)

WaitForEvent connects and waits for one matching event within one deadline. The default deadline is 12 seconds, including connection establishment.

type ClientContextOptions

type ClientContextOptions struct {
	UserID       string
	UserName     string
	AuthToken    string
	AuthProvider string
	AuthClaims   map[string]any
	Roles        []string
	Platform     string
	Source       string
	Destination  string
	Channel      string
	DeviceID     string
	Locale       string
	Metadata     map[string]any
	SessionID    string
}

type ClientKeyRejectedError added in v0.12.0

type ClientKeyRejectedError struct {
	KeyFolder      string
	OtherKeyFolder string
}

ClientKeyRejectedError is a hub refusing this client's own Noise static key.

A hub pins the first static key a connection presents and refuses any other for good, closing the link the moment the XX handshake that showed it ends. Two programs that read the same identity but keep their keys in different folders -- a satellite and a command-line tool run by another user, say -- each present their own key, and whichever came second is locked out.

KeyFolder is the folder this client's key is in; OtherKeyFolder, when there is a likely one, where another program reading the same identity keeps its key. The fix is to pair again (a new connection pins afresh), or to share the key folder: point every program that reads this identity at the folder holding the key the hub trusts (the transports' NoiseStateDir). It matches ErrClientKeyRejected, ErrHubRefused and ErrConnection.

func (*ClientKeyRejectedError) Error added in v0.12.0

func (e *ClientKeyRejectedError) Error() string

func (*ClientKeyRejectedError) Unwrap added in v0.12.0

func (e *ClientKeyRejectedError) Unwrap() []error

Unwrap makes a ClientKeyRejectedError match ErrClientKeyRejected, and through it ErrHubRefused, and ErrConnection.

type ClientOptions added in v0.2.2

type ClientOptions struct {
	Protocol       HubProtocol
	ConnectTimeout time.Duration
}

type CodeOptions

type CodeOptions struct {
	Kind      string
	Label     string
	Lang      string
	Context   Context
	SessionID string
	RequestID string
}

type Context

type Context map[string]any

func BuildClientContext

func BuildClientContext(base Context, opts ClientContextOptions) Context

func ContextWithCorrelation

func ContextWithCorrelation(raw Context, sessionID, siteID, lang, requestID string) Context

func MergeContext

func MergeContext(base, extra Context) Context

func ReplyContext added in v0.11.0

func ReplyContext(eventContext Context) Context

ReplyContext is the context of a reply to a message that carried eventContext (OVOS-MSG-1 §5.2): a deep copy, so the reply keeps the request's session and everything else it said, with the routing turned round. The reply goes to whoever sent the request ("destination" becomes the old "source") and comes from whoever it was sent to ("source" becomes the old "destination", its first entry when that is a list). A request with a destination and no source gets a reply with no destination: keeping the old one would address the reply to its own sender. A hub uses this to route the answer back to the peer that asked, across bridges and NAT. Otherwise a key that is absent or null stays as it was.

func RequestContext added in v0.7.0

func RequestContext(base Context, opts RequestContextOptions) Context

RequestContext copies the context and its session before applying nonempty hints.

type ControlPlane added in v0.2.2

type ControlPlane struct {
	APIURL      string
	AccessToken string
	UserAgent   string
	HTTPClient  *http.Client
	// TokenID names the API token in AccessToken when the sign-in that stored
	// it said which one (a device login does), for RevokeAPIToken; "" otherwise.
	// Every sign-in sets it from its own answer.
	TokenID string
	// contains filtered or unexported fields
}

func NewControlPlane added in v0.2.2

func NewControlPlane(apiURL string, accessToken string) *ControlPlane

func NewDefaultControlPlane added in v0.2.8

func NewDefaultControlPlane(accessToken string) *ControlPlane

func (*ControlPlane) BeginDeviceLogin added in v0.11.0

func (c *ControlPlane) BeginDeviceLogin(ctx context.Context, scopes []string, clientName string) (*DeviceAuthorization, error)

BeginDeviceLogin starts a device sign-in: a code for a person to approve in a browser, and the interval to poll at. It is LoginWithBrowser one step at a time, for a caller that runs its own loop -- a setup screen that shows the code and polls on its own schedule -- and it prints and opens nothing.

scopes are what the token will carry; nil or empty lets the API choose its default (hubs:read and clients:write) -- the API refuses an empty list, so one is never sent. HomeAssistantScopes is what a Home Assistant link asks for. clientName, when set, names the device on the approval page.

A verification URL that is not HTTP(S), has no host, or carries credentials is refused: it is about to be opened in a browser.

func (*ControlPlane) BeginDeviceLoginWithOptions added in v0.12.0

func (c *ControlPlane) BeginDeviceLoginWithOptions(ctx context.Context, opts DeviceLoginOptions) (*DeviceAuthorization, error)

BeginDeviceLoginWithOptions is BeginDeviceLogin with the options LoginWithBrowser takes; it reads Scopes, ClientName and ClientID, and ignores the rest.

ClientID signs in as a registered app, such as HomeAssistantClientID: the approval screen shows the platform's name for the app as verified (ClientName becomes the device's own label beside it), and approving the app again replaces the token it already holds. Such an app may ask only for its own scopes, and an id the API does not know is refused with a 400 unknown_client *APIError. "" leaves the field out.

func (*ControlPlane) ClearHubRating added in v0.3.6

func (c *ControlPlane) ClearHubRating(ctx context.Context, hubID string) (map[string]any, error)

ClearHubRating removes the caller's rating from a public hub and returns the updated hub.

Requires a token with the hubs:write scope; it is not paid-gated.

func (*ControlPlane) CompleteNativeSignIn added in v0.9.2

func (c *ControlPlane) CompleteNativeSignIn(ctx context.Context, code string, verifier string, clientID string, redirectURI string) (map[string]any, error)

CompleteNativeSignIn exchanges an authorization code for a scoped access token and stores it.

The verifier is sent here and nowhere else; it never entered the browser, which is what makes an intercepted code useless to whoever intercepted it.

A code presented twice revokes the token the first exchange minted (RFC 9700), so retrying a failed exchange with the same code destroys the token it is trying to obtain. Start again from BeginNativeSignIn.

func (*ControlPlane) CreateClient added in v0.2.2

func (c *ControlPlane) CreateClient(ctx context.Context, payload map[string]any, idempotencyKey string) (map[string]any, error)

func (*ControlPlane) CreateClientIdentity added in v0.2.2

func (c *ControlPlane) CreateClientIdentity(ctx context.Context, hub map[string]any, opts BootstrapIdentityOptions) (BootstrapIdentityResult, error)

CreateClientIdentity provisions a connection to a hub and returns the identity to connect with. The secrets are generated here and sent to the API once; the usable identity is in the result.

With opts.ConnectionType set, the connection is of that kind, and the refusals a caller can branch on come back as errors to test with errors.Is: ErrUnsupportedConnectionType (an *UnsupportedConnectionTypeError: the API does not know the kind, or made an ordinary connection instead, which is deleted), ErrPlan (the plan does not allow it), ErrAlreadyLinked (the hub already holds the one link of its kind; APIError.LinkedClientID names it) and ErrAuth (sign in again). Each still carries the *APIError the API answered with.

The result's Operation tracks the hub admitting the connection, about ninety seconds; WaitForAdmission waits for it.

func (*ControlPlane) CreateClientIdentityForHubID added in v0.2.2

func (c *ControlPlane) CreateClientIdentityForHubID(ctx context.Context, hubID string, opts BootstrapIdentityOptions) (BootstrapIdentityResult, error)

func (*ControlPlane) CreateHub added in v0.3.6

func (c *ControlPlane) CreateHub(ctx context.Context, payload map[string]any, opts HubCreateOptions) (map[string]any, error)

CreateHub creates a hub.

payload mirrors the API's hub create body: "name" and "spec" are required, and "slug", "namespace", "runtime_group_id", "domain", "active", "visibility", "capacity_profile", and "owner_id" are optional. camelCase keys are accepted and sent as snake_case.

An Idempotency-Key header is always sent. To retry safely after a timeout, reuse the same explicit HubCreateOptions.IdempotencyKey for every attempt. An empty option generates a new key for this call only; repeating such a call can create another hub.

Requires a paid plan and a token with the hubs:write scope. A free-plan token fails with HTTP 402.

func (*ControlPlane) CreateMemoryItem added in v0.2.13

func (c *ControlPlane) CreateMemoryItem(ctx context.Context, payload map[string]any) (map[string]any, error)

func (*ControlPlane) CreateRuntimeGroup added in v0.3.6

func (c *ControlPlane) CreateRuntimeGroup(ctx context.Context, payload map[string]any) (map[string]any, error)

CreateRuntimeGroup creates a runtime group.

payload takes the API's create body: "name" is required, and "description", "environment", "owner_id", and "clone_from_default" are optional. camelCase keys are accepted and sent as snake_case.

Requires a paid plan and a token with the hubs:write scope.

func (*ControlPlane) DeleteClient added in v0.11.0

func (c *ControlPlane) DeleteClient(ctx context.Context, clientID string, etag string) error

DeleteClient deletes a connection.

The API wants the connection's current etag as If-Match. With etag "" this reads it first; if another writer changed the connection in between (HTTP 412) it reads it once more and retries once. A connection that is already gone (HTTP 404 on either request) counts as deleted.

func (*ControlPlane) DeleteHub added in v0.3.6

func (c *ControlPlane) DeleteHub(ctx context.Context, hubID string, etag string) error

DeleteHub deletes a hub and its dependent clients and ACLs.

Like UpdateHub this route requires the hub's current etag, sent as If-Match; a stale value fails with HTTP 412. Empty or whitespace-only etags fail locally with ErrAPI before sending a request.

Requires a paid plan and a token with the hubs:write scope.

func (*ControlPlane) DeleteMemoryItem added in v0.2.13

func (c *ControlPlane) DeleteMemoryItem(ctx context.Context, memoryID string) error

func (*ControlPlane) DeleteRuntimeGroup added in v0.3.6

func (c *ControlPlane) DeleteRuntimeGroup(ctx context.Context, runtimeGroupID string) error

DeleteRuntimeGroup deletes a runtime group.

The API answers HTTP 409 for the workspace default group and for a group that still has hubs attached.

Requires a paid plan and a token with the hubs:write scope.

func (*ControlPlane) DescribeDeviceLogin added in v0.12.0

func (c *ControlPlane) DescribeDeviceLogin(ctx context.Context, userCode string) (*DeviceLoginRequest, error)

DescribeDeviceLogin reads a pending device sign-in by its user code, as its approver sees it: GET /v1/auth/device/codes/{userCode}, signed in as the person who would approve it. It says which app asked and whether the platform vouches for its name (ClientVerified). A code that is unknown, expired or already answered is a 404 *APIError.

func (*ControlPlane) GetAnalyticsOverview added in v0.2.13

func (c *ControlPlane) GetAnalyticsOverview(ctx context.Context, opts AnalyticsOverviewOptions) (map[string]any, error)

func (*ControlPlane) GetClient added in v0.11.0

func (c *ControlPlane) GetClient(ctx context.Context, clientID string) (map[string]any, error)

GetClient reads one connection (a client of a hub), with the etag a change to it needs.

func (*ControlPlane) GetHub added in v0.2.2

func (c *ControlPlane) GetHub(ctx context.Context, hubID string) (map[string]any, error)

func (*ControlPlane) GetHubRuntimeCapabilities added in v0.3.6

func (c *ControlPlane) GetHubRuntimeCapabilities(ctx context.Context, hubID string) (map[string]any, error)

GetHubRuntimeCapabilities reads the live skill and intent inventory a hub runtime exposes.

Requires a token with the hubs:inspect scope. The API answers HTTP 409 when the hub has no connected client that can report inventory and no runtime group snapshot to fall back on. ListRuntimeGroupInventory is the read that reports a pending source instead of failing.

func (*ControlPlane) GetMemoryItem added in v0.2.13

func (c *ControlPlane) GetMemoryItem(ctx context.Context, memoryID string) (map[string]any, error)

func (*ControlPlane) GetMemorySummary added in v0.2.13

func (c *ControlPlane) GetMemorySummary(ctx context.Context, ownerID string) (map[string]any, error)

func (*ControlPlane) GetOperation added in v0.2.16

func (c *ControlPlane) GetOperation(ctx context.Context, operationID string) (OperationResource, error)

func (*ControlPlane) GetPublicHub added in v0.2.6

func (c *ControlPlane) GetPublicHub(ctx context.Context, hubRef string) (map[string]any, error)

func (*ControlPlane) GetRuntimeGroup added in v0.3.6

func (c *ControlPlane) GetRuntimeGroup(ctx context.Context, runtimeGroupID string) (map[string]any, error)

GetRuntimeGroup fetches one runtime group.

Requires a token with the hubs:read scope.

func (*ControlPlane) GetRuntimeGroupConfig added in v0.3.6

func (c *ControlPlane) GetRuntimeGroupConfig(ctx context.Context, runtimeGroupID string) (map[string]any, error)

GetRuntimeGroupConfig reads a runtime group's runtime configuration and personas.

Requires a token with the hubs:read scope.

func (*ControlPlane) InstallHubSkill added in v0.6.0

func (c *ControlPlane) InstallHubSkill(ctx context.Context, hubID, skill, version string, opts HubSkillWaitOptions) (map[string]any, error)

InstallHubSkill installs on the shared runtime; every hub using it is affected. Use version "latest" or an exact version. Requires hubs:write and a paid plan.

func (*ControlPlane) InstallRuntimeGroupSkill added in v0.3.6

func (c *ControlPlane) InstallRuntimeGroupSkill(ctx context.Context, runtimeGroupID string, skillID string, opts RuntimeGroupSkillInstallOptions) (map[string]any, error)

InstallRuntimeGroupSkill installs, or re-installs, a skill in a runtime group.

The default source type of "catalog" installs a marketplace skill and requires the skill to exist in the catalog; a "git" install needs RuntimeGroupSkillInstallOptions.SourceRef. Installing a skill that is already present updates the existing entry.

Requires a paid plan and a token with the hubs:write scope. Paid marketplace skills also need marketplace access on the tenant plan.

func (*ControlPlane) ListHubSkillHistory added in v0.6.0

func (c *ControlPlane) ListHubSkillHistory(ctx context.Context, hubID string, limit int) (map[string]any, error)

ListHubSkillHistory reads newest-first events and operations. Limit is 1–200.

func (*ControlPlane) ListHubSkills added in v0.6.0

func (c *ControlPlane) ListHubSkills(ctx context.Context, hubID string) (map[string]any, error)

ListHubSkills reads the skills of the hub's shared runtime group.

func (*ControlPlane) ListHubs added in v0.2.2

func (c *ControlPlane) ListHubs(ctx context.Context, limit int, cursor string, ownerID string) (map[string]any, error)

func (*ControlPlane) ListMarketplaceSkills added in v0.3.6

func (c *ControlPlane) ListMarketplaceSkills(ctx context.Context, opts MarketplaceSkillListOptions) (map[string]any, error)

ListMarketplaceSkills lists the marketplace skill catalog visible to the authenticated user.

The returned "data" entries carry the catalog fields an install needs -- "skill_id", "source_type", "source_ref", "package_name", "version" compatibility, "config_schema" and "secret_schema" -- alongside presentation and access fields such as "category", "tags", "verified", "access_tier" and "billing_sku". Global catalog entries and the caller's own tenant entries are both included.

Requires a token with the hubs:read scope. Unlike the provisioning routes this catalog is not paid-gated, so free-plan callers can browse the marketplace before upgrading; only the install itself needs a paid plan.

func (*ControlPlane) ListMemoryItems added in v0.2.13

func (c *ControlPlane) ListMemoryItems(ctx context.Context, opts MemoryListOptions) (map[string]any, error)

func (*ControlPlane) ListPublicHubs added in v0.2.6

func (c *ControlPlane) ListPublicHubs(ctx context.Context, limit int, cursor string) (map[string]any, error)

func (*ControlPlane) ListRuntimeGroupInventory added in v0.3.6

func (c *ControlPlane) ListRuntimeGroupInventory(ctx context.Context, runtimeGroupID string, opts RuntimeGroupInventoryOptions) (map[string]any, error)

ListRuntimeGroupInventory lists the skills a runtime group is actually observed running.

Where ListRuntimeGroupMarketplace answers "what could be installed here", this answers "what is loaded right now": each entry carries "skill_id", "version", "source", "active", "adapt_intents", "padatious_intents", "total_intents" and "observed_at". The envelope reports the observation's provenance in "source" -- "ovos-runtime-operator", "runtime-group-cache" or "ovos-runtime-operator-pending" -- plus "operator_phase" and "operator_message".

Unlike GetHubRuntimeCapabilities this route does not answer HTTP 409 when nothing is reporting: it returns an empty "data" list with a pending "source" instead.

Requires a token with the hubs:inspect scope; no paid plan is needed.

func (*ControlPlane) ListRuntimeGroupMarketplace added in v0.3.6

func (c *ControlPlane) ListRuntimeGroupMarketplace(ctx context.Context, runtimeGroupID string, opts RuntimeGroupMarketplaceOptions) (map[string]any, error)

ListRuntimeGroupMarketplace lists the marketplace catalog resolved against one runtime group.

This is the discovery view to use before installing: every catalog entry is returned with the group's own state folded in -- whether the skill is desired ("active", "version_pin", "source_type"), whether it was observed running ("observed_source", "observed_at", intent counts), operator status fields, and the access verdict for the tenant plan ("purchase_required", "installable", "access_message"). The envelope also carries "runtime_group_id", "observed_at", "source", "operator_phase" and "operator_message".

Requires a token with the hubs:inspect scope; no paid plan is needed to browse. The API answers HTTP 404 for an unknown group and HTTP 403 when the caller does not own it.

func (*ControlPlane) ListRuntimeGroups added in v0.3.6

func (c *ControlPlane) ListRuntimeGroups(ctx context.Context, ownerID string) (map[string]any, error)

ListRuntimeGroups lists the runtime groups visible to the authenticated user. An empty ownerID is omitted from the query.

Requires a token with the hubs:read scope.

func (*ControlPlane) Login added in v0.2.2

func (c *ControlPlane) Login(ctx context.Context, email string, password string, scope string) (map[string]any, error)

func (*ControlPlane) LoginWithBrowser added in v0.3.3

func (c *ControlPlane) LoginWithBrowser(ctx context.Context, opts DeviceLoginOptions) (map[string]any, error)

LoginWithBrowser signs in through the browser device flow and stores the returned API token. This is the sign-in path for accounts without a password (for example Google sign-in). It requests a device authorization, tells the user to visit verification_uri and enter the short user_code (set DeviceLoginOptions.Prompt to present it yourself), opens the browser at verification_uri_complete on a best-effort basis unless DeviceLoginOptions.OpenBrowser is false, and polls until the request is approved, denied, expired, the timeout elapses, or ctx is cancelled.

On approval the returned access_token is a durable scoped API token and is stored on ControlPlane.AccessToken exactly like Login, with its token_id on ControlPlane.TokenID. Denial, expiry, and timeout are reported as ErrDeviceAccessDenied (a *DeviceLoginDeniedError), ErrDeviceCodeExpired (a *DeviceLoginExpiredError), and ErrTimeout respectively. BeginDeviceLogin and PollDeviceLogin are the same flow one step at a time.

func (*ControlPlane) LoginWithOptions added in v0.3.2

func (c *ControlPlane) LoginWithOptions(ctx context.Context, email string, password string, opts LoginOptions) (map[string]any, error)

func (*ControlPlane) PollDeviceLogin added in v0.11.0

func (c *ControlPlane) PollDeviceLogin(ctx context.Context, authorization *DeviceAuthorization) (*APIToken, error)

PollDeviceLogin asks once whether a device sign-in was approved.

On approval it returns the token and keeps it on this ControlPlane (AccessToken and TokenID), exactly like Login. Otherwise it returns:

  • *DeviceLoginPendingError (errors.Is ErrDeviceLoginPending): nobody has approved yet; poll again after its Interval. A slow_down answer lengthens authorization.Interval by five seconds, for good, as RFC 8628 §3.5 asks, so keep polling with the same authorization;
  • *DeviceLoginExpiredError (errors.Is ErrDeviceCodeExpired): start again;
  • *DeviceLoginDeniedError (errors.Is ErrDeviceAccessDenied);
  • any other refusal as an *APIError carrying what the API said.

All of them match ErrAPI. Neither the device code nor the token ever appears in an error.

func (*ControlPlane) ReleaseHub added in v0.3.6

func (c *ControlPlane) ReleaseHub(ctx context.Context, hubID string, opts ReleaseOptions) (map[string]any, error)

ReleaseHub applies a hub release policy and returns the updated hub.

Requires a paid plan and a token with the hubs:write scope.

func (*ControlPlane) ReleaseRuntimeGroup added in v0.3.6

func (c *ControlPlane) ReleaseRuntimeGroup(ctx context.Context, runtimeGroupID string, opts ReleaseOptions) (map[string]any, error)

ReleaseRuntimeGroup applies a runtime image policy and returns the updated runtime group. Options behave like ReleaseHub.

Requires a paid plan and a token with the hubs:write scope.

func (*ControlPlane) RemoveHubSkill added in v0.6.0

func (c *ControlPlane) RemoveHubSkill(ctx context.Context, hubID, skill string, opts HubSkillWaitOptions) (map[string]any, error)

RemoveHubSkill removes the shared runtime attachment, affecting every served hub.

func (*ControlPlane) ReplaceRuntimeGroupConfig added in v0.7.0

func (c *ControlPlane) ReplaceRuntimeGroupConfig(ctx context.Context, runtimeGroupID string, config map[string]any, opts RuntimeGroupConfigOptions) (map[string]any, error)

ReplaceRuntimeGroupConfig explicitly replaces configuration via unconditional PATCH. Personas are replaced only when non-nil. Requires paid hubs:write.

func (*ControlPlane) RequireRuntimeProtocol added in v0.2.2

func (c *ControlPlane) RequireRuntimeProtocol(result BootstrapIdentityResult, protocol HubProtocol) (*SelectedHubEndpoint, error)

func (*ControlPlane) RevokeAPIToken added in v0.11.0

func (c *ControlPlane) RevokeAPIToken(ctx context.Context, tokenID string) error

RevokeAPIToken revokes an API token; with tokenID "" the one this ControlPlane signed in with (TokenID). A token may always revoke itself, whatever its scopes. Revoking the token in use forgets it here too, so a later call fails locally rather than with an HTTP 401.

Revoking the token in use is idempotent. A token already revoked, or expired, cannot authenticate its own revoke, so the API answers 401; the token is dead either way, so that counts as revoked and the token is forgotten. Revoking it again then sends nothing and returns nil, until the next sign-in. Revoking another token by id is not idempotent: the API's own answer, such as a 404 for a token it does not know, is returned as usual.

A sign-in that finishes on another goroutine while the revoke is on its way keeps its token: the revoke forgets only the token it revoked, checked and cleared under the same lock a sign-in stores its token and id under. The lock is never held across the request.

func (*ControlPlane) SetHubRating added in v0.3.6

func (c *ControlPlane) SetHubRating(ctx context.Context, hubID string, rating int) (map[string]any, error)

SetHubRating rates a public hub from 1 to 5 and returns the updated hub.

Only public hubs can be rated, and owners cannot rate their own hubs. Requires a token with the hubs:write scope; unlike the provisioning routes this one is not paid-gated.

func (ControlPlane) String added in v0.3.7

func (c ControlPlane) String() string

String implements fmt.Stringer so the %v, %s, and %+v verbs render a ControlPlane with its AccessToken (a bearer API token) redacted. The receiver is a value so a dereferenced *ControlPlane printed with %v is redacted too. This is a human-facing formatting guard only; it does not affect json.Marshal.

func (*ControlPlane) UninstallRuntimeGroupSkill added in v0.3.6

func (c *ControlPlane) UninstallRuntimeGroupSkill(ctx context.Context, runtimeGroupID string, skillID string) error

UninstallRuntimeGroupSkill removes a skill from a runtime group.

Requires a paid plan and a token with the hubs:write scope.

func (*ControlPlane) UpdateHub added in v0.3.6

func (c *ControlPlane) UpdateHub(ctx context.Context, hubID string, payload map[string]any, etag string) (map[string]any, error)

UpdateHub partially updates a hub.

The API enforces optimistic locking on this route, so etag is required: pass the "etag" of the hub resource you read and the SDK sends it as If-Match. A stale value fails with HTTP 412 and changes nothing; re-read the hub with GetHub and retry with the new etag. Empty or whitespace-only etags fail locally with ErrAPI before sending a request.

Requires a paid plan and a token with the hubs:write scope.

func (*ControlPlane) UpdateHubSkill added in v0.6.0

func (c *ControlPlane) UpdateHubSkill(ctx context.Context, hubID, skill, version string, opts HubSkillWaitOptions) (map[string]any, error)

UpdateHubSkill moves a skill to an exact version or "latest" on the shared runtime.

func (*ControlPlane) UpdateMemoryItem added in v0.2.13

func (c *ControlPlane) UpdateMemoryItem(ctx context.Context, memoryID string, payload map[string]any) (map[string]any, error)

func (*ControlPlane) UpdateRuntimeGroup added in v0.3.6

func (c *ControlPlane) UpdateRuntimeGroup(ctx context.Context, runtimeGroupID string, payload map[string]any) (map[string]any, error)

UpdateRuntimeGroup updates a runtime group's "name", "description", or "spec". "spec" patches "replicas" and container "resources". Unlike the hub routes this one reads no If-Match header.

Requires a paid plan and a token with the hubs:write scope.

func (*ControlPlane) UpdateRuntimeGroupConfig added in v0.3.6

func (c *ControlPlane) UpdateRuntimeGroupConfig(ctx context.Context, runtimeGroupID string, config map[string]any, opts RuntimeGroupConfigOptions) (map[string]any, error)

UpdateRuntimeGroupConfig deep-merges with a revision precondition and at most three attempts. Only 412 conflicts trigger a fresh read and merge. Older APIs fail before writing. Requires hubs:read and paid hubs:write.

func (*ControlPlane) WaitForAdmission added in v0.11.0

func (c *ControlPlane) WaitForAdmission(ctx context.Context, operation *OperationResource, opts AdmissionOptions) error

WaitForAdmission waits until the hub has admitted a new connection, about ninety seconds after CreateClientIdentity returned it. Pass the result's Operation.

It follows the operation, reading GET /v1/operations/{id}:

  • ready: admitted, and it returns nil;
  • failed or timed_out: *AdmissionFailedError, with the operation's ErrorCode;
  • requested, committed, applied: it keeps reading;
  • no operation at all (nil), or HTTP 404 (the API no longer tracks it): admitted at once;
  • HTTP 429: the next read waits what the API asks (APIError.RetryAfter: the body's retry_after_seconds, else Retry-After, else RateLimit-Reset), or the poll interval when that is longer; asking for longer than the time left is the timeout at once;
  • HTTP 5xx: ridden out;
  • HTTP 401 or 403: the *APIError itself, since the token rather than the connection is the trouble (errors.Is ErrAuth for a revoked token or a missing scope);
  • any other refusal of the wait: *AdmissionFailedError, whose Err is the *APIError with the status, code and detail the API answered;
  • a request that did not reach the API: returned as it is (errors.Is ErrAPIUnreachable), since losing the API says nothing about the hub.

When opts.Timeout passes first it returns *AdmissionTimeoutError, which matches both ErrConnection and ErrTimeout: the connection may still be admitted later. Cancelling ctx ends the wait with ErrTimeout and the context's error. A hub that refuses the new credentials inside this window is not admitting them yet, not refusing them.

An operation whose links.self points at another origin than the API's is refused and never fetched: the token goes to the API and nowhere else.

func (*ControlPlane) WaitForHubSkillOperation added in v0.6.0

func (c *ControlPlane) WaitForHubSkillOperation(ctx context.Context, accepted map[string]any, opts HubSkillWaitOptions) (map[string]any, error)

WaitForHubSkillOperation resumes an accepted write without repeating it. On failure the accepted response is also returned so its operation_id survives.

type Conversation

type Conversation struct {
	Client  *Client
	Options ConversationOptions
}

func (Conversation) Ask

func (c Conversation) Ask(ctx context.Context, text string, opts RequestOptions) (Reply, error)

func (Conversation) Query added in v0.2.15

func (c Conversation) Query(ctx context.Context, text string, opts QueryOptions) (Reply, error)

func (Conversation) SendAction

func (c Conversation) SendAction(ctx context.Context, payload string, opts ActionOptions) error

func (Conversation) SendCode

func (c Conversation) SendCode(ctx context.Context, value string, opts CodeOptions) error

func (Conversation) SendUtterance

func (c Conversation) SendUtterance(ctx context.Context, text string, opts RequestOptions) error

type ConversationOptions

type ConversationOptions struct {
	SessionID string
	Lang      string
	Context   Context
}

type Data

type Data map[string]any

func AnswerHomeRequest added in v0.11.0

func AnswerHomeRequest(ctx context.Context, replier Replier, event Event, handler HomeHandler, opts HomeAnswerOptions) (Data, error)

AnswerHomeRequest answers one request: it runs handler, then replies with whatever happened, and returns the payload it sent.

Everything happens inside the hub's bound (opts.HubTimeout, HomeRequestTimeout by default), counted from the call: the hub gives up on a request after that, and an answer it has given up on only confuses the next one. The handler gets opts.Timeout (DefaultHomeHandlerTimeout by default) or what is left of the bound, whichever is less, under a context derived from ctx; the reply gets whatever the handler left. When the handler fails the answer is failed_to_handle, and when it is still running at its deadline the answer is timeout, sent at once: a handler that ignores its context is left to finish on its own goroutine, and what it returns then is dropped.

A reply is never started after the bound, and one still waiting to be sent when it passes is withdrawn; a frame already being written is finished, since half of one would break the Noise stream. When there was no time to answer, AnswerHomeRequest returns a nil payload and a nil error. When ctx itself ends first, nothing is sent and ctx's error is returned: the link is going away.

func HomeResponse added in v0.11.0

func HomeResponse(request HomeRequest, answer HomeAnswer) Data

HomeResponse is the thalovant.home.response payload for answer, held to the contract: an unknown response type or error code becomes HomeError with HomeErrorUnknown, keeping the speech.

func UtterancePayload

func UtterancePayload(text, lang string) Data

type DeviceAuthorization added in v0.11.0

type DeviceAuthorization struct {
	DeviceCode      string
	UserCode        string
	VerificationURI string
	// VerificationURIComplete is the verification URL with the code in it, or
	// "" when the API sent none.
	VerificationURIComplete string
	// Interval is how long to wait between two polls. A slow_down answer
	// lengthens it on the authorization that was polled.
	Interval time.Duration
	// ExpiresIn is how long the code stays valid from when it was issued.
	ExpiresIn time.Duration
}

DeviceAuthorization is a started device sign-in (RFC 8628): what to show a person, and what to poll with. Show VerificationURI and UserCode, or open VerificationURIComplete, which carries the code; then call PollDeviceLogin every Interval.

DeviceCode is the secret half and never needs showing; String() redacts it. Keep the whole value to resume polling later, in this process or another.

func (DeviceAuthorization) GoString added in v0.11.0

func (a DeviceAuthorization) GoString() string

GoString keeps %#v from printing the device code.

func (DeviceAuthorization) String added in v0.11.0

func (a DeviceAuthorization) String() string

String renders the authorization with its device code redacted.

type DeviceLoginDeniedError added in v0.11.0

type DeviceLoginDeniedError struct {
	APIError *APIError
}

DeviceLoginDeniedError is a device sign-in the person refused in the browser. It matches ErrDeviceAccessDenied, and errors.As reaches the *APIError the API answered with.

func (*DeviceLoginDeniedError) Error added in v0.11.0

func (e *DeviceLoginDeniedError) Error() string

func (*DeviceLoginDeniedError) Unwrap added in v0.11.0

func (e *DeviceLoginDeniedError) Unwrap() []error

Unwrap makes a DeviceLoginDeniedError match ErrDeviceAccessDenied and ErrAPI.

type DeviceLoginExpiredError added in v0.11.0

type DeviceLoginExpiredError struct {
	APIError *APIError
}

DeviceLoginExpiredError is a device sign-in code that expired before anyone approved it; start a new sign-in for a new code. It matches ErrDeviceCodeExpired, and errors.As reaches the *APIError the API answered with.

func (*DeviceLoginExpiredError) Error added in v0.11.0

func (e *DeviceLoginExpiredError) Error() string

func (*DeviceLoginExpiredError) Unwrap added in v0.11.0

func (e *DeviceLoginExpiredError) Unwrap() []error

Unwrap makes a DeviceLoginExpiredError match ErrDeviceCodeExpired and ErrAPI.

type DeviceLoginOptions added in v0.3.3

type DeviceLoginOptions struct {
	Scopes     []string
	ClientName string
	// ClientID signs in as a registered app, such as HomeAssistantClientID;
	// "" leaves it out. See BeginDeviceLoginWithOptions.
	ClientID    string
	OpenBrowser *bool
	Prompt      func(grant map[string]any)
	Timeout     time.Duration
}

DeviceLoginOptions carries optional device-flow sign-in inputs for LoginWithBrowser. Scopes and ClientName are forwarded to the device authorization request when set (an empty Scopes is left out, as the API refuses one); the server may expand the echoed scopes during normalization. OpenBrowser defaults to true when nil. Prompt, when set, receives the device authorization payload instead of the default message printed to stdout. Timeout bounds the whole approval wait and defaults to DefaultDeviceLoginTimeout when zero.

type DeviceLoginPendingError added in v0.11.0

type DeviceLoginPendingError struct {
	// Interval is how long to wait before the next poll.
	Interval time.Duration
	// APIError is what the API answered.
	APIError *APIError
}

DeviceLoginPendingError is a device sign-in nobody has approved yet: poll again after Interval. A slow_down answer has already lengthened it, for good, on the DeviceAuthorization that was polled. It matches ErrDeviceLoginPending, and errors.As reaches the *APIError the API answered with (HTTP 400).

func (*DeviceLoginPendingError) Error added in v0.11.0

func (e *DeviceLoginPendingError) Error() string

func (*DeviceLoginPendingError) Unwrap added in v0.11.0

func (e *DeviceLoginPendingError) Unwrap() []error

Unwrap makes a DeviceLoginPendingError match ErrDeviceLoginPending and ErrAPI.

type DeviceLoginRequest added in v0.12.0

type DeviceLoginRequest struct {
	Scopes     []string
	ClientName string
	// ExpiresAt is when the code stops being approvable; zero when the API did
	// not say.
	ExpiresAt      time.Time
	ClientID       string
	ClientVerified bool
	DeviceName     string
}

DeviceLoginRequest is a pending device sign-in as the person approving it sees it; see DescribeDeviceLogin.

ClientVerified is true only when a registered app asked (it named its ClientID): ClientName is then the platform's own name for that app, and DeviceName whatever the device called itself, which nothing checks. Otherwise ClientName is the device's own claim.

type DisplayItem

type DisplayItem struct {
	Kind    string
	Text    string
	Data    any
	Title   string
	Payload string
	URL     string
	Silent  bool
}

func DisplayItemsFromEventData

func DisplayItemsFromEventData(data Data, eventName string, maxTextChars int) []DisplayItem

type Event

type Event struct {
	Name    string
	Data    Data
	Context Context
	Raw     any
}

func (Event) AudioBytes added in v0.7.0

func (e Event) AudioBytes() ([]byte, error)

AudioBytes decodes embedded hex only. It never fetches a skill path or URL.

func (Event) AudioBytesWithLimit added in v0.7.0

func (e Event) AudioBytesWithLimit(maxBytes int) ([]byte, error)

func (Event) DisplayItems

func (e Event) DisplayItems(maxTextChars int) []DisplayItem

func (Event) DisplayText

func (e Event) DisplayText() string

func (Event) HasAudio added in v0.7.0

func (e Event) HasAudio() bool

func (Event) IsAudio added in v0.7.0

func (e Event) IsAudio() bool

func (Event) IsFailure

func (e Event) IsFailure() bool

func (Event) Lang added in v0.7.0

func (e Event) Lang() string

func (Event) RequestID

func (e Event) RequestID() string

func (Event) RichMedia

func (e Event) RichMedia() map[string]any

func (Event) SessionID

func (e Event) SessionID() string

func (Event) Text

func (e Event) Text() string

func (Event) Utterances

func (e Event) Utterances() []string

type EventOptions added in v0.5.0

type EventOptions struct {
	Timeout   time.Duration
	Context   Context
	SessionID string
	RequestID string
	Predicate func(Event) bool
}

EventOptions scopes an event waiter or listener. A matching request ID takes precedence over a hub-assigned session ID; ID-less replies retain the shared legacy session fallback. Predicate runs after event-name and context filtering.

type EventSubscriber added in v0.5.0

type EventSubscriber interface {
	SubscribeEvents(capacity int) *Subscription[Event]
}

EventSubscriber is optional for custom transports, preserving RuntimeTransport. Built-in transports implement it. Custom transports should implement it when concurrent calls or independent passive subscribers are required.

type HTTPTransport

type HTTPTransport struct {
	Identity     Identity
	UserAgent    string
	PollInterval time.Duration
	HTTPClient   *http.Client
	// NoiseStateDir selects the persistent client key and hub pin directory.
	// Empty uses the directory of the file the identity was read from
	// (Identity.SourcePath), else NoiseStateDir().
	NoiseStateDir string

	BusEvents  chan Event
	HiveEvents chan HiveMessage
	// contains filtered or unexported fields
}

func NewHTTPTransport

func NewHTTPTransport(identity Identity) *HTTPTransport

func (*HTTPTransport) Authorization

func (t *HTTPTransport) Authorization() string

func (*HTTPTransport) BaseURL

func (t *HTTPTransport) BaseURL() string

func (*HTTPTransport) ClosedRefused added in v0.12.0

func (t *HTTPTransport) ClosedRefused() bool

ClosedRefused reports whether the hub refused this connection's credentials the last time it ended: a request answered 401 or 403 before the hub had sent anything that decrypted under the session's keys. A hub that has spoken accepted the credentials, so a refusal after that is not read as one. A new connection attempt clears it.

func (*HTTPTransport) Connect

func (t *HTTPTransport) Connect(ctx context.Context) error

Connect opens the HTTP session and completes the v3 Noise handshake. A KK handshake the hub refuses, or whose answer does not authenticate, is followed at once by one XX handshake inside this connect, since only XX tells a changed password (ErrHubRefused) from a changed hub key (ErrHubKeyChanged); the XX attempt's outcome is the connect's.

A request answered 401 or 403 while this client sends the last frames of an XX handshake it completed is the hub refusing this client's own key: a *ClientKeyRejectedError.

func (*HTTPTransport) ConnectionInfo added in v0.2.14

func (t *HTTPTransport) ConnectionInfo() TransportConnectionInfo

func (*HTTPTransport) Disconnect

func (t *HTTPTransport) Disconnect(ctx context.Context) error

Disconnect bounds the caller while retaining teardown ownership until old readers retire. Only an acknowledged remote reset clears admission.

func (*HTTPTransport) EmitBus

func (t *HTTPTransport) EmitBus(ctx context.Context, eventType string, data Data, eventContext Context) error

func (*HTTPTransport) Events added in v0.2.4

func (t *HTTPTransport) Events() <-chan Event

func (*HTTPTransport) Healthcheck

func (t *HTTPTransport) Healthcheck() TransportHealth

func (*HTTPTransport) HiveMessages added in v0.2.15

func (t *HTTPTransport) HiveMessages() <-chan HiveMessage

func (*HTTPTransport) IsHandshakeComplete

func (t *HTTPTransport) IsHandshakeComplete() bool

func (*HTTPTransport) PollOnce

func (t *HTTPTransport) PollOnce(ctx context.Context) error

func (*HTTPTransport) RemoteStaticKey added in v0.4.4

func (t *HTTPTransport) RemoteStaticKey() string

RemoteStaticKey returns the authenticated peer key, empty outside a session.

func (*HTTPTransport) SendHiveMessage added in v0.2.15

func (t *HTTPTransport) SendHiveMessage(ctx context.Context, message HiveMessage, encrypt bool) error

func (*HTTPTransport) SubscribeEvents added in v0.5.0

func (t *HTTPTransport) SubscribeEvents(capacity int) *Subscription[Event]

func (*HTTPTransport) SubscribeHiveMessages added in v0.5.0

func (t *HTTPTransport) SubscribeHiveMessages(capacity int) *Subscription[HiveMessage]

type HiveMessage

type HiveMessage struct {
	MsgType string         `json:"msg_type"`
	Payload map[string]any `json:"payload"`
	// Binary is set only on a BINARY frame, whose payload is bytes, not JSON.
	Binary       *ThalovantBinary `json:"-"`
	Metadata     map[string]any   `json:"metadata"`
	Route        []any            `json:"route"`
	Node         any              `json:"node"`
	TargetSiteID any              `json:"target_site_id"`
	TargetPubKey any              `json:"target_pubkey"`
	SourcePeer   any              `json:"source_peer"`
}

func DecodeHiveBinaryFrame added in v0.2.5

func DecodeHiveBinaryFrame(payload []byte) (HiveMessage, error)

type HiveMessageSubscriber added in v0.5.0

type HiveMessageSubscriber interface {
	SubscribeHiveMessages(capacity int) *Subscription[HiveMessage]
}

type HomeAnswer added in v0.11.0

type HomeAnswer struct {
	Speech               string
	ResponseType         string
	ErrorCode            string
	ContinueConversation bool
	ConversationID       string
}

HomeAnswer is what a handler says back. Speech may carry markup; it is sent as plain text. An empty ResponseType is HomeActionDone. ErrorCode is sent only with HomeError. ConversationID, when empty, echoes the request's.

type HomeAnswerOptions added in v0.11.0

type HomeAnswerOptions struct {
	// Timeout is how long each handler has at most; DefaultHomeHandlerTimeout
	// when zero.
	Timeout time.Duration
	// HubTimeout is the hub's bound on a request, counted from its arrival,
	// which the handler and the reply share; HomeRequestTimeout when zero.
	HubTimeout time.Duration
	// OnReplyError, when set, hears about an answer that could not be sent.
	// An answer there was no time left to send is not an error.
	OnReplyError func(request HomeRequest, err error)
}

HomeAnswerOptions tunes AnswerHomeRequest and AnswerHomeRequests.

type HomeHandler added in v0.11.0

type HomeHandler func(ctx context.Context, request HomeRequest) (HomeAnswer, error)

HomeHandler answers one request. It runs on its own goroutine under a context that ends when the handler's time is up; an error, a panic or running out of time is answered for it (failed_to_handle, failed_to_handle, timeout).

type HomeLink interface {
	Replier
	On(eventType string, handler func(Event)) (unsubscribe func())
}

HomeLink is a connection a hub's requests arrive on: somewhere to register a handler for one event type, and to reply. HubSession is one.

type HomeRequest added in v0.11.0

type HomeRequest struct {
	RequestID      string
	Utterance      string
	Lang           string
	ConversationID string
	// Event is what the request arrived as; the answer is a reply to it.
	Event Event
}

HomeRequest is one thalovant.home.request: what was said, in which language. Absent fields are "".

func HomeRequestFromEvent added in v0.11.0

func HomeRequestFromEvent(event Event) HomeRequest

HomeRequestFromEvent reads a request out of the event it arrived as. Only a non-empty string counts as a value.

type HubCreateOptions added in v0.3.6

type HubCreateOptions struct {
	IdempotencyKey string
}

HubCreateOptions carries the optional inputs of CreateHub. IdempotencyKey overrides the key the SDK generates for the Idempotency-Key header; leave it empty to let CreateHub mint one.

type HubDataPlaneEndpoints added in v0.2.1

type HubDataPlaneEndpoints struct {
	HTTPS string `json:"https,omitempty"`
	WSS   string `json:"wss,omitempty"`
	MQTT  string `json:"mqtt,omitempty"`
}

func DataPlaneEndpointsFromHub added in v0.2.1

func DataPlaneEndpointsFromHub(hub map[string]any) HubDataPlaneEndpoints

func DataPlaneEndpointsFromMap added in v0.2.1

func DataPlaneEndpointsFromMap(values map[string]any) HubDataPlaneEndpoints

func (HubDataPlaneEndpoints) EndpointFor added in v0.2.1

func (e HubDataPlaneEndpoints) EndpointFor(protocol HubProtocol) string

func (HubDataPlaneEndpoints) HTTPBase added in v0.2.1

func (e HubDataPlaneEndpoints) HTTPBase(fallbackMaster string, fallbackPort int, fallbackPath string) string

func (HubDataPlaneEndpoints) Map added in v0.2.1

func (e HubDataPlaneEndpoints) Map(redactCredentials bool) map[string]string

Map renders the data-plane endpoints as a plain map. Polarity note: the boolean is redactCredentials, where true STRIPS any embedded userinfo credentials from each endpoint URL and false returns them verbatim. This is the OPPOSITE polarity of MqttBrokerCredentials.Map(includeSecrets bool) in identity.go, which reveals when its boolean is true — keep the two straight at call sites.

type HubFallback added in v0.5.0

type HubFallback struct {
	SkillID  string `json:"skill_id"`
	Priority int64  `json:"priority"`
}

type HubIntent added in v0.3.13

type HubIntent struct {
	SkillID   string              `json:"skill_id"`
	Name      string              `json:"name"`
	Engine    string              `json:"engine"`
	Enabled   bool                `json:"enabled"`
	Languages []string            `json:"languages"`
	Phrases   map[string][]string `json:"phrases"`
}

HubIntent is one thing a hub can be asked, with the sentences that ask it, per language. Phrases is keyed by the language tag the inventory was asked for; Languages lists those keys in the order they were asked. A names-only inventory carries neither.

func (HubIntent) Examples added in v0.3.13

func (i HubIntent) Examples(lang string, limit int) []string

Examples returns complete phrases before prefixes and slots, preferring fuller wording up to eight words. A non-positive limit returns all results.

func (HubIntent) ExamplesWithOptions added in v0.7.0

func (i HubIntent) ExamplesWithOptions(lang string, limit int, opts IntentExampleOptions) []string

func (HubIntent) ID added in v0.3.13

func (i HubIntent) ID() string

ID is the intent's "<skill_id>:<name>" name, as the engines' manifests spell it.

func (HubIntent) PhrasesFor added in v0.3.13

func (i HubIntent) PhrasesFor(lang string) []string

PhrasesFor returns the closest OVOS-compatible language registration.

type HubIntentCapabilities added in v0.5.0

type HubIntentCapabilities struct {
	Inventory      HubIntentInventory `json:"inventory"`
	Fallbacks      []HubFallback      `json:"fallbacks"`
	FallbacksKnown bool               `json:"fallbacks_known"`
}

HubIntentCapabilities enriches the existing inventory without changing HubIntentInventory struct literals. Unknown fallbacks do not mean none.

func (HubIntentCapabilities) MayAnswer added in v0.5.0

func (c HubIntentCapabilities) MayAnswer(lang string) bool

MayAnswer conservatively avoids declaring a language unsupported just because no registered intent has phrases. It is not a language guarantee.

type HubIntentInventory added in v0.3.13

type HubIntentInventory struct {
	Languages []string          `json:"languages"`
	Skills    []HubSkillIntents `json:"skills"`
	Source    string            `json:"source"`
	Denied    []string          `json:"denied"`
	// ListedIn is the tag the hub actually listed each requested language
	// under, in Languages order. Equal to Languages unless a listing came
	// back empty and the language's usual form answered instead, which is the
	// only way the two differ. Callers rendering sentences must read them
	// from the tag that answered.
	ListedIn []string `json:"listed_in"`
}

HubIntentInventory is everything a hub can be asked, grouped by skill.

Source says how it was read: IntentSourceManifest carries sentences per language; IntentSourceEngines is the names-only fallback, and Denied then names the query the hub refused.

func (HubIntentInventory) HasPhrases added in v0.3.13

func (inv HubIntentInventory) HasPhrases() bool

HasPhrases reports whether any intent carries a sentence, which a names-only inventory never does.

func (HubIntentInventory) Intents added in v0.3.13

func (inv HubIntentInventory) Intents() []HubIntent

Intents flattens the inventory: every skill's intents, skills in order.

type HubProtocol added in v0.2.1

type HubProtocol string
const (
	ProtocolWSS   HubProtocol = "wss"
	ProtocolHTTPS HubProtocol = "https"
	ProtocolMQTT  HubProtocol = "mqtt"
)

type HubProtocolSettings added in v0.2.1

type HubProtocolSettings struct {
	WSS  bool `json:"wss"`
	HTTP bool `json:"http"`
	MQTT bool `json:"mqtt"`
}

func DefaultHubProtocolSettings added in v0.2.1

func DefaultHubProtocolSettings() HubProtocolSettings

func ProtocolSettingsFromMap added in v0.2.1

func ProtocolSettingsFromMap(values map[string]any) HubProtocolSettings

func (HubProtocolSettings) EnabledProtocols added in v0.2.1

func (s HubProtocolSettings) EnabledProtocols() []HubProtocol

func (HubProtocolSettings) IsEnabled added in v0.2.1

func (s HubProtocolSettings) IsEnabled(protocol HubProtocol) bool

func (HubProtocolSettings) SpecMap added in v0.2.1

func (s HubProtocolSettings) SpecMap() map[string]any

type HubSession added in v0.9.0

type HubSession struct {
	// contains filtered or unexported fields
}

HubSession owns one connection. Either call Run in a goroutine, which keeps the link up by policy until the session closes, or call Probe at ProbeDelay intervals yourself. Warm is asynchronous; foreground calls bypass the unattended retry ladder. No admitted Ask or Emit is replayed after an ambiguous transport failure.

func NewHubSession added in v0.9.0

func NewHubSession(connect func(context.Context) (HubSessionClient, error), policy HubSessionPolicy, options ...HubSessionOption) (*HubSession, error)

func (*HubSession) Ask added in v0.9.0

func (s *HubSession) Ask(ctx context.Context, text string, options AskOptions) (reply Reply, err error)

func (*HubSession) Close added in v0.9.0

func (s *HubSession) Close(ctx context.Context) error

Close retires the session permanently, stops Run, and waits for its admitted call.

func (*HubSession) Connect added in v0.11.0

func (s *HubSession) Connect(ctx context.Context) error

Connect makes one attempt: it returns with a live link, or says why there is none. A link the session already holds is kept. A new one must stay up for the settle window (WithSettle); a hub that closes it inside the window with no status, 1000, 1005 or 1008 has refused the credentials, which is ErrHubRefused (always with ErrConnection). Any other failure is ErrConnection or ErrTimeout.

func (*HubSession) Connected added in v0.11.0

func (s *HubSession) Connected() bool

Connected reports whether the session holds a client whose link is up.

func (*HubSession) Emit added in v0.9.0

func (s *HubSession) Emit(ctx context.Context, eventType string, data Data, eventContext Context) error

func (*HubSession) Held added in v0.9.0

func (s *HubSession) Held() bool

func (*HubSession) On added in v0.11.0

func (s *HubSession) On(eventType string, handler func(Event)) (unsubscribe func())

On calls handler for every event named eventType the session receives, on the client it holds now and on every client it builds after a reconnect, until the returned function is called; once it has returned, no delivery calls handler again. Handlers run one at a time, in order, on the session's delivery goroutine, so a handler that does slow work starts a goroutine of its own. Events and their maps are read-only.

func (*HubSession) OnStateChange added in v0.11.0

func (s *HubSession) OnStateChange(notify func(up bool)) (unsubscribe func())

OnStateChange calls notify with true when the link comes up and false when it goes down, until the returned function is called. It runs on the goroutine that changed the state while that goroutine holds the session, so it must return promptly and must not call Ask, Emit, Reply, Connect or Close itself; start a goroutine for that.

func (*HubSession) Probe added in v0.9.0

func (s *HubSession) Probe(ctx context.Context) error

func (*HubSession) ProbeDelay added in v0.9.0

func (s *HubSession) ProbeDelay() time.Duration

func (*HubSession) Reply added in v0.11.0

func (s *HubSession) Reply(ctx context.Context, event Event, msgType string, data Data, eventContext Context) error

Reply answers an event the hub sent, back along the route it came (OVOS-MSG-1 §5.2): see Client.Reply. Like Emit, it is never replayed.

func (*HubSession) RetryAt added in v0.9.0

func (s *HubSession) RetryAt() time.Time

func (*HubSession) RetryWait added in v0.9.0

func (s *HubSession) RetryWait() time.Duration

func (*HubSession) Run added in v0.11.0

func (s *HubSession) Run(ctx context.Context) error

Run keeps the link up until the session closes or ctx ends; start it in a goroutine of its own. It makes the first attempt at once, and after every attempt does what a LinkSupervisor decides: a link that drops is dialled again at once; a failed attempt waits the retry ladder (Retry, doubling up to RetryCeiling); a hub that refuses the credentials is retried the same way until the refusals have lasted the refusal grace (WithRefusalGrace), since a new connection is refused until its hub admits it, and then Run returns the refusal (ErrHubRefused); a hub whose key changed ends Run at once with that error (ErrHubKeyChanged), since retrying cannot change it, and so does a hub that refused this client's own key (ErrClientKeyRejected). A held link is looked at continually. Run returns nil once the session is closed, and ctx's error when ctx ends first.

func (*HubSession) SubscribeEvents added in v0.9.0

func (s *HubSession) SubscribeEvents(capacity int) *Subscription[Event]

func (*HubSession) Warm added in v0.9.0

func (s *HubSession) Warm(ctx context.Context) bool

type HubSessionClient added in v0.9.0

type HubSessionClient interface {
	AskWithOptions(context.Context, string, AskOptions) (Reply, error)
	Emit(context.Context, string, Data, Context) error
	Close(context.Context) error
	ConnectionInfo() TransportConnectionInfo
	SubscribeEvents(int) *Subscription[Event]
}

type HubSessionOption added in v0.11.0

type HubSessionOption func(*HubSession)

HubSessionOption adjusts a HubSession built by NewHubSession.

func WithRefusalGrace added in v0.11.0

func WithRefusalGrace(grace time.Duration) HubSessionOption

WithRefusalGrace sets how long Run keeps retrying a hub that refuses the credentials before it returns the refusal (DefaultHubRefusalGrace). Nonpositive values are ignored.

func WithSettle added in v0.11.0

func WithSettle(window time.Duration) HubSessionOption

WithSettle sets how long a link opened by Connect or Run must stay up before it counts (DefaultHubSettle); 0 turns the check off. A close inside the window with no status, 1000, 1005 or 1008 is the hub refusing the credentials (ErrHubRefused); any other is a drop. Negative values are ignored.

type HubSessionPolicy added in v0.9.0

type HubSessionPolicy struct{ Retry, RetryCeiling, Probe, ProbeDown time.Duration }

func DefaultHubSessionPolicy added in v0.9.0

func DefaultHubSessionPolicy() HubSessionPolicy

func (HubSessionPolicy) NextWait added in v0.9.0

func (p HubSessionPolicy) NextWait(current time.Duration) time.Duration

func (HubSessionPolicy) Validate added in v0.9.0

func (p HubSessionPolicy) Validate() error

type HubSkillIntents added in v0.3.13

type HubSkillIntents struct {
	SkillID string      `json:"skill_id"`
	Intents []HubIntent `json:"intents"`
}

HubSkillIntents groups the intents one skill registered.

func (HubSkillIntents) Languages added in v0.3.13

func (s HubSkillIntents) Languages() []string

Languages lists every language one of the skill's intents has, in the order they were asked.

type HubSkillWaitOptions added in v0.6.0

type HubSkillWaitOptions struct {
	Wait         bool
	Timeout      time.Duration
	PollInterval time.Duration
}

HubSkillWaitOptions controls optional polling after one accepted write. Zero durations use a 120-second timeout and two-second polling interval. Cancellation or polling failure never retries the accepted mutation.

type Identity

type Identity struct {
	AccessKey          string                 `json:"access_key"`
	Password           string                 `json:"password"`
	SiteID             string                 `json:"site_id"`
	DefaultMaster      string                 `json:"default_master"`
	DefaultPort        int                    `json:"default_port"`
	DefaultPath        string                 `json:"default_path,omitempty"`
	PublicKey          string                 `json:"public_key,omitempty"`
	Metadata           map[string]any         `json:"metadata,omitempty"`
	DataPlaneEndpoints HubDataPlaneEndpoints  `json:"data_plane_endpoints,omitempty"`
	Protocols          HubProtocolSettings    `json:"protocols,omitempty"`
	MQTT               *MqttBrokerCredentials `json:"mqtt,omitempty"`
	// contains filtered or unexported fields
}

func IdentityFromConfig added in v0.2.11

func IdentityFromConfig(path string, profile string) (Identity, error)

func IdentityFromEnv

func IdentityFromEnv(prefix string) (Identity, error)

func IdentityFromFile

func IdentityFromFile(path string) (Identity, error)

func IdentityFromMap

func IdentityFromMap(values map[string]any) (Identity, error)

func (Identity) EnabledProtocols added in v0.2.1

func (i Identity) EnabledProtocols() []HubProtocol

func (Identity) EndpointBase

func (i Identity) EndpointBase() string

func (Identity) EndpointFor added in v0.2.1

func (i Identity) EndpointFor(protocol HubProtocol) string

func (Identity) SourcePath added in v0.12.0

func (i Identity) SourcePath() string

SourcePath is the file this identity was read from (IdentityFromFile, IdentityFromConfig), as an absolute path, or "" when it came from anywhere else. With no NoiseStateDir named, a transport keeps this identity's Noise key and hub pins in that file's directory, so every program that reads the same file presents the same key to the hub. It is not identity material: never serialized.

func (Identity) String added in v0.3.7

func (i Identity) String() string

String implements fmt.Stringer so the %v, %s, and %+v verbs render an Identity with its AccessKey and Password (and the nested MQTT credentials) redacted. Without it, %+v would print the client's data-plane secrets into any log line or error string. This affects human-facing formatting ONLY: json.Marshal does not consult String(), so the wire protocol and the identity file on disk still round-trip the real secret values.

func (Identity) Summary

func (i Identity) Summary() map[string]any

func (Identity) SupportsProtocol added in v0.2.1

func (i Identity) SupportsProtocol(protocol HubProtocol) bool

type Intent added in v0.9.0

type Intent struct {
	// Languages preserves phrase-map order when no requested language is supplied.
	Languages []string            `json:"languages"`
	ID        string              `json:"id"`
	Name      string              `json:"name"`
	SkillID   string              `json:"skill_id"`
	Engine    string              `json:"engine"`
	Phrases   map[string][]string `json:"phrases"`
}

func (Intent) Examples added in v0.9.0

func (i Intent) Examples(language string, limit int) []string

func (Intent) MarshalJSON added in v0.9.0

func (i Intent) MarshalJSON() ([]byte, error)

func (*Intent) UnmarshalJSON added in v0.9.0

func (i *Intent) UnmarshalJSON(raw []byte) error

type IntentDefinition added in v0.3.13

type IntentDefinition struct {
	SkillID    string         `json:"skill_id"`
	IntentName string         `json:"intent_name"`
	Lang       string         `json:"lang"`
	Method     string         `json:"method"`
	Samples    []string       `json:"samples"`
	Raw        map[string]any `json:"raw"`
}

IntentDefinition is a registration as the skill made it, from ovos.intent.describe. Samples are the sentences a template intent answers to, slots in braces; Raw is the whole definition as the hub sent it.

func (IntentDefinition) Engine added in v0.3.13

func (d IntentDefinition) Engine() string

Engine names the intent engine behind the definition: "padatious" for a template intent, "adapt" for a keyword one.

type IntentExampleOptions added in v0.7.0

type IntentExampleOptions struct {
	Speakable bool
	Sentence  bool
	Slots     map[string]string
	Listing   *ListingRules
}

IntentExampleOptions controls locale-aware illustrative rendering.

type IntentOptions added in v0.3.13

type IntentOptions struct {
	Timeout            time.Duration
	Describe           *bool
	Fallback           *bool
	IncludeDefinitions bool
	// Nearest retries an empty listing once in the language's usual form.
	// Nil means on: a listing that returns nothing from a hub which
	// demonstrably answers in that language is a fault, not a preference.
	// See UsualForm.
	Nearest *bool
}

IntentOptions tunes Client.Intents, Client.ListIntents and Client.DescribeIntent. Timeout bounds each query the hub is sent and defaults to DefaultIntentTimeout when zero. Describe, when nil or true, has Client.Intents fetch every template intent's sentences; false leaves the inventory with names and engines only. Fallback, when nil or true, has Client.Intents read the engines' own manifests when the hub refuses ovos.intent.list; false returns the *PolicyDeniedError instead. IncludeDefinitions asks the runtime to attach each row's definition to the ovos.intent.list reply in Client.ListIntents; Client.Intents sets it itself whenever it describes.

type IntentRegistration added in v0.3.13

type IntentRegistration struct {
	SkillID    string         `json:"skill_id"`
	IntentName string         `json:"intent_name"`
	Lang       string         `json:"lang"`
	Method     string         `json:"method"`
	Enabled    bool           `json:"enabled"`
	SessionID  string         `json:"session_id"`
	Definition map[string]any `json:"definition,omitempty"`
}

IntentRegistration is one row of the hub's intent manifest. Definition is set only when the runtime attached it to the listing.

func (IntentRegistration) Engine added in v0.3.13

func (r IntentRegistration) Engine() string

Engine names the intent engine behind the registration: "padatious" for a template intent, "adapt" for a keyword one.

type Inventory added in v0.9.0

type Inventory struct {
	CacheVersion int      `json:"cache_version"`
	HubID        string   `json:"hub_id"`
	HubName      string   `json:"hub_name"`
	Source       string   `json:"source"`
	GeneratedAt  string   `json:"generated_at"`
	Notes        []string `json:"notes"`
	Skills       []Skill  `json:"skills"`
}

func InventoryFromJSON added in v0.9.0

func InventoryFromJSON(raw []byte) (Inventory, error)

func (Inventory) HasPhrases added in v0.9.0

func (i Inventory) HasPhrases() bool

func (Inventory) Intents added in v0.9.0

func (i Inventory) Intents() []Intent

func (Inventory) Live added in v0.9.0

func (i Inventory) Live() bool

func (Inventory) MarshalJSON added in v0.9.0

func (i Inventory) MarshalJSON() ([]byte, error)

type InventoryCache added in v0.9.0

type InventoryCache struct {
	Directory string
	TTL       time.Duration
}

func NewInventoryCache added in v0.9.0

func NewInventoryCache(directory string) *InventoryCache

func (*InventoryCache) Load added in v0.9.0

func (c *InventoryCache) Load(key string) *Inventory

func (*InventoryCache) Path added in v0.9.0

func (c *InventoryCache) Path(key string) (string, error)

func (*InventoryCache) Store added in v0.9.0

func (c *InventoryCache) Store(key string, inventory Inventory)

type LinkAction added in v0.11.0

type LinkAction string

LinkAction is what to do after an attempt.

const (
	// LinkHold keeps the link that is up.
	LinkHold LinkAction = "hold"
	// LinkRetry dials again after LinkDecision.Wait.
	LinkRetry LinkAction = "retry"
	// LinkGiveUp stops; LinkDecision.Reason says why.
	LinkGiveUp LinkAction = "give_up"
)

The actions a LinkSupervisor decides.

type LinkDecision added in v0.11.0

type LinkDecision struct {
	Action LinkAction
	// Wait is how long to wait before dialling again, for LinkRetry.
	Wait time.Duration
	// Reason is the outcome that ended the link, for LinkGiveUp:
	// LinkRefused, LinkKeyChanged or LinkClientKeyRejected.
	Reason LinkOutcome
}

LinkDecision is a LinkSupervisor's answer to one outcome.

type LinkOutcome added in v0.11.0

type LinkOutcome string

LinkOutcome is what one attempt to hold a link came to.

const (
	// LinkUp: the link is up.
	LinkUp LinkOutcome = "up"
	// LinkDropped: an established link went down.
	LinkDropped LinkOutcome = "dropped"
	// LinkFailed: the hub or the network could not be reached.
	LinkFailed LinkOutcome = "failed"
	// LinkRefused: the hub turned the credentials away (ErrHubRefused).
	LinkRefused LinkOutcome = "refused"
	// LinkKeyChanged: the hub's key is not the pinned one (ErrHubKeyChanged).
	LinkKeyChanged LinkOutcome = "key_changed"
	// LinkClientKeyRejected: the hub refused this client's own key, having
	// pinned another one for the connection (ErrClientKeyRejected).
	LinkClientKeyRejected LinkOutcome = "client_key_rejected"
)

The outcomes a LinkSupervisor decides on.

type LinkSupervisor added in v0.11.0

type LinkSupervisor struct {
	// contains filtered or unexported fields
}

LinkSupervisor decides how a long-lived link is kept up, as a pure function of what happened and when; HubSession's Run asks it after every attempt, and every SDK follows the same rules (link-keeping-vectors.json):

  • LinkUp: hold, and start the ladder and the refusal clock afresh;
  • LinkDropped: dial again at once;
  • LinkFailed: wait the ladder's step -- the policy's Retry, doubling to RetryCeiling -- and stop counting refusals;
  • LinkRefused: wait the ladder's step the same way, until the refusals have lasted the refusal grace since the first of them (inclusive), then give up;
  • LinkKeyChanged: give up at once, since retrying cannot change it;
  • LinkClientKeyRejected: give up at once too: no handshake can make the hub accept a key it did not pin.

A LinkSupervisor is not safe for concurrent use.

func NewLinkSupervisor added in v0.11.0

func NewLinkSupervisor(policy HubSessionPolicy, refusalGrace time.Duration) *LinkSupervisor

NewLinkSupervisor is a supervisor for policy that gives refusals refusalGrace (DefaultHubRefusalGrace when nonpositive).

func (*LinkSupervisor) After added in v0.11.0

func (l *LinkSupervisor) After(outcome LinkOutcome, now time.Time) LinkDecision

After is the decision after outcome, observed at now; only the differences between the times it is given matter.

type ListenOptions added in v0.5.0

type ListenOptions struct {
	EventOptions
	MaxEvents int
	Capacity  int
}

ListenOptions bounds a listener's duration, event count and buffered backlog. Zero Timeout and MaxEvents leave lifetime/count to the caller's context and Close. Capacity defaults to 256 and is capped at 65536. Predicates should return promptly; cancellation unsubscribes immediately even if a predicate is pending.

type ListingData added in v0.8.0

type ListingData struct {
	SentenceEnds string                     `json:"sentence_ends"`
	Languages    map[string]ListingLanguage `json:"languages"`
}

ListingData is a complete language data tree, rather than an overlay.

type ListingLanguage added in v0.8.0

type ListingLanguage struct {
	TrailingWords         []string          `json:"trailing_words,omitempty"`
	QuestionOpeners       []string          `json:"question_openers,omitempty"`
	QuestionWordsAnywhere []string          `json:"question_words_anywhere,omitempty"`
	QuestionPatterns      []string          `json:"question_patterns,omitempty"`
	WrittenForms          map[string]string `json:"written_forms,omitempty"`
	SlotExamples          map[string]string `json:"slot_examples,omitempty"`
}

ListingLanguage contains optional canonical thalovant-languages listing rules.

type ListingRules added in v0.8.0

type ListingRules struct {
	// contains filtered or unexported fields
}

ListingRules snapshots language rules; its methods may be used concurrently. Construct with nil for bare rendering without language data.

func DefaultListing added in v0.8.0

func DefaultListing() *ListingRules

DefaultListing returns the immutable bundled language rules.

func NewListingRules added in v0.8.0

func NewListingRules(data *ListingData) (*ListingRules, error)

NewListingRules validates patterns before publishing an immutable snapshot. Invalid patterns return an error. Runtime backtracking is bounded by a 100ms per-pattern deadline and a 65536-entry stack; Asks reports matching failures.

func (*ListingRules) AsSentence added in v0.8.0

func (r *ListingRules) AsSentence(text, lang string) string

AsSentence capitalizes and punctuates a phrase using known locale rules. Unknown rules, dangling prefixes and regex failures leave a bare line.

func (*ListingRules) Asks added in v0.8.0

func (r *ListingRules) Asks(text, lang string) (bool, error)

Asks recognizes questions and reports bounded regex failures to the caller.

func (*ListingRules) Available added in v0.8.0

func (r *ListingRules) Available() bool

Available reports whether this snapshot contains language data.

func (*ListingRules) Dangling added in v0.8.0

func (r *ListingRules) Dangling(text, lang string) bool

Dangling reports a locale's trailing prefix waiting for an entity.

func (*ListingRules) LanguageData added in v0.8.0

func (r *ListingRules) LanguageData(lang string) ListingLanguage

LanguageData returns a copy of the closest locale's rules.

func (*ListingRules) Rank added in v0.8.0

func (r *ListingRules) Rank(phrases []string, lang string) []string

Rank keeps whole phrases ahead of prefixes and slots, preferring fuller wording up to eight words and shorter strings after that.

func (*ListingRules) Speakable added in v0.8.0

func (r *ListingRules) Speakable(pattern string, slots map[string]string, lang string) string

Speakable renders a pattern using locale slot examples and caller overrides.

type LocationOptions added in v0.7.0

type LocationOptions struct {
	City, Region, Country, Timezone string
	Latitude, Longitude             any
}

LocationOptions accepts numeric or string coordinates. A city is required.

type LoginOptions added in v0.3.2

type LoginOptions struct {
	Scope        string
	OTPCode      string
	RecoveryCode string
}

LoginOptions carries optional login inputs. Scope overrides the default token scopes. OTPCode and RecoveryCode satisfy an MFA challenge; the API rejects MFA-enabled accounts with HTTP 401 {"code": "mfa_required"} when neither is provided.

type MQTTTransport added in v0.2.4

type MQTTTransport struct {
	Identity   Identity
	UserAgent  string
	Topics     MqttTopicSet
	BusEvents  chan Event
	HiveEvents chan HiveMessage

	// NoiseStateDir selects the persistent client key and hub pin directory.
	// Empty uses the directory of the file the identity was read from
	// (Identity.SourcePath), else NoiseStateDir().
	NoiseStateDir string
	// TLSConfig optionally supplies broker trust roots or a client certificate.
	TLSConfig *tls.Config
	// contains filtered or unexported fields
}

func NewMQTTTransport added in v0.2.4

func NewMQTTTransport(identity Identity) (*MQTTTransport, error)

func (*MQTTTransport) Connect added in v0.2.4

func (t *MQTTTransport) Connect(ctx context.Context) error

Connect opens the broker session and completes the v3 Noise handshake. A KK handshake whose answer does not authenticate is followed at once by one XX handshake inside this connect, since only XX tells a changed password (ErrHubRefused) from a changed hub key (ErrHubKeyChanged); the XX attempt's outcome is the connect's.

func (*MQTTTransport) ConnectionInfo added in v0.2.14

func (t *MQTTTransport) ConnectionInfo() TransportConnectionInfo

func (*MQTTTransport) Disconnect added in v0.2.4

func (t *MQTTTransport) Disconnect(ctx context.Context) error

func (*MQTTTransport) EmitBus added in v0.2.4

func (t *MQTTTransport) EmitBus(ctx context.Context, eventType string, data Data, eventContext Context) error

func (*MQTTTransport) Events added in v0.2.4

func (t *MQTTTransport) Events() <-chan Event

func (*MQTTTransport) Healthcheck added in v0.2.4

func (t *MQTTTransport) Healthcheck() TransportHealth

func (*MQTTTransport) HiveMessages added in v0.2.15

func (t *MQTTTransport) HiveMessages() <-chan HiveMessage

func (*MQTTTransport) IsHandshakeComplete added in v0.2.4

func (t *MQTTTransport) IsHandshakeComplete() bool

func (*MQTTTransport) RemoteStaticKey added in v0.4.4

func (t *MQTTTransport) RemoteStaticKey() string

RemoteStaticKey returns the authenticated hub key, empty outside a session.

func (*MQTTTransport) SendHiveMessage added in v0.2.15

func (t *MQTTTransport) SendHiveMessage(ctx context.Context, message HiveMessage, encrypt bool) error

func (*MQTTTransport) SubscribeEvents added in v0.5.0

func (t *MQTTTransport) SubscribeEvents(capacity int) *Subscription[Event]

func (*MQTTTransport) SubscribeHiveMessages added in v0.5.0

func (t *MQTTTransport) SubscribeHiveMessages(capacity int) *Subscription[HiveMessage]

type MarketplaceSkillListOptions added in v0.3.6

type MarketplaceSkillListOptions struct {
	OwnerID         string
	IncludeInactive bool
	ForceRefresh    bool
}

MarketplaceSkillListOptions carries the optional inputs of ListMarketplaceSkills. OwnerID and IncludeInactive are honored for admin tokens only; the API silently scopes a non-admin caller to their own tenant and to active entries instead of failing. ForceRefresh re-syncs the global catalog from its source before answering, which is slower.

type MemoryListOptions added in v0.2.13

type MemoryListOptions struct {
	Scope          string
	Kind           string
	OwnerID        string
	HubID          string
	Query          string
	IncludeDeleted bool
	IncludeExpired bool
	Limit          int
	Offset         int
}

type MqttBrokerCredentials added in v0.2.3

type MqttBrokerCredentials struct {
	Endpoint    string `json:"endpoint"`
	Username    string `json:"username"`
	Password    string `json:"password"`
	TopicPrefix string `json:"topic_prefix,omitempty"`
	QOS         byte   `json:"qos,omitempty"`
	TLS         bool   `json:"tls"`
}

func MqttBrokerCredentialsFromMap added in v0.2.3

func MqttBrokerCredentialsFromMap(raw any) *MqttBrokerCredentials

func (MqttBrokerCredentials) Map added in v0.2.3

func (m MqttBrokerCredentials) Map(includeSecrets bool) map[string]any

Map renders the broker credentials as a plain map. Polarity note: the boolean is includeSecrets, where true REVEALS the username, password, and topic details and false returns only the non-sensitive endpoint and tls fields. This is the OPPOSITE polarity of HubDataPlaneEndpoints.Map(redactCredentials bool) in protocols.go, which redacts when its boolean is true — keep the two straight at call sites.

func (MqttBrokerCredentials) String added in v0.3.7

func (m MqttBrokerCredentials) String() string

String implements fmt.Stringer so the %v, %s, and %+v verbs render the broker credentials with the Username and Password redacted, mirroring how Map(false) omits them. Like Identity.String this is a formatting-only guard and does not affect json.Marshal, which still serializes the real values.

type MqttTopicSet added in v0.2.4

type MqttTopicSet struct {
	Inbound  string
	Outbound string
	Status   string
}

func MQTTTopicsForIdentity added in v0.2.4

func MQTTTopicsForIdentity(identity Identity) (MqttTopicSet, error)

MQTTTopicsForIdentity derives the data-plane topic set from the identity's MQTT credentials. TopicPrefix is the full base -- hivemind/<hub-id>/<access-key> -- and the channels append a fixed suffix to it: publish requests go to <prefix>/in, subscribe replies arrive on <prefix>/out, and the retained presence/LWT lives on <prefix>/status.

type NativeSignIn added in v0.9.2

type NativeSignIn struct {
	// AuthorizationURL is opened in a browser.
	AuthorizationURL string
	// State proves the redirect answers this attempt and not a replayed one.
	State string
	// Verifier is never sent to the browser. Exchanged with the code, once.
	Verifier string
	// RedirectURI is what this attempt asked the callback to arrive at. One
	// that lands anywhere else is not this attempt's, however good its state.
	RedirectURI string
}

NativeSignIn is one sign-in attempt in progress. Keep it until the browser comes back; it holds the two secrets that make the round trip safe.

func BeginNativeSignIn added in v0.9.2

func BeginNativeSignIn(opts NativeSignInOptions) (NativeSignIn, error)

BeginNativeSignIn starts a sign-in, returning the URL to open and the secrets to keep.

func (NativeSignIn) CodeFrom added in v0.9.2

func (s NativeSignIn) CodeFrom(redirect string) (code string, ok bool)

CodeFrom returns the authorization code out of the redirect the browser came back with. ok is false when it is not an answer to this attempt.

A bool rather than an error on a state mismatch, a missing code, or an error= response -- including one that also carries a code: all of those mean "do not continue", and a caller that handles them alike cannot accidentally treat one of them as success.

type NativeSignInOptions added in v0.9.2

type NativeSignInOptions struct {
	ClientID     string
	RedirectURI  string
	Scopes       []string
	DashboardURL string
}

NativeSignInOptions carries the inputs for BeginNativeSignIn. RedirectURI must be one the API has registered for ClientID; the authorization endpoint matches it exactly and refuses anything else, so it cannot be turned into an open redirect.

type OperationResource added in v0.2.16

type OperationResource struct {
	ID            string             `json:"id"`
	Kind          string             `json:"kind"`
	AggregateType string             `json:"aggregate_type"`
	AggregateID   *string            `json:"aggregate_id"`
	Status        OperationStatus    `json:"status"`
	Details       map[string]any     `json:"details"`
	GitCommitSHA  *string            `json:"git_commit_sha"`
	ErrorCode     *string            `json:"error_code"`
	ErrorMessage  *string            `json:"error_message"`
	CreatedAt     string             `json:"created_at"`
	UpdatedAt     string             `json:"updated_at"`
	CommittedAt   *string            `json:"committed_at"`
	AppliedAt     *string            `json:"applied_at"`
	ReadyAt       *string            `json:"ready_at"`
	TerminalAt    *string            `json:"terminal_at"`
	Links         map[string]*string `json:"links"`
}

type OperationStatus added in v0.2.16

type OperationStatus string
const (
	OperationRequested OperationStatus = "requested"
	OperationCommitted OperationStatus = "committed"
	OperationApplied   OperationStatus = "applied"
	OperationReady     OperationStatus = "ready"
	OperationFailed    OperationStatus = "failed"
	OperationTimedOut  OperationStatus = "timed_out"
)

type OriginAttempt added in v0.9.0

type OriginAttempt struct {
	Address, Host                    string
	HandshakeTimeout, ConnectTimeout time.Duration
}

OriginAttempt leaves the URL host, certificate validation and SNI unchanged. The factory owns a transport-local dial override and cleans up failed attempts.

type OriginPreference added in v0.9.0

type OriginPreference struct {
	Address                    string
	HandshakeTimeout, Cooldown time.Duration
	// contains filtered or unexported fields
}

func (*OriginPreference) Connect added in v0.9.0

func (*OriginPreference) CoolingDown added in v0.9.0

func (o *OriginPreference) CoolingDown() bool

type PolicyDeniedError added in v0.3.13

type PolicyDeniedError struct {
	// DeniedType is the message type the hub refused, such as
	// "recognizer_loop:utterance".
	DeniedType string
	// Code is the hub's refusal code: PolicyCodeACL,
	// PolicyCodeQuotaExceeded or PolicyCodeBackendUnavailable.
	Code string
	// Reason is the hub's human-readable explanation, when it gave one.
	Reason string
	// Allowed lists the message types the connection may publish, when the
	// hub said.
	Allowed []string
	// Quota carries the numbers behind a spent allowance; nil for any other
	// refusal.
	Quota *Quota
}

PolicyDeniedError reports that the hub refused a message, the instant it did. The hub sends hive.policy.denied as soon as it refuses; returning this saves the caller a timeout and says which kind of refusal it was.

It wraps ErrRuntime, so errors.Is(err, ErrRuntime) holds, and it is retrieved with errors.As:

var denied *thalovant.PolicyDeniedError
if errors.As(err, &denied) && denied.Quota != nil {
	fmt.Println(denied.Quota.Used, "of", denied.Quota.Limit)
}

func (*PolicyDeniedError) Error added in v0.3.13

func (e *PolicyDeniedError) Error() string

func (*PolicyDeniedError) Unwrap added in v0.3.13

func (e *PolicyDeniedError) Unwrap() error

Unwrap makes a PolicyDeniedError match ErrRuntime under errors.Is, the same way the hub's other refusals do.

type QueryOptions added in v0.2.15

type QueryOptions struct {
	Timeout   time.Duration
	Lang      string
	Context   Context
	SessionID string
	RequestID string
	QueryID   string
}

type Quota added in v0.10.1

type Quota struct {
	Period string
	Limit  int64
	Used   int64
	// ResetAfter is seconds until the counter resets, or 0 when the hub did
	// not say.
	ResetAfter int64
}

Quota is the numbers behind a refusal that is a spent allowance, not a policy. The intent-quota policy sends which counter ran out ("daily", "monthly"), what it allows, how much was used, and how many seconds until it resets. Without them a caller can only say "refused", which is what an app showed somebody who had simply used up the day.

type ReleaseOptions added in v0.3.6

type ReleaseOptions struct {
	Channel string
	Mode    string
	Version string
	Images  map[string]string
	Reason  string
}

ReleaseOptions carries the release policy ReleaseHub and ReleaseRuntimeGroup apply. Every field is optional and an unset field is omitted from the request body, so the API falls back to the workspace release policy for it. Setting Images switches the target to "custom" mode unless Mode is also set.

Unless the caller is a platform administrator, each image must be one the platform releases for its key: a catalog pin of the stable or alpha channel, the resource's current, recommended or release-policy image, or the platform's default image. A runtime group's "core" and a hub's "listener" also accept any tag or digest of the platform's own repository (ghcr.io/thalovant/ovos-core, ghcr.io/thalovant/hivemind-listener); "bus" and "preview_bridge" take only the listed images. The API refuses anything else with HTTP 403 "platform_image_required": an *APIError whose Code says so, whose ProblemDetail names what each refused key may be instead, and whose Problem carries refused_images, allowed_images and allowed_repositories.

type Replier added in v0.11.0

type Replier interface {
	Reply(ctx context.Context, event Event, msgType string, data Data, eventContext Context) error
}

Replier sends a reply to an event, back along the route it came. Client and HubSession are Repliers.

type Reply

type Reply struct {
	DroppedMedia int
	Text         string
	Utterances   []string
	Handled      bool
	OK           bool
	SessionID    string
	RequestID    string
	Events       []Event
	FailureEvent *Event
}

func (Reply) Claimed added in v0.9.1

func (r Reply) Claimed() bool

Claimed is advisory: an OK reply from any non-fallback stage is claimed. An older hub without stage stamps retains its existing OK behavior.

func (Reply) DisplayItems

func (r Reply) DisplayItems(maxTextChars int) []DisplayItem

func (Reply) DisplayText

func (r Reply) DisplayText() string

func (Reply) HasAudio added in v0.7.0

func (r Reply) HasAudio() bool

func (Reply) Lang added in v0.7.0

func (r Reply) Lang() string

func (Reply) MediaEvents added in v0.7.0

func (r Reply) MediaEvents() []Event

func (Reply) PipelineIDs added in v0.9.1

func (r Reply) PipelineIDs() []string

PipelineIDs reports nonempty string stage stamps in first-seen order.

func (Reply) SkillIDs added in v0.9.1

func (r Reply) SkillIDs() []string

SkillIDs reports nonempty string skill stamps in first-seen order.

type RequestContextOptions added in v0.7.0

type RequestContextOptions struct {
	STTLang  string
	Pipeline []string
	Location map[string]any
}

RequestContextOptions carries per-request hints read by OVOS.

type RequestOptions

type RequestOptions struct {
	Timeout   time.Duration
	Lang      string
	Context   Context
	SessionID string
	RequestID string
}

type RuntimeGroupConfigOptions added in v0.3.6

type RuntimeGroupConfigOptions struct {
	Personas map[string]any
}

RuntimeGroupConfigOptions carries the optional inputs of UpdateRuntimeGroupConfig. Personas replaces the stored personas when non-nil and is omitted from the request body when nil.

type RuntimeGroupInventoryOptions added in v0.3.6

type RuntimeGroupInventoryOptions struct {
	Refresh bool
}

RuntimeGroupInventoryOptions carries the optional inputs of ListRuntimeGroupInventory. Refresh forces a live read from the runtime operator; the API also refreshes on its own when it holds no cached snapshot.

type RuntimeGroupMarketplaceOptions added in v0.3.6

type RuntimeGroupMarketplaceOptions struct {
	RefreshInventory bool
}

RuntimeGroupMarketplaceOptions carries the optional inputs of ListRuntimeGroupMarketplace. RefreshInventory forces a live read from the runtime operator instead of answering from the cached inventory snapshot.

type RuntimeGroupSkillInstallOptions added in v0.3.6

type RuntimeGroupSkillInstallOptions struct {
	MarketplaceSkillID string
	SourceType         string
	SourceRef          string
	VersionPin         string
	Active             *bool
}

RuntimeGroupSkillInstallOptions carries the optional inputs of InstallRuntimeGroupSkill. The zero value installs an active skill from the marketplace catalog: SourceType defaults to "catalog" when empty and Active defaults to true when nil. A "git" install needs SourceRef set to the repository URL.

type RuntimeTransport added in v0.2.4

type RuntimeTransport interface {
	Connect(ctx context.Context) error
	Disconnect(ctx context.Context) error
	Healthcheck() TransportHealth
	ConnectionInfo() TransportConnectionInfo
	EmitBus(ctx context.Context, eventType string, data Data, eventContext Context) error
	Events() <-chan Event
}

type SelectedHubEndpoint added in v0.2.2

type SelectedHubEndpoint struct {
	Protocol HubProtocol `json:"protocol"`
	Endpoint string      `json:"endpoint"`
}

func SelectDataPlaneEndpoint added in v0.2.2

func SelectDataPlaneEndpoint(endpoints HubDataPlaneEndpoints, protocols HubProtocolSettings, preferred []HubProtocol) *SelectedHubEndpoint

type Skill added in v0.9.0

type Skill struct {
	ID      string   `json:"id"`
	Title   string   `json:"title"`
	Locales []string `json:"locales"`
	Intents []Intent `json:"intents"`
}

func (Skill) DeclaresLocales added in v0.9.0

func (s Skill) DeclaresLocales() bool

func (Skill) MarshalJSON added in v0.9.0

func (s Skill) MarshalJSON() ([]byte, error)

func (Skill) Speaks added in v0.9.0

func (s Skill) Speaks(language string) *bool

type Subscription added in v0.5.0

type Subscription[T any] struct {
	C <-chan T
	// contains filtered or unexported fields
}

Subscription owns an independent, bounded stream. Read until C closes, then inspect Err; call Close when done. Events and their maps are read-only.

func (*Subscription[T]) Close added in v0.5.0

func (s *Subscription[T]) Close()

func (*Subscription[T]) Err added in v0.5.0

func (s *Subscription[T]) Err() error

type ThalovantBinary added in v0.10.0

type ThalovantBinary struct {
	// Kind is tts_audio, file, ... or binary:<wire number> for an unnamed type.
	Kind string
	// Data is the payload itself. Never parsed, never decompressed.
	Data []byte
	// Metadata is what the hub sent beside it.
	Metadata map[string]any
	// Utterance is what was said, when this is rendered speech.
	Utterance string
	// Lang is the language it was said in.
	Lang string
	// FileName is the name a file arrived under. An empty name is no name.
	FileName string
}

ThalovantBinary is a binary frame: the bytes a hub sent, and what it said about them.

func BinaryFrame added in v0.10.0

func BinaryFrame(kind string, data []byte, metadata map[string]any) *ThalovantBinary

BinaryFrame reads a hub's metadata into the shape above. A value the hub did not send and one it sent empty both read as empty: rendering "" as a filename would put a blank name in front of somebody as though the hub had chosen it.

type TransportConnectionInfo added in v0.2.14

type TransportConnectionInfo struct {
	Phase           TransportConnectionPhase `json:"phase"`
	StartedAt       time.Time                `json:"started_at,omitempty"`
	ConnectedAt     time.Time                `json:"connected_at,omitempty"`
	TransportOpenMS float64                  `json:"transport_open_ms,omitempty"`
	SocketOpenMS    float64                  `json:"socket_open_ms,omitempty"`
	HandshakeMS     float64                  `json:"handshake_ms,omitempty"`
	ConnectMS       float64                  `json:"connect_ms,omitempty"`
	LastError       string                   `json:"last_error,omitempty"`
}

type TransportConnectionPhase added in v0.2.14

type TransportConnectionPhase string
const (
	ConnectionIdle       TransportConnectionPhase = "idle"
	ConnectionConnecting TransportConnectionPhase = "connecting"
	ConnectionHandshake  TransportConnectionPhase = "handshake"
	ConnectionReady      TransportConnectionPhase = "ready"
	ConnectionClosed     TransportConnectionPhase = "closed"
	ConnectionError      TransportConnectionPhase = "error"
)

type TransportHealth

type TransportHealth struct {
	Connected         bool
	HandshakeComplete bool
	TransportAlive    bool
	LastError         string
	Connection        TransportConnectionInfo
}

type UnansweredError added in v0.10.1

type UnansweredError struct {
	// Said is the hub's own words, when it sent any.
	Said string
}

UnansweredError reports that the hub understood a question and has nothing for it. ovos.intent.unmatched (complete_intent_failure from older hubs) is neither a refusal nor a fault; as a bare runtime error a caller could only report that something failed. It wraps ErrRuntime.

func (*UnansweredError) Error added in v0.10.1

func (e *UnansweredError) Error() string

func (*UnansweredError) Unwrap added in v0.10.1

func (e *UnansweredError) Unwrap() error

Unwrap makes an UnansweredError match ErrRuntime under errors.Is.

type UnsupportedConnectionTypeError added in v0.11.0

type UnsupportedConnectionTypeError struct {
	// ConnectionType is the kind asked for.
	ConnectionType string
	// Answered is the kind the API made instead; "" when it named none.
	Answered string
	// ClientID is the connection the API made instead, when it made one.
	ClientID string
	// Deleted reports that the connection the API made instead is gone.
	Deleted bool
	// DeleteErr is why deleting it failed, when it did; remove it in the
	// dashboard.
	DeleteErr error
	// APIError is the API's refusal, when it refused.
	APIError *APIError
}

UnsupportedConnectionTypeError reports that the API could not make a connection of the kind asked for. Either it refused the kind (HTTP 422 about spec.connection_type; APIError carries the answer), or it made an ordinary connection instead, which the SDK then deleted (APIError is nil; ClientID and Deleted say what happened to it). It matches ErrUnsupportedConnectionType and ErrAPI.

func (*UnsupportedConnectionTypeError) Error added in v0.11.0

func (*UnsupportedConnectionTypeError) Unwrap added in v0.11.0

func (e *UnsupportedConnectionTypeError) Unwrap() []error

Unwrap makes an UnsupportedConnectionTypeError match ErrUnsupportedConnectionType, and ErrAPI through the API's own answer when there is one.

type WSSTransport added in v0.2.4

type WSSTransport struct {
	Identity  Identity
	UserAgent string

	// NoiseStateDir overrides where the persistent static key and the server
	// pin file live. Empty uses the directory of the file the identity was
	// read from (Identity.SourcePath), else the directory holding the SDK
	// config file (NoiseStateDir()).
	NoiseStateDir string

	BusEvents  chan Event
	HiveEvents chan HiveMessage
	// contains filtered or unexported fields
}

func NewWSSTransport added in v0.2.4

func NewWSSTransport(identity Identity) *WSSTransport

func (*WSSTransport) Authorization added in v0.2.4

func (t *WSSTransport) Authorization() string

func (*WSSTransport) ClosedRefused added in v0.11.0

func (t *WSSTransport) ClosedRefused() bool

ClosedRefused reports whether the hub refused this connection's credentials the last time it ended: a handshake message that did not authenticate (a wrong password), or a close with no status, 1000, 1005 or 1008 during the handshake or within DefaultHubSettle after it. A hub that does not know a client's static key says so only by closing then; the same codes later are a hub going away, which is a drop. A new connection attempt clears it.

func (*WSSTransport) Connect added in v0.2.4

func (t *WSSTransport) Connect(ctx context.Context) error

func (*WSSTransport) ConnectionInfo added in v0.2.14

func (t *WSSTransport) ConnectionInfo() TransportConnectionInfo

func (*WSSTransport) Disconnect added in v0.2.4

func (t *WSSTransport) Disconnect(_ context.Context) error

func (*WSSTransport) EmitBus added in v0.2.4

func (t *WSSTransport) EmitBus(ctx context.Context, eventType string, data Data, eventContext Context) error

func (*WSSTransport) Events added in v0.2.4

func (t *WSSTransport) Events() <-chan Event

func (*WSSTransport) Healthcheck added in v0.2.4

func (t *WSSTransport) Healthcheck() TransportHealth

func (*WSSTransport) HiveMessages added in v0.2.15

func (t *WSSTransport) HiveMessages() <-chan HiveMessage

func (*WSSTransport) IsHandshakeComplete added in v0.2.4

func (t *WSSTransport) IsHandshakeComplete() bool

func (*WSSTransport) RemoteStaticKey added in v0.4.0

func (t *WSSTransport) RemoteStaticKey() string

RemoteStaticKey is the server's Noise static public key for the current session, hex encoded. Empty before the handshake completes.

func (*WSSTransport) SendHiveMessage added in v0.2.15

func (t *WSSTransport) SendHiveMessage(ctx context.Context, message HiveMessage, encrypt bool) error

func (*WSSTransport) SubscribeEvents added in v0.5.0

func (t *WSSTransport) SubscribeEvents(capacity int) *Subscription[Event]

func (*WSSTransport) SubscribeHiveMessages added in v0.5.0

func (t *WSSTransport) SubscribeHiveMessages(capacity int) *Subscription[HiveMessage]

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL