Prompt Templates
Every prompt that Overdeck 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 an xBRIEF — is built from a versioned Markdown template undersrc/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 namedissue_idat 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.
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 throwsPromptError 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 viarenderPrompt({ 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 tasks — 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 eitherrequires 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
Signature
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
Mapkeyed by name. Subsequent calls reuse the parsedParsedPrompt(frontmatter + body + resolved path). - Parse frontmatter. The leading
---YAML block is extracted viajs-yaml. Missing frontmatter, invalid YAML, or a missingname/descriptionfield all throwPromptError. - Validate
requires. Every key listed inrequiresmust be present invarsand notundefined/null. Missing keys are collected and reported in one error message. - Validate unknown keys. Every key in
varsmust be listed in eitherrequiresoroptional. 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 throwsPromptError — a subclass of Error exported from the
module. Typical messages:
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
__dirnamecontains/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.
*.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.
Fields
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
Ifvars 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.
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
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.
{{#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 boolean.
Boolean flags
{{#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 undersrc/lib/cloister/prompts/, grouped by the role they play in the pipeline.
Work agent templates
Templates that drive the work agent — the specialist that implements xBRIEF checklist items.Planning agent templates
Templates for the planning agent, the discovery-phase agent that converts a PRD or raw issue into an xBRIEF plan.Specialist agent templates
Templates for the post-work pipeline specialists (review → test → merge) that run automatically after a work agent signals completion.Bootstrap templates
Short templates used during specialist initialization — not part of any per-issue workflow.Legacy (ad-hoc) templates
Templates still on the pre-loader ad-hoc pattern. They work, but new edits should migrate torenderPrompt — see the Legacy templates section below for migration guidance.
Deleted (replaced by the loader)
These files used to live underprompts/ or were built inline by dead code.
They no longer exist:
merge-agent.md→ replaced bymerge.mdwithDO_PUSH/DO_BUILDflagsreview-agent.md→ replaced byreview.mdtest-agent.md→ replaced bytest.mduat-agent.md→ uat specialist was removed from the pipelinework-agent.md→ replaced bywork.md
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
replacechain ininspect-agent.ts), or - Migrate it to
renderPrompt, wire the migration intobuildInspectPrompt, and delete the ad-hoc replace chain.
Orchestration markers
Two templates wrap their output in HTML comments:planning.md— marks the orchestration-context block so that session summarizers and the dashboard know which part of the planning prompt is Overdeck setup vs. the agent’s actual work.inspect-agent.ts— wraps its rendered prompt in the same markers via a wrapping template literal.
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
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. Themerge.mdpush/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.
Testing prompts
Prompt tests live insrc/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, missingrequires,
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 realisticvars 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.
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) oroptional(if it’s fine to omit). Write a one-linedescription. - Write the body using
{{VAR}}for substitution and{{#VAR}}...{{/VAR}}for optional sections. - Add a caller in TypeScript:
- Add at least one smoke test in
prompts.test.ts. - Run
npx vitest run src/lib/cloister/__tests__/prompts.test.tsandnpx tsc --noEmit. - Run
npm run build— the build pipeline copies*.mdfromsrc/lib/cloister/prompts/todist/dashboard/prompts/. If you skip this step, the production dashboard will throw aPromptErrorat 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
requiresoroptional— 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>.mdtemplate 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.
Troubleshooting
”requires variables that are missing”
optional, unless the template genuinely works with it absent.
”was passed unknown variables”
“missing YAML frontmatter”
--- 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”
- Typo in
name.renderPrompt({ name: 'work-agent' })when the file iswork.md. - Missing build copy. You edited or added a template but didn’t run
npm run build, sodist/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 theprompts/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
- Implementation:
src/lib/cloister/prompts.ts— the full loader (~180 lines). - Templates:
src/lib/cloister/prompts/— the current catalogue. - Tests:
src/lib/cloister/__tests__/prompts.test.ts— loader contract tests + live smoke tests. - Build pipeline:
docs/BUILD.md— how templates are copied into the bundled dashboard. - Specialist pipeline:
docs/SPECIALIST_WORKFLOW.md— where each template sits in the lifecycle (work → review → test → merge). - Mustache.js: github.com/janl/mustache.js — canonical reference for section syntax, context fall-through, and the set of things we deliberately don’t use.