Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

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:

CategorySyntax
Literals"str", 'str', 123, 1.5, true, false, nil
Pathsident, a.b.c, a[0], a["key"]
Unarynot x, !x
Equality==, !=
Comparison<, <=, >, >= (numbers and strings)
Boolean (short-cut)and / &&, or / ||
Grouping(expr)
Filtersexpr | 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 kindTruthy?
nilfalse
falsefalse
0, 0.0false
empty/whitespace-only stringfalse
empty list / set / dictfalse
everything elsetrue

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:

FieldTypeDescription
loop.indexint1-based index of the current item
loop.index0int0-based index
loop.firstbooltrue on the first iteration
loop.lastbooltrue on the final iteration
loop.lengthinttotal 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 (nearest harn.toml ancestor).

  • @<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.prompt includes b.prompt which includes a.prompt) and produce a circular include detected error 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

FilterArgsDescription
upperUppercase the string form
lowerLowercase
trimStrip leading/trailing whitespace
capitalizeFirst char upper, rest lower
titleTitle Case (uppercase each word)
lengthNumber of items (string chars, list/set/dict entries, range size)
firstFirst element (or char)
lastLast element (or char)
reverseReversed list or string
joinsep: stringJoin list items with sep
defaultfallback: anyUse fallback when the value is falsey
jsonpretty?: boolSerialize as JSON (pass true for pretty)
indentwidth: int, first?: boolIndent every line by width spaces; pass true to indent the first line too
linesSplit string on \n into a list
escape_mdEscape Markdown special characters
replacefrom: str, to: strReplace 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_call builtin for its full call duration (covers middleware-driven re-renders during schema retry),
  • default_llm_caller for the entire handler-stack closure (so user-authored middleware that renders sees the same context), and
  • agent_loop for 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:

SectionPurpose
taskPrimary user task or instruction.
examplesFew-shot examples or demonstrations.
output_formatOutput schema or parse contract. Suppressed when structured_output_mode == "native_json" because the provider envelope carries the schema.
toolsTool definitions rendered as XML, Markdown/JSON, or a ReAct-style text contract depending on the route.
thinking_scaffoldThinking envelope for models that need one; no output for models with no thinking section style.
chain_of_thoughtA provider-shaped private-reasoning instruction.
system_framingTop-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:

CapabilityEffect
prefers_xml_scaffoldingtask, examples, and system_framing render as XML tags.
prefers_markdown_scaffoldingThose sections render as Markdown headings.
prefers_xml_toolstools renders as <tools>...</tools>.
native_toolstools renders as a Markdown/JSON description when XML tools are not preferred.
text_tool_wire_format_supportedtools renders a ReAct-style text-tool contract for local/text routes.
structured_output_modeControls output_format; native_json omits the section.
thinking_block_styleControls thinking_scaffold (none, thinking_blocks, reasoning_summary, inline).
prefers_role_developersystem_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 llm snapshot — provider, model, family, and the full capabilities map 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:

RuleWhat it catches
template-unknown-filterA 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-branchBranching 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-explosionMore 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.