How Pi Builds Its System Prompt at Runtime — And the Innovations That Make It Stand Out
7th March 2026
A deep dive into the open-source coding agent that assembles its brain on the fly.
Introduction
Most coding agents ship with a static system prompt — a big block of text hardcoded somewhere that tells the LLM how to behave. Pi takes a radically different approach. It assembles the system prompt dynamically at runtime, adapting to what tools are available, what project you’re in, what extensions are loaded, and even what’s happening mid-conversation.
But the dynamic system prompt is just the beginning. Pi has several innovations that set it apart from other coding agents. This blog covers:
- Dynamic System Prompt Assembly — How Pi builds the system prompt from 6+ sources
- Per-Turn System Prompt Modification — How extensions can change the prompt every single turn
- Adaptive Guidelines — How the rules change based on available tools
- Hierarchical Context Files — How context cascades up the directory tree
- Smart Compaction — How Pi uses the LLM itself to summarize old context
- Extension Hook Architecture — How third parties can inject into the prompt pipeline
- Iterative Compaction Summaries — How Pi avoids information loss during summarization
- File Operation Tracking — How the system prompt stays aware of what files were touched
- Mid-Conversation Steering — How users can interrupt and redirect the LLM mid-loop
1. Dynamic System Prompt Assembly — Built from 6 Layers
Pi doesn’t have one system prompt file. Instead, the function buildSystemPrompt() in src/core/system-prompt.ts assembles it from multiple sources every time it’s needed:
┌──────────────────────────────────────────────────────┐
│ Layer 1: Identity & Role │
│ "You are an expert coding assistant operating │
│ inside a terminal..." │
├──────────────────────────────────────────────────────┤
│ Layer 2: Available Tools (DYNAMIC) │
│ Lists only the tools currently enabled │
├──────────────────────────────────────────────────────┤
│ Layer 3: Guidelines (ADAPTIVE) │
│ Rules change based on which tools are active │
├──────────────────────────────────────────────────────┤
│ Layer 4: Pi Documentation Paths │
│ So the LLM can self-serve its own docs │
├──────────────────────────────────────────────────────┤
│ Layer 5: Project Context + Skills (FROM FILESYSTEM) │
│ .pi/agents files, SKILL.md files, package resources │
├──────────────────────────────────────────────────────┤
│ Layer 6: Runtime Metadata │
│ Current date/time, working directory, git status │
└──────────────────────────────────────────────────────┘
Here’s what the assembled prompt looks like (simplified):
You are an expert coding assistant operating inside a terminal-based test harness.
You help users by reading files, executing commands, editing code, and writing new files.
Available tools:
- read: Read file contents
- bash: Execute bash commands (ls, grep, find, etc.)
- edit: Make surgical edits to files (find exact text and replace)
- write: Create or overwrite files
In addition to the tools above, you may have access to other custom tools
depending on the project.
Guidelines:
- Use bash for file operations like ls, rg, find
- Use read to examine files before editing. You must use this tool instead of cat or sed.
- Use edit for precise changes (old text must match exactly)
- Use write only for new files or complete rewrites
- When summarizing your actions, output plain text directly
- Be concise in your responses
- Show file paths clearly when working with files
Pi documentation (read only when the user asks about pi itself...):
- Main documentation: /path/to/README.md
- Additional docs: /path/to/docs
- Examples: /path/to/examples
Current date and time: Saturday, March 7, 2026 at 12:18:20 PM EST
Current working directory: /Users/you/your-project
Why it matters: Most agents hardcode all tools and all guidelines. If you disable a tool, the system prompt still references it, wasting tokens and confusing the LLM. Pi avoids this entirely.
2. Adaptive Guidelines — The System Prompt Knows Its Own Tools
This is one of Pi’s cleverest details. The guidelines section changes based on which tools are currently active. The code dynamically decides which rules to include:
// Only if BOTH read and edit are active
if (hasRead && hasEdit) {
addGuideline("Use read to examine files before editing.");
}
// Different guidance if bash is the ONLY exploration tool
if (hasBash && !hasGrep && !hasFind && !hasLs) {
addGuideline("Use bash for file operations like ls, rg, find");
}
// But if specialized tools exist, prefer them
if (hasBash && (hasGrep || hasFind || hasLs)) {
addGuideline("Prefer grep/find/ls tools over bash for file exploration");
}
// Only mention write if write exists
if (hasWrite) {
addGuideline("Use write only for new files or complete rewrites");
}
Additionally, extensions can inject their own guidelines via promptGuidelines:
// An extension registering a custom "deploy" tool can add:
registerTool({
name: "deploy",
description: "Deploy to production",
promptGuidelines: [
"Always run tests before deploying",
"Never deploy to production without user confirmation"
]
});
These get automatically deduplicated (via a Set) and appended to the guidelines section.
Why it matters: The LLM only sees rules relevant to what it can actually do. No dead instructions. No confusion. Every token in the system prompt earns its place.
3. Hierarchical Context Files — Project Knowledge Cascades Up
Pi loads AGENTS.md or CLAUDE.md context files from every directory up to the root, plus a global one:
function loadProjectContextFiles(options) {
// 1. Global context: ~/.pi/agent/AGENTS.md
const globalContext = loadContextFileFromDir(resolvedAgentDir);
// 2. Walk UP from cwd to root, collecting all AGENTS.md files
let currentDir = resolvedCwd;
while (true) {
const contextFile = loadContextFileFromDir(currentDir);
if (contextFile) ancestorContextFiles.unshift(contextFile);
if (currentDir === root) break;
currentDir = resolve(currentDir, "..");
}
return [globalContext, ...ancestorContextFiles];
}
For a project at /home/user/company/frontend/app, Pi loads:
~/.pi/agent/AGENTS.md ← Your personal coding preferences
/home/AGENTS.md ← (if exists)
/home/user/AGENTS.md ← (if exists)
/home/user/company/AGENTS.md ← Company-wide coding standards
/home/user/company/frontend/AGENTS.md ← Frontend team conventions
/home/user/company/frontend/app/AGENTS.md ← Project-specific rules
All of these get injected into the system prompt under a # Project Context section:
# Project Context
Project-specific instructions and guidelines:
## /home/user/company/AGENTS.md
All code must follow our internal style guide. Use TypeScript strict mode.
## /home/user/company/frontend/app/AGENTS.md
This is a Next.js app. Use App Router conventions.
Run `pnpm test` before committing.
Why it matters: In a monorepo, different sub-projects need different instructions. You don’t want your backend rules leaking into frontend work. Hierarchical context files solve this naturally, just like .gitignore rules cascade.
4. Per-Turn System Prompt Modification — Extensions Can Rewrite the Prompt Every Turn
This is where Pi gets truly innovative. Extensions can modify the system prompt before every single LLM call via the before_agent_start hook:
// Inside AgentSession.prompt():
if (this._extensionRunner) {
const result = await this._extensionRunner.emitBeforeAgentStart(
expandedText, // The user's message
currentImages, // Any attached images
this._baseSystemPrompt // The current system prompt
);
if (result?.systemPrompt) {
// Extension modified the system prompt for THIS turn only
this.agent.setSystemPrompt(result.systemPrompt);
} else {
// Reset to base prompt (in case previous turn had modifications)
this.agent.setSystemPrompt(this._baseSystemPrompt);
}
}
Inside the ExtensionRunner, multiple extensions chain their modifications:
async emitBeforeAgentStart(prompt, images, systemPrompt) {
let currentSystemPrompt = systemPrompt;
// Each extension gets the prompt modified by the previous one
for (const ext of this.extensions) {
const handlers = ext.handlers.get("before_agent_start");
for (const handler of handlers) {
const result = await handler({
type: "before_agent_start",
prompt,
images,
systemPrompt: currentSystemPrompt, // ← Chained!
}, ctx);
if (result?.systemPrompt !== undefined) {
currentSystemPrompt = result.systemPrompt;
}
}
}
return { systemPrompt: currentSystemPrompt };
}
Real use case: An extension that provides “plan mode” could prepend planning instructions to the system prompt only when the user types /plan, without permanently modifying the base prompt.
Why it matters: Most agents make you choose: either you customize the entire system prompt, or you use the default. Pi lets extensions surgically modify it per-turn, and the base prompt resets automatically. No state leaks.
5. Skills — Lazy-Loaded Specialized Instructions
Skills are .md files with YAML frontmatter that represent specialized knowledge. But here’s the innovation: skills are NOT included in the system prompt. Only their name, description, and file path are:
<available_skills>
<skill>
<name>deploy</name>
<description>Instructions for deploying to production</description>
<location>/path/to/SKILL.md</location>
</skill>
<skill>
<name>database-migration</name>
<description>How to create and run database migrations</description>
<location>/path/to/migration/SKILL.md</location>
</skill>
</available_skills>
The system prompt tells the LLM:
“Use the read tool to load a skill’s file when the task matches its description.”
So the LLM decides on its own when it needs a skill, reads the file using the read tool, and follows the instructions inside.
When a user explicitly invokes a skill via /skill:deploy, Pi wraps it in XML:
<skill name="deploy" location="/path/to/SKILL.md">
References are relative to /path/to/.
[full skill content here]
</skill>
Why it matters: If you have 20 skills, putting all of them in the system prompt wastes thousands of tokens. Pi’s approach keeps the system prompt lean — the LLM only loads what it needs, when it needs it. It’s the difference between loading an entire library vs. importing a single function.
6. Smart Compaction — The LLM Summarizes Itself
When a conversation grows too long for the context window, most agents just truncate old messages. Pi does something smarter: it asks the LLM to summarize the old conversation into a structured checkpoint.
The compaction prompt
Pi uses a specific structured format for summaries:
## Goal
[What is the user trying to accomplish?]
## Constraints & Preferences
- [Any constraints or requirements mentioned]
## Progress
### Done
- [x] [Completed tasks]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues preventing progress]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [Ordered list of what should happen next]
## Critical Context
- [Data, examples, or references needed to continue]
Iterative updates — the second innovation
On the first compaction, Pi generates a fresh summary. But on subsequent compactions, it uses an update prompt that merges new information into the existing summary:
// If we already have a previous summary, use the update prompt
let basePrompt = previousSummary
? UPDATE_SUMMARIZATION_PROMPT // "Update the existing summary with new info"
: SUMMARIZATION_PROMPT; // "Create a new summary"
The update prompt tells the LLM:
- PRESERVE all existing information from the previous summary
- ADD new progress, decisions, and context
- UPDATE the Progress section: move items from “In Progress” to “Done”
- UPDATE “Next Steps” based on what was accomplished
Why it matters: Traditional truncation loses everything. Even single-pass summarization can lose important early context. Iterative summarization preserves the full arc of the conversation, updating the checkpoint rather than rewriting it from scratch.
File operation tracking during compaction
Pi tracks every read, write, and edit tool call and appends a file manifest to the summary:
<read-files>
src/config.ts
src/utils/helper.ts
</read-files>
<modified-files>
src/core/agent-session.ts
src/core/system-prompt.ts
</modified-files>
This means after compaction, the LLM still knows which files it read (and might need to re-read) and which files it already modified. This prevents re-reading files unnecessarily or forgetting that a file was already edited.
Split-turn summarization
Sometimes the compaction cut point falls in the middle of a turn (e.g., the LLM was executing 5 tool calls, and the cut point is between tool 3 and 4). Pi handles this by generating two summaries in parallel:
if (isSplitTurn && turnPrefixMessages.length > 0) {
// Generate both summaries in parallel
const [historySummary, turnPrefixSummary] = await Promise.all([
generateSummary(messagesToSummarize, ...),
generateTurnPrefixSummary(turnPrefixMessages, ...)
]);
// Merge into single summary
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixSummary}`;
}
Why it matters: Cutting in the middle of a turn is dangerous — the LLM would see tool results without context about what requested them. The turn prefix summary bridges this gap.
7. Extension Hook Architecture — 13+ Interception Points
Pi’s system prompt doesn’t just adapt at build time. The extension system provides hooks at virtually every point in the lifecycle:
| Hook | When | What it can do |
|---|---|---|
before_agent_start | Before each LLM call | Modify system prompt, inject context messages |
input | Before user message is processed | Transform or intercept user input |
before_provider_request | Right before HTTP request | Modify the raw API payload |
session_before_compact | Before compaction | Provide custom compaction, cancel it |
session_compact | After compaction | React to compaction results |
resources_discover | On startup/reload | Dynamically add skill/prompt/theme paths |
turn_start / turn_end | Each ReAct loop iteration | Track turn-level state |
tool_execution_start/end | Each tool call | Modify or observe tool behavior |
model_select | When model changes | React to model switches |
session_before_switch | Before changing session | Cancel session switch |
session_before_fork | Before branching | Cancel or modify fork |
The before_provider_request hook — raw API payload access
This is an extremely powerful hook. Extensions can inspect and modify the raw API payload before it’s sent to the LLM provider:
async emitBeforeProviderRequest(payload: unknown): Promise<unknown> {
let currentPayload = payload;
for (const ext of this.extensions) {
const handlers = ext.handlers.get("before_provider_request");
for (const handler of handlers) {
const result = await handler({
type: "before_provider_request",
payload: currentPayload,
}, ctx);
if (result?.payload !== undefined) {
currentPayload = result.payload;
}
}
}
return currentPayload;
}
This means extensions can:
- Add provider-specific parameters (e.g., Anthropic’s
metadatafield) - Modify temperature, top_p, etc. per-request
- Add custom headers
- Implement prompt caching strategies
Why it matters: No other coding agent gives you this level of control without forking the codebase.
8. Custom Tool Registration with Prompt Integration
When extensions register tools, they don’t just register a function — they can also specify how the tool affects the system prompt:
pi.registerTool({
name: "database_query",
description: "Execute a read-only SQL query against the project database",
// One-line description for the "Available tools:" section
promptSnippet: "Execute read-only SQL queries (SELECT only)",
// Additional guidelines added to the "Guidelines:" section
promptGuidelines: [
"Always use parameterized queries to prevent SQL injection",
"Limit result sets to 100 rows unless the user asks for more"
],
parameters: { /* JSON Schema */ },
execute: async (id, params) => { /* ... */ },
});
When this tool is active, the system prompt automatically includes:
Available tools:
- read: Read file contents
- bash: Execute bash commands
- edit: Make surgical edits
- write: Create or overwrite files
- database_query: Execute read-only SQL queries (SELECT only) ← Added!
Guidelines:
- Use read to examine files before editing
- ...
- Always use parameterized queries to prevent SQL injection ← Added!
- Limit result sets to 100 rows unless the user asks for more ← Added!
And when the tool is deactivated, its guidelines automatically disappear because the system prompt is rebuilt:
setActiveToolsByName(toolNames: string[]): void {
// Rebuild base system prompt with new tool set
this._baseSystemPrompt = this.buildSystemPrompt(validToolNames);
this.agent.setSystemPrompt(this._baseSystemPrompt);
}
Why it matters: In other agents, adding a tool means manually editing the system prompt. In Pi, tools carry their own prompt context with them. Enable a tool → its guidance appears. Disable it → its guidance vanishes. Zero manual maintenance.
9. Prompt Templates — Reusable Parameterized Prompts
Pi supports bash-style argument substitution in prompt templates:
---
description: Code review with specific focus
---
Review the code in $1 focusing on $2.
Check for:
- Security vulnerabilities related to $2
- Performance implications
- Edge cases
Additional context from arguments: ${@:3}
Usage: /review src/auth.ts "SQL injection" also check for XSS
This expands to:
Review the code in src/auth.ts focusing on SQL injection.
Check for:
- Security vulnerabilities related to SQL injection
- Performance implications
- Edge cases
Additional context from arguments: also check for XSS
The expansion supports:
$1,$2, ... — positional arguments$@or$ARGUMENTS— all arguments joined${@:2}— all arguments from 2nd onwards (bash-style slicing)${@:2:3}— 3 arguments starting from 2nd
Why it matters: Instead of typing the same complex prompt repeatedly, you create it once and invoke it with parameters. It’s like creating shell aliases for your LLM interactions.
10. The Full Runtime Pipeline — Everything Connected
Here’s how all these innovations work together in a single prompt cycle:
User types: "/review src/app.ts security"
│
├─ 1. PROMPT TEMPLATE EXPANSION
│ "/review src/app.ts security" →
│ "Review the code in src/app.ts focusing on security..."
│
├─ 2. SKILL EXPANSION (if /skill:name)
│ Reads SKILL.md, wraps in <skill> XML tags
│
├─ 3. EXTENSION INPUT HOOK
│ Extensions can transform or intercept the message
│
├─ 4. EXTENSION before_agent_start HOOK
│ Extensions can:
│ ├─ Modify system prompt for this turn
│ └─ Inject custom context messages
│
├─ 5. SYSTEM PROMPT ASSEMBLED from:
│ ├─ Base prompt (identity + tools + guidelines)
│ ├─ Extension modifications (per-turn)
│ ├─ Context files (AGENTS.md hierarchy)
│ ├─ Skills list (lazy-loaded references)
│ └─ Date/time + working directory
│
├─ 6. EXTENSION before_provider_request HOOK
│ Can modify the raw API payload
│
├─ 7. HTTP REQUEST TO LLM (streaming)
│ System prompt + all messages → LLM API
│
├─ 8. LLM RESPONDS with text + tool calls
│ └─ Tools execute, results go back to LLM
│ └─ ReAct loop continues until LLM is done
│
├─ 9. AUTO-COMPACTION CHECK
│ If context > threshold:
│ ├─ Extension session_before_compact hook
│ ├─ LLM generates structured summary
│ ├─ Old messages replaced with summary
│ ├─ File operations tracked
│ └─ Extension session_compact hook (notified)
│
└─ 10. DONE — Ready for next turn
The Design Philosophy
Pi’s system prompt isn’t a string — it’s a pipeline. Each piece of the prompt can be:
- Added at startup (context files, skills, tools)
- Modified per-turn (extension hooks)
- Removed dynamically (tool deactivation removes its guidelines)
- Summarized when it gets too large (smart compaction)
- Extended by third parties (without forking Pi)
The core design philosophy: the system prompt should reflect the current state of the world, not a static snapshot of what the developer imagined. When your tools change, your project changes, or your extensions change — the system prompt adapts automatically.
This is what makes Pi more than a coding agent. It’s a coding agent harness — a framework designed so that you adapt it to your workflow, not the other way around.
Built by examining the Pi source code at packages/coding-agent/src/core/system-prompt.ts, agent-session.ts, compaction/compaction.ts, extensions/runner.ts, resource-loader.ts, prompt-templates.ts, and skills.ts.
More recent articles
- OpenUSD: Advanced Patterns and Common Gotchas. - 28th March 2026
- OpenUSD Mastery: From Composition to Pipeline — A SO-101 Arm Journey - 25th March 2026
- Learning OpenUSD — From Curious Questions to Real Understanding - 19th March 2026