Course · Pi · Skills & packages
Prompt templates and themes: the lighter no-code layer
The Skill you just wrote fires on its own when a request matches its description -
that’s the point of it, the whole reason it’s worth the frontmatter. Not everything
you’d like to save typing on deserves that. Some things are just a prompt you find
yourself retyping verbatim, with one or two words swapped each time, and for those
Pi has something simpler than a Skill: a prompt template, invoked by hand, no
auto-detection, no description field doing pattern-matching.
Prompt templates: a markdown snippet with blanks
Section titled “Prompt templates: a markdown snippet with blanks”A prompt template is a plain markdown file. No auto-invocation - you type the slash
command yourself, every time. Say you keep asking Pi to triage the failed feed
syncs of stash (the course’s running example, a small FastAPI feed reader)
the same way: list the feeds whose last sync failed, name what went wrong, and
say whether a retry is worth it. The failures come in a short list of real shapes -
an HTTP 403 or 429 from a site that’s blocking or rate-limiting you, a timeout,
an XML parse error on a malformed body, or a 200 that parses to zero entries - and
each of those has a different next step. Instead of typing that framing out again, put it in .pi/prompts/triage-fetch.md:
---description: Triage stash feeds whose syncs are failingargument-hint: "[since]"---
Look at stash's failed feed syncs ${1:-in the last 24 hours}. For eachfailed feed, report: the feed URL, the failure class (HTTP 403 or 429,timeout, XML parse error, or zero entries parsed), and one recommendednext step (retry later, back off and slow the sync loop, fixfetch_feed, or drop the feed).Frontmatter here is optional and thinner than a Skill’s: description (if you skip
it, Pi just uses the file’s first non-empty line) and argument-hint, which is what
shows up in autocomplete - angle brackets for a required argument, square brackets
for optional ones. The body is where the argument syntax does its work:
| Syntax | Meaning |
|---|---|
$1, $2, … |
positional arguments, in the order you typed them |
$@ or $ARGUMENTS |
every argument you passed, joined into one string |
${1:-default} |
falls back to default if that argument wasn’t supplied |
${@:N} / ${@:N:L} |
a slice of the arguments starting at position N, optionally limited to length L |
Type /triage-fetch and ${1:-in the last 24 hours} falls back to its default;
type /triage-fetch "since last Tuesday" and $1 becomes that string instead.
Invocation is just the filename without its extension - /triage-fetch, same
pattern as any other slash command you’ve already used.
Templates live in the same kind of tiered locations as everything else in Pi:
~/.pi/agent/prompts/*.md globally, .pi/prompts/*.md per project (gated behind
project trust, same as extensions), inside an installed package’s prompts/
directory, in a settings.json prompts array, or handed in directly with a
repeatable --prompt-template <path> flag.
The distinction that matters: a Skill’s description is read by the model,
deciding on your behalf whether to pull the whole thing in. A prompt template’s
description is read by you, in autocomplete, deciding whether to type the slash
command. If you find yourself wanting a template to fire automatically when a
situation arises rather than when you type it - that’s your sign it should have
been a Skill all along.
A template costs nothing until you type it, and buys you nothing unless you
remember to. That trade cuts both ways, which is the honesty worth naming here:
triage-fetch.md doesn’t know when it’s stale. Its ${1:-in the last 24 hours}
blank assumes failed syncs are something you can slice by a time window at all -
that the failure record, wherever it lives, answers a plain “since X” query. Today
stash doesn’t record failed syncs anywhere: the sync endpoint raises, FastAPI
returns a 500, and nothing is written down, so the model has to reconstruct the
window from the session transcript or by re-running syncs. The day the sync path
starts writing failures down - say an error column on the feeds table, no
timestamp attached - nothing tells the template its framing is wrong. Pi expands
${1:-in the last 24 hours} exactly as written and hands the model a question that
no longer matches the data underneath it. A Skill’s body at least gets re-read
fresh by an agent that might notice something’s off along the way; a template’s
expansion drops straight into the prompt, unchecked against whatever it’s actually
driving. Update the template the same day you change what it wraps, or it quietly
starts lying to you.
Themes: the terminal you’re staring at for eight hours
Section titled “Themes: the terminal you’re staring at for eight hours”Unrelated to either of the above, but worth the five minutes here because it’s the
same “just a file” shape: a Pi theme is a JSON file describing the TUI’s color
palette - not just an accent color, a full token set covering syntax highlighting,
message backgrounds, tool-output states, even a distinct border color per
thinking-effort level. The real schema names 56 color tokens (51 required, 5 optional) - you can count them yourself against the
theme JSON schema that Pi’s
themes documentation
points to. The walkthrough below uses 53 of them: it leaves out searchMatchBg,
searchMatchText, and thinkingMax, which only appear during a transcript
search or at the highest thinking level. Too many to walk one at a time, so trace the
handful you’d actually watch scroll by during the add-feed-source run from the last
lesson instead.
Here’s the slice that matters for that trace, from a stash-dark theme:
{ "name": "stash-dark", "vars": { "primary": "#e0a458", "secondary": 242 }, "colors": { "text": "", "toolSuccessBg": "#1e2e1e", "toolErrorBg": "#2e1e1e", "toolDiffAdded": "#00ff00", "toolDiffRemoved": "#ff0000" }}Two of those choices are reasoned from what stash sessions actually are, not
picked blind. primary is a warm amber (#e0a458) rather than the stock
blue-white - a backlog re-sync has you staring at this TUI for hours, and the
accent color is the thing your eye tracks most, so it might as well be one you like
living with. And toolDiffAdded / toolDiffRemoved stay a loud, saturated green
and red rather than something softer - because steps 4 and 6 of the procedure are
both diffs you’re supposed to actually read, confirming the fetch_feed change
didn’t mangle entries and that the new rows came out right. A muted diff color is
exactly how a bad parser change slips past a tired reviewer at hour six.
text is left as an empty string on purpose - it falls back to whatever your
terminal already uses, since body text is the one thing you don’t want a theme
second-guessing. toolSuccessBg and toolErrorBg are the tool-call backgrounds
you watch on every step of the procedure, since every one of them is a tool call.
Now predict before you look: swap primary from amber to something else entirely.
Of the five tokens above, how many change?
None of them. Not one of text, toolSuccessBg, toolErrorBg, toolDiffAdded,
or toolDiffRemoved references "primary" - they’re all independent literal
values. Swapping primary only moves the tokens that actually point at it, and if
you count the sample theme in Pi’s themes documentation, that’s 8: accent,
border, customMessageLabel, toolTitle, mdLink, syntaxKeyword,
syntaxOperator, thinkingMinimal. “Primary” describes what the color is for,
not how much of the theme depends on it - 45 of those 53 tokens don’t care
what you set it to.
A vars entry is a shortcut for typing one hex string in eight places, not a
dial that controls the theme.
The same walkthrough answers a bigger question: of the 53 tokens this walkthrough counts,
how many actually render during one real run of the add-feed-source procedure, and
how many just sit there? Walk the six steps. Every one of the first five is a
tool call - fetch, read, edit, pytest, sync - and the sixth is the report the
model writes from what it ran, so the five tool-status tokens (toolPendingBg,
toolSuccessBg, toolErrorBg, toolTitle, toolOutput) fire from step one. Step
4 edits stash/feeds.py and tests/test_feeds.py, and step 6 reports diffs, so
the three diff tokens fire and the nine syntax* tokens light up once the edited
files are shown on screen. Add the always-on chrome around all of that - accent,
border, borderAccent, borderMuted, text, dim, muted, userMessageBg,
userMessageText, and thinkingText (10 tokens; thinkingText is the base text
color, sitting apart from the six per-level thinking* tokens) - and exactly one
of those six levels, whichever one this session runs at. That’s 28 tokens
guaranteed to render over one run of the procedure.
15 more never render in this procedure, full stop: the three customMessage*
tokens exist for extensions that push their own message type, and nothing in this
run does that - /stash and resync_feed output is tool output; selectedBg
needs a picker menu the procedure never opens; bashMode needs you typing straight
into a shell with ! instead of describing the task in English; the standalone
success, error, and warning tokens (distinct from the tool*Bg ones already
counted) need a banner nothing in this procedure raises; the other five thinking*
levels sit dark because only one is ever active at a time; and scrollbarTrack
with scrollbarThumb color the fullscreen TUI’s scrollbar, which only matters once
the transcript outgrows the screen; this walkthrough assumes a run short enough
that it never does.
That leaves 10 - the md* tokens for headings, links, quotes, and bullet
lists - genuinely conditional on whether the model’s own final report happens to
use that formatting. Total it: 28 plus 15 plus 10 is 53. Most of a theme file isn’t
“sometimes” at all; it’s decided the moment the session starts, one way or the
other, by what kind of turn is actually running - under a fifth of the tokens are
up to the model’s prose on the day.
None of this is measured from Pi’s own telemetry - Pi doesn’t publish per-session paint counts. It’s a walk through the same six steps from the last lesson against the same 53 counted names, and you can redo it yourself against a different procedure and get a different split.
name can’t contain a forward slash and must be unique. Each color value can be a
hex string, a 256-color palette index, a vars name, or an empty string to fall
back to whatever your terminal already uses.
Pi ships three built-in themes: system, the default, which derives its colors
from your terminal’s own palette and rebuilds them when the terminal switches
between light and dark, plus dark and light. Beyond those, discovery follows the
same tiered pattern: ~/.pi/agent/themes/*.json globally, .pi/themes/*.json per
project (after trust), a package’s themes/ directory, a settings.json themes
array, or --theme <path> on the command line - and --no-themes turns the whole
discovery pass off if you just want the built-ins. Switch with /settings or
by setting "theme": "stash-dark" directly in settings.json. One nice touch
worth knowing about if you end up tweaking one: editing the file behind your
currently active custom theme hot-reloads it immediately - no restart, so you can
nudge a color and watch it land in the same terminal.
Zero of a theme’s tokens ever reach the model. They cost nothing in the window, which also means they buy nothing the model needs - a theme is a human-comfort layer, full stop, and that’s fine.
The real leverage in this module is what you built in the last lesson and what you’re about to do with it: hand it to someone else. As layers, the two price out simply: a template costs nothing until typed (no startup listing at all) and buys the end of retyping a recurring one-off prompt verbatim, entirely at your discretion - the model has no say in when it fires. A theme costs nothing in the window, ever - a rendering cost, not a token cost - and buys nothing the model needs, only comfort for the person staring at the screen.
Next: package the skill, the extensions, and the templates, and share the whole thing.