Prompt Templates
Every prompt that Panopticon sends to Claude — the work agent that implements a
feature, the review specialist that audits the diff, the merge agent that lands
a PR, the planning agent that generates a vBRIEF — is built from a versioned
Markdown template under src/lib/cloister/prompts/.
A single loader, renderPrompt at
src/lib/cloister/prompts.ts, is the only sanctioned way to turn one of
those templates into a finished prompt string. Every specialist, every handoff,
every resume call goes through it.
This page is the authoritative guide to that system: what the templates are,
how the loader works, the frontmatter contract, the full template catalogue,
and how to add, edit, or migrate a prompt without breaking production.
Why templates at all
Before the unified loader, prompts were assembled a dozen different ways —
inline template literals in TypeScript, readFileSync of raw Markdown files,
ad-hoc String.replace passes, and a pair of home-grown processIfBlocks /
processEnvBlocks helpers. Each specialist invented its own variable syntax
and its own idea of what an “optional” section looked like.
That sprawl produced three recurring failures:
- Silent typos. A variable like
{{issueId}} referenced in the template
but named issue_id at the call site rendered as the literal string
{{issueId}} in the prompt sent to Claude. No error, no warning — the
specialist just got a broken prompt and ran on bad context.
- Drift between similar templates. The merge-agent prompt used by the
full-push path and the wake-time validation path were two separate string
builders that were supposed to stay in sync and didn’t.
- No way to see what a prompt expects. Finding the variables a template
depended on meant reading the TypeScript call site line by line.
The template system fixes all three. Every template declares its variables in
YAML frontmatter; the loader validates them on every render; missing or
unknown variables throw a PromptError with the exact template path and the
list of offending keys.
Core principles
Keep these five rules in mind when you touch any prompt template.
1. Fail loud, fail early
Missing a required variable is a programming error, not a runtime condition to
recover from. The loader throws PromptError with the template path and the
list of missing keys. There is no silent fallback, no empty-string
substitution, no “render partial output and hope nobody notices”. If you see a
PromptError in a log, it means a call site is wrong and needs to be fixed —
not that the loader needs a guard.
2. One template, one API
Templates are read only via renderPrompt({ name, vars }). There is no
readFileSync on a prompt file anywhere in the codebase. If you find one,
that’s a bug — migrate it to renderPrompt.
3. Composition happens in TypeScript
Mustache is deliberately a small, logic-less templating language. The loader
disables HTML escaping globally (Mustache.escape = String) because we’re
generating prompts for an LLM, not HTML — but everything else is vanilla
Mustache with its usual section syntax.
Any non-trivial composition — choosing between two instruction variants,
joining an array of file paths, formatting a table of beads — happens in
TypeScript. The TypeScript builds a string, then passes that string to the
template as a single variable. This keeps templates readable and keeps
business logic in a place where tsc and your tests can see it.
4. Variables are declared, not discovered
Every variable used by the template’s body must appear in the frontmatter as
either requires or optional. Undeclared variables are rejected by the
loader. Undefined required variables are rejected by the loader. Both errors
point at the template path and name the offending keys.
This means reading the frontmatter tells you exactly what a template expects
— you never have to grep the body to reconstruct the contract.
5. Prompts are not docstrings
A prompt template is production code that steers an LLM’s behavior. Treat it
accordingly: prefer explicit instructions over implicit assumptions, prefer
concrete examples over abstract rules, and when you change a template, think
about what’s going to happen the first time an agent runs it against a real
workspace.
The renderPrompt API
import { renderPrompt } from './prompts.js';
const prompt = renderPrompt({
name: 'work',
vars: {
ISSUE_ID: 'PAN-742',
BRANCH: 'feature/pan-742',
// ...every required variable declared in work.md
},
});
Signature
export function renderPrompt(options: RenderPromptOptions): string;
export interface RenderPromptOptions {
name: string; // template basename without .md
vars: Record<string, unknown>; // values for required + optional vars
}
The name is the filename under src/lib/cloister/prompts/ without the
.md extension. renderPrompt({ name: 'work' }) loads work.md.
What happens on every call
- Load + cache. The file is read from disk once and cached in an in-memory
Map keyed by name. Subsequent calls reuse the parsed ParsedPrompt
(frontmatter + body + resolved path).
- Parse frontmatter. The leading
--- YAML block is extracted via
js-yaml. Missing frontmatter, invalid YAML, or a missing name /
description field all throw PromptError.
- Validate
requires. Every key listed in requires must be present in
vars and not undefined/null. Missing keys are collected and reported
in one error message.
- Validate unknown keys. Every key in
vars must be listed in either
requires or optional. Undeclared keys are collected and reported in one
error message.
- Render. The body is passed to
Mustache.render(body, vars) with HTML
escaping disabled.
Error handling
Everything throws PromptError — a subclass of Error exported from the
module. Typical messages:
Prompt "work" (.../prompts/work.md) requires variables that are missing: BRANCH, DIFFICULTY.
Provided: ISSUE_ID, VBRIEF_PATH
Prompt "review" (.../prompts/review.md) was passed unknown variables: DEBUG_MODE.
Declare them in the template's "requires" or "optional" frontmatter, or remove them from the call site.
Prompt template "work" at .../prompts/work.md is missing YAML frontmatter (expected "---\n<yaml>\n---\n<body>" header).
Failed to load prompt template "work" from .../prompts/work.md: ENOENT: no such file or directory
Do not catch these. They indicate bugs in call sites or templates that must
be fixed, not runtime conditions. The fail-loud posture is deliberate — if a
call site is allowed to silently skip a required variable, the resulting
prompt silently loses context and the agent silently does the wrong thing.
Cache invalidation
clearPromptCache() is exported for test isolation. Production code never
calls it — templates are bundled at build time and don’t change during a
dashboard’s lifetime.
Prompts directory resolution
resolvePromptsDir() looks for prompts/ in two places, in order:
join(__dirname, 'prompts') — the expected location in both dev
(src/lib/cloister/prompts) and production (dist/dashboard/prompts).
- If
__dirname contains /src/, the resolver strips everything from
/src/ onward and re-anchors to <package>/src/lib/cloister/prompts.
This is the tsx dev-mode fallback for cases where the module is imported
from a nested path that has been re-exported through multiple barrel files.
See BUILD.md
for how the build pipeline copies *.md from source to dist/dashboard/prompts/.
Frontmatter contract
Every template starts with a YAML frontmatter block delimited by ---. The
loader enforces this structure — a missing or malformed block throws
PromptError before a single byte of the body is read.
---
name: work
description: Work-agent prompt — implementation-phase instructions, per-bead workflow, finalization steps.
requires:
- ISSUE_ID
- ISSUE_TITLE
- BRANCH
- VBRIEF_PATH
- DIFFICULTY
optional:
- SPEC_SECTION
- POLYREPO_HEADER
- TRACKER_CONTEXT
---
Body of the prompt starts here...
Fields
| Field | Type | Required | Purpose |
|---|
name | string | yes | Must match the filename (without .md). Used for error messages and cross-references. |
description | string | yes | One-line summary shown in catalogues and reference docs. Write it for the reader who’s scanning a list of templates, not for the one already inside the file. |
requires | string array | no (defaults to []) | Variables that MUST be provided in vars. A missing or undefined/null value throws PromptError. |
optional | string array | no (defaults to []) | Variables that MAY be provided. Rendering with an optional variable omitted is fine — the corresponding Mustache section simply renders empty. |
requires vs optional
The rule of thumb:
- If the template’s output would be broken or misleading without the
variable, list it as
requires.
- If the variable is genuinely a “fill this in if you have it, leave the
section out otherwise” case, list it as
optional.
requires is stricter than you might think: passing an empty string
("") satisfies the check — only undefined and null fail. This is
intentional. An empty-string CURRENT_PHASE in the resume prompt means
“STATE.md had no phase”, not “the caller forgot to compute one”. The
caller is the one who decides whether “empty” is a legitimate value.
Unknown keys are an error
If vars contains a key that isn’t listed in requires or optional, the
loader refuses to render. This catches two classes of bug:
- You renamed a template variable and forgot to update the call site.
- You added a new variable to the call site and forgot to declare it in
the frontmatter.
Both show up as a single clear error message with the offending keys.
Mustache syntax reference
We use Mustache.js v4. HTML escaping is
disabled globally, so {{VAR}} and {{&VAR}} behave identically and you
never need to worry about < or & showing up in prompts.
Variable substitution
Issue: {{ISSUE_ID}}
Branch: {{BRANCH}}
Truthy sections
Render the enclosed block if the variable is truthy (non-empty string, true,
a non-empty array, an object). Empty strings and false hide the block.
{{#USER_MESSAGE}}
## Operator Message
{{USER_MESSAGE}}
{{/USER_MESSAGE}}
Note the pattern {{#VAR}}{{VAR}}{{/VAR}} — that’s Mustache context
fall-through: inside a truthy section, the variable itself is still
accessible in the parent scope and gets substituted normally. This is how the
resume prompt hides entire headings when their content is empty.
Inverted sections
Render if the variable is falsy/empty.
{{^DO_PUSH}}
Do NOT push to main — you are validating only.
{{/DO_PUSH}}
The merge template uses inverted sections extensively to toggle between
validation-only and full-push flows from a single DO_PUSH boolean.
Boolean flags
renderPrompt({
name: 'merge',
vars: {
// ...
DO_PUSH: true,
DO_BUILD: true,
SKIP_DONE_REPORT: false,
},
});
Booleans work as expected inside {{#VAR}} and {{^VAR}}. They are the
cleanest way to toggle whole sections of a template based on a flow
decision made in TypeScript.
No partials, no lambdas, no custom helpers
Mustache supports partials and lambdas; we don’t use them. Composing from
smaller template fragments would push the contract across multiple files;
lambdas would put logic back into the template. Both defeat the point of
having a declared contract per file.
If you find yourself reaching for a helper, the right move is almost always
to do the work in TypeScript and pass the result in as a pre-built string
variable.
Template catalogue
The ten templates currently under src/lib/cloister/prompts/, grouped by the role they play in the pipeline.
Work agent templates
Templates that drive the work agent — the specialist that actually writes code against beads tasks.
| Template | Used by | Purpose |
|---|
work.md | Work agent (spawnSpecialist, buildWorkAgentPrompt) | Implementation instructions: per-bead loop, commit + update STATE.md + bd close, quality gates, finalization. |
resume-work.md | buildResumePrompt | Context-rich restart message sent via claude --resume when a work agent is woken up from a stopped session. Includes STATE.md snapshot, open beads, pending feedback, and next-step instructions. |
handoff-to-work.md | buildHandoffPrompt | Model-to-model handoff: wraps a serialized HandoffContext (git state, beads, STATE.md, AI summaries) with a preamble explaining who took over and why. |
Planning agent templates
Templates for the planning agent, the discovery-phase agent that converts a PRD or raw issue into a vBRIEF plan.
| Template | Used by | Purpose |
|---|
planning.md | Planning agent (buildPlanningPrompt) | Discovery session: explore codebase, ask questions via AskUserQuestion, generate plan.vbrief.json + STATE.md, run pan plan-finalize. NEVER implements code. |
Specialist agent templates
Templates for the post-work pipeline specialists (review → test → merge) that run automatically after a work agent signals completion.
| Template | Used by | Purpose |
|---|
review.md | Review specialist (wake path) | Strict code review against vBRIEF acceptance criteria; pass/fail decision posted back to the dashboard. |
test.md | Test specialist (wake path) | Runs the project’s test suite, compares against baseline, posts pass/fail with structured markers. |
merge.md | Merge specialist (wake path) and spawnMergeAgentForBranches (full push flow) | Unified merge template. DO_PUSH/DO_BUILD/SKIP_DONE_REPORT booleans toggle between validation-only and full push + build + report flows. |
sync-main.md | syncMainIntoWorkspace (via merge specialist) | Sync latest main into the workspace’s feature branch; abort on conflict and hand back to work agent. |
Bootstrap templates
Short templates used during specialist initialization — not part of any per-issue workflow.
| Template | Used by | Purpose |
|---|
identity-wake.md | initializeSpecialist | Short identity prompt — tells a newly-initialized specialist who it is and to wait for tasks. |
Legacy (ad-hoc) templates
Templates still on the pre-loader ad-hoc pattern. They work, but new edits should migrate to renderPrompt — see the Legacy templates section below for migration guidance.
| Template | Used by | Purpose |
|---|
inspect-agent.md | Inspect specialist (buildInspectPrompt) | Per-bead spec verification mid-implementation. Still uses the legacy readFileSync + String.replace path. |
Deleted (replaced by the loader)
These files used to live under prompts/ or were built inline by dead code.
They no longer exist:
merge-agent.md → replaced by merge.md with DO_PUSH/DO_BUILD flags
review-agent.md → replaced by review.md
test-agent.md → replaced by test.md
uat-agent.md → uat specialist was removed from the pipeline
work-agent.md → replaced by work.md
If you find references to any of those paths in comments, PRD docs, or old
commits, they are stale.
Legacy templates
inspect-agent.md still uses the pre-loader pattern: inspect-agent.ts
reads it via readFileSync and does ad-hoc String.replace for a fixed set
of {{variable}} placeholders. It also wraps its output in the
orchestration markers that the dashboard uses to collapse context.
It works and it’s in active use, so it hasn’t been migrated. If you’re
editing it, either:
- Keep using the ad-hoc pattern (add your new variable, update the
replace chain in inspect-agent.ts), or
- Migrate it to
renderPrompt, wire the migration into
buildInspectPrompt, and delete the ad-hoc replace chain.
Option 2 is strongly preferred for any non-trivial change, but a small
one-line tweak can stay on the legacy path.
Orchestration markers
Two templates wrap their output in HTML comments:
<!-- panopticon:orchestration-context-start -->
...template body...
<!-- panopticon:orchestration-context-end -->
planning.md — marks the orchestration-context block so that session
summarizers and the dashboard know which part of the planning prompt is
Panopticon setup vs. the agent’s actual work.
inspect-agent.ts — wraps its rendered prompt in the same markers via a
wrapping template literal.
Do not add these markers to new templates unless you have a specific
dashboard-display reason and you’ve checked the code that consumes them. For
the vast majority of prompts, unmarked Markdown is the right shape.
Composition pattern
Most non-trivial templates use a pattern we call pre-built blocks: the
TypeScript call site composes a multi-line Markdown string based on runtime
state, and passes that string in as a single optional variable. The template
then conditionally renders the block via a Mustache section.
Example: resume-work.md
{{#PENDING_FEEDBACK_BLOCK}}
{{PENDING_FEEDBACK_BLOCK}}
{{/PENDING_FEEDBACK_BLOCK}}
And the call site:
const pendingFeedbackBlock = ctx.pendingFeedback.length > 0
? `## Pending Feedback (ACTION REQUIRED)\n\nThese feedback files exist in \`.planning/feedback/\` — read and address them:\n${ctx.pendingFeedback.map(f => `- \`.planning/feedback/${f}\``).join('\n')}`
: '';
return renderPrompt({
name: 'resume-work',
vars: {
// ...
PENDING_FEEDBACK_BLOCK: pendingFeedbackBlock,
// ...
},
});
When pendingFeedbackBlock is non-empty, the section renders; when it’s
empty, the section disappears entirely. The template stays readable (no
nested conditionals, no formatting logic) and the TypeScript stays honest
about what the block looks like.
When to use in-template conditionals vs pre-built blocks
- In-template sections (
{{#VAR}}...{{/VAR}}) are right when the block
is a fixed, static chunk of instructions that’s either present or absent
based on a boolean flag. The merge.md push/no-push toggles are a good
example: the alternative paths are both long, both fully specified in the
template, and both known at template-authoring time.
- Pre-built blocks are right when the block’s content is dynamic — it
joins a list, formats a table, runs git commands, reads STATE.md — and
the template’s job is just to decide whether to include it. The
resume-work feedback list is a good example: the TypeScript knows how to
format it, the template just needs to show or hide it.
If you’re torn, default to pre-built blocks. It’s always easier to read a
template that delegates dynamic content to the call site than one that
tries to be clever.
Testing prompts
Prompt tests live in src/lib/cloister/__tests__/prompts.test.ts. They fall
into three categories:
1. Loader contract tests
These test the loader itself against small inline fixture templates created
in a temp directory: missing frontmatter, invalid YAML, missing requires,
unknown vars, empty strings as requires, Mustache section rendering, etc.
They exercise every error path in renderPrompt and every branch of the
frontmatter parser.
2. Live-template smoke tests
These render the real templates against realistic vars fixtures and assert
that key strings appear (or don’t appear) in the output. The merge.md
tests are a good example: four tests covering the push+build flow, the
validation-only flow, the SKIP_DONE_REPORT branch, and the polyrepo
header.
Smoke tests are load-bearing — they catch cases where someone edits a
template in a way that looks fine to Mustache but breaks a downstream
specialist’s expectations.
3. Caller-side tests
Tests that verify TypeScript call sites pass the right variables, live
alongside the calling module (e.g., build-test-prompt.test.ts for the
test-specialist dispatch path). These catch the “call site got out of sync
with the frontmatter” class of bug.
Writing a new smoke test
When you add a template, add at least one smoke test that:
- Renders the template with a realistic set of
vars.
- Asserts the presence of fixed strings that the template always produces.
- Asserts the presence / absence of conditional blocks for each branch the
template can take.
Keep the fixture vars small and local to the test — no shared state, no
global setup. The loader is pure, so tests can be dead simple.
Authoring workflow
Adding a new template
- Create
src/lib/cloister/prompts/<name>.md.
- Write the frontmatter first. List every variable you’ll reference in the
body under
requires (if it must be provided) or optional (if it’s
fine to omit). Write a one-line description.
- Write the body using
{{VAR}} for substitution and {{#VAR}}...{{/VAR}}
for optional sections.
- Add a caller in TypeScript:
import { renderPrompt } from './prompts.js';
const prompt = renderPrompt({
name: '<name>',
vars: { /* every required variable */ },
});
- Add at least one smoke test in
prompts.test.ts.
- Run
npx vitest run src/lib/cloister/__tests__/prompts.test.ts and
npx tsc --noEmit.
- Run
npm run build — the build pipeline copies *.md from
src/lib/cloister/prompts/ to dist/dashboard/prompts/. If you skip
this step, the production dashboard will throw a PromptError at
runtime.
Editing an existing template
- Decide whether the change is contract-breaking: does it add a new
required variable, remove one, or change the meaning of an existing
one? If yes, update every call site in the same commit.
- Update the frontmatter. Adding a new variable means adding it to
requires or optional — the loader will refuse to render until you
do.
- Update the body.
- Update or add smoke tests.
- Run typecheck + prompts tests.
Migrating a legacy ad-hoc prompt
If you find a call site that builds a prompt via template literals,
readFileSync + ad-hoc replace, or any other pre-loader pattern, the
migration path is:
- Create the new
prompts/<name>.md template with frontmatter that
matches the variables the ad-hoc code was substituting.
- Replace the ad-hoc builder with a single
renderPrompt({ name, vars })
call.
- If the old code had conditional logic (e.g., “if foo, include bar”),
decide per case: simple toggles become Mustache sections, dynamic
content becomes a pre-built block variable.
- Delete the ad-hoc builder, the old template file if there was one,
and any helper functions that existed only to support it.
- Add smoke tests.
- Run typecheck + prompts tests.
The typical commit layout is: template + loader call + tests + deletion
of the old code, all in one PR. The loader’s fail-loud validation makes
it very hard to leave a half-migrated path in place — either the call
site works or it throws.
Troubleshooting
”requires variables that are missing”
Prompt "merge" (.../prompts/merge.md) requires variables that are missing: TARGET_BRANCH.
Provided: ISSUE_ID, SOURCE_BRANCH, PROJECT_PATH, DO_PUSH, DO_BUILD, API_URL
The call site isn’t passing a variable the template declares as required.
Fix the call site — don’t “fix” the template by moving the variable to
optional, unless the template genuinely works with it absent.
”was passed unknown variables”
Prompt "review" (.../prompts/review.md) was passed unknown variables: DEBUG_MODE.
Declare them in the template's "requires" or "optional" frontmatter, or remove them from the call site.
Either the call site is passing a variable that the template no longer
uses (delete it from the call site), or the variable is new and hasn’t
been declared yet (add it to the frontmatter).
“missing YAML frontmatter”
Prompt template "<name>" at .../<name>.md is missing YAML frontmatter (expected "---\n<yaml>\n---\n<body>" header).
The template file doesn’t start with a --- frontmatter block. This
happens when someone deletes the frontmatter by accident, or pastes a
body-only template from an old pattern. Add the frontmatter.
”Failed to load prompt template”
Failed to load prompt template "work" from .../prompts/work.md: ENOENT: no such file or directory
Three likely causes:
- Typo in
name. renderPrompt({ name: 'work-agent' }) when the file
is work.md.
- Missing build copy. You edited or added a template but didn’t run
npm run build, so dist/dashboard/prompts/ is stale. Rebuild.
- Wrong working directory. The dashboard server resolves prompts
relative to
dist/dashboard/ — if you’re running a script from some
other location and it imports the loader, double-check that the
prompts/ directory actually exists where the loader expects it.
A template renders the literal {{VAR}} instead of a value
Mustache left the variable in place because nothing in vars matched that
name. The loader would normally throw an “unknown variables” error if the
variable was passed but undeclared — if you’re seeing literal braces in the
output, the variable isn’t being passed at all and the template doesn’t
list it in requires.
Either add the variable to requires (to force the call site to provide
it) or fix the call site to pass it.
A Mustache section renders when it shouldn’t
Check whether the value you’re passing is actually falsy by Mustache’s
rules. Empty string: falsy. 0: falsy. false: falsy. null /
undefined: falsy. An empty array: falsy. An empty object: truthy —
the section will render. This trips people up when they pass
{ some: 'object' } expecting the section to hide.
If you want a section to hide on “nothing to say”, pass an empty string,
not an empty object.
Further reading