Akshay Parkhi's Weblog

Subscribe

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:

  1. Dynamic System Prompt Assembly — How Pi builds the system prompt from 6+ sources
  2. Per-Turn System Prompt Modification — How extensions can change the prompt every single turn
  3. Adaptive Guidelines — How the rules change based on available tools
  4. Hierarchical Context Files — How context cascades up the directory tree
  5. Smart Compaction — How Pi uses the LLM itself to summarize old context
  6. Extension Hook Architecture — How third parties can inject into the prompt pipeline
  7. Iterative Compaction Summaries — How Pi avoids information loss during summarization
  8. File Operation Tracking — How the system prompt stays aware of what files were touched
  9. 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:

HookWhenWhat it can do
before_agent_startBefore each LLM callModify system prompt, inject context messages
inputBefore user message is processedTransform or intercept user input
before_provider_requestRight before HTTP requestModify the raw API payload
session_before_compactBefore compactionProvide custom compaction, cancel it
session_compactAfter compactionReact to compaction results
resources_discoverOn startup/reloadDynamically add skill/prompt/theme paths
turn_start / turn_endEach ReAct loop iterationTrack turn-level state
tool_execution_start/endEach tool callModify or observe tool behavior
model_selectWhen model changesReact to model switches
session_before_switchBefore changing sessionCancel session switch
session_before_forkBefore branchingCancel 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 metadata field)
  • 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.

This is How Pi Builds Its System Prompt at Runtime — And the Innovations That Make It Stand Out by Akshay Parkhi, posted on 7th March 2026.

Next: How Claude Team Agents ACTUALLY Connect — No Fluff

Previous: Scaling Agents: The Definitive Open-Source Guide — From 1 Agent to 100 Agents, 1 Tool to 100 Tools, Managing Context