Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Prompt templating

Harn ships a small template language for rendering .harn.prompt and .prompt asset files. It is invoked by the render(path, bindings?) and render_prompt(path, bindings?) builtins (and, equivalently, via the template.render host capability). 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.

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 } }}
{{# 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" }}

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" } }}

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>.

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.

Preflight checks

harn check parses every template referenced by a literal render(...) / render_prompt(...) call and surfaces syntax errors before you run the pipeline. Catches things like an unterminated {{ for }} block at static time 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.