Course · Pi · Skills & packages
Write the add-feed-source procedure as a SKILL.md
The two extensions you wrote for stash (the course’s running example, a small FastAPI feed reader) got you gates that hold whether the model likes it or not.
Here’s the clearest thing in stash that isn’t that kind of problem. Every time a
new feed source shows up - a teammate’s blog, a newsletter, a podcast feed - you run
the same procedure: confirm the source actually serves a feed, fetch a sample and
read the raw XML (namespace first, then link shape - the same inspection the core
chapter walked you through), check fetch_feed against it, extend it with a test if the shape defeats
it, sync, then confirm the rows came out right. You’ve narrated those steps to Pi
more than once now, typing roughly the same instructions in slightly different words
each time. That’s the tell. When you catch yourself re-narrating a multi-step
routine instead of just naming it, it’s time to write it down once - and this time
the right format for “write it down” isn’t TypeScript.
Skill and extension are different layers
Section titled “Skill and extension are different layers”An extension - what you built across Modules 5 and 6 - is a TypeScript module that
registers a tool, a command, or a hook through the ExtensionAPI. It’s code: it can
enforce, block, compute, reach into stash.db directly. stash-tools.ts shells out
to Python; damage-control.ts vetoes tool calls before they run. A Skill is the
layer below that. It’s markdown with a bit of YAML on top, and it can’t do anything
an extension couldn’t already do more directly - a Skill has no execution semantics
of its own beyond “here are the instructions, go follow them” (though its body can
absolutely tell the agent to run a script that ships alongside it). What a Skill
buys you instead is that you can write one without touching TypeScript, and that
it stays nearly free in context until the moment it’s actually relevant.
An extension enforces; a Skill only informs. Reach for a Skill when the job is a procedure worth naming, not a rule that has to hold on every tool call.
That second part is worth planting a number against before you see the file. Once
you do, count two things in it: the words between the two --- frontmatter markers,
and the words below the second one. Here they are: 61 words of frontmatter, 225
words of body - just over three and a half times as long. At startup, Pi only ever
pays for the first number. Whether it pays for the second depends entirely on
whether your request matches the description.
That’s the mechanism worth understanding before you write one: progressive
disclosure. At startup, Pi scans every Skill it can find and pulls only two
fields - name and description - into the system prompt. The rest of the file,
and anything else in the Skill’s directory, stays on disk. Only when your request
matches a Skill’s description does the agent reach for its read tool and pull in
the full body.
The file structure
Section titled “The file structure”A Skill is a directory with one required file inside it:
add-feed-source/├── SKILL.md (required)├── scripts/├── references/└── assets/scripts/, references/, and assets/ are optional siblings - a helper script the
body tells Pi to run, a longer reference doc, a template file to copy. None of it
loads until the body of SKILL.md actually points at it. For the feed procedure, a
single SKILL.md covers it; you reach for the subdirectories only once a Skill’s
instructions genuinely outgrow one file.
Pi looks for Skill directories in several places:
- Global -
~/.pi/agent/skills/and~/.agents/skills/, yours across every project - Project -
.pi/skills/and.agents/skills/, the latter walked from your current directory up to the git root (or the filesystem root outside a repo); both load only after the project is trusted - Packages - a
skills/directory orpi.skillsentries in an installed package’spackage.json- the subject of the packages lesson - Settings - a
skillsarray insettings.json, taking files or directories - CLI - a path passed with
--skill <path>, repeatable
Pi’s skills documentation lists those locations without promising a strict order. The only precedence rule that actually exists is for collisions: two Skills with the same name from different places warn, and Pi keeps the first one it found - so keep names unique across your sources rather than relying on which one lands first.
Since this procedure is stash-specific - it names fetch_feed,
tests/test_feeds.py, and the feeds table - it belongs in the repo, in
.pi/skills/ or .agents/skills/, committed so anyone who clones stash gets it
too. A dozen Skills cost a dozen short descriptions at startup - their
bodies stay free until one fires.
Frontmatter: the label Pi reads before it opens the box
Section titled “Frontmatter: the label Pi reads before it opens the box”Here’s the whole Skill, at .pi/skills/add-feed-source/SKILL.md:
---name: add-feed-sourcedescription: >- Wire a new feed source into stash when a feed URL isn't syncing or parses to nothing - a teammate's blog, a newsletter's Atom link, an unfamiliar RSS URL. Use when the user wants to add a feed, check whether a site serves one, or debug a feed that saves no rows or loses dates and links.---
Follow these steps when a feed source needs wiring up. Ask for thefeed's URL if the request doesn't name one - never guess where a sitekeeps its feed.
1. Confirm the source serves a feed: fetch the URL and check the body for a feed or rss root, not an HTML page. stash's discover() exists for this but returns the URL unchanged on both branches today - read the body yourself.2. Inspect the raw XML before touching code. Is the root element namespaced, the way Atom almost always is? Is each entry's link a child element with href, not an attribute on the entry?3. Check fetch_feed against a sample entry. It handles namespaced Atom with link child elements; an RSS feed (item, pubDate) defeats it and is the usual reason to extend.4. Only if the shape defeats it, extend fetch_feed and pin the new behavior with a test in tests/test_feeds.py, following the existing SAMPLE fixture.5. Add the feed to the feeds table (there is no endpoint for this - one sqlite3 insert) and sync it: POST /feeds/{id}/sync, or the resync_feed tool. Confirm the saved rows: published parsed to a real date, link non-empty, no duplicates after a second sync.6. Report the diff of what changed - code, test, and new row counts - before calling the source live.Count it yourself if you want to check the numbers planted above: 61 words between
the two --- markers, 225 in the steps below the second one, 286 total. More than
three-quarters of this file’s words never enter your context unless a real request
matches the description first.
Only two fields are required, and they’re the only two doing any work at Skill-selection time:
name- 1 to 64 characters, lowercase letters, numbers, and hyphens only (no leading, trailing, or doubled hyphens). Unlike the plain Agent Skills standard, Pi doesn’t require this to match the directory name - a deliberate deviation, so a repo whose folders already satisfy the stricter tools drops into Pi unchanged (some of those tools still demand the match; Pi just declines to add its own demand on top).description- up to 1024 characters, and it’s the entire trigger surface. Pi’s system prompt carries this string for every Skill it knows about; when your request semantically matches it, the Skill fires. The description above names the situation (“a feed URL isn’t syncing or parses to nothing”) rather than just an action - that’s what lets it match “hey, can we add this newsletter’s feed?” without you ever typing the Skill’s name.
A handful of optional fields round out the spec: license, compatibility
(environment requirements), metadata (arbitrary key/value), allowed-tools (an
experimental pre-approved tool list for this Skill), and disable-model-invocation
- set that last one and the Skill drops out of the system prompt entirely, invocable only by hand.
Two fields drive selection; disable-model-invocation removes a Skill from the
prompt outright, and the rest carry metadata or permissions.
Invoking it
Section titled “Invoking it”Once it’s saved, you can call it directly:
> /skill:add-feed-source> /skill:add-feed-source https://example.com/feeds/atom.xmlAnything after the command rides along with the skill’s content, appended as a
User: line. Or just describe the problem and let the description do the matching:
> this newsletter's feed synced but stash saved zero rows - can you wire it up properly?Either way Pi’s read tool is what actually loads the body - you can watch that
happen in whatever context view you built in the observability
lesson. The docs are honest about a
wrinkle worth knowing here: models don’t always load the body on their own
recognizance, and /skill:name is the documented way to force it. The /skill:
slash-command surface itself is a toggle: enableSkillCommands in settings.json
controls whether the explicit /skill:name form exists at all, separate from
whether the model can invoke a Skill on its own recognition.
Here’s the shape of a SKILL.md, and where it sits relative to how other agents
handle the same idea. name and description are the two fields every tool in that matrix
reads; most require them outright (Pi does - it refuses to load a skill with no
description), so treat both as required in any file you want portable. Before you click through: guess which field
only three of the five tools support - the one that lets you say “never fire this
on your own,” full stop.
Three of five: Claude Code, Cursor, and Copilot all read
disable-model-invocation. Codex reaches the same outcome through a sidecar file
instead (policy.allow_implicit_invocation: false), and OpenCode has no documented
equivalent at all - a Skill you write for OpenCode can’t be told to sit fully
manual.
What’s actually out there
Section titled “What’s actually out there”Pi didn’t invent this format. SKILL.md is the same Agent Skills standard Claude
Code and Codex CLI read, and Pi’s own maintainers publish a landing point for it:
badlogic/pi-skills on GitHub, billed explicitly as compatible with Claude Code
and Codex too. That’s the same portability the name-doesn’t-have-to-match-folder
deviation exists for - one skills repo, reused across tools without renaming a
single folder. Worth a look before you write one from scratch: its shelves hold web
search, browser automation, Google APIs, and transcription, and one of those may be
the procedure you were about to write by hand.
The security line, same as any code you didn’t write
Section titled “The security line, same as any code you didn’t write”The docs are blunt about this, and it’s worth taking at face value: a Skill’s body
is instructions the model will follow, and it can tell Pi to run arbitrary scripts
sitting right next to it in scripts/. Installing someone else’s Skill - which the
next-but-one lesson is entirely about -
deserves the same read-before-you-trust caution you’d give an extension. A Skill
being “just markdown” doesn’t mean it’s inert.
What a Skill can’t do
Section titled “What a Skill can’t do”Name the cost this format creates before you move past it: a Skill can’t force
anything. It fires when the model’s read of your request matches the description,
and that’s a judgment call, not a guarantee - too broad a description and it fires
on something adjacent but wrong; too narrow and it silently doesn’t fire on the
exact case you wrote it for. There’s no way to tell a Skill “always run this check
before touching stash.db,” the way the damage-control gate one module back holds
on every single tool call whether the model likes it or not. If a stash chore
needs that kind of unconditional enforcement, a Skill is the wrong layer - you’re
back to a hook. What a Skill buys you is cheap, on-demand instructions for a
procedure. What it can’t buy you is certainty that the procedure runs at all.
That is the Skill’s whole price-and-payoff story: frontmatter only (61 words here) at startup, the 225-word body loading only if a request matches, and no more re-typing the feed-onboarding procedure by hand every time a new source shows up. You wrote the description; the model decides when it matches.
Two lighter no-code layers are worth the same five minutes each - a shortcut for the prompt you type often, and a theme for the terminal you’re staring at all week. Neither one tries to fire on its own the way this Skill does. Next: templates and themes.