← Pi Agents

Skills That Carry Files

SKILL.md can reference scripts, references and assets by relative path; here is the layout, the path convention, and how to distribute the whole directory as a package.

The review guard’s checklist is forty items. Three screens of Markdown in SKILL.md would load all forty every time the skill fires, including for the small reviews where only three apply.

skills.md offers a better arrangement: skills can bundle scripts, references, and assets alongside their instructions. The body stays short and names the files; those files are not included automatically in the model context. The agent must explicitly read or execute them when needed.

The layout

review-guard/
├── SKILL.md
├── scripts/
│   ├── run-checks.sh
│   └── collect-diff.sh
├── references/
│   ├── checklist.md
│   └── severity-rubric.md
└── assets/
    └── finding-template.md

The four directory names are not enforced. skills.md shows exactly this shape in its own example — scripts/, references/, assets/ — and the convention is what makes a skill legible to the next person. Nothing stops you adding templates/ or data/; nothing encourages you either.

Reference files by relative path

SKILL.md becomes:

---
name: review-guard
description: Reviews changed code against this repository's conventions and runs its own checks. Use when reviewing a diff, a pull request, or uncommitted changes.
---

# Review guard

1. Run `scripts/run-checks.sh` and read its output before reviewing anything.
2. Read `references/severity-rubric.md` and classify each finding.
3. Read `references/checklist.md` and check each item that applies to the changed files.
4. Write each finding using the shape in `assets/finding-template.md`.

Report findings ordered by severity. Report nothing when nothing is wrong.

skills.md states the rule plainly: use relative paths from the skill directory when referring to bundled files, because Pi tells the model where the skill lives so it can resolve those paths.

That last clause is the part to rely on. In SKILL.md, name the file as references/checklist.md, relative to the skill directory. But that text is an instruction to the model, not an automatic filesystem resolution rule: a tool call must actually address the right location. When the working directory is elsewhere, the agent needs to resolve the path against the advertised skill directory. Moving the skill preserves its internal relative layout, not necessarily an unqualified command executed from any working directory.

Three ways to use a bundled file

Kind Use it for How it is loaded
references/ Detail consulted after the decision to work is made The model reads it with the read tool
scripts/ Deterministic work that should not be improvised The model invokes it through an available execution tool, if permitted
assets/ Material to copy or fill in The model reads or writes it with tools

The distinction that matters is when. The model may read a reference after selecting the skill, run a script when the workflow requires it, and use an asset when producing output. These actions depend on the model following the instructions and having suitable tools; listing a path does not itself load or execute the file. Putting all the material directly into SKILL.md instead includes it whenever the full skill body is read.

Wrong: "Bundling files into the skill makes them available to the model."
Correct: "A bundled file is a file on disk. It reaches the context when the body tells the model to read it, using the model's own tools."

Scripts earn their place by being boring

A script inside a skill should do something a model would do worse or less reliably by hand. Running the repository’s own checks qualifies:

#!/usr/bin/env bash
# scripts/run-checks.sh - run the target repository's own verification.
set -euo pipefail

# Which repository to check, in order of preference:
#   1. $PI_REVIEW_GUARD_ROOT, set by whoever launched Pi or a wrapper script.
#      Skill frontmatter does not become environment variables.
#   2. the top level of the Git working tree containing Pi's working directory.
# Counting parents would be wrong here: a project skill lives at
# repo/.pi/skills/<name>/scripts/ and a shared one at
# ~/.pi/agent/skills/<name>/scripts/, so the depth is not a constant.
root="${PI_REVIEW_GUARD_ROOT:-$(git rev-parse --show-toplevel)}"
cd "$root"

echo "== repository: $root =="
echo "== typecheck =="
pnpm typecheck

echo "== unit tests =="
pnpm test:unit

Note what the script does not do: it does not derive the repository from its own path. dirname "$0" is the skill’s scripts/ directory, and how many parents separate that from the repository depends on where the skill was installed — a project skill under repo/.pi/skills/<name>/ and a shared one under ~/.pi/agent/skills/<name>/ sit at different depths, and a packaged skill sits somewhere else again. Asking the environment is both shorter and the only version that survives being installed as a package.

How a variable actually gets set

This is worth being exact about, because the obvious answer is wrong. Skill frontmatter does not become environment variables. The skills.md field table lists name, description, license, compatibility, metadata, allowed-tools and disable-model-invocation, and compatibility is documented as environment requirements — a statement of what the skill needs, not a mechanism that provides it. Nothing in Pi reads frontmatter and writes process.env. An earlier draft of this chapter said a skill could “set PI_REVIEW_GUARD_ROOT in its own front matter or a bundled config”, which would only be true if Pi did that, and it does not.

