# Skills API

> The server-to-server routes that keep an environment's Skills in step with the ones your system holds: endpoints, request and response fields, going live, listing, deleting, limits, errors and what version 1 promises.

Your system lists an environment's Skills, pushes the ones it keeps, reads them back and deletes the ones it no longer has, with an org API key. No person and no AI model takes part, and nothing needs doing in the Studio after the key is made.

## Before you start

| | |
|---|---|
| Key | Created in **Settings → API keys**, in the same environment as `envSlug`. Choose **Skills only** under **Access**: that key lists, reads, pushes and deletes Skills and reaches nothing else. A **Full access** key works too |
| Header | The key in `x-api-key` |
| Callers | Servers only. Keep the key out of a browser |
| Base path | `https://YOUR-MINDSET-HOST/api/v1/orgs/{orgSlug}/envs/{envSlug}` |

## Endpoints

| Method and path | What it does | Scope |
|---|---|---|
| `GET /skills` | Every Skill in the environment, in byte order of the handle, a page at a time | `skills:read` |
| `PUT /skills/{handle}` | Makes the Skill at this handle equal the body, then applies `live` | `skills:write` |
| `DELETE /skills/{handle}` | Deletes the Skill, with every version | `skills:write` |
| `GET /skills/{handle}` | The Skill and its newest version, in full | `skills:read` |
| `GET /skills/{handle}/versions` | Its versions, newest first, a page at a time | `skills:read` |
| `GET /skills/{handle}/versions/{version}` | One version, in full | `skills:read` |

A handle is 1 to 64 lowercase letters, digits and hyphens, with no hyphen at either end and no two in a row: `invoice-exceptions`. Every route names the Skill by its handle.

## Keep an environment in step

A system that holds the Skills an environment should have makes three kinds of call:

1. `GET /skills`, following `nextCursor` to the last page, for the handles the environment has now.
2. `PUT /skills/{handle}` for each Skill the system holds, with `live` as it should be. A Skill that hasn't changed answers `unchanged` and adds nothing, so pushing every one on every run is safe.
3. `DELETE /skills/{handle}` for each handle the environment has that the system doesn't.

## From a SKILL.md folder

A Skill in the Agent Skills format is a folder: a `SKILL.md` that opens with frontmatter between two `---` lines, and other files beside it.

| In the folder | In the push |
|---|---|
| The frontmatter's `name` | The handle, in the path |
| The frontmatter's `description`, `license`, `compatibility`, `metadata` and `allowed-tools` | `description`, `license`, `compatibility`, `metadata` and `allowedTools` |
| Everything after the line that closes the frontmatter, the blank line that usually follows it included | `instructions`, exactly as it is |
| Every other file, by its path from the folder | `files`, each path to its text |
| Nothing | `name`, a display name you choose, such as the first heading. Agents see it beside the description |

For example, this folder:

```text
invoice-exceptions/
  SKILL.md
  references/tolerances.md
  scripts/check_totals.py
```

with this `SKILL.md`:

```markdown
---
name: invoice-exceptions
description: Use when an invoice does not match its purchase order.
license: Proprietary
metadata:
  owner: finance-ops
---

# Invoice exceptions

Flag any line over 5% of the agreed price.
```

is this push, to `PUT /skills/invoice-exceptions`. The instructions start with the blank line that follows the frontmatter, and the script is left out:

```json
{
  "name": "Invoice exceptions",
  "description": "Use when an invoice does not match its purchase order.",
  "license": "Proprietary",
  "metadata": { "owner": "finance-ops" },
  "instructions": "\n# Invoice exceptions\n\nFlag any line over 5% of the agreed price.\n",
  "files": { "references/tolerances.md": "| Category | Tolerance |\n|---|---|\n| Goods | 2% |\n" },
  "live": true
}
```

Leave out files that are programs: anything under `scripts/`, and any file ending `.py`, `.sh`, `.bash`, `.zsh`, `.js`, `.mjs`, `.cjs`, `.ts`, `.rb`, `.pl`, `.php`, `.ps1`, `.bat`, `.cmd` or `.exe`. Mindset doesn't run them, and refuses a push that holds one with `reason` `scripts_not_supported`.

## Push a Skill

