# Provision Hubs

> Discover marketplace skills, then create a runtime group and a hub, install a skill, and release it from code.

Source: https://docs.thalovant.com/developers/provisioning/

---
{/* Last Edit: Claude Opus 5.5 - 2026-09-28 - Motive: The API checks the plan (402) before the token scopes (403); the docs said the reverse. */}

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](/developers/mcp/) exposes them as tools. Embedded C supplies runtime protocol helpers; provision its hub and client identity separately.

## At A Glance

| 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](#error-guidance). |

Method names follow each language. The table uses the Python spelling; see [Call Names By Language](#call-names-by-language) for the others.

## What Provisioning Needs

1. **Mint an API token.** Create it on the dashboard's [API Tokens page](/account/api-tokens/) with the scopes below.
2. **Check the plan.** Every hub and runtime group write needs a paid workspace plan. Browsing the catalog does not.
3. **Decide the runtime group.** A hub runs its skills in a runtime group. Create the group first so the hub can attach to it.

### Scopes

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

  The catalog is deliberately not paid-gated. A free-plan token can browse every skill and decide what it wants before upgrading. Only the install and the other writes need a paid plan.

## Provision A Hub

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](/concepts/hubs/#standard-and-autoscaling-capacity)
is a separate hub setting controlled by the workspace plan. Changing a runtime
group's replica settings does not enable it.

  

```python

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")
```

  
  

```ts

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>) {
  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" });
```

  
  

```go
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"})
```

  
  

```rust
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](/developers/sdks/#supported-versions); see [Add a Skill to a Hub](/manage/add-a-skill-to-a-hub/) for the routes, the app path, and the errors.

### Repeat A Create Safely

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.

## Check Before You Install

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.

  
    `list_marketplace_skills` lists every published skill. It needs `hubs:read` and no paid plan.
  
  
    `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`.
  
  
    `list_runtime_group_inventory` lists the skills actually observed running in the group. It needs `hubs:inspect`.
  

## Update Or Delete A Hub

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.

  

```python
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"])
```

  
  

```ts
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 });
```

  
  

```go
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)
}
```

  
  

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

  Send only the fields you are changing. Round-tripping a whole hub resource back into an update sends fields the update route does not accept.

## What Cannot Change

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.

## Call Names By Language

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](/developers/sdks/kotlin/), [Swift](/developers/sdks/swift/), and [.NET](/developers/sdks/dotnet/) SDK pages show the exact signatures.

## Error Guidance

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

### Read 402 Before 403

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.

### Read The Whole Refusal

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

```python
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](/developers/mcp/).

## Provision From An MCP Client

The [MCP Server](/developers/mcp/) 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](/developers/mcp/#destructive-tools).

## Related Pages
