Skip to content

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 failing
argument-hint: "[since]"
---
Look at stash's failed feed syncs ${1:-in the last 24 hours}. For each
failed feed, report: the feed URL, the failure class (HTTP 403 or 429,
timeout, XML parse error, or zero entries parsed), and one recommended
next step (retry later, back off and slow the sync loop, fix
fetch_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.