# Add a Skill to a Hub

> Install, update, or remove a skill on one hub from the app, the API, the SDKs, the CLI, or MCP, without restarting the hub.

Source: https://docs.thalovant.com/manage/add-a-skill-to-a-hub/

---
{/* Last Edit: Claude Opus 5.5 - 2026-09-28 - Motive: The plan check (402) runs before the scope check (403). */}

Open a hub’s Skills tab to install a skill on its attached runtime group. Every hub sharing that group receives the change. Installation normally applies live without restarting the runtime; follow its status to confirm it succeeded.

The same change is one call from the API, all seven managed SDKs, the `thalovant` CLI, and the MCP server, so an agent or a script can add a skill to a hub too.

This page installs a skill on the runtime group behind a hub. Open the hub, then the **Skills** tab, then **Add skill**. You're done when the row says **installed** and the hub answers the skill's phrases.

## Hub Skills And Skill Sets

A [skill set](/concepts/skill-sets/) is a bundle of skills that several hubs share. Change the skill set and every hub on it changes.

A hub-addressed skill call resolves the hub’s attached runtime group and changes that group’s skill attachment. Adding, updating or removing it affects every hub served by the group. Use a separate runtime group when hubs need different installed skills.

A hub can start with no skills at all and gain them one at a time. That makes a new hub a blank device you fill in as you go, rather than one that has to pick a full skill set on day one.

Changes apply live. The hub picks up an added, updated, or removed skill in about 15 seconds and doesn't restart.

## A Runtime Group, A Paid Plan, A Token

Confirm:

