Identity parsing
Parse the identity JSON issued by the Thalovant API, with the same field aliases the Node and Go SDKs accept.
Build a C99 device client for Thalovant hubs with your own MQTT or WebSocket transport.
Use the embedded C library when your client is a microcontroller or small device, such as ESP32, Zephyr, bare-metal, or a Linux single-board computer.
This is a protocol library, not a full SDK. It is transport-agnostic: you bring your own MQTT client or WebSocket client and your own TLS stack, and the library provides everything protocol-specific. It is pure C99 with zero external dependencies, and the core paths never allocate; every function writes into caller-provided buffers.
Source and releases: thalovant-embedded-c on GitHub.
The library gives a device everything it needs to open an encrypted, authenticated session with a hub; your application supplies the sockets or broker connection and drives the exchange. Older envelope helpers remain for legacy integrations, but they cannot connect to a current hub.
Identity parsing
Parse the identity JSON issued by the Thalovant API, with the same field aliases the Node and Go SDKs accept.
MQTT topics
Derive per-client MQTT topics, connection endpoints, ports, and client IDs, byte-for-byte identical to the Node SDK.
Payload encryption
Authenticated encryption for every message, verified against independent test vectors.
Wire framing
Build and parse hub messages, run the authentication exchange, and encrypt every message, with ask request and reply helpers.
| You provide | The library provides |
|---|---|
| MQTT and/or WebSocket client | Topics, endpoints, frame and envelope codecs |
| TLS stack | TLS flag plus scheme and port parsing |
| Cryptographically secure random generator | You supply a persistent client key and a fresh ephemeral key for every connection; the library manages message counters |
| Event loop and timers | Frame classifier and ask-loop semantics |
| Protected storage for the identity, the client key, and the saved hub identity | Identity parser and an authentication exchange that checks the saved hub identity |
The library has no control-plane client. Create the client identity through the dashboard, the API, or another SDK, then store the identity JSON on the device.
Use a fresh Ask request ID and a fresh Query ID for every logical operation, including later calls. Keep concurrent IDs unique within each matching namespace; Ask and Query namespaces are independent. This library leaves collector ownership to your transport. Check every negative builder or codec result before casting a length or publishing bytes.
The shallow object-key scanner validates the complete direct object before returning a matching field, including separators after that field. Nested containers retain their documented shallow parsing rules.
Vendor the library or fetch it by an immutable release tag. The current tag is v0.8.0.
git submodule add https://github.com/thalovant/thalovant-embedded-c.git \ third_party/thalovant-embedded-cgit -C third_party/thalovant-embedded-c checkout v0.8.0CMake FetchContent, ESP-IDF component references, and Zephyr west manifests work the same way with the v0.8.0 tag. Each release includes a source archive and a SHA256SUMS file for checking your download.
Building needs only a C99 compiler:
make # build/libthalovant.amake test # host-side, offline test suiteUse the integration guide in the repository for the complete API and transport sequence:
thalovant_noise_select.thalovant_noise_psk. It needs 64 MiB of caller-owned uint64_t scratch memory and wipes it afterwards. A smaller device must receive the same secret through secure provisioning after deriving it off-device; never weaken the derivation.thalovant_noise_init, your protected client key, a fresh cryptographically random ephemeral key, and any saved hub identity. Exchange the authentication messages with thalovant_noise_write and thalovant_noise_read.thalovant_noise_encrypt, and decrypt replies with thalovant_noise_decrypt before classifying them. Any authentication or framing error closes the session.Your transport must keep all chunks of one message together, cap encrypted frames at 65535 bytes, and split large payloads into 65000-byte chunks. Reassemble into a caller-owned bounded buffer no larger than 32 MiB; choose a smaller limit if the device requires it. A reconnect needs a fresh ephemeral key while keeping the client key and the saved hub identity.
A connected client can list every intent its hub answers, per language, with the sentences a person says to reach each one, over its own session and with no control-plane token. See What Can My Hub Be Asked? for what the hub returns, which message types the connection must be allowed to publish, and what a refusal or a failed listing looks like.
Since release v0.2.0 the library carries the protocol side of it, in the same style as thalovant_ask_*. thalovant_intent_list_build_frame and thalovant_intent_describe_build_frame build the ovos.intent.list and ovos.intent.describe frames, and thalovant_intent_classify reads a reply into a thalovant_intent_event. Your transport seals and sends the frames, feeds the replies back, and correlates them by the request id you set.
struct inventory_query { const char *request_id; bool answered; };
static bool on_row(const thalovant_intent_registration *row, void *user){ printf("%s:%s (%s) %s\n", row->skill_id, row->intent_name, row->lang, row->method); return true; /* false stops the walk */}
struct inventory_query query = { "req-2", false };thalovant_intent_list_request list = { "en-us", "sess-1", identity.site_id, query.request_id, false };if (thalovant_intent_list_build_frame(&list, frame, sizeof(frame)) < 0) { /* THALOVANT_ERR_NOMEM: frame[] too small; nothing was built to send */}/* seal and publish the frame, then classify each reply that arrives */
thalovant_intent_event reply;thalovant_intent_classify(plaintext, plaintext_len, query.request_id, &reply);switch (reply.kind) {case THALOVANT_INTENT_LIST_RESPONSE: if (query.answered) break; /* ignore a duplicate reply */ query.answered = true; thalovant_intent_list_rows(&reply, on_row, NULL); break;case THALOVANT_INTENT_POLICY_DENIED: /* reply.denied_type names the query */ break;default: break; /* THALOVANT_INTENT_IGNORE */}The walkers deliver one item at a time through a callback, so a manifest of any size is read in bounded memory: thalovant_intent_list_rows for the registrations, thalovant_intent_definitions for a describe reply, and thalovant_intent_samples for the sentences behind one definition. thalovant_intent_same_language folds language tags the way the other SDKs do, so fr-fr and fr_FR match. A row’s method is template or keyword, which the full SDKs expose as the engines padatious and adapt.
A refusal arrives as THALOVANT_INTENT_POLICY_DENIED with the refused type in reply.denied_type. A listing the hub accepted and failed is a different case: since v0.3.0, thalovant_intent_list_rows returns THALOVANT_ERR_HUB_REFUSED with the hub’s own words in reply.error, because a failed listing is not an empty hub. thalovant_intent_definitions returns zero definitions for the same answer to a describe, which is a real one: the hub does not know that registration. Correlation, retries, and the result model are yours to own; see What Can My Hub Be Asked? for the shape the full SDKs build from the same replies.
Use Python, Node.js, Go, or Rust when the device can run a full runtime. Those SDKs own the transport, reconnect logic, and control-plane calls for you. Use MQTT for broker details and topic scope.
| Symptom | Check |
|---|---|
| Identity parse fails | Confirm the stored JSON is the untouched identity payload from the API or dashboard download. |
| Broker rejects the connection | Confirm the derived endpoint, port, and TLS flag, and use the per-client broker credentials from the identity. |
| Replies never classify | Confirm the subscribe topic is topics.outbound and the request ID matches the one sent in the ask frame. |
| Authentication or decryption fails | Confirm the password, the server node ID, the saved hub identity, and the frame order. Close the failed session; do not retry with legacy envelopes or delete the saved hub identity automatically. |
thalovant_fallback_list_build_payload and thalovant_fallback_list_build_frame build the fallback listing. Both use thalovant_intent_list_request for session and request context, and send ovos.skills.fallback.list with an empty data object; handlers are language-neutral. thalovant_intent_classify identifies the response as THALOVANT_FALLBACK_LIST_RESPONSE and exposes the data.fallbacks array through items_json, items_len, and count.
The application owns the 1.5-second optional probe, priority parsing, sorting, and result model. Keep an unavailable, failed, or malformed response distinct from an explicitly empty list. Missing intent phrases alone cannot establish that a hub will not answer. See fallback discovery.
The library supports GCC, Clang, and 32-bit targets. It uses C99, caller-owned buffers, and the transport you supply.
thalovant_ask_build_payload_with_hints and thalovant_ask_build_frame_with_hints
accept thalovant_ask_hints: stt_lang, a JSON string array pipeline_json,
and a JSON object location_json. Language and pipeline stages are trimmed;
empty stages are omitted. Build the location object with
thalovant_build_location; a city is required and invalid/zero coordinates are
omitted. Existing builders retain their original wire bytes.
The complete frame uses the caller’s output buffer directly. Each decoded
pipeline stage must fit THALOVANT_ASK_TEXT_MAX including its terminator;
hint JSON must fit THALOVANT_WIRE_MAX_TOKENS. These compile-time limits keep
the tokenizer and normalization buffers bounded without dynamic allocation.
thalovant_speakable renders an intent pattern into a caller-owned buffer with
optional thalovant_speakable_slot replacements. Rank rendered examples using
the original pattern’s slot presence, retain the best rank when deduplicating,
and apply the requested limit afterward.
thalovant_ask_classify recognizes THALOVANT_ASK_AUDIO without marking it a
failure. Audio never settles or extends an ask: retain its order alongside speech
within the existing reply window. thalovant_ask_event_language returns the
first nonempty language from event data, context, or session.
thalovant_ask_audio_decode validates correlation and decodes only embedded
hexadecimal audio into caller-owned storage; scratch space holds the unescaped
hex string. It never reads a file or URL. Check its returned length/error before
playing bytes. Buffer limits may be smaller than the 4 MiB clip maximum.
Zero-initialize thalovant_audio_budget for each reply and call
thalovant_audio_budget_accept before retaining encoded audio. It caps each
clip at 4 MiB and all reply clips at 16 MiB (encoded upper bounds); rejected
clips increment dropped and do not affect speech collection. Deduplicate
transport deliveries before collection. The library remains transport-agnostic;
control-plane skill/configuration management belongs to managed SDK clients.
thalovant_ask_event_pipeline_id() and thalovant_ask_event_skill_id() extract string stamps from recognized, correlated reply frames into caller-provided buffers. Keep distinct IDs in first-seen order in your own storage. thalovant_reply_claimed() classifies the reply from its handled/failure status and the retained pipeline IDs. Embedded NUL is rejected; insufficient buffers return THALOVANT_ERR_NOMEM.
Pipeline and skill IDs are nonempty strings in first-seen order, with duplicates removed. A successful reply with only pipeline IDs containing the case-sensitive text fallback reports an unclaimed reply. Any non-fallback stage makes a successful reply claimed. Successful replies from older hubs without stage stamps remain claimed. Failed or unhandled replies are never claimed. Malformed non-string stamps are ignored.
Use this as a hint when deciding whether to continue a conversation. Fallback text and the existing success status are retained. These stamps are not verified peer identity and must not authorize actions.