← Pi Agents

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, despite the tool being declared to the model. The tool description makes it available; it does not guarantee selection. A reminder placed only in conversation history can also disappear from the request after compaction, leaving the model without that guidance on a later turn.

This chapter focuses on four ways to change what the model receives, and when to use each. Project context files form an additional, standing source of instructions.

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 Recomputed when the event runs In requests built from the resulting prompt
context event Transforms conversation messages Request-local On requests where the handler runs

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.md and APPEND_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.

Context files are another source of model instructions, covered in chapter 8: AGENTS.md and related files are discovered by directory and, unlike project SYSTEM.md and APPEND_SYSTEM.md, are not gated by project trust.

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 the model can use in a turn must reach it through the assembled request. But if Pi chooses npm test over the declared run_checks tool, the cause is not necessarily missing context: the model can ignore guidance even when it is present. Inspect the active tool declarations and the request actually sent before deciding whether the problem is missing guidance, lost history, or model behaviour.

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.

The final two lines add repository-specific guidance on when to use run_checks and how to react to a blocked force push. The active tool declaration already tells the model that run_checks exists; these instructions try to influence its choice of tool. They do not guarantee compliance or make the guard a security boundary.

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.

The project-local files are gated by project trust; their agent-directory counterparts are user-level resources and do not depend on trusting the current project. Command-line alternatives are --system-prompt <text|path> to replace and repeatable --append-system-prompt <text|path> to append.

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 setting forceSystemPrompt, 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;
	});
}

One thing the compiled test established: a section added this way reaches the provider as a named block, <tool_guidance>…</tool_guidance>, in the system message. It does not appear in session.systemPrompt, which on 1.0.4 still showed only the base prompt after the run. If you are checking whether your section took effect, look at what the provider receives, not at that accessor.

Two things to notice in prompt-customizer.ts. The section is written or deleted, rather than left stale when its inputs change. And the guidance is derived from selectedTools: the example describes only selected tools and skills. It does not mention run_checks specifically; that is the job of the separate review-guard section below.

Telling the model the guard exists

Now the gap this chapter opened. An active tool is already declared to the model, with its name, schema and description. What the model may still lack is repository-specific guidance to prefer that tool over bash for a particular workflow. A prompt section can provide that guidance, but cannot guarantee the choice.

export default function (pi: ExtensionAPI) {
	pi.on("before_agent_start", (event) => {
		// getActiveTools(), not getAllTools(). getAllTools() reports every
		// *registered* tool, so a tool the session disabled would still be
		// described here — and the model would be told to use something it
		// cannot call. The active set is what is declared to the model.
		const active = pi.getActiveTools();
		if (!active.includes("run_checks")) {
			// Delete rather than leave stale: a section written for a previous
			// turn outlives the condition that produced it.
			delete event.systemPromptOptions.sections.review_guard;
			return;
		}

				event.systemPromptOptions.sections.review_guard = [
			"- `run_checks` runs this repository's type check and unit tests. Use it before reporting on a change instead of inventing a separate check command.",
			"- The review-guard extension may block force pushes. If one is refused, do not retry using a different spelling.",
		].join("\n");
	});
}

Deriving the section from pi.getActiveTools() 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.

Which of the two accessors you reach for is the whole lesson, and the first version of this example got it wrong. pi.getAllTools() reports every tool the session has registered, including ones the active selection has since disabled; pi.getActiveTools() is the set declared to the model. Observed on 1.0.4: register run_checks, set the active tools to ['read'], then prompt — with getAllTools() the run_checks guidance was still in the provider’s system prompt, describing a tool the model had no way to call. The existing tests here compared a registered tool against an absent one, which both accessors agree about, so they passed either way. The tests below now walk active → disabled → re-enabled.

getAllTools()      registered   what this session has defined
getActiveTools()   active       what the model has been told it may call

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;

	// Nudge once. Without this, a model that never runs the checks is asked
	// again after every answer, forever.
	const alreadyNudged = event.context.contextMessages.some(
		(m) => m.role === "custom" && m.customType === "review-guard",
	);
	if (alreadyNudged) 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.

This is not a hypothetical. The first version of this guard in the book checked only whether run_checks had run. Run against a model that never calls it, that version asked again after every answer and never terminated. The guard above is bounded: it records that it has already nudged, by looking for its own custom_message in the context, and nudges once. The test in examples/ch18-knowledge/ runs a model that ignores it and asserts exactly two requests. The condition that makes a continuation safe has to be about what the guard has already done, not only about what the model has not.

Choose the cheapest surface that works

What you want Surface Cost
Standing repository guidance AGENTS.md prompt tokens on applicable requests
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
Checking for a missing step before settling agent_before_settle an additional request when continuation is requested; requires a loop guard

The ordering principle is the same as chapter 11’s, applied to knowledge instead of behaviour: use an instruction file when static guidance suffices, and an extension when the guidance must be computed from runtime state.

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, with distinct roles. The template is invoked explicitly, the skill can be selected by the model, the extension registers executable behaviour, and the prompt section adds guidance about when to use that behaviour. Guidance can improve selection; it does not ensure the model follows it.

Verify

/reload
Ask: "review my current diff"
Expected: the model calls run_checks, then reports findings.
Then: pi.getAllTools() includes run_checks with annotations (registration),
and pi.getActiveTools() includes run_checks (what the model may call).

Then test the negative case, which is the one that fails silently:

Ask: "review my current diff, but run the tests with bash yourself"
Inspect: did the model call run_checks or bash, and what guidance and tool declarations did the provider receive?
Neither outcome is guaranteed by the prompt section; this is a behavioural probe, not a pass/fail test of the extension contract.

If the model ignores the prompt section, first confirm that the section and active tool declaration reached the provider. If they did, review both the tool description in chapter 17 and the competing instructions; do not assume the description alone is at fault.

What was run

examples/ch18-knowledge/ runs each surface through a real session and inspects what the provider receives, by capturing the transcript the scripted model is sent.

  • A section written in before_agent_start reaches the provider as a named block, <tool_guidance>…</tool_guidance>, inside the system message, and is derived from the tools that are actually active. With only read active, the bash guidance is absent.
  • session.systemPrompt does not show that section on 1.0.4: after the run it still held the base prompt. If you are checking whether a section took effect, check the request, not the accessor.
  • The run_checks section is intended to follow activation, not merely registration. The chapter describes an active → disabled → re-enabled check; that is the relevant test for the getActiveTools() fix, whereas a registered-versus-absent test cannot distinguish the two accessors.
  • The settle guard looped. The first version of it checked only whether run_checks had run. Against a model that never calls the tool it asked again after every answer and the test never finished. The bounded version above nudges once, and a test with a model that ignores it asserts exactly two requests.

What survives the request

These mechanisms affect requests in different ways; they do not all have the same persistence. Prompt files remain on disk, before_agent_start can inject entries, and an agent_before_settle handler can append a custom_message to the session. A context transformation, by contrast, is request-local. 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.