Four Ways to Change Pi
A decision procedure for choosing between context files, prompt templates, skills, and extensions, with four worked cases and one running example for the rest of this part.
You keep typing the same three lines before every code review. Then you notice the same file appearing in three repositories. Then someone asks for a /review-tests command and you write forty lines of TypeScript to expand a string.
Every one of those three moves was available. Pi’s documentation gives a one-line rule for choosing between them — quickstart.md says to start with the least powerful mechanism that meets your need — and then leaves the reader to guess what “least powerful” means at three in the afternoon.
This chapter turns it into a procedure: four questions and one diagram that a reader can come back to. Chapters 12 through 18 each take one branch of that procedure and go deep, carrying the same running example between them.
The mechanisms
quickstart.md tabulates the whole set, in the order it wants you to climb it:
| Need | Start with |
|---|---|
| Give Pi persistent instructions for a folder | AGENTS.md |
Reuse a prompt from the / menu |
Prompt template |
| Add task-specific instructions and supporting files | Skill |
| Add executable tools, commands, or event handlers | Extension |
| Build a custom terminal component | Terminal UI |
| Connect an unsupported model service | Custom provider |
| Install or distribute several resources | Pi package |
The first three are primarily instruction and content mechanisms; the fourth is an extension, a TypeScript module running inside the Pi process with your operating-system permissions. Chapters 12 through 18 develop these mechanisms through a running example.
The last three rows are not higher rungs. They solve different problems rather than harder versions of the same one: a terminal component renders, a provider supplies models, and a package is a distribution format that can contain any of the four.
The procedure
Ask the questions in order and follow the first applicable branch. The fourth question is the fallback, not another yes/no test. Use the result as a starting point, then check whether the requirement is advisory or must be enforced independently of the model.
flowchart TD
N["A need you keep repeating"] --> Q1{"Needs executable behaviour?"}
Q1 -->|"yes"| EXT["Extension"]
Q1 -->|"no"| Q2{"Applies to every task here?"}
Q2 -->|"yes"| AG["AGENTS.md or context file"]
Q2 -->|"no"| Q3{"Longer than a screen, or needs files?"}
Q3 -->|"yes"| SK["Skill"]
Q3 -->|"no"| PT["Prompt template"]
1. Does the change need to run code, or observe or block something at runtime?
If it needs to intercept a tool call, react to a session event, add a slash command with behaviour, or change the system prompt programmatically, it is an extension. extensions.md is explicit that extensions are the mechanism for workflows needing “tools, commands, event handlers, model providers, session state, or terminal UI rather than instructions alone.”
2. Does the change apply on every request, for every task, in this folder?
If yes, start with a context file. configuration.md describes a context file as applying “whenever Pi runs in its directory or anywhere below it.” A context file cannot conditionally load itself for one task, although it can contain conditional instructions for the model. If an entire workflow should be supplied only when relevant, a prompt template or skill is a better fit.
3. Is the content more than about a screen of text, or does it need files of its own?
If yes, it is a skill. skills.md says to use one “when a workflow needs more context than a prompt template but does not need a new executable integration point,” and skills can bundle scripts, references, and assets.
4. Otherwise it is a prompt template.
prompt-templates.md says to use one “when you want to reuse the same prompt without adding executable behavior or a larger set of supporting instructions.”
Why not the rung below
Stopping at the first “yes” only works if the rejections are forced rather than preferred. They are:
| Rejected | Because |
|---|---|
AGENTS.md, for a conditional need |
A context file has no condition. configuration.md says it applies “whenever Pi runs in its directory or anywhere below it”, so a rule that should fire only for reviews fires on every task in the folder. |
| Prompt template, for a workflow with supporting assets | A template can express a long prompt, but a skill can package reusable references, scripts, and assets alongside its instructions. |
| Skill, for a guard | skills.md reserves skills for a workflow that “does not need a new executable integration point”. A skill is instructions the model reads and follows; only pi.on("tool_call") runs before the call does. |
A package is not a fifth rung
packages.md describes a package as an ordinary directory or npm package that installs extensions, skills, prompt templates, and themes “as one unit,” with its own runtime dependencies. It answers “how do these travel together”, not “what should this be”.
The order of decisions is therefore: choose the mechanism first, then choose the distribution.
Signals and their answers
The four questions cover the general case. When you already recognise the situation, these are the answers — including the ones people get wrong in both directions.
| Signal in your situation | Mechanism | Why |
|---|---|---|
| “I need a new operation the model can call” | Extension | Only an extension adds a tool |
| “I need to block or rewrite a tool call” | Extension | Only an extension registers a tool_call handler |
“I need a / command that does something” |
Extension | pi.registerCommand() takes a handler |
| “I want this repository convention applied across tasks” | AGENTS.md |
Scope and persistence, not the number of lines, make it a context-file requirement |
| “It depends on which file type I am working on” | Skill | The description is the routing mechanism |
Worked case 1: “always use the repo’s test command”
You want Pi to run pnpm test:unit rather than inventing a command.
Decision. Question 1 says no — this needs no code. Question 2 says yes — it should hold for every task in this repository. Context file.
<!-- AGENTS.md in the repo root -->
## Commands
- Unit tests: `pnpm test:unit`
- Type check: `pnpm typecheck`
- Do not run `npm test`; this repo's default script targets the wrong runner.
You did not need a template, because the trigger is “always”, not “when I ask for a review”.
Worked case 2: “review the diff”
You keep typing the same review prompt.
Decision. Question 1 no, question 2 no — a review is not every task. Question 3: is it more than a screen? Not yet. So: prompt template.
---
description: Review uncommitted git changes
argument-hint: "[focus]"
---
Review the uncommitted git changes (`git status --short`, `git diff`).
Focus on ${1:-correctness, security, and error handling}.
Keep findings short, ordered by severity, each with the file path.
Note the argument substitution. A prompt template takes an explicit invocation and expands its argument into task text; a context file is loaded for the directory rather than invoked with a per-use argument.
Worked case 3: “our release checklist”
The checklist is forty items across three files, it applies only when releasing, and half the items require running a script that validates the tarball.
Decision. Question 1 no — nothing needs to intercept. Question 2 no — it applies only sometimes. Question 3 yes — it exceeds a screen and needs supporting files. Skill.
release-check/
├── SKILL.md
├── references/
│ ├── checklist.md
│ └── versioning-rules.md
└── scripts/
└── validate-tarball.sh
The description is what routes it, so it has to say both what the skill does and when it applies. Chapter 13 covers the routing rules; chapter 14 covers the bundled files.
Worked case 4: “no force pushes, and confirm before anything in migrations/”
Two requirements, and they are not the same kind of requirement. “Confirm before
anything in migrations/” is a confirmation guard: it asks a person. “No force
pushes” is an unconditional prohibition, and it should not be implemented as
something a person can wave through.
Decision. Question 1 says yes for both halves — blocking or confirming a bash
call is runtime behaviour with no text-only equivalent. Extension. But once you are
writing one, the two halves need different code, and the example shows that.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash" && event.toolName !== "edit") return;
const raw = JSON.stringify(event.input);
// \b, not (^|\s): in the serialised input "git" follows a quote, not whitespace.
const forcePush = /\bgit\s+push\b.*(?:\s--force\b|\s-f\b|--force-with-lease\b)/.test(raw);
// An unconditional prohibition, not a question: no confirmation prompt, so
// there is nothing for a person to approve.
if (forcePush) return { block: true, reason: "Force pushes are not permitted by this repository's guard" };
// A different kind of rule. Ask, then proceed on the answer.
if (!/\bgit\s+push\b/.test(raw) && !/migrations\//.test(raw)) return;
if (ctx.hasUI && (await ctx.ui.confirm("Allow this call?", raw.slice(0, 120)))) return;
return { block: true, reason: "Blocked by guard extension" };
});
}
Read the two branches as different tools. The force-push branch returns before the confirmation, so no prompt appears and no person can approve one. The migrations branch asks. If both halves had been written as one confirmation, the guard would have satisfied “confirm before migrations” and quietly failed “no force pushes”, because a model that asks nicely is a model that gets answered yes.
What this guard does not cover
Name the reach, because a guard that reads as more than it is worse than none.
It inspects bash and edit, and nothing else. A write to a file under
migrations/, a multi_edit, or an extension’s own tool is not matched by these
patterns. Nor is gh pr merge --admin, or a git invocation whose spelling the
pattern does not anticipate — git -C /repo push --force is not caught by this pattern, because -C /repo sits between git and push. Nor is git push origin +main caught, even though the leading + forces a ref update.
And the matching is over JSON.stringify(event.input), which means it is matching a
serialised command string, not parsing a shell command. git push && echo done and
echo "git push --force" are both matched by a pattern looking for the flag
anywhere. This may be adequate for a narrowly tested demonstration, but it can both block harmless text and miss destructive commands. It is a procedure, not containment, and chapter 42 treats the difference at length. If you need a boundary rather than a procedure, the answer
is an operating-system one, not a longer regular expression.
This guard was run, in examples/ch11-guard/, and the first version of it was wrong in a way worth keeping. It matched git push with (^|\s)git\s+push, which looks right and is not: the handler tests the serialised input, {"command":"git push origin main"}, where git follows a double quote and not whitespace. A plain git push was never asked about. The pattern only fired for compound commands such as cd x && git push, which is exactly the form someone tests with when they are not looking for this bug. \bgit\s+push\b fixes it. The test keeps the original beside the fixed version as a regression, because a guard that silently misses the common case is worse than no guard: it reads as safety.
The fixed version also refuses when ctx.hasUI is false, since there is nobody to ask. That is the same rule chapter 16 gives for the force-push guard.
Chapter 16 covers the event lifecycle; chapter 17 covers the tool contracts this relies on.
Three distinctions that survive all four
Explicit versus routed. A prompt template is invoked: you type /review. A skill is chosen by the model because its description matched. skills.md is candid that this can fail — “A model might fail to load a relevant skill” — and gives /skill:name as the manual override.
Registration versus activation. Registering a tool makes it exist. Declaring it to the model makes it usable. Chapter 6 introduced this; chapter 17 makes it concrete.
Capability versus authority. Registering a tool does not establish that its use is authorised in every situation. security.md is blunt that enabled tools run with the operating-system permissions of the Pi process. A tool_call handler can enforce a check for calls it intercepts, but a rule that must hold against bypasses belongs at a lower authority boundary, as chapter 42 explains.
The running example
From here to the end of this part we develop a repository review workflow that eventually includes a guard. Worked case 2 supplies its first, text-only review prompt. Later chapters add supporting instructions and files, then executable tooling and a separate pre-tool-call guard. Not every step is simply a more powerful version of the same mechanism.
| Chapter | Mechanism | What the running example becomes |
|---|---|---|
| 12 | Prompt template | /review with a focus argument |
| 13 | Skill | A routing description and a short checklist, so the model reaches for it unprompted |
| 14 | Skill with files | The checklist moves to references/, plus a script that runs the repo’s own checks |
| 15 | Extension | A first extension that registers the reviewer’s commands tool |
| 16 | Events | A tool_call handler that blocks force pushes |
| 17 | Tools in depth | The guard gains exposures, nested calls, and annotations |
| 18 | Context | A prompt section that tells the model the guard exists |
Each step is justified by the procedure above, not by preference. If at any step you cannot say which question moved you forward, that step was decoration.
Where the procedure fails
The procedure assumes that a requirement fits a single mechanism and that you know whether execution needs a new integration point. Two cases complicate that choice:
- You do not yet know whether the workflow needs to run a script. Write the skill; if the model cannot run the script without help, that is the signal to move to an extension.
- The need is genuinely two things. Write both. A context file and a prompt template are not rivals, and a project with an
AGENTS.mdstating the repo’s conventions plus a/reviewtemplate is the normal case, not a compromise.
The cheapest rung is the one people skip, because saving a prompt’s wording feels too small to count as a mechanism. It is one: a file, an optional argument, and a / command. Knowing precisely what it can and cannot do is also what tells you when you have outgrown it — which is why the lowest rung comes before the heavier ones.
Three rules, and the order to ask them
The ladder above answers which Pi mechanism. It is the first of three rules, and the other two take over whenever the mechanism is right and the placement is wrong. Chapter 1 drew the layers and gave the principle behind the third. This chapter turns them into a procedure, and chapter 42 adds a fourth question for the requirements that have to hold.
Behaviour. Use the least powerful mechanism that solves the requirement.
Placement. Put the decision in the narrowest layer that owns it.
Timing. A layer owns a decision only if it can act before the consequence becomes irreversible.
The third is a qualifier on the second, and it is easy to skip, so here is what it rules out. A layer can be the narrowest one that knows about a decision and still not own it, because by the time it hears about it the decision has been made. Chapter 1 called that the difference between observing a decision and owning it, and chapter 39 measured it on the agent core’s hooks: a gate before the effect means the tool body never runs, and a rewrite after the effect cannot take it back.
So ask, in order, for any requirement you are about to implement:
- Which mechanism is the least powerful one that gives the behaviour? (The ladder above. Text first.)
- Who owns the reason the behaviour exists, and which layer is that? (The table below. Not the layer where it happens to be convenient to write.)
- Can that layer act in time? If it can only observe the decision after it is made, it is advice, not ownership. Move the decision earlier or lower, or call it what it is.
- Does the requirement have to hold, or only to happen? A convention that usually happens is a text file. A prohibition that must hold even if the model, the prompt and your own code are wrong is not the same problem, and the answer to it is in chapter 42: the lowest boundary that can still prevent the consequence.
Questions 1 to 3 are placement. Question 4 is where the behaviour rule stops being the right rule, and it is the reason the cheapest mechanism is not always the answer.
Concretely, each line below is the ladder restated for a layer rather than a mechanism:
do not modify provider configuration for project policy
do not modify the agent loop for terminal presentation
do not use a runtime extension where a local instruction suffices
do not use UI confirmation where an operating-system boundary is required
A useful way to apply question 2 is to ask who owns the reason the behaviour exists:
| Behaviour | Reason it exists | Belongs in |
|---|---|---|
| “Use this repository’s test command” | this repo’s conventions | project context, i.e. AGENTS.md |
| “Never deploy to production directly” | our change-management policy | the application or its authority boundary |
| “This provider needs this header” | the provider’s protocol | provider config |
| “Draw this tool call as a card” | this user wants a picture | the renderer |
| “After a tool result, decide if another turn is needed” | the agent’s own control flow | the agent core |
Chapter 1 gives the layers and chapter 42 gives the authority boundary, so this table is the join between them. The failure it prevents is a provider adapter quietly learning that one particular repository has an unusual release process: a fact about the application sitting in a layer that every other application shares.
The worked cases together illustrate all four questions. “Use pnpm test:unit” is worked case 1: it requires no executable integration point, is owned by the repository, and needs no runtime interception. “Block
force pushes and confirm before migrations/” is a decision about a tool call, which
an extension’s tool_call handler can act on before the call runs, so questions 2
and 3 are satisfied by the guard above. Question 4 is the one the guard cannot
answer: it matches the call it is shown, and a pattern over a command string blocks
the spellings its author thought of. If “never push to the release branch” has to
hold whatever the agent does, it needs something lower than a handler, and chapter 42
says what.
The placement rule has a cost, and it is worth naming. Putting a decision in the narrowest layer means a different application cannot inherit it for free. That is the point rather than the objection: behaviour that generalises across projects probably does not belong to one project, and if you find yourself putting application policy in shared infrastructure, the layer table is telling you something.
Claim types. The mechanism table and each mechanism’s contract are documented. The three rules, the four questions and the placement table are this book’s proposals, offered as a procedure. The timing qualifier is tested, for the agent core’s hooks, in chapter 39.