Changing What Pi Knows
Four surfaces change the model's input - SYSTEM.md, APPEND_SYSTEM.md, before_agent_start sections, and the context event - each with a different lifetime and cost.
The guard works. Pi asks before a force push, run_checks runs the project’s own tests, and the failing-test tool answers with structured data.
What still goes wrong is quieter. Pi reviews a diff using npm test instead of run_checks, because nothing in its context said that tool existed. Then compaction happens, the summary drops the mention, and the next turn does it again.
This chapter is about the four surfaces that change what the model knows, and which one to reach for.
Four surfaces, four lifetimes
| Surface | Scope | Lifetime | Reaches the model when |
|---|---|---|---|
SYSTEM.md |
Replaces the default prompt | Every request | Every request |
APPEND_SYSTEM.md |
Adds to the prompt | Every request | Every request |
before_agent_start sections |
Adds or replaces one named section | Every run | Every run |
context event |
Transforms conversation messages | Every request | Every request |
configuration.md gives the file locations: <agent-dir>/SYSTEM.md replaces Pi’s default system prompt, <agent-dir>/APPEND_SYSTEM.md adds instructions to it, and .pi/SYSTEM.md and .pi/APPEND_SYSTEM.md do the same for the project. It also states the precedence rule that surprises people:
For
SYSTEM.mdandAPPEND_SYSTEM.md, the trusted project file takes precedence over the corresponding agent-directory file. Files with the same name are not combined.
So a project APPEND_SYSTEM.md does not merge with a user one. It wins outright. Given that context files load regardless of project trust while SYSTEM.md and APPEND_SYSTEM.md do not, this is a meaningful difference in how much of your prompt a stranger’s repository can change.
And context files are a fifth surface, covered in chapter 8: AGENTS.md and friends apply whenever Pi runs in that directory or below, and they are instructions rather than system prompt.
Everything arrives through a channel
Chapter 1 listed the ways the model’s context can be written. That list is not a decoration; it is the set of things that can be wrong when the model does not know something. Sorted by who does the writing, it is three groups.
flowchart TD
subgraph DISK["You write a file"]
D1["context files"] --> REQ
D2["APPEND_SYSTEM.md adds"] --> REQ
D3["SYSTEM.md replaces the base"] --> REQ
D4["skill descriptions"] --> REQ
end
subgraph CODE["You write code"]
C1["before_agent_start sections"] --> REQ
C2["before_agent_start messages"] --> REQ
C3["context event transform"] --> REQ
end
subgraph LOOP["Pi supplies it"]
L1["skill instructions on a match"] --> REQ
L2["tool definitions for active tools"] --> REQ
L3["the active branch and editor input"] --> REQ
end
REQ["ONE MODEL REQUEST"]
Everything converges on one request, and nothing outside that box reaches the model. So the opening failure — Pi running npm test instead of run_checks — is not a model failure. It is a channel that was never written to, or one that was replaced before the request went out.
Context files, first
They are the cheapest thing available and the one most often skipped.
<!-- AGENTS.md in the repository root -->
## Commands
- Unit tests: `pnpm test:unit` (not `npm test`)
- Type check: `pnpm typecheck`
## Conventions
- Errors are returned, not thrown, from service-layer functions.
- Database access lives under src/db/ and nothing else imports pg directly.
## Guard
- The `run_checks` tool runs the checks above. Use it instead of running a command yourself.
- Never run `git push --force`. The review-guard extension will block it.
Two lines at the bottom do exactly what this chapter is about: they make tools registered in chapters 15-17 visible to the model’s planning, which no amount of correct tool code can achieve on its own.
APPEND_SYSTEM.md
When the guidance should apply to every request but should not displace Pi’s own prompt, append rather than replace.
# Project instructions
This repository is a pnpm monorepo. Never run `npm install` or `npm test`;
the npm scripts target a different runner than the one the CI uses.
The bundled .pi/APPEND_SYSTEM.md in the reference project follows the same shape — a short statement of what this project is, plus two rules about how answers should be shaped.
SYSTEM.md is the destructive option. It replaces Pi’s default system prompt. Use it only when you are deliberately taking over the whole instruction surface, and only knowing what you are replacing.
Both are gated by project trust and reachable from the command line: --system-prompt <text|path> replaces, --append-system-prompt <text|path> appends and is repeatable.
before_agent_start and prompt sections
An extension changes the system prompt without owning a file. extensions.md describes the mechanism and the preference:
Prefer changing prompt sections, selected tools, or guidelines so Pi can append a transcript delta. Returning
systemPrompt, or settingforceSystemPrompt, replaces the whole prompt for that run while the transcript continues recording the structured sections.
The distinction is transcript cost. A section change is a patch Pi can record; a full replacement is a new baseline. message-types.md explains why that matters — a SystemMessage can patch sections by name with null removing one, and list toolsAdded/toolsRemoved, and replaying them in order yields the current state.
The event exposes both the rendered prompt and the structured options:
import type { BuildSystemPromptOptions, ExtensionAPI } from "@earendil-works/pi-coding-agent";
function buildToolGuidance(options: BuildSystemPromptOptions): string {
const has = (name: string) => options.selectedTools?.includes(name) ?? false;
const rules: string[] = [];
if (has("read")) rules.push("- Use `read` for file contents; it supports text and images.");
if (has("bash")) rules.push("- Use `bash` for file operations such as `ls`, `find`, and `grep`.");
if (has("edit")) rules.push("- Use `edit` for precise text replacements that match existing content exactly.");
if (has("write")) rules.push("- Use `write` to create new files or replace existing files completely.");
if (options.skills?.length) {
rules.push(`- Available skills: ${options.skills.map((s) => s.name).join(", ")}.`);
}
return rules.join("\n");
}
export default function promptCustomizer(pi: ExtensionAPI) {
pi.on("before_agent_start", (event) => {
const guidance = buildToolGuidance(event.systemPromptOptions);
if (guidance) event.systemPromptOptions.sections.tool_guidance = guidance;
else delete event.systemPromptOptions.sections.tool_guidance;
});
}
Two things to notice, both from the shipped prompt-customizer.ts. The section is written or deleted, not left stale — a run_checks tool that is disabled should stop being described. And the guidance is derived from selectedTools, so it cannot describe a tool that is not active.
Telling the model the guard exists
Now the gap this chapter opened. The extension knows what it registered; the model does not, unless you say so. And a description in the tool declaration is not the same as a line in the prompt — the model may still choose bash first.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("before_agent_start", (event) => {
const tools = pi.getAllTools().map((t) => t.name);
if (!tools.includes("run_checks")) return;
event.systemPromptOptions.sections.review_guard =
"- `run_checks` runs this repository's type check and unit tests. " +
"Use it instead of running a command yourself before reporting on a change. " +
"- Force pushes are blocked by an extension. If a push is refused, do not retry with a different form.";
});
}
Deriving the section from pi.getAllTools() rather than hardcoding it is the whole trick. Disable the tool and the instruction disappears with it, which is what stopped the run_checks description problem in the previous example.
Transforming messages with context
extensions.md describes two events: context transforms conversation messages without prompt and tool system messages, with Pi restoring that state afterward, and context_with_system for when a request-local transformation must own the complete transcript, with a system message kept at index zero.
A handler returns { messages }. Two cautions: it runs on every request, so it is a hot path; and it is request-local, changing what this request sends rather than what the session stored. For a durable, branch-relative change, context_edit is the entry type chapter 5 introduced.
Steering at the boundary
turn_end and agent_before_settle can chain proposed custom, custom_message, context_edit, or compaction entries and return continue: true for one next model request. That is the hook for “the model finished without running the checks”:
pi.on("agent_before_settle", async (event) => {
const ranChecks = event.context.contextMessages.some(
(m) => m.role === "toolResult" && m.toolName === "run_checks",
);
if (ranChecks) return;
return {
entries: [
{
type: "custom_message",
customType: "review-guard",
content: "You reported on a change without running `run_checks`. Run it now and revise.",
display: false,
},
],
continue: true,
};
});
The event state carries a preview - contextEntries, contextMessages, llmMessages, pendingMessages and canContinue - so you can inspect the projection before deciding. Guard the condition: extensions.md is explicit that an unconditional continuation can loop, and a guard that always demands another check is exactly that loop.
Choose the cheapest surface that works
| What you want | Surface | Cost |
|---|---|---|
| Behaviour true everywhere in this folder | AGENTS.md |
none |
| A rule for every request, project-wide | APPEND_SYSTEM.md |
prompt tokens, every request |
| Guidance that depends on active tools | before_agent_start section |
prompt tokens, recomputed per run |
| A note relevant to one conversation | before_agent_start message |
stored and sent |
| Rewriting what an existing message says | context event |
per-request transform |
| Ensuring a step happened before settling | agent_before_settle |
one extra request at most |
The ordering principle is the same as chapter 11’s, applied to knowledge instead of behaviour: text file first, extension last.
The guard, complete
| Chapter | Mechanism | Contribution |
|---|---|---|
| 11 | — | The decision procedure |
| 12 | Prompt template | /review invoked explicitly |
| 13 | Skill | Routed on description, body loads on demand |
| 14 | Skill files | Checklist and script in references/ and scripts/ |
| 15 | Extension | review_scope tool registered |
| 16 | Events | tool_call guard blocking force pushes |
| 17 | Tools in depth | run_checks with exposure, annotations, structured output |
| 18 | Context | Prompt section so the model uses the guard rather than working around it |
Four mechanisms, none redundant. The template is what you type, the skill is what the model finds, the extension is what runs, and the prompt section is what makes the first three get used.
Verify
/reload
Ask: "review my current diff"
Expected: the model calls run_checks, then reports findings.
Then: pi.getAllTools() includes run_checks with annotations.
Then test the negative case, which is the one that fails silently:
Ask: "review my current diff, but run the tests with bash yourself"
Expected: it still prefers run_checks, because the instruction is in the prompt rather than only in the tool description.
If the model ignores the prompt section, the tool description is too weak — go back to chapter 17 and strengthen it rather than lengthening the section.
What survives the request
Every mechanism in this chapter is a statement about the next request: what to read, what to add, what to do before settling. None of them stores anything. The blocked count in chapter 16’s /guard-status lives in a module variable, and /reload takes it to zero.
That is the other half of the problem, and it is chapter 19: where a fact that has to outlive the request goes, why a session entry beats a file, and why the storage choices in extensions.md are not interchangeable.