---
title: "Skills"
description: "A skill is a markdown file that gives an agent one scoped capability. What it is, how it differs from a tool and an agent, and where it lives."
type: explanation
audience: both
verified: 2026-09-06
canonical: https://one.ie/docs/skills
---
A skill is a markdown file with YAML frontmatter. It holds instructions for one
job, it is stored per workspace, and it is run by name. This page explains what
a skill is and where it sits between a tool and an agent. Everything below was
read out of the shipped code on 2026-09-06; the limits section names what is
declared but not wired.

## Skill, tool, agent

These are the three rungs of the autonomy ladder, and each compiles to a
different receiver.

| Rung | Receiver | What it is |
|---|---|---|
| tool | `tools:composio`, or a named receiver | an integration; no judgment |
| skill | `skill:run` | a capability, scoped by its own file |
| agent | `agent:run` | an actor with open judgment |

The clearest mechanical difference is in the workflow run guard. In
`one.ie/web/src/lib/step-ceiling.ts`, `stepAllowed()` checks a tool step's
receiver name against the run's `enabledToolIds` list and an agent step's
`actorId` against `enabledAgentIds`. `skill:run` is exempt from both, because a
skill name is a different namespace from a receiver name. It is exempt from the
rung ceiling too: `stepWithinCeiling()` reads the receiver's explicit
`roleAction`, and `skill:run` declares none, so the comparison passes. Inside a
workflow run, the only thing standing between a step and a skill is authority
over the workspace holding it.

A workflow step picks its rung from the shape of its config, in
`workflow-executor.ts`'s `stepBinding()`: `{ receiver }` dispatches verbatim,
`{ composio }` routes to `tools:composio`, `{ actorId }` becomes `agent:run`,
and `{ skill }` becomes `skill:run`. Two more shapes sit between them —
`{ entity }` compiles to the record receiver for its dimension, `{ lifecycle }`
to `entity:tag`. A step naming none of the six is a no-op that traverses
without side effects.

## The file

A skill opens with `---` and a YAML block. The parser
(`one.ie/web/src/lib/skill/parser.ts`, mirrored in
`packages/cli/src/skill-parser.ts`) is deliberately lenient: it reads scalars and
`- ` block lists, retries once with unquoted colons stripped, and emits
diagnostics rather than throwing.

Fields the loader lifts into a `Skill` object: `name`, `title`, `summary`,
`description`, `price`, `accepts`, `trigger`, `applyTo`, `tags`, `when_to_use`,
`inputSchema`, `outputSchema`, `version`, `license`, `compatibility`, `scripts`,
`references`, `assets`, `evals`.

Only a few fields change behaviour today. `inputs` and `ssr` are read by
separate parsers off the raw frontmatter, not through the loader:

| Field | Read by |
|---|---|
| `name` | addressing — the string `skill:run` resolves |
| `description` | `skills:list` when queried by name, to label the skill |
| `inputs` | `skills:list` when queried by name; `name\|Label\|required` lines |
| `evals` | `skill eval` and `skill_eval`, against `POST /api/eval` |
| `ssr` | `skill:run`, which projects the answer over `reference_sets` |
| `price` | stamped into frontmatter on import |

The rest is carried metadata. Missing pieces get filled in rather than refused:
no `name` and the path stem becomes the name, no `description` and the first
body paragraph becomes one, a `triggers` field folds into the description. Each
substitution raises a diagnostic — `no-frontmatter`, `missing-name`,
`name-mismatch`, `name-too-long`, `description-from-body`, `triggers-merged`,
`lenient-yaml` — which `oneie skill validate` prints.

The repo's shared pool shows the shape at scale: 152 skills at the top level of
`one.ie/ai/skills/`, each a directory holding a `SKILL.md`, and 324 `SKILL.md`
files counting the nested sub-pools (`platforms/`, `strategies/`, `content/`,
`seo/` and four others). 51 of those directories carry an `evals/evals.json`
bank beside the `SKILL.md`.

## Where a skill lives

A workspace's catalog is an R2 prefix, `{slug}/skills/`. Three key shapes live
there, and which one a skill has decides which readers see it.

```text
{slug}/skills/{name}.md        flat — skill:save writes it
{slug}/skills/{name}/SKILL.md  dir  — skill import writes it
{slug}/skills/{name}/ref.json  ref  — sealed import, no body
```

`getSkillOrRef()` reads all three, in that order of preference: ref, then
directory, then flat. `getSkill()` reads only the flat key. `skill:run` uses the
first; `agent:run` uses the second. That asymmetry is named again below.

## Running one

