Skip to content

10 min read

A skill carries the procedure; the tool performs the act

On this page

The pull request adds one tool and is ninety lines long, and seventy of them are a string. A teammate wants the agent to be able to apply database migrations, so she has written run_migration(sql: string) and, because the team has firm opinions about how migrations are written, she has put those opinions in the tool's description where the model is guaranteed to read them before it calls anything.

python
RUN_MIGRATION = {
    "name": "run_migration",
    "description": (
        "Applies a SQL migration to the primary Postgres cluster and records it "
        "in schema_migrations. Rules you MUST follow before calling this: add "
        "new columns as NULL-able first and backfill in a separate statement; "
        "never combine a backfill with a schema change in one migration; every "
        "migration requires a matching rollback file under db/rollback/ named "
        "with the same timestamp; migrations touching more than 1,000,000 rows "
        "must batch at 10,000 rows with a 200 ms pause between batches; index "
        "creation on tables over 50,000 rows must use CREATE INDEX CONCURRENTLY "
        "and therefore cannot run inside a transaction; do not apply migrations "
        "to prod between 09:00 and 18:00 UTC on weekdays."
    ),
    "input_schema": {
        "type": "object",
        "properties": {"sql": {"type": "string"}},
        "required": ["sql"],
    },
}

The reasoning behind this is sound as far as it goes. The rules sit next to the thing they govern, the model cannot call the tool without the description having been in front of it, and there is no routing step that might fail to bring the guidance in. Compared to a document sitting somewhere the agent may or may not find, that looks like the stronger design. It buys less than it appears to, and the two mechanisms it is choosing between are doing genuinely different jobs.

The mechanism she did not reach for has a name. An agent skill is a folder that an agent loads only when the task in front of it calls for that folder: a file of instructions written in ordinary markdown, and alongside it, when the work needs them, scripts the agent can run and reference documents it can read. Those instructions are prose the model reads and carries out with its own judgement rather than code that executes, and that is the problem the mechanism was built for. Most of what a team knows about its own work is procedure and local convention — add columns NULL-able first, write the rollback before you propose the change — which is too long and too frequently revised to sit permanently in the agent's standing instructions, and is not shaped like a function anyone could call. A skill gives that knowledge a place to live and gives the agent a way to pick it up on the runs where it is relevant and leave it alone on the rest. Where you meet one in practice is a directory checked into the repository: skills/db-migrations/ sitting next to the application code, reviewed in pull requests like anything else, which is the obvious second home for every rule currently trapped in that description string.

Two different things arrive in the model's context

A tool hands the model an operation it can invoke. The schema describes what arguments the operation takes, the description tells the model when invoking it is appropriate, and the body is code you wrote that runs in your process and returns a result. Whatever else is true, the work happens outside the model.

A skill hands the model text. It is a directory — a SKILL.md with YAML frontmatter, optionally reference files and scripts alongside it — whose body is pulled into context when the description matches what the user is asking for. Nothing executes on its own. The model reads the procedure and carries it out with the tools it already has, which is the same relationship you have with a runbook: the runbook does not restart the service, it tells you which order to restart things in and who to tell.

text
skills/db-migrations/
├── SKILL.md
├── reference/column-types.md
└── scripts/check_backfill.py
markdown
---
name: db-migrations
description: Use when writing, reviewing, or applying a database migration — adding or altering a column, creating an index, backfilling a table, or preparing a rollback for the primary Postgres cluster.
---

## Writing the migration

1. Add new columns as NULL-able. A NOT NULL column with a default rewrites the
   table on Postgres 10 and earlier; we are on 14, where it does not, but the
   two-step form also keeps the rollback trivial, so we still require it.
2. Put the backfill in its own migration file. A backfill in the same statement
   as the schema change holds an ACCESS EXCLUSIVE lock for the duration of the
   write, which on `events` (about 340 M rows) has taken 11 minutes.