```bash
curl -X PUT "https://YOUR-MINDSET-HOST/api/v1/orgs/acme/envs/production/skills/invoice-exceptions" \
  -H "x-api-key: $SKILLS_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "name": "Invoice exceptions",
    "description": "Use when an invoice does not match its purchase order.",
    "instructions": "# Invoice exceptions\n\nCompare the invoice lines with the purchase order. Flag any line over 5% of the agreed price.",
    "files": { "references/tolerances.md": "| Category | Tolerance |\n|---|---|\n| Freight | 10% |\n" },
    "live": true
  }'
```

The call is declarative: send the whole Skill as it should be.

| Before the call | What happens | Status | `outcome` |
|---|---|---|---|
| No Skill at the handle | The Skill is created at version 1 | 201 | `created` |
| A Skill whose newest version differs | One version is added | 200 | `updated` |
| A Skill whose newest version is this document | Nothing is added | 200 | `unchanged` |

So a retried call never creates a second Skill or a second version. A different `name` renames the Skill and adds no version.

### Request body

| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | The display name, 1 to 255 characters. Every agent the Skill is attached to sees it beside the description, and the Studio shows it |
| `description` | string | yes | When the Skill applies, up to 1,024 characters. An agent reads it to decide whether to load the Skill |
| `instructions` | string | yes | The body of the Skill's SKILL.md |
| `files` | object | no | Other text files, by path relative to the Skill's root: `{ "references/tolerances.md": "…" }`. No scripts, no binary files, no `SKILL.md` |
| `license` | string | no | Up to 500 characters |
| `compatibility` | string | no | Up to 500 characters |
| `metadata` | object | no | Up to 50 entries, each key up to 64 characters and each value up to 500 |
| `allowedTools` | string | no | Kept as written. It has no effect in Mindset |
| `live` | boolean | yes | `true` makes this version the one agents load. `false` stores it and leaves the live version as it was |

The body is strict: a field not in this table is refused, and the refusal names it. Instructions and files are stored byte for byte as sent, line endings and trailing spaces included.

### Going live

`live` is required on every push. With `true`, the pushed version becomes the one agents load in the same step, and the version that was live stops being used. A Skill never has more than one live version, even when two pushes arrive at the same moment. With `false`, the version is stored and nothing agents load changes: a version that was live stays live, and a Skill with none stays unused. Push the same document again with `true` when it should go live.

Going live is refused when an agent the Skill is attached to already holds 50 published Skills, since this would make one more. An org admin attaches Skills to agents through the `mindset-admin` MCP server; this API doesn't.

### Conditions

| Header | Effect |
|---|---|
| `If-None-Match: *` | Create only. A 412 if a Skill already holds the handle |
| `If-Match: "3"` | Store the push, or make the delete, only while version 3 is still the newest. A 412 if it has moved |

Every read of a Skill and every push returns the newest version as the `ETag`, ready for `If-Match`. Writes to one Skill are taken one at a time, from this API and the `mindset-admin` MCP server alike.

### Response

| Field | What it is |
|---|---|
| `outcome` | `created`, `updated` or `unchanged` |
| `skill` | The Skill: `id`, `handle`, `name`, `status` (`published` while a version is live, `draft` when none is), `activeVersion` (the live version, or null), `createdBy`, `createdAt`, `updatedAt` |
| `version` | The version the pushed document is |
| `live` | Whether that version is the one agents load now |
| `warnings` | Advice the content earned, such as instructions longer than the Agent Skills specification recommends. The push still succeeded |

## Read a Skill

`GET /skills/{handle}` and `GET /skills/{handle}/versions/{version}` return:

| Field | What it is |
|---|---|
| `skill` | The Skill, as in a push's response |
| `version` | The version's record: `version`, `description`, `author` and `authorLabel` (who wrote it), `approvedBy`, `approvedByLabel` and `approvedAt` (who last made it live, and when), `isActive`, `totalBytes`, `createdAt` |
| `document` | Everything the version holds, in the shape a push takes. Its `name` is the Skill's name now, since a name isn't versioned |

Read `document`, change it, and push it back with `live` to make a change.

`GET /skills/{handle}/versions` returns `versions`, newest first, and `nextCursor`. Pass `?limit=` for the page size (50 unless you ask, at most 200) and `?cursor=` with the last page's `nextCursor` for the versions before it. `nextCursor` is null on the last page.

## List Skills