There are three ways to supply the value without relying on skill frontmatter. They differ in who chooses it and how the script obtains it.

1. The operator exports it. Someone runs PI_REVIEW_GUARD_ROOT=/path/to/repo pi. The script reads it because the shell put it in the environment it inherited. This is the ordinary case, and the reason the variable is named with a PI_ prefix: it is a convention for your script, not a Pi feature. Pi passes its environment through untouched, which is documented and unremarkable.

2. The script reads a bundled config file it can actually find. For a skill shipped as a package, a value that must travel with the skill is better in a file the script locates relative to its own path — which is the one place parent-relative resolution is correct, because a file inside the skill directory is at a fixed depth inside it:

config="$(dirname "$0")/../guard.conf"
[ -f "$config" ] && . "$config"
root="${PI_REVIEW_GUARD_ROOT:-${GUARD_ROOT:-$(git rev-parse --show-toplevel)}}"

That sources a guard.conf file at the root of the skill directory, if one is present, and then falls back the same way. Sourcing a file executes its shell contents, so only do this for a configuration file you trust; a data-only format is preferable when the file should not execute code. The distinction is the point: a bundled file may be found relative to the skill because it moves with it. A target repository may not, because it is somewhere else entirely.

3. A wrapper script sets it. If the value depends on something only the caller knows, the caller sets it:

#!/usr/bin/env bash
# review - run the guard for a specific repository.
set -euo pipefail
PI_REVIEW_GUARD_ROOT="${1:?usage: review <repository>}" \
  "$(dirname "$0")/scripts/run-checks.sh"

A model can set an environment variable in a shell command, but that makes the value model-controlled rather than operator-controlled. If the repository root is security-sensitive, neither a model-supplied path nor an unverified file bundled with an untrusted skill establishes authority. The operator or an isolated execution environment must determine and enforce the permitted target.

This discovery is a convention, not a boundary. A model that has been told to run a script has been given a path into your machine, and the script’s git rev-parse picks up whatever working directory Pi was launched in. Chapter 42 treats this properly: it is a procedure that makes the common case correct, not containment against a model that means to leave the repository.

skills.md says to run scripts relative to this skill directory, and to keep environment setup inside the skill. That is a meaningful instruction: the skill is responsible for its own prerequisites rather than assuming the host has them.

The size rule of thumb

This is my recommendation, not a documented limit. skills.md gives no token budget for a SKILL.md. The reasoning:

  • The body loads every time the skill fires. Keep it to the steps, not the reference material.
  • If a rule is needed before deciding what to do, it belongs in the body. If it is needed after, it belongs in references/.
  • If a file is needed on every single run, it is not really a supporting file; inline it and accept the cost.

A body of fifteen lines that reads three files usually beats a body of three hundred lines that reads none.

Keep bundled scripts reviewable

Chapter 8 said project skills can instruct the model to run scripts, and that unfamiliar skills and their supporting files should be reviewed before granting project trust. That review is easier when scripts are readable, versioned text rather than opaque binaries or code fetched at run time. Readable source alone is not a guarantee of safety; inspect its dependencies and what it invokes as well.

Who chooses the working directory

Worth being explicit, because it is the thing that makes a bundled script ambiguous. The model runs a script through bash, and the tool inherits Pi’s working directory — the directory you started Pi in, not the skill’s.

    flowchart TD
  S["Pi started in repo/"] --> C["cwd is repo/"]
  C --> M["model calls bash"]
  M --> B["run-checks.sh inherits cwd: repo/"]
  K["the skill directory<br/>repo/.pi/skills/review-guard/"] -. "a different place entirely" .-> B
  B --> G["script asks git rev-parse --show-toplevel"]
  G --> R["runs checks in the repository"]
  

So the skill’s own location never determines where the script runs. That is why the script asks the environment. A script that counted parents from $0 would be deriving a target directory from a location that is not where it executes, and the depth it would need depends on how the skill was installed.

Two habits that help: keep every bundled script versioned with the skill, and make it fail loudly rather than silently continuing:

set -euo pipefail

Without set -e, a failing intermediate command can be followed by a successful final command, leaving the script with a zero exit status. -u and pipefail catch other common mistakes. These options help failures surface, but they cannot prove that every required check ran or that the model will report the result accurately.