- the hub belongs to a runtime group (without one, there's no **Skills** tab yet);
- your workspace is on a paid plan, because adding, updating, and removing hub skills are paid actions;
- for the API, SDKs, CLI, or MCP, you've an [API token](/account/api-tokens/) with `hubs:write`, or `hubs:inspect` to list only (`hubs:read` includes it); a restricted token must cover every hub sharing the affected runtime group;
- you know the hub's **id**, not its slug, when you work from code.

## Add A Skill In The App

1. Open the hub from **Hubs**.
2. Open the **Skills** tab.
3. Choose **Add skill**.
4. Pick the skill and a version. **Latest** is the default.
5. Confirm. The row shows the install status until it settles on **installed**.

Each row has **Update** and **Remove**. Update lets you pick a version; remove takes the skill out of the attached skill set, affecting every hub that uses it. Both normally apply while the runtime keeps running and show their progress on the row.

  Nothing restarts. Add the first skill to an empty hub, try it, then add the next one.

## From The API And SDKs

Every call addresses the hub by its id. Slugs aren't accepted on these routes. Install, update, and remove return HTTP 202 with an `operation_id`; the change is then applied on the hub while you poll or wait.

### Call The API

List the skills on a hub:

```bash
curl --fail-with-body \
  -H "Authorization: Bearer $THALOVANT_API_TOKEN" \
  "https://api.thalovant.com/v1/hubs/$HUB_ID/skills"
```

```json
{
  "hub_id": "8d1c2f4e-5b6a-4c7d-9e8f-0a1b2c3d4e5f",
  "runtime_group_id": "3f9a7b21-6c4d-4e8f-a1b2-c3d4e5f60718",
  "observed_at": "2026-09-09T14:02:11Z",
  "source": "runtime",
  "data": [
    {
      "skill": "skill-weather",
      "title": "Weather",
      "marketplace_skill_id": "b7e2c9d0-1f3a-4b5c-8d6e-7f8091a2b3c4",
      "package_name": "thalovant-skill-weather",
      "source_type": "catalog",
      "install_source": "marketplace",
      "version": "1.1.0",
      "version_pin": "1.1.0",
      "installed_version": "1.1.0",
      "observed_version": "1.1.0",
      "previous_version": "1.0.4",
      "latest_version": "1.2.0",
      "available_version": "1.2.0",
      "update_available": true,
      "changelog": "Adds hourly forecasts.",
      "active": true,
      "state": "installed",
      "last_transition_at": "2026-09-09T14:02:11Z"
    }
  ]
}
```

The rows sit under `data`, an empty array when nothing is installed this way. `observed_at` and `source` say when and from where the list was last observed. The envelope and each row also carry the runtime's phase, message, and last-error fields, left out of the sample above; they are `null` when the runtime has nothing to report. `installed_version` is what is running, `version_pin` is what you asked for, and `update_available` is true when a newer version exists.

Each row's `state` is one of:

| State | Meaning |
| --- | --- |
| `pending` | A change is in progress. Install, update, and remove all show this until the change lands. |
| `installed` | The skill runs at the requested version. |
| `failed` | The runtime could not install or update the skill. Its last-error field says why. |
| `removing` | The skill is being taken off. |
| `drifted` | The running version differs from the one requested. |
| `quarantined` | The runtime disabled the skill after repeated failures. |
| `unmanaged` | The skill is running but is not managed through this route. |

Install a skill. `version` is always sent and defaults to `latest`:

```bash
curl --fail-with-body -X POST \
  -H "Authorization: Bearer $THALOVANT_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"skill": "skill-weather", "version": "latest"}' \
  "https://api.thalovant.com/v1/hubs/$HUB_ID/skills"
```

```json
{
  "operation_id": "op_01J9Q0S2M5H8ZC3VQK7T1XB4RD",
  "hub_id": "8d1c2f4e-5b6a-4c7d-9e8f-0a1b2c3d4e5f",
  "runtime_group_id": "3f9a7b21-6c4d-4e8f-a1b2-c3d4e5f60718",
  "skill": "skill-weather",
  "version": "latest",
  "previous_version": null,
  "state": "installing"
}
```

Posting a skill that's already installed at another version performs an update, and `previous_version` then carries the version being replaced. Posting the same version answers HTTP 409.

Update to an explicit version. `version` is required here:

```bash
curl --fail-with-body -X PATCH \
  -H "Authorization: Bearer $THALOVANT_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"version": "1.2.0"}' \
  "https://api.thalovant.com/v1/hubs/$HUB_ID/skills/skill-weather"
```

Remove the skill:

```bash
curl --fail-with-body -X DELETE \
  -H "Authorization: Bearer $THALOVANT_API_TOKEN" \
  "https://api.thalovant.com/v1/hubs/$HUB_ID/skills/skill-weather"
```

```json
{
  "operation_id": "op_01J9Q0T7NA2KX4D9RW6PZ8E3HM",
  "hub_id": "8d1c2f4e-5b6a-4c7d-9e8f-0a1b2c3d4e5f",
  "runtime_group_id": "3f9a7b21-6c4d-4e8f-a1b2-c3d4e5f60718",
  "skill": "skill-weather",
  "version": null,
  "previous_version": "1.2.0",
  "state": "removing"
}
```

Update answers with the same body shape and `"state": "updating"`. Each write has its own `operation_id`. Poll that operation until it is `ready`, `failed`, or `timed_out`:

```bash
curl --fail-with-body \
  -H "Authorization: Bearer $THALOVANT_API_TOKEN" \
  "https://api.thalovant.com/v1/operations/$OPERATION_ID"
```

`ready` means the hub converged: the skill is `installed` after an install or update, and gone after a remove. See [Operations](/developers/operations/) for the full status sequence.

### Use An SDK Or The CLI

All seven managed SDKs support hub skill management and history. Use Python `0.6.0`, Node.js `0.4.0`, Go and Rust `0.6.0`, or Kotlin, Swift and .NET `0.4.0`. Pass `wait` to poll every 2 seconds with a 120-second default polling budget. No new status read starts at or after that deadline; the polling deadline doesn't cancel a request already in flight. A failed status read retains the accepted operation ID, so resume polling that operation instead of submitting the write again. The `thalovant` CLI ships with the Python SDK and reads `THALOVANT_API_TOKEN`.

  

```python

from thalovant import ThalovantControlPlane

api = ThalovantControlPlane(access_token=os.environ["THALOVANT_API_TOKEN"])

skills = api.list_hub_skills("hub-id")  # HubSkillList
print(skills.source, skills.observed_at)
for skill in skills.data:
    print(skill.skill, skill.installed_version, skill.state, skill.update_available)

result = api.install_hub_skill("hub-id", "skill-weather", version="latest", wait=True)
print(result.state)  # installed

api.update_hub_skill("hub-id", "skill-weather", version="1.2.0", wait=True)
api.remove_hub_skill("hub-id", "skill-weather", wait=True)
```

  
  

```ts

const api = new ThalovantControlPlane(undefined, {
  accessToken: process.env.THALOVANT_API_TOKEN,
});

const skills = await api.listHubSkills("hub-id"); // { data, source, observed_at, ... }
console.log(skills.source, skills.observed_at);
for (const skill of skills.data) {
  console.log(skill.skill, skill.installed_version, skill.state, skill.update_available);
}

const result = await api.installHubSkill("hub-id", "skill-weather", { version: "latest", wait: true });
console.log(result.state); // installed

await api.updateHubSkill("hub-id", "skill-weather", { version: "1.2.0", wait: true });
await api.removeHubSkill("hub-id", "skill-weather", { wait: true });
```

  
  

```bash
export THALOVANT_API_TOKEN="tvpat_..."

thalovant skills list --hub <hub-id>
thalovant skills add --hub <hub-id> skill-weather --version latest --wait
thalovant skills update --hub <hub-id> skill-weather --version 1.2.0
thalovant skills remove --hub <hub-id> skill-weather

# Machine-readable output for scripts:
thalovant --json skills list --hub <hub-id>
```

  

The list call returns the same envelope as the route: `HubSkillList` in Python and `{ data, source, observed_at, ... }` in Node.js, with the rows under `data`. Each write returns the accepted operation with `operation_id`, `skill`, `version`, `previous_version`, and `state`. Without `wait`, install, update, and remove return at once, and you poll the operation with `get_operation` or `getOperation`. Go, Rust, Kotlin, Swift, and .NET provide equivalent calls and resume helpers; see the [language guides](/developers/sdks/). Read shared-runtime history with `list_hub_skill_history(hub_id, limit=50)` in Python or `listHubSkillHistory(hubId, { limit: 50 })` in Node. The limit is an integer from 1 to 200.

### Use The MCP Server

From MCP `0.1.23`, the [MCP Server](/developers/mcp/) exposes the same four actions as tools: `thalovant_list_hub_skills`, `thalovant_install_hub_skill`, `thalovant_update_hub_skill`, and `thalovant_remove_hub_skill`. All four take `hubId`; the three write tools also take `skill`, plus `version`, `wait`, and `timeoutMs` where the action needs them. The three write tools need `hubs:write` and are hidden in read-only mode. MCP `0.2.0` uses Node SDK `0.4.0` and adds the read-only `thalovant_list_hub_skill_history` tool with an optional integer `limit` from 1 to 200. Polling failures preserve the accepted operation ID.

## If Something Fails

Errors come back as RFC 7807 problem JSON with `code` and `message` at the root of the body. The Python and Node.js SDKs put both into the error text as `HTTP <status>: <message> (<code>)`, for example `HTTP 409: Skill version already installed. (skill_version_already_installed)`; there's no separate code attribute on the error yet.

| Response | What it means | What to do |
| --- | --- | --- |
| HTTP 402 | The workspace is on the Free plan. The plan check runs before the scope check, so a Free-plan call always lands here, never on 403. | Upgrade the workspace, then retry. Listing still works. |
| HTTP 403 | The plan is paid but the token does not carry `hubs:write`. | Mint a token with `hubs:write` on the [API Tokens page](/account/api-tokens/). |
| HTTP 404, `hub_without_runtime_group` | The hub is not in a runtime group yet. | Add it to a runtime group, then retry. |
| HTTP 404 with no `code` | The id is unknown, or the skill is not installed there. | Check you passed the id rather than the slug, then list the installed skills. |
| HTTP 409, `skill_version_already_installed` | The hub already has that skill at that version. | Nothing to do, or pick a different version. |
| HTTP 422 | `latest` could not be resolved to a version, or the version string is invalid. | Pass a version the skill publishes, such as `1.2.0`. |
| State `failed` | The runtime could not install or update the skill. | Read the error on the row, the row's last-error field, or the operation's `error_message`; fix the cause and retry. The rest of the hub keeps running. |

A skill that stays in `pending` for much longer than 15 seconds usually means the hub itself isn't healthy. Check its live state on [Hubs](/concepts/hubs/) before retrying.

## The Skill Is Ready When

- the row shows **installed** and the version you picked;
- `GET /v1/hubs/$HUB_ID/skills` lists it under `data` with `"state": "installed"`;
- the operation reports `ready`;
- the hub answers to the skill's intents.

## Publish A Version Of Your Skill

To install a skill you wrote this way, it needs a published version. Skill authors publish one with a multipart `POST` to `/v1/marketplace/skills/{id}/versions`, with the fields `version` and `channel`, an optional `changelog`, and the built wheel as the `wheel` file part:

```bash
curl --fail-with-body -X POST \
  -H "Authorization: Bearer $THALOVANT_API_TOKEN" \
  -F "version=1.2.0" \
  -F "channel=stable" \
  -F "changelog=Adds hourly forecasts." \
  -F "wheel=@dist/thalovant_skill_weather-1.2.0-py3-none-any.whl" \
  "https://api.thalovant.com/v1/marketplace/skills/$SKILL_ID/versions"
```

A successful upload returns HTTP 201 with the skill ID, package name, version, channel, and checksum. You can then select that version when installing or updating the skill.

To write your first skill, follow the [Pocket Quiz tutorial](/developers/skillkit-tutorial/). To install it directly from your own Git repository or a Python package, follow [Add Your Own Skill](/manage/add-your-own-skill/).

## Next Step

For creating the hub and its runtime from code, see [Provision Hubs](/developers/provisioning/), and for how shared skill sets work, see [Skills](/concepts/skill-sets/).

Skill attachments belong to the selected hub’s runtime group. Every hub sharing
that group receives the change; a restricted token must cover all served hubs.
