Catalog
list_marketplace_skills lists every published skill. It needs hubs:read and no paid plan.
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. Every Thalovant SDK exposes the same five calls, and the MCP Server exposes them as tools.
| 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. |
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.
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 creation accepts an idempotency key, so a retry after a network timeout returns the original hub instead of creating a second one. Every SDK generates a key when you do not pass one. 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?;A missing etag and a stale etag both fail the same way: HTTP 412 with the detail ETag mismatch, and nothing is changed. Read the hub again, take the new etag, and retry.
Runtime group writes do not use etags. Runtime group resources carry no etag to round-trip, and no runtime group route reads If-Match.
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.
Everything else on the hub, including active, visibility, slug, spec, and the runtime group it attaches to, stays editable through the etag flow above.
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) |
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 |
|---|---|---|
| 403 | Insufficient scopes |
The token is missing the scope the route needs. Mint a token with hubs:write for provisioning, or hubs:read for the catalog. |
| 402 | API access requires a paid plan. |
The scope is right but the workspace is on a free plan. Upgrade the workspace, then retry. Discovery calls keep working on the free plan. |
| 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. |
The scope check runs before the plan check, so the two never overlap. A free-plan API token is capped at hubs:read, clients:read, and clients:write, so it can never carry hubs:write. A provisioning call with that token fails at the scope check with 403 and never reaches the plan check.
Read a 403 on a provisioning call as “this token cannot do this”, and a 402 as “this workspace cannot do this”. Both 402s share the same shape, so tell them apart by their detail text.
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.