Ship it as a package

skills.md says to use a Pi package to distribute one or more skills through npm or git, and to keep environment setup inside the skill and declare any required runtime dependencies in the package.

packages.md describes what that takes. A package is an ordinary directory or npm package, and without a pi manifest Pi discovers skill directories from conventional locations:

my-pi-package/
├── package.json
├── extensions/
├── skills/
├── prompts/
└── themes/

review-guard/ drops straight into skills/.

With an explicit manifest, resources can live elsewhere or be filtered:

{
  "name": "team-review-tools",
  "keywords": ["pi-package"],
  "pi": {
    "skills": ["./skills/review-guard"],
    "prompts": ["./resources/prompts/*.md"]
  }
}

Paths are relative to the package root. Arrays accept glob patterns and exclusions. If a resource root is dot-prefixed or symlinked, list it directly, because traversal through a glob would not discover it.

Declare dependencies correctly

packages.md is emphatic here, and the reason is technical rather than stylistic. Pi supplies @earendil-works/pi-ai, @earendil-works/pi-agent-core, @earendil-works/pi-coding-agent, @earendil-works/pi-tui, and typebox to extensions and skills. Declare those in peerDependencies with a "*" range and do not bundle them. Do not list them in dependencies: a physical copy can bypass Pi’s extension module mapping in compiled ESM and create duplicate classes, registries, and initialization work, and Pi reports an extension warning when it detects that configuration.

For a skill with a bundled shell script, there are no Node dependencies at all. That is a feature of skills over extensions.

Narrow what loads from a package

The object form in settings filters a package’s resources:

{
  "packages": [
    {
      "source": "npm:@example/team-review-tools",
      "skills": [],
      "prompts": ["prompts/review.md"],
      "extensions": ["extensions/*.ts", "!extensions/legacy.ts"]
    }
  ]
}

Omit the property to load everything the package allows; use [] to load none of that type. Filters narrow the package manifest — they do not expose resources the package did not declare.

Install it

pi install npm:@example/team-review-tools@1.0.0
pi install git:github.com/example/pi-tools@v1
pi install ./local-package
pi -e npm:@example/pi-tools          # try it for one invocation
pi list

Versioned npm specifications are pinned, as are git tags and commits; package updates reconcile the checkout but do not move a configured ref. Relative local paths resolve from the settings file that contains them.

Personal installs are written to ~/.pi/agent/settings.json. Add --local or -l to write the declaration to .pi/settings.json, which Pi reads only after project trust is granted.

Identity and double-loading

packages.md explains how Pi identifies a package so the same one cannot load twice: npm packages by package name, git packages by repository URL without the ref, and local packages by resolved absolute path. The same package can appear in personal and project settings; a project entry normally replaces the personal one, and with autoload: false it acts as a filtering delta instead.

Installed packages load with separate module roots. Do not rely on two packages sharing one dependency instance.

The running example, complete at skill scope

review-guard/ is now complete at skill scope: a short body that routes on its description, a checklist and a severity rubric in references/, and a script that runs the repository’s own checks before anything is reported.

What it still cannot do is enforce a prohibition. SKILL.md can say never run git push --force, but the instruction is not a control on tool execution. An extension can intercept supported Pi tool calls, while a stronger prohibition needs enforcement at the Git server, credential, or operating-system boundary. Chapter 15 introduces executable integration points; later chapters examine their limits.

What was run

examples/ch14-skill-files/ checks the claims about bundled files:

  • the skill’s path is in the prompt, so a relative path in SKILL.md can be resolved against its directory;
  • a bundled file is not in the prompt, and reaches the context only when the model reads it with its own read tool: a marker in references/checklist.md was absent until the scripted model read the file;
  • the reported test ran a run-checks.sh implementation that derived its target from its own location and invoked pnpm two levels above skills/. That is not the script printed in this chapter, which instead uses PI_REVIEW_GUARD_ROOT or git rev-parse --show-toplevel from the process working directory. The reported result therefore does not verify the printed script’s root-selection behaviour;
  • set -euo pipefail is the difference between a script that fails and one that reports success: the same failing line exits non-zero with it and zero without it, and without it the script still printed its final line.

The final result concerns Bash rather than Pi. It supports the specific failure-propagation example, not a general guarantee that the checks ran or that a model will interpret their outcome correctly. The root-selection test likewise needs to be reconciled with the displayed script before claiming that example is verified.