`GET /skills` returns `skills`, in byte order of the handle (`-` before digits, digits before letters), and `nextCursor`. Each entry is the Skill, as in a push's response, with `newestVersion`: the newest version's number, live or not. Pass `?limit=` for the page size (50 unless you ask, at most 200; a larger number reads 200 and the answer's `limitClamped` says so) and `?cursor=` with the last page's `nextCursor` for the Skills after it. `nextCursor` is null on the last page. A cursor is opaque: pass it back as you got it.

A Skill created or deleted while you page moves no other Skill between pages. A deleted Skill isn't listed, and neither is another environment's.

## Delete a Skill

```bash
curl -X DELETE "https://YOUR-MINDSET-HOST/api/v1/orgs/acme/envs/production/skills/invoice-exceptions" \
  -H "x-api-key: $SKILLS_API_KEY"
```

A 200 returns `deleted: true` and `skill`, the Skill as it was. It is gone with every version: agents stop loading it, and it is no longer read or listed. Its handle is free, so a later push to it creates a new Skill at version 1. A delete is recorded in the org's audit trail, naming the key, and isn't undone through this API.

When agents hold the Skill, the first delete removes nothing. It answers 409 `has_dependents`, with the agents in `context.dependents` and a `context.fingerprint` for that list. Show the list to whoever decides, and to delete anyway, repeat the call with `?confirmDependents=` and the fingerprint. If the agents holding it have changed since, the answer is 409 `dependents_changed` with the current list and a new fingerprint. A Skill no agent holds is deleted on the first call.

With `If-Match: "3"`, the Skill is deleted only while version 3 is still its newest; if a newer version has been pushed since you read it, the answer is 412 `precondition_failed` and nothing is deleted.

A delete where there is no Skill, a retried delete that already succeeded among them, answers 404.

## Limits

| What | Limit |
|---|---|
| One version, instructions and files together, as stored | 4 MiB (4,194,304 bytes) |
| The request body | 16 MiB (16,777,216 bytes), so a Skill at its limit fits once escaped as JSON |
| Files in one version | 10,000 |
| A file's path | 512 characters |

A Skill over a limit is refused naming the limit and its size. Nothing is stored.

## Errors

Every error body is JSON with a `code`, a `message`, and often a `context`. An invalid Skill is refused once, with every problem in `context.problems`, each with its `field` and `reason` (up to 20, and `context.problemsTotal` when there are more).

| What happened | Status | `code` |
|---|---|---|
| A body that isn't JSON, misses a field, carries one this API doesn't define, or breaks a rule, including text that can't be stored as sent (a NUL character, or half of an emoji); or a `limit`, `cursor` or `confirmDependents` that isn't valid, or a query parameter the route doesn't define (named in the message) | 400 | `validation_error` |
| A handle that isn't a valid handle | 400 | `invalid_handle` |
| No key in `x-api-key` | 401 | `unauthorized` |
| A key that is unknown, revoked or expired | 401 | `invalid_api_key` |
| A push or delete with a key that can read Skills but not write them | 403 | `unauthorized` |
| A suspended org | 403 | `org_suspended` |
| No such Skill or version | 404 | `not_found` |
| The org or environment in the path isn't the key's own, or the key holds no Skills scope | 404 | `not_found` |
| Another change to the same Skill, made at the same moment, got there first. Read it again and retry | 409 | `version_conflict` |
| A delete of a Skill agents hold, without `confirmDependents` | 409 | `has_dependents` |
| A delete whose `confirmDependents` no longer matches the agents holding the Skill | 409 | `dependents_changed` |
| An `If-None-Match` or `If-Match` condition that didn't hold | 412 | `precondition_failed` |
| An `If-Match` that names no version, or `If-None-Match` on a delete | 400 | `validation_error` |
| A body over 16 MiB | 413 | `payload_too_large` |

The 404s for a wrong org, environment or key look the same on purpose, so nobody holding a key can learn which orgs or environments exist.

## What version 1 promises

Within `/api/v1`, changes only add: new routes, new optional request fields, new response fields and new error codes. Ignore response fields you don't know, and treat an unknown `code` by its status. Nothing here is removed, renamed or narrowed within v1. A change that would break a caller ships as `/api/v2` beside `/api/v1`, which keeps answering through a declared deprecation window and says so in `Deprecation` and `Sunset` headers.

Every push and every delete is recorded in the org's audit trail, naming the key, and every call is counted against the key that made it.

The OpenAPI 3.1 document for these routes, generated from the code that serves them, is [customer-v1.json](https://docs.mindset.ai/openapi/customer-v1.json).
