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

Website: https://harnlang.com/prompt-templating.html

This page documents Harn, which is pre-1.0. Language, standard library, and CLI APIs may change. If the intended version is unclear, clarify before using this page.

---

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:

```harn,ignore
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](./docs/llm/harn-quickref.md)
has a condensed version for agents writing Harn.

## At a glance

```text
{{ 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:

```text
{{ 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

```text
{{ 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

```text
{{ 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

```text
{{ 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:

```text
{{ 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:

```text
{{ include "partials/item.prompt" with { item: x, style: "bold" } }}
```

### Package-root paths

Refactor-safe alternatives to `../../partials/foo.harn.prompt`:

```text
{{ 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:

  ```toml
  # 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](./modules.md#package-root-prompt-assets) 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

```text
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.):

```text
{{ 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:

```text
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:

```text
{{ 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:

```text
<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 (`harness.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:

```harn-prompt
{{ if llm.capabilities.native_tools }}
  Call `finish_task` when done.
{{ else }}
  When done, output: `<<DONE>>`
{{ end }}
```

The injected value is shaped:

```text
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 }}`:

```harn-prompt
{{ 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 `harness.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`:

```harn-prompt
{{ 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`.

```harn-prompt
{{ 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
(`harness.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:

| 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](#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 }}`](#logical-sections), [`{{ raw }}`](#raw-blocks),
and multi-line [`{{# #}}` comments](#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](#built-in-filters) it can apply, the [section names](#logical-sections)
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](editor-integration.md#prompt-templates).

## 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](./migrations/template-engine-v2.md).

---

## Read next

- [Project scanning](https://harnlang.com/project-scan.md)
- [Editor integration](https://harnlang.com/editor-integration.md)
