Prompt templating
Harn ships a small template language for rendering .harn.prompt and .prompt
asset files or inline template strings. It is invoked by the
harness.fs.render_prompt(path, bindings?) and
harness.fs.render_template(template, bindings?) methods. Use
harness.fs.render_prompt_with_provenance(...) when the caller also needs the
resolved asset provenance. The engine is intentionally minimal—a rendering
layer for prompts, not a scripting language—but it covers the ergonomics most
prompt authors reach for: conditionals with else/elif, loops, includes,
filters, comments, and whitespace control.
Use harness.fs.render_template(...) when the template should live inline in
source. Use harness.fs.render_prompt(...) when the template should be loaded
from a separate file relative to the calling module. The template syntax and
error shape are identical across both entrypoints.
Under an active execution policy, file-backed templates and included templates
must stay within the policy's workspace_roots; embedded std/... prompt
assets do not touch the filesystem.
Stdlib-owned model-facing prompt prose lives in embedded std/...harn.prompt
assets. These render without touching the filesystem and report stable
std://... provenance URIs:
const tool_contract = harness.fs.render_prompt("std/agent/prompts/tool_contract_text.harn.prompt", {})
Protected Rust orchestration paths are checked by make lint-no-rust-prompt-prose;
move reusable model-facing instructions into stdlib prompt assets unless the
Rust literal is a short diagnostic, parser/protocol token, provider API string,
or other primitive constant.
This page is the reference. The one-page quickref has a condensed version for agents writing Harn.
At a glance
{{ name }} interpolation
{{ user.name }} / {{ items[0] }} nested path access
{{ name | upper | default: "anon" }} filter pipeline
{{ if expr }} ... {{ elif expr }} ... {{ else }} ... {{ end }}
{{ for item in xs }} ... {{ else }} ... {{ end }} else = empty-iterable fallback
{{ for key, value in dict }} ... {{ end }}
{{ include "partial.harn.prompt" }}
{{ include "partial.harn.prompt" with { x: name } }}
{{ section "task" }} ... {{ endsection }}
{{# stripped at parse time #}}
{{ raw }} ... literal {{braces}} ... {{ endraw }}
{{- name -}} whitespace-trim markers
Interpolation
{{ path }} evaluates an expression and writes its string form into the
output. Paths support nested field access and integer/string indexing:
{{ user.name }} — field
{{ user.tags[0] }} — list index
{{ user.tags[-1] }} — negative index (counts from end)
{{ config["api-key"] }} — string key with non-identifier characters
Missing values render as the empty string, except for legacy bare
identifiers (e.g. {{ name }} with no dots/brackets/filters). For
back-compat, those render their source verbatim on a miss (the pre-v2
behavior), so existing templates that relied on "missing → literal passthrough"
keep working.
Conditionals
{{ if role == "admin" }}
welcome, admin
{{ elif role == "user" and active }}
welcome back!
{{ else }}
please sign in
{{ end }}
Only {{ if expr }} ... {{ end }} is required; elif and else branches are
optional and can be combined. The expression grammar is:
| Category | Syntax |
|---|---|
| Literals | "str", 'str', 123, 1.5, true, false, nil |
| Paths | ident, a.b.c, a[0], a["key"] |
| Unary | not x, !x |
| Equality | ==, != |
| Comparison | <, <=, >, >= (numbers and strings) |
| Boolean (short-cut) | and / &&, or / || |
| Grouping | (expr) |
| Filters | expr | filter, expr | filter: arg1, arg2 |
String escapes inside quoted literals: \n, \t, \r, \\, \", \'.
Truthiness
Used both by if and by the short-circuit and/or:
| Value kind | Truthy? |
|---|---|
nil | false |
false | false |
0, 0.0 | false |
| empty/whitespace-only string | false |
| empty list / set / dict | false |
| everything else | true |
Loops
{{ for x in xs }}
- {{ loop.index }}. {{ x }}
{{ else }}
(no items)
{{ end }}
{{ else }} inside a for block renders when the iterable is empty — a
cleaner alternative to wrapping the loop in an {{ if }}.
Loop variables
Inside the loop body, a synthetic loop dict is in scope:
| Field | Type | Description |
|---|---|---|
loop.index | int | 1-based index of the current item |
loop.index0 | int | 0-based index |
loop.first | bool | true on the first iteration |
loop.last | bool | true on the final iteration |
loop.length | int | total number of items |
Dict iteration
{{ for key, value in my_dict }}
{{ key }} = {{ value }}
{{ end }}
Dicts iterate in their canonical (BTreeMap) order.
Includes
Include another template file. Paths resolve relative to the including file's directory:
{{ include "partials/header.harn.prompt" }}
Embedded stdlib prompts can include other embedded stdlib prompts with either
relative paths or explicit std/... paths. Relative stdlib includes stay inside
the embedded stdlib prompt catalog rather than escaping to the filesystem.
The included template inherits the parent's scope by default. Pass explicit
bindings with with { ... } — these are merged into the parent scope for the
inner render only:
{{ include "partials/item.prompt" with { item: x, style: "bold" } }}
Package-root paths
Refactor-safe alternatives to ../../partials/foo.harn.prompt:
{{ include "@/prompts/header.harn.prompt" }}
{{ include "@partials/header.harn.prompt" }}
-
@/<rel>— anchored at the calling file's project root (nearestharn.tomlancestor). -
@<alias>/<rel>— anchored at an entry in the project's[asset_roots]table:# harn.toml [asset_roots] partials = "src/prompts/partials"
These forms work in both the runtime template engine and the
harness.fs.render_prompt(...) builtins. harn check validates
them against harn.toml. See
modules.md for the full
reference.
Safety:
- Circular includes are detected (e.g.
a.promptincludesb.promptwhich includesa.prompt) and produce acircular include detectederror with the full chain. - Include depth is capped at 32 levels.
- A missing included file fails with
failed to read included template <path>. @/...and@<alias>/...reject..segments and absolute targets; package-rooted assets cannot escape the project root.
Comments
Before{{# this never renders #}}After
Comments are stripped entirely at parse time. Use them to document a template without leaking the note into the final prompt.
Raw blocks
When a prompt needs to emit literal {{ / }} (say, the prompt includes
another template language, JSON with braces, etc.):
{{ raw }}
{{ this is output verbatim }}
{{ endraw }}
Everything between {{ raw }} and {{ endraw }} is passed through as-is,
no directive interpretation.
Whitespace control
Directives support {{- ... -}} trim markers (Jinja-style). A leading -
strips the preceding whitespace and one newline; a trailing - strips the
following whitespace and one newline. This is the idiomatic way to keep
templates readable without emitting extra blank lines:
Items:
{{- for x in xs -}}
{{ x }},
{{- end -}}
DONE
renders Items: a, b, c,DONE — no leading or trailing newlines introduced
by the control directives.
Filters
Apply transformations to a value via a pipeline. Filters can be chained and some accept arguments after a colon:
{{ items | join: ", " }}
{{ name | upper }}
{{ user.bio | default: "(no bio)" | indent: 4 }}
Built-in filters
| Filter | Args | Description |
|---|---|---|
upper | — | Uppercase the string form |
lower | — | Lowercase |
trim | — | Strip leading/trailing whitespace |
capitalize | — | First char upper, rest lower |
title | — | Title Case (uppercase each word) |
length | — | Number of items (string chars, list/set/dict entries, range size) |
first | — | First element (or char) |
last | — | Last element (or char) |
reverse | — | Reversed list or string |
join | sep: string | Join list items with sep |
default | fallback: any | Use fallback when the value is falsey |
json | pretty?: bool | Serialize as JSON (pass true for pretty) |
indent | width: int, first?: bool | Indent every line by width spaces; pass true to indent the first line too |
lines | — | Split string on \n into a list |
escape_md | — | Escape Markdown special characters |
replace | from: str, to: str | Replace all occurrences |
Unknown filters raise a clear error at render time.
Errors
On any parse or render error, the engine raises a thrown value (via
VmError::Thrown) with a message of the form:
<template-path> at <line>:<col>: <what went wrong>
Typical cases:
unterminated directive— a{{without a matching}}.unterminated comment— a{{#without a matching#}}.unterminated \{{ raw }}` block— missing{{ endraw }}`.unknown filter \foo`` — the named filter isn't registered.circular include detected: a.prompt → b.prompt → a.prompt.include path must be a string—{{ include }}target wasn't a string.unknown template section \foo`—{{ section "foo" }}` is not one of the built-in logical sections.{{ section }}` missing matching `{{ endsection }}— a logical section was not closed.
The llm scope
When harness.fs.render_prompt() / harness.fs.render_template() is invoked from
inside an LLM-aware frame (llm_call, the default handler stack, or
agent_loop), the engine auto-injects a reserved llm binding so a
single logical template can adapt its wire envelope per provider
without manual option threading:
{{ if llm.capabilities.native_tools }}
Call `finish_task` when done.
{{ else }}
When done, output: `<<DONE>>`
{{ end }}
The injected value is shaped:
llm = {
provider: "anthropic", // resolved provider name
model: "claude-3-5-sonnet", // resolved model id
family: "anthropic-claude", // canonical review-diversity family token
capabilities: { ... }, // result of harness.llm.provider_capabilities()
}
family is a normalized token such as anthropic-claude,
openai-gpt, google-gemini, qwen, llama, mistral, or
deepseek, derived from catalog metadata with a provider-alias
fallback. Branch on family for human-readable identity checks;
branch on capabilities.* for wire-format adaptation (native vs
text-format tools, prompt caching, structured output, etc.).
Bare harness.fs.render_prompt() calls outside any LLM frame leave llm = nil, so the
same template works in CI / doc-gen contexts as long as it guards with
{{ if llm }}:
{{ if llm }}
Targeting {{ llm.provider }}/{{ llm.model }} — using
{{ if llm.capabilities.native_tools }}native{{ else }}text{{ end }} tools.
{{ else }}
Generic prompt — no model selected.
{{ end }}
The frame is published by:
- the native
llm_callbuiltin for its full call duration (covers middleware-driven re-renders during schema retry), default_llm_callerfor the entire handler-stack closure (so user-authored middleware that renders sees the same context), andagent_loopfor the whole loop, so every per-turn system-prompt partial (loop contract, native-tool contract, skill catalog, …) renders against the active provider/model.
User bindings that already supply an llm key are left untouched —
the engine emits a one-shot lint warning under the
template.llm_scope category and the user's value wins for
back-compat. Rename your key (e.g. local_model, current_llm) to
clear the warning.
Logical sections
Logical sections encode the role of a prompt chunk and let the renderer choose
the wire envelope from llm.capabilities:
{{ section "task" }}
Build a CLI that prints a directory tree.
{{ endsection }}
{{ section "output_format" schema=output_schema }}{{ endsection }}
{{ section "tools" tools=tool_list }}{{ endsection }}
The built-in section names are:
| Section | Purpose |
|---|---|
task | Primary user task or instruction. |
examples | Few-shot examples or demonstrations. |
output_format | Output schema or parse contract. Suppressed when structured_output_mode == "native_json" because the provider envelope carries the schema. |
tools | Tool definitions rendered as XML, Markdown/JSON, or a ReAct-style text contract depending on the route. |
thinking_scaffold | Thinking envelope for models that need one; no output for models with no thinking section style. |
chain_of_thought | A provider-shaped private-reasoning instruction. |
system_framing | Top-level persona or policy framing; uses developer-role wording when prefers_role_developer is true. |
Section arguments use name=value pairs. output_format accepts schema=...;
tools accepts tools=.... Section bodies can contain normal template
directives, including if, for, and include.
{{ section "examples" }}
{{ for ex in examples }}
- Input: {{ ex.input }}
- Output: {{ ex.output }}
{{ end }}
{{ endsection }}
Capability dispatch is feature-based, not provider-string-based:
| Capability | Effect |
|---|---|
prefers_xml_scaffolding | task, examples, and system_framing render as XML tags. |
prefers_markdown_scaffolding | Those sections render as Markdown headings. |
prefers_xml_tools | tools renders as <tools>...</tools>. |
native_tools | tools renders as a Markdown/JSON description when XML tools are not preferred. |
text_tool_wire_format_supported | tools renders a ReAct-style text-tool contract for local/text routes. |
structured_output_mode | Controls output_format; native_json omits the section. |
thinking_block_style | Controls thinking_scaffold (none, thinking_blocks, reasoning_summary, inline). |
prefers_role_developer | system_framing targets developer instructions instead of system instructions. |
Variant resolution in transcripts
Every harness.fs.render_prompt() call made under an LLM-aware frame
(llm_call, default_llm_caller, agent_loop) emits a
template.render event into the run's llm_transcript.jsonl. The event
captures:
- The resolved
llmsnapshot —provider,model,family, and the fullcapabilitiesmap at render time. - A branch trace listing every
{{ if }}/{{ elif }}/{{ else }}decision and every{{ section }}envelope that fired, anchored to source line + column. - The template URI and a stable content hash (
template_revision_hash) so replay can detect "the rendered text matches but the source drifted underneath" drift.
The portal renders this as a "Variant resolution" panel in the run
detail view. Renders outside any LLM frame (doc-gen, CI) emit no event.
The trace is deterministic — the same llm snapshot and the same
bindings always produce the same trace, which is what makes replay
reproducible.
Drift-prevention lints
harn lint walks .harn.prompt (and bare .prompt) files alongside
.harn programs and enforces three prompt-specific rules:
| Rule | What it catches |
|---|---|
template-unknown-filter | A filter name the engine does not implement. Near-miss names such as uppr suggest and can fix the closest built-in filter. |
template-provider-identity-branch | Branching directly on llm.provider, llm.model, or llm.family. The diagnostic suggests the corresponding capability flag (e.g. provider == "anthropic" → llm.capabilities.prefers_xml_scaffolding). |
template-variant-explosion | More than N capability-aware conditionals in a single template. Default N=3, configurable via [lint] template_variant_branch_threshold in harn.toml. |
Both rules can be disabled per-file via the standard [lint] disabled
list. Lifting capability branches into logical sections
is the recommended way to silence variant-explosion without raising the
threshold — the section's envelope dispatch lives in one shared place
instead of being scattered across the prompt.
In your editor
harn-lsp serves .harn.prompt and .prompt documents directly, so the
rules above show up as you type rather than only on harn lint. Prompt
documents are parsed by the template engine and never by the Harn parser.
Alongside diagnostics, the server folds {{ if }} / {{ elif }} / {{ else }},
{{ for }}, {{ section }}, {{ raw }},
and multi-line {{# #}} comments — from the same parse the
renderer uses, so folds always match the real block structure.
Completion and hover read the same tables the engine dispatches on: the
filters it can apply, the section names
it accepts, and the directive keywords it parses. One consequence is worth
stating plainly — {{ endif }} and {{ endfor }} do not exist, so they are
never suggested. Both {{ if }} and {{ for }} close with {{ end }}; writing
{{ endif }} is not an error but a bare variable lookup, which renders as the
literal text and leaves a block silently unclosed.
Inside a {{ for }} body, the names the loop binds are offered as completions.
Nothing else is: a template's other names come from the bindings its caller
passes at render time, which the editor cannot see.
See Editor integration.
Preflight checks
harn check parses every template referenced by a literal harness.fs.render_prompt(...) /
harness.fs.render_prompt(...) call and surfaces syntax errors before you run the
pipeline. harness.fs.render_template(...) is available to the checker as a builtin, but
its template body is evaluated at runtime because it comes from normal string
expressions rather than a source-relative asset path. This catches things like
an unterminated {{ for }} block at static time for file-backed templates,
rather than at first render.
Back-compat
The engine is a strict superset of the pre-v2 syntax:
{{ name }}— interpolation, missing bare identifier passes through verbatim{{ if key }} ... {{ end }}— truthy test
All pre-v2 templates render identically. Migrating awkward workarounds to the new forms is optional but usually shorter — see the migration guide.