Catalog
list_marketplace_skills lists every published skill. It needs hubs:read and no paid plan.
Discover marketplace skills, then create a runtime group and a hub, install a skill, and release it from code.
Use this page when your code should create the hub instead of clicking through the dashboard.
Provisioning covers five steps: discover skills in the marketplace catalog, create a runtime group, create a hub attached to it, install a skill into the group, and release both onto a channel. Python, Node.js, Go, Rust, Kotlin, Swift, and .NET expose these calls, and the MCP Server exposes them as tools. Embedded C supplies runtime protocol helpers; provision its hub and client identity separately.
| Step | Call | What it needs |
|---|---|---|
| Discover skills | list_marketplace_skills | hubs:read. Any plan. |
| Create a runtime group | create_runtime_group | hubs:write and a paid plan. |
| Create a hub | create_hub | hubs:write and a paid plan. |
| Install a skill | install_runtime_group_skill | hubs:write, a paid plan, and marketplace access for paid skills. |
| Release | release_runtime_group and release_hub | hubs:write and a paid plan. Pinned images must be ones the platform releases unless you are a platform administrator; see the platform_image_required row under Error Guidance. |
Method names follow each language. The table uses the Python spelling; see Call Names By Language for the others.
| Scope | What it covers |
|---|---|
hubs:read | The marketplace catalog and the list of runtime groups. |
hubs:inspect | Runtime group inventory and the group’s resolved marketplace view. |
hubs:write | Creating, updating, releasing, and deleting hubs and runtime groups, and installing skills. |
These scopes imply one another. hubs:write grants hubs:read, which grants hubs:inspect. A token minted with hubs:read covers every discovery call on this page.
This flow discovers skills, creates a runtime group and a hub, installs one skill, and releases both.
The examples create a Standard hub. Autoscaling capacity is a separate hub setting controlled by the workspace plan. Changing a runtime group’s replica settings does not enable it.
import os
from thalovant import ThalovantControlPlane
api = ThalovantControlPlane(access_token=os.environ["THALOVANT_API_TOKEN"])
# 1. Discover what is installable before provisioning anything.for skill in api.list_marketplace_skills()["data"]: print(skill["skill_id"], skill["title"], skill["access_tier"])
# 2. Create a runtime group to run the skills.group = api.create_runtime_group({"name": "kiosks", "description": "Lobby kiosks"})
# 3. Create a hub attached to it.hub = api.create_hub( { "name": "joke-garden", "runtime_group_id": group["id"], "spec": {"protocols": {"wss": {"enabled": True}}}, })
# 4. Install a skill from the marketplace catalog.api.install_runtime_group_skill(group["id"], "skill-weather")
# 5. Roll the runtime group and the hub onto a release channel.api.release_runtime_group(group["id"], channel="stable")api.release_hub(hub["id"], channel="stable")import { ThalovantControlPlane } from "@thalovant/sdk";
const api = new ThalovantControlPlane(undefined, { accessToken: process.env.THALOVANT_API_TOKEN,});
// 1. Discover what is installable before provisioning anything.const catalog = await api.listMarketplaceSkills();for (const skill of catalog.data as Array<Record<string, unknown>>) { console.log(skill.skill_id, skill.title, skill.access_tier);}
// 2. Create a runtime group to run the skills.const group = await api.createRuntimeGroup({ name: "kiosks", description: "Lobby kiosks" });
// 3. Create a hub attached to it.const hub = await api.createHub({ name: "joke-garden", runtimeGroupId: group.id as string, spec: { protocols: { wss: { enabled: true } } },});
// 4. Install a skill from the marketplace catalog.await api.installRuntimeGroupSkill(group.id as string, "skill-weather");
// 5. Roll the runtime group and the hub onto a release channel.await api.releaseRuntimeGroup(group.id as string, { channel: "stable" });await api.releaseHub(hub.id as string, { channel: "stable" });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 { log.Fatal(err)}
// 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 { log.Fatal(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 { log.Fatal(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 { log.Fatal(err)}
// 5. Roll the runtime group and the hub onto a release channel.control.ReleaseRuntimeGroup(ctx, groupID, thalovant.ReleaseOptions{Channel: "stable"})control.ReleaseHub(ctx, hubID, thalovant.ReleaseOptions{Channel: "stable"})use serde_json::json;use thalovant::{ControlPlane, MarketplaceSkillsOptions, ReleaseOptions, SkillInstallOptions};
let control = ControlPlane::with_access_token(std::env::var("THALOVANT_API_TOKEN")?);
// 1. Discover what is installable before provisioning anything.let catalog = control .list_marketplace_skills(MarketplaceSkillsOptions::default()) .await?;
// 2. Create a runtime group to run the skills.let group = control .create_runtime_group(json!({"name": "kiosks", "description": "Lobby kiosks"})) .await?;let group_id = group["id"].as_str().unwrap_or_default().to_string();
// 3. Create a hub attached to it.let hub = control .create_hub( json!({ "name": "joke-garden", "runtime_group_id": group_id, "spec": {"protocols": {"wss": {"enabled": true}}}, }), None, ) .await?;let hub_id = hub["id"].as_str().unwrap_or_default().to_string();
// 4. Install a skill from the marketplace catalog.control .install_runtime_group_skill(&group_id, "skill-weather", SkillInstallOptions::default()) .await?;
// 5. Roll the runtime group and the hub onto a release channel.let channel = || ReleaseOptions { channel: Some("stable".into()), ..Default::default()};control.release_runtime_group(&group_id, channel()).await?;control.release_hub(&hub_id, channel()).await?;Kotlin, Swift, and .NET expose the same five calls with native names. Their SDK pages show the language-specific form.
Hub-addressed skill methods manage the hub’s attached runtime group, affecting every hub sharing it. list_hub_skills returns the hub’s skill rows under data, each with an installed_version, update_available, and a state; install_hub_skill, update_hub_skill, and remove_hub_skill select the runtime group by hub ID, return an operation you can wait on, and apply to all hubs sharing that group. Listing needs hubs:inspect; the writes need hubs:write and a paid plan, and a hub that is not in a runtime group yet answers HTTP 404 with the code hub_without_runtime_group. All seven managed SDKs now provide these methods and skill history at the current versions; see Add a Skill to a Hub for the routes, the app path, and the errors.
Generate and retain an explicit idempotency key before the first hub-create attempt. Reuse that key and the same payload when retrying that logical create after a network timeout. Omitting the key generates a new one on each SDK call, so a separate call can create a second hub. The SDKs do not automatically retry creation. Reusing a key with a different body is an error; see the table below.
Runtime group creation and skill install do not take an idempotency key.
A skill in the catalog is not always installable for your plan. The group’s marketplace view resolves the catalog against one runtime group and reports whether each entry can be installed, so you can decide up front instead of reading an error afterwards.
Catalog
list_marketplace_skills lists every published skill. It needs hubs:read and no paid plan.
Group marketplace view
list_runtime_group_marketplace resolves the catalog for one runtime group and reports whether each skill is installable and whether it needs a purchase. It needs hubs:inspect.
Group inventory
list_runtime_group_inventory lists the skills actually observed running in the group. It needs hubs:inspect.
Hub update and delete use optimistic locking. Both require the hub’s current etag, sent as an If-Match header by the SDK.
The etag is only in the body of the hub resource. The API sends no ETag response header, so read the hub first and take the etag field from that response.
hub = api.get_hub(hub_id)hub = api.update_hub(hub_id, {"active": False}, etag=hub["etag"])api.delete_hub(hub_id, etag=hub["etag"])let hub = await api.getHub(hubId);hub = await api.updateHub(hubId, { active: false }, { etag: hub.etag as string });await api.deleteHub(hubId, { etag: hub.etag as string });hub, err := control.GetHub(ctx, hubID)if err != nil { log.Fatal(err)}
hub, err = control.UpdateHub(ctx, hubID, map[string]any{"active": false}, hub["etag"].(string))if err != nil { log.Fatal(err)}
if err := control.DeleteHub(ctx, hubID, hub["etag"].(string)); err != nil { log.Fatal(err)}let hub = control.get_hub(&hub_id).await?;let etag = hub["etag"].as_str().unwrap_or_default().to_string();
let hub = control .update_hub(&hub_id, json!({"active": false}), &etag) .await?;
let etag = hub["etag"].as_str().unwrap_or_default().to_string();control.delete_hub(&hub_id, &etag).await?;Pass a nonempty current etag. SDKs reject empty values before sending where local validation is provided; a stale etag reaches the API and fails with HTTP 412 and the detail ETag mismatch. Read the hub again and reconcile your changes before retrying with its new etag.
Runtime group writes do not use etags. Runtime group resources carry no etag to round-trip, and no runtime group route reads If-Match.
Legacy PATCH runtime-group config updates replace the configuration, except
the protected control section. Omitted runtime keys are dropped; personas
changes only when supplied. Coordinate writers using that unconditional route.
For a guarded update, read GET /v1/runtime-groups/{id}/config, merge your
changes into its config, and send PUT to the same path with config and
expected_revision set to the returned revision. HTTP 412 means nothing was
written: read again and reapply your original changes. PUT requires a revision;
legacy PATCH remains supported. Older API versions reject PUT without writing.
Python 0.7.5, Node/Swift/.NET/Kotlin 0.7.1, Go 0.9.1 and Rust 0.9.0,
and MCP 0.5.1 use this guarded flow by default, with at most three write
attempts. Only HTTP 412 triggers another read and merge of the original delta.
Objects merge recursively; arrays and scalars replace. Omitted personas stay
unchanged; supplied personas replace. Merge requires the hubs:read and
hubs:write scopes and a paid plan. An older server without a valid revision fails before a write;
there is no unconditional fallback.
For an intentional full replacement, use Python merge=False, Node/MCP
merge: false, or the other SDKs’ replaceRuntimeGroupConfig equivalent.
Replacement requires the hubs:write scope, a paid plan, and coordination with other writers.
When upgrading code that relied on replacement, select it explicitly.
A hub’s name, namespace, and domain are fixed at creation. Sending a new value for any of them fails with HTTP 400 and a message such as Name cannot be changed after hub creation. Pick them carefully, because moving a hub to a new name means creating a new hub and connecting its clients again.
Sending the current value again is accepted. The field is dropped from the update rather than rejected, so a read-modify-write that carries these fields along does not break.
Editable fields include active, visibility, slug, spec, capacity_profile, and the runtime group the hub attaches to. Use the etag flow above and send only accepted update fields. The plan determines the worker ceiling when selecting Autoscaling capacity.
Use this table when you already know the task and need the language-specific name.
| Task | Python | Node.js | Go | Rust |
|---|---|---|---|---|
| List catalog skills | list_marketplace_skills(...) | listMarketplaceSkills(...) | ListMarketplaceSkills(ctx, opts) | list_marketplace_skills(opts) |
| Create hub | create_hub(payload, ...) | createHub(payload, options) | CreateHub(ctx, payload, opts) | create_hub(payload, idempotency_key) |
| Get hub | get_hub(hub_id) | getHub(hubId) | GetHub(ctx, hubID) | get_hub(hub_id) |
| Update hub | update_hub(id, payload, etag=) | updateHub(id, payload, { etag }) | UpdateHub(ctx, id, payload, etag) | update_hub(id, payload, etag) |
| Delete hub | delete_hub(id, etag=) | deleteHub(id, { etag }) | DeleteHub(ctx, id, etag) | delete_hub(id, etag) |
| Create runtime group | create_runtime_group(payload) | createRuntimeGroup(payload) | CreateRuntimeGroup(ctx, payload) | create_runtime_group(payload) |
| List runtime groups | list_runtime_groups(...) | listRuntimeGroups(options) | ListRuntimeGroups(ctx, ownerID) | list_runtime_groups(owner_id) |
| Group inventory | list_runtime_group_inventory(...) | listRuntimeGroupInventory(...) | ListRuntimeGroupInventory(ctx, id, opts) | list_runtime_group_inventory(id, refresh) |
| Group marketplace view | list_runtime_group_marketplace(...) | listRuntimeGroupMarketplace(...) | ListRuntimeGroupMarketplace(ctx, id, opts) | list_runtime_group_marketplace(id, refresh) |
| Install skill | install_runtime_group_skill(...) | installRuntimeGroupSkill(...) | InstallRuntimeGroupSkill(ctx, id, skill, opts) | install_runtime_group_skill(id, skill, opts) |
| Release runtime group | release_runtime_group(...) | releaseRuntimeGroup(id, options) | ReleaseRuntimeGroup(ctx, id, opts) | release_runtime_group(id, opts) |
| Release hub | release_hub(...) | releaseHub(id, options) | ReleaseHub(ctx, id, opts) | release_hub(id, opts) |
| List hub skills | list_hub_skills(hub_id) | listHubSkills(hubId) | pending | pending |
| Install hub skill | install_hub_skill(hub_id, skill, version=, wait=) | installHubSkill(hubId, skill, { version, wait }) | pending | pending |
| Update hub skill | update_hub_skill(hub_id, skill, version=, wait=) | updateHubSkill(hubId, skill, { version, wait }) | pending | pending |
| Remove hub skill | remove_hub_skill(hub_id, skill, wait=) | removeHubSkill(hubId, skill, { wait }) | pending | pending |
Kotlin and Swift use the Node.js spelling with native option types, such as createHub and updateHub(id, payload, etag). C# adds the async suffix, such as CreateHubAsync and UpdateHubAsync(id, options, etag). The Kotlin, Swift, and .NET SDK pages show the exact signatures.
| Status | Detail | What it means and what to do |
|---|---|---|
| 402 | API access requires a paid plan. | The workspace is on the Free plan. The API checks this before the token’s scopes. Upgrade the workspace, then retry. Discovery calls keep working on the Free plan. |
| 403 | Insufficient scopes | The plan is paid but the token is missing the scope the route needs. Mint a token with hubs:write for provisioning, or hubs:read for the catalog. |
| 402 | This skill requires paid marketplace access for the tenant plan. | The plan is paid but does not cover this paid catalog entry. Check the group marketplace view first, which reports installable, purchase_required, and access_message per skill. |
| 412 | ETag mismatch | The If-Match etag on a hub update or delete was missing or stale. Read the hub again, take the etag from the response body, and retry. Nothing was changed. |
| 409 | Idempotency key re-used with different payload | A hub create reused an idempotency key with a different body. Use a fresh key for a genuinely new hub, or resend the exact original body to replay the first result. |
| 400 | Name cannot be changed after hub creation | The update tried to change name, namespace, or domain. Create a new hub instead. |
| 403 | Only an administrator can run an image the platform does not release: … | The code is platform_image_required. A release, or a hub update that changed spec.image or spec.previewBridge.image, named an image the platform does not ship. Leave images out to take the recommended release, or pin, for each image key, a catalog pin of the stable or alpha channel, the resource’s current or recommended image, its release-policy image, or the platform’s default image. A runtime group’s core also takes any tag or digest of ghcr.io/thalovant/ovos-core, and a hub’s listener any tag or digest of ghcr.io/thalovant/hivemind-listener. bus and preview_bridge take only the images listed for them. The error’s allowed_images and allowed_repositories fields name what each refused key may be. Only a platform administrator can run anything else. |
The plan check runs before the scope check, so the two never overlap. A Free-plan provisioning call answers 402 API access requires a paid plan. whatever scopes the token carries. That message names the real fix: no scope a Free token can hold would make the call work, because a Free-plan token can never carry hubs:write.
Only a paid workspace reaches the scope check. There, a token without hubs:write answers 403 Insufficient scopes.
Read a 402 on a provisioning call as “this workspace cannot do this”, and a 403 as “this token cannot do this”. The one 403 that is about something else is platform_image_required: the token is fine, the image is not. Both 402s share the same shape, so tell them apart by their detail text.
The error message is one line and can be cut short, so read a refusal from the error’s fields instead. The platform_image_required sentence names every image each refused key may be, and it is longer than any SDK’s message line. problem is the whole error body when it is a JSON object, so a field the API adds later is there without a new SDK release.
| SDK | Status | Code | Whole sentence | Whole body |
|---|---|---|---|---|
| Python 0.8.7 | status_code | code | detail | problem (dict) |
| Node.js 0.8.7 | statusCode | code | detail | problem (object) |
| Go v0.10.6 | StatusCode | Code | ProblemDetail | Problem (map[string]any) |
| Rust v0.12.0 | status_code() | api_code() | api_detail() | api_problem() (ApiProblem) |
| Kotlin 0.7.17 | statusCode | errorCode | detail | problem (JsonObject) |
| Swift 0.9.0 | statusCode | errorCode | detail | problem (JSONObject) |
| .NET 0.8.6 | StatusCode | ErrorCode | Detail | Problem (JsonObject) |
For platform_image_required, problem carries refused_images, allowed_images (per image key) and allowed_repositories (the keys that take any tag or digest). For plan_limit, it carries resource, limit, used, and plan.
from thalovant import ThalovantAPIError
try: api.release_runtime_group(group["id"], images={"core": "docker.io/me/ovos-core:dev"})except ThalovantAPIError as error: if error.code != "platform_image_required": raise print(error.detail) print(error.problem["allowed_images"])A value that a validation error echoes back from your request stays in problem and never reaches the message. The MCP server puts the code, the whole sentence, and the other fields in the tool error text; see MCP Server.
The MCP Server exposes the same flow as tools, so an agent can discover skills and provision a hub without writing SDK code. The delete tools are not available by default; see Destructive Tools.