A pi extension for context templating. Any text part of the system prompt —
the global and project AGENTS.md (including the cwd ancestor chain),
SYSTEM.md/APPEND_SYSTEM.md, skill descriptions and bodies, prompts, agent
files — can branch on variables whose values come from the config, the
environment, or a shell command.
- A source is an
X.templatefile, the only file edited by hand. A target is the rendered copyXnext to the source. Sources are the files pi consumes natively (AGENTS.md,SKILL.md,*.mdprompts,SYSTEM.md,APPEND_SYSTEM.md) and include sources —X.templatefiles a template mentions as a partial; the target of an include source is the rendered copy to read, pi never loads it. None of pi's mechanisms sees.templatefiles. - At start a session computes the view — the full set of variable values — and renders the targets. The current context version is kept in memory and delivered into the prompt by pointwise section replacement; the targets are the last-success cache.
- Foreign reads of targets (the read tool for skill bodies, pi-subagents for agent files) run under a file lock: the extension re-renders the target under its session's view, the reader reads, the lock is released.
- A file render failure does not rewrite the target and does not let raw tags into the prompt: the consumer gets the previous successful text, and the reason goes to the UI as a warning.
A mode is how the agent works in a session — planning, developing,
reviewing — and the mode text is templated: a variable picks which text
enters AGENTS.md (rules, tone, delegation policy). A mode is not an
engine concept: it is an ordinary variable plus a dynamic partial.
The reference setup — modes live in modes/, one source per mode; the
agent directory:
~/.pi/agent/
├── template.json
├── AGENTS.md.template
└── modes/
├── plan.md.template
└── code.md.template
template.json — the variable whose values are the mode files:
{
"vars": {
"mode": {
"values": { "shell": "basename -s .md.template modes/*.md.template" },
"initial": "plan"
}
},
"statusVariable": "mode"
}AGENTS.md.template — the selected mode is included as a dynamic partial:
{{> (concat "modes/" mode ".md")}}The partial name is the plain name (modes/plan.md), resolved on disk
at render time; the text is read from the source modes/plan.md.template
when it is present, otherwise from modes/plan.md. Adding a mode is
dropping a file into modes/. Every mentioned X.template — mode files
included — is an include source: its target X is written next to it, and
a plain X without a source is hand-written and never overwritten.
The package installs as a pi git package: in the agent directory
settings.json
{ "packages": ["git:github.com/NikolayXHD/pi-template"] }The entry point is declared in package.json (pi.extensions). The
dependency is handlebars (make deps or npm install).
It lives in the agent directory (~/.pi/agent/template.json) and declares
variables:
{
"vars": {
"vllm": {
"values": ["on", "off"],
"initial": "on"
},
"git_branch": {
"initial": { "shell": "git rev-parse --abbrev-ref HEAD" },
"cwd": "project"
}
},
"statusVariable": "vllm"
}The top-level statusVariable names the variable the status line shows
(template ✓ vllm=on); it must name a declared variable with a non-empty
value list — otherwise the config is an error. Without the field the status
line is just the state glyph.
Variable fields:
values— a static list of strings or{"shell": "…"}: the values are stdout lines, one per line (trim, empty lines ignored, duplicates collapsed). A non-zero exit or a timeout (5 s) gives a warning and leaves the variable without a list. The list feeds lint: condition literals are checked against it, dynamic partial names are resolved on disk.initial— the machine value: a string,{"shell": "…"}or{"env": "VAR"}(a lookup with no command run). A computedinitialmakes the variable read-only — a session override is forbidden for it.compute— a pure derivation from other variables, evaluated in the process (no shell) and re-derived whenever the view changes. The expression is a value: a variable name (its value),concatof literals and variables, or a condition (eq/in,and/or/not) as the strings"true"/"false". A boolean expression gives the implied values list["true", "false"]; a string one has no list. Not combined withinitialorvalues. The variable is overridable — a session override wins over the expression; for a boolean variable an override or an@value outsidetrue/falseis an error. Expression references must name variables declared earlier (or the implicitmodel/level) — an unknown or later name is a config error;and,or,not,eq,inare reserved names.@and@<agent name>— the variable value at a subagent;@<name>wins over@. They replace both the machine and the computed value; without either key a subagent sees the machine value (a computed one sees its computation). Strings only.cwd— the command working directory:"agent"(default, the agent directory) or"project"(the session cwd; children use their own).
Config rules:
- variable names are identifiers; the name is the env variable name, and a collision with the process environment is an error;
modelandlevelare implicit: declared by pi itself, read-only for the config; declaring them is an error;- an unknown key is an error; a broken
template.jsonmeans the view is not built and there is no re-render, with a warning naming the reason; - commands run in the platform system shell; string substitution of values into the command text is forbidden: the command receives variable values through the environment;
- computation follows declaration order; a variable is never computed
twice;
computeexpressions see the variables declared earlier.
Value priority: a session override wins over initial and over compute;
shell/env computed variables have no override.
The vocabulary is deliberately narrow; everything outside it is a lint error, not a silent rejection:
{{#if (eq mode "plan")}}…{{else}}…{{/if}},{{#if (in vllm "on" "auto")}}…{{/if}}, and the same with{{#unless}}; conditions compose —and,or,notover comparisons and boolean variables:{{#if (or is_flash (eq level "off"))}}…{{/if}};- a bare name as a condition — a boolean variable (values
true/false, acomputeexpression):{{#if is_flash}}…{{/if}}. Truth is the exact string"true", anything else — including"false"— is false; - interpolation
{{mode}}— declared variables and the implicit{{model}}/{{level}}with the model address parts{{model_provider}}/{{model_id}}; - partials:
{{> roles/snippets/tests.md}}— the name is a path from the root of its directory (agent ones from the agent directory, project ones from the project root with the.pi/prefix). The name is resolved on disk at render time: the text comes fromX.templatewhen present, otherwise fromX. A mentionedX.templateis an include source — its targetXcarries the rendered text (nested includes expanded); a.templatethat no template mentions has no target, and the rewrite report names it. There is no pre-scan of directories, so an include from an arbitrary directory ({{> modes/plan.md}}) resolves like any other; a name that escapes its root is not resolved. A missing static name is a lint error; a missing value of a dynamic partial is a warning, and a selected value without a file fails the render naming the value. The include text is linted when it is read (an@qualifier is legal only in agent files). Names with a leading dot are written as string literals:{{> ".pi/skills/x/SKILL.md"}}; a dynamic partial is{{> (concat "modes/" mode ".md")}},concatof literal segments and variables, the name is checked for every value in the list; - part helpers:
{{frontmatterKeys "x"}}and{{bodyAfterFrontmatter "x"}}— the frontmatter keys of a foreign file (the lines inside the---wrapper) or its body, as is. The wrapper is written by the template, so a foreign agent file is re-used with your own keys (atimeoutMsline) added. The name argument is a string literal, a variable orconcat— the partial name vocabulary; a bare path is not a name (an argument resolves as an expression). The cut is strict: the first line is---up to a BOM and trailing spaces, the closing line is---up to trailing spaces — otherwiseno frontmatter/unclosed frontmatter. A missing static name is a lint error, a missing dynamic one is a warning and a render error for the selected value (as partials). The part text is not a template — not rendered, not linted; a raw{{in it is a warning, and such a target legitimately contains raw tags; - comments
{{! … }}; a literal{{in text is escaped as\{{; - frontmatter is rendered alongside the body; a rendered file with frontmatter is validated by YAML parsing — skill descriptions and agent headers live there.
Part helpers in agents/reviewer.md.template — the agent file of a package
with your own timeoutMs in frontmatter:
---
{{frontmatterKeys "npm/node_modules/pi-subagents/agents/reviewer.md"}}
timeoutMs: 3600000
---
{{bodyAfterFrontmatter "npm/node_modules/pi-subagents/agents/reviewer.md"}}The @ qualifier references a variable value at a subagent:
{{[vllm@]}} (any subagent) and {{[vllm@foo]}} (agent foo). It is
legal only in agent files (agents/**); in the file of agent foo only
@ and @foo are allowed.
Two-stage agent file render. The child's model and level are determined
by the header of the same file, so the header is rendered iterated to a
fixpoint (preliminary model/level → YAML of the header → actual values),
and the body with the full future child view: model@/level@ get actual
values, custom @ variables get the subagent values from the config. A
model or level expression that does not converge is a render error ("header
model depends on model@").
The extension's only command opens the context management menu:
- session context status as one glyph on the menu title:
✓applied (the prompt equals a fresh render),!a re-render is waiting (a source was edited, or a variable change altered the render),‼no view built (a broken config); - rows of the variables: the value; for shell-computed ones the value age
and recompute on Enter (a
computederivation is an ordinary row — overridable, without an age); for overridden ones "session override"; the variable status as the last segment — a colored glyph:✓green (the value is in the applied context) or!yellow (it differs — the context has not been applied since); implicitmodel/levelare rows of the list as well — read-only, they change during a session (model switch, thinking level), so their status matters like any other's; - "recompute all variables";
- "rewrite files on disk" — writing the current version into the targets
without applying it. The notification is the summary (how many written,
unchanged, failed); the menu keeps the report of the last rewrite — one
row per file:
written,unchanged,failedwith the reason, ornot included— a.templateno template mentions, so no target exists for it; - "update current session context" — applying the current version.
The file buttons follow the workflow: rewrite first, read the files if you
like, then apply to the session context — the button or ctrl+alt+r. An
action does not close the menu — the actions are granular and several in a
row is the usual case; Esc does.
A variable change is recorded in the
session (customType: "var", payload {name, value}) and does not update
the context by itself — only the status changes. Resume picks up the last
value of each variable from the records.
The quick switch cycles values without the menu: ctrl+r takes the next
value of the status variable, alt+r takes the next variable of the ring —
declared ones with a non-empty value list. A value change is a session
override, exactly as a menu change: the context is not applied; alt+r
writes its choice into template.json immediately. ctrl+alt+r applies the
current version — the button action without the menu. In pi ctrl+r is the
built-in "rename session" shortcut; the extension overrides it — pi reports
the conflict on startup.
The status line shows one state glyph (✓ applied, ! a re-render is
waiting, ‼ no view) and, when statusVariable is set, its pair:
template ✓ vllm=on (a long value is compacted). The fact of a session
override stays in the menu (session override).
The extension keeps two versions of the context files in memory: the applied one (in the prompt and in the targets) and the current one (re-rendered from fresh sources and values).
- Editing a source or an included file, changing a session variable or the model/level re-renders only the current version and marks the context stale: the prompt itself never changes.
- The applied version is fixed at the first prompt assembly: everything that
changed since the session start (a variable, model/level, a source or
config edit) enters the first prompt by itself — before it there is no
conversation and no cache to lose. After it applying is explicit: the
"update current session context" button or
ctrl+alt+r. The start apply and the button deliver the text by pointwise section replacement;/reloadre-reads the targets by pi itself (the extension writes the current version onsession_shutdown). "Rewrite files on disk" writes the targets without applying anything. - Sources and the files of the last render's includes are compared by
mtime+sizeon every turn; shell computations run only by explicit menu actions,computeexpressions are re-derived on every view change (model/level, overrides). Atemplate.jsonedit is picked up the same way — the config is re-read and the view rebuilt on the next turn or menu open; a broken file keeps the previous config with a warning.
The mechanism requires templates to expand fully in child sessions too: the
extension registers itself as required for them
(registerRequiredChildExtensions). A child's variable values are set by
@/@<name> keys; a child session does not inherit the parent's session
overrides. Outside the pi process (machine/pane-native runs, external CLI
agents) the extension does not work — the targets remain coherent last
successful text.
Package resources (skills and prompts of git/npm packages) are not templated.
node probe.mjs — a scenario run of the engine and consumption points.
Live runs against the pi SDK — live/*.mjs; scenario descriptions and
manual checks — TESTING.md.