`signal("skill:run", { skill: "copywriting", topic: "launch" })`. Everything
past `skill` is the skill's input. The receiver resolves the workspace, loads
the body from R2, and posts one bounded turn to the channels runtime with that
body as the instructions. It returns `{ ok, text, skill }`. Web never calls a
model itself for this — a missing or unreachable channels runtime comes back as
`channels_not_configured`, `channels_<status>` or `channels_unreachable`, a
clean failure rather than a thrown run.

Authority is checked against the workspace whose catalog is read, not the
caller's own. `runSkill` takes the caller's workspace as-is; a `slug` naming a
different one goes through `decide()` for `manage_workflows` and comes back
`forbidden` or `unavailable`. See [Authority and access](/docs/authority).

One structured field can come back beside the text. If the answer carries the
eight-field spec with a real JSON array under `Deliverables:`, `skill:run` adds
`deliverables` so a workflow step can fan out over it. Prose bullets add
nothing, so a bad answer fails loudly instead of writing zero rows.

## Sealed skills

A sealed skill is a `ref.json` in the importer's catalog naming the publisher
and the skill. The body never leaves the publisher's prefix. On a run,
`skill:run` follows the ref, loads the publisher's body, optionally checks a D1
entitlement, and returns `{ ok, text, sealed: true }`. The importer can invoke
it and cannot read it.

Sealing is decided server-side, not by the caller: `POST /api/skill/import`
looks up an active `market_listings` row for `(publisher, name)` and seals only
when its `visibility` is `sealed`. The request must carry `publisher` for that
lookup to happen at all.

## Surfaces

| Where | Names |
|---|---|
| Receivers | `skill:run`, `skill:save`, `skills:list` |
| CLI | `oneie skill` — 11 subcommands |
| MCP | `skill_run`, `skills_list`, `list_skills`, `discover_skill`, `skill_eval`, `unimport_skill` |
| HTTP | `/api/skill/import`, `/api/skills/workspace`, `/api/skills/:name/fork`, `/api/skills/:name/enable`, `/api/eval` |

`skill:run` and `skills:list` carry `surfaces: { mcp: true }` in the receiver
registry, so their MCP tools are derived from the contract rather than
hand-written. The other four MCP tools are curated modules. See
[MCP server](/docs/mcp) and [CLI](/docs/cli).

## What does not work yet

- **An agent's `skills:` frontmatter is not read at runtime.** `resolvePersona`
  parses it and carries it onto the persona, and
  `channels/src/personas.ts` says so in the field's own comment: nothing in
  channels reads `persona.skills`. The same holds for the `skillResolver` map
  form. The wired path is `agent:run` with an explicit `skill` argument, which
  folds the body into the instructions for that one turn.
- **`agent:run` loads only flat-key skills.** It calls `getSkill()`, which reads
  `{slug}/skills/{name}.md` and nothing else. A skill that arrived through
  `skill import` lives at `{name}/SKILL.md`, so the load returns null and the
  agent runs with no skill body and no error.
- **`skills:list` queried by name is flat-key too.** The unfiltered listing sees
  every key shape, but the `name` branch reads through `getSkill()`, so asking
  for an imported skill by name answers `{ skills: [] }` — no `description`, no
  `inputs`.
- **`oneie skill fork` does not copy anything.** It fetches the workspace list,
  refuses if the skill is sealed, and prints a success line. The copy is
  `POST /api/skills/:name/fork`, which the CLI never calls.
- **That fork route is flat-key too.** It reads through `getSkill()`, so forking
  an imported skill returns 404.
- **`oneie skill refresh` is a stub.** It always answers `refreshed: 0` and
  ignores the refs passed to it.
- **`oneie skill publish` always fails**, with
  `agentskills.io API key required — set AGENTSKILLS_API_KEY`.
- **`price:` does not bill a run.** Import stamps it into the frontmatter and
  the import response echoes it. `skill:run` charges nothing, and `skills:list`
  does not return it; the only paywall on the run path is the D1 entitlement
  check on a sealed ref.
- **`agent_skills` is display state.** `POST /api/skills/:name/enable` writes
  rows into that D1 table, and the only readers are the `/u/[slug]/skills` page,
  which uses them to show which agents a skill is wired to, and a fleet probe
  that counts them. No runtime consults the table when running a skill.

## Next

- [Agents](/docs/agents) — the rung above a skill
- [Workflows](/docs/workflows) — where a skill becomes one step
- [Receivers](/docs/receivers) — the contracts behind `skill:run`
- [CLI](/docs/cli) — the `oneie skill` verbs