3. Write the rollback file first, under `db/rollback/`, same timestamp prefix.
   If you cannot write it, the migration is not reversible and needs a review
   from the data team before it goes any further.

The difference that matters for the review is that the skill can say why, at whatever length the reason takes, and can be structured — headings, a numbered procedure, a worked example, a table of column types in a separate file that only gets read when the question is about column types. A tool description is a flat string that is serialised into the request every time the tool is available, which puts a hard ceiling on how much of the team's thinking can go in it before the cost becomes silly.

Splitting a release workflow along the seam

The clearest way to see the seam is on a task where both mechanisms obviously have a role. The team ships services with a deploy tool:

python
def deploy(service: str, env: str, ref: str) -> DeployResult:
    """Deploy `ref` of `service` to `env`. Returns the deployment id and status."""
    _assert_ref_is_built(service, ref)
    deployment = releases.create(service=service, env=env, ref=ref)
    return _await_rollout(deployment, timeout_s=600)

The operation is the release. Talking to the release API, waiting for the rollout, timing out at ten minutes, returning a deployment id — that is code, it behaves the same way on every call, and there is no version of this that the model can do by reading a paragraph.

What is not code is the local convention around it. Which environments need a human approval before anything is deployed, who counts as an approver for a payments service, that a deploy to staging from a branch is fine but a deploy to prod from anything other than a tag is not, that Fridays after 15:00 UTC are frozen except for incident fixes and the fix needs an incident id in the release note. These are the team's policies, they change when the team changes its mind, and they are exactly what a skill is for.

markdown
## Approvals

- `dev`, `staging`: no approval. Deploy from any ref.
- `prod`: requires an approval recorded in the release ticket before you call
  `deploy`. For services tagged `payments`, the approver must be someone on the
  payments on-call rota, and their name goes in the release note.
- Anything during a freeze window: only with an incident id, which goes in the
  `ref` message. If you do not have one, stop and say so rather than deploying.

So the division the review should land on is that the skill states which environments need approval and the tool performs the release. Getting this backwards in either direction produces a recognisable kind of mess. Push the approval policy into the tool and you have an API that needs a code change whenever the payments team reorganises its rota. Push the release into the skill — instructions telling the model to shell out to curl against the release API with a token — and you have replaced a function with a paragraph, and every failure mode of that API is now the model's problem to interpret.

Some of the work sits in the middle, and ownership breaks the tie

Running the same exercise over the migration task rather than the release task is worth doing on paper, because it produces a list with a genuinely ambiguous middle. Connecting to the cluster, executing the statement, recording the row in schema_migrations, retrying on a transient connection error, returning the elapsed time — all tool. The naming convention for migration files, the requirement that a rollback exists before the migration is written, the judgement about whether a table is big enough to need CONCURRENTLY — all skill, because each one is a decision the model makes while composing the SQL, before any tool is called.

Then there is the batching rule, which reads like policy and behaves like an implementation detail. "Batch at 10,000 rows with a 200 ms pause" could be a sentence in the skill that the model writes into the SQL it generates, or it could be a parameter of the tool with a default, or it could be behaviour the tool imposes regardless of what the model asked for. All three work. The tie-breaker is who is going to change the number and how often: if the data team wants to drop the batch size to 2,000 during a busy quarter and put it back afterwards, a skill file they own is where that lives. If the number exists because the connection pool falls over above a certain write rate, it is a property of the infrastructure, the model has no business choosing it, and it belongs in the tool with no parameter at all.

Writing the split down in that form — every piece of the task in one column or the other, with a named owner — is a five-minute exercise that ends most versions of this argument, because the pieces that genuinely need code are usually obvious once they are separated from the pieces that need a paragraph.

What the description shortcut actually costs

Return to run_migration with that seam in mind. The rules in that description are policy: they are about how the team wants migrations written, they were decided by the data team, and they will be revised. The description is not a free place to put them.

Tool definitions are serialised into every request in the session, whether the conversation is about migrations or about renaming a CSS variable. That block of migration rules is roughly 190 words, which is about 250 tokens once you count the JSON scaffolding, and it is present on all of them. Prompt caching makes those tokens cheaper to re-send — a cache read is around a tenth of the price of a fresh input token — so the money is not usually the argument. The argument is that the guidance is in front of the model on every turn of every session, competing for attention with everything else that is in front of it, in exchange for being useful on the small fraction of turns that touch the database. In a repo where fourteen tools have each grown their own paragraph of house rules, that block is several thousand tokens of standing instruction that nobody reads as a whole and no request needs all of.

The colocation is not stronger, either. When the description is in context, the model reads it and may still ignore it, which is precisely the failure mode a skill has. Neither mechanism enforces anything; both are text that asks for compliance. If the rule must hold, it goes in the tool body, where it can refuse:

python
def run_migration(sql: str) -> MigrationResult:
    if _has_schema_change(sql) and _has_backfill(sql):
        raise ToolError(
            "refused: this migration both alters the schema and backfills. "
            "Split it into two files and apply them in order."
        )
    ...

That check is worth more than the same sentence in the description, because it turns a request into a guarantee, and because a refusal is a visible event in the trace — the ordered record a run leaves behind, holding every message, tool call and tool result in the sequence they happened — and it carries a message the model can act on.

The two change on different clocks

The dimension that usually settles the argument in practice is not the token cost at all. A rule inside a tool description moves when someone ships a code release of the agent service: a pull request against a Python repo, review from the people who own that service, CI, and whatever release train it rides. On this team the median time from merge to production for the agent service is nine days, and the data engineers who actually own the migration rules do not have commit access to it, so a change to the backfill policy is a ticket filed against another team.

A skill is a markdown file. The data team owns the directory, reviews changes to it themselves, and the change lands when they merge it. That difference in cadence and ownership is the reason the rules end up somewhere they can be maintained, rather than somewhere they can be technically colocated.

SkillTool
What it hands the modelText it reads and applies itselfAn operation it can invoke
Present at request timeBody loads when the description matches the requestName, schema and description in every request of the session
Room for reasons and structureWhole document, plus reference files loaded on demandOne flat string, paid for on every turn
Who can change itWhoever owns the skill directoryWhoever ships the agent service
How a change reaches productionMerge the fileCode release, 9-day median here
Can it enforce anythingNo — it asksYes, in the body: it can refuse a call
How failure looksSilent; the work comes out wrong-looking-rightAn error in the trace with a message attached

The last row is the one 12.1 already established about the mechanisms in general, and it is worth carrying into everything that follows: a tool failure announces itself and a skill failure does not.

The line this does not draw

A skill is not a sandbox, a permission, or a guardrail. It cannot stop the agent from running a migration during the freeze window; it can only tell it not to. Any rule where the consequence of non-compliance is unacceptable belongs in code — in the tool body, or in a check that runs before it — and the skill's job is then to explain the rule so the agent does not hit the refusal in the first place. Treating a skill as a control is the single most expensive misreading of the mechanism, because the resulting system looks governed and is not.

Nor is a skill the answer to every piece of knowledge the agent needs. 12.1 drew the line between what belongs in always-resident configuration and what should load on demand, and that line still holds: a convention every session must obey has no business being routed to.

Which leaves the assumption that has been sitting under this whole chapter. Everything a skill supplies is supplied only on the runs where it loads, and whether it loads is decided by one field — the description in the frontmatter, matched against a request the user phrased however they felt like phrasing it. The db-migrations skill above says it applies when someone is "writing, reviewing, or applying a database migration", which is a decent first attempt and not obviously right. A great many skills that are well written below the frontmatter never get read at all, and the next chapter is about why.

Agent skills — read the current version at https://daviddominguez.dev/learn/en/agent-skills/a-skill-carries-the-procedure-the-tool-performs-the-act

Back to top