jinja · not ninja · prompt internals

The ninja Jinja template
your agent hides
from the model.

When opencode, Claude Code, vLLM or llama.cpp call an LLM, your neat messages: [] array does not reach the model. A Jinja template flattens it into one long string of text — special tokens, role markers, tool schemas and all. That string is what the model reads. Get it wrong and the model degrades, hallucinates tools, never stops talking… or the server just returns an HTTP error.

3
delimiter types
1
flat string the model sees
400
HTTP status when a template raises (500 on llama.cpp)
0
Jinja on Anthropic's own Messages API path
apply_chat_template() — live
template · chat_template.jinja
rendered · what the model actually reads
01 — the engine

What Jinja actually is

Jinja (usually shipped as the jinja2 Python package, part of the Pallets project) is a plain text templating engine. It knows nothing about LLMs. It does one thing:

template string + context dict rendered string

Text in, text out. That's the whole contract.

a Why an LLM stack needs it

Chat models are trained on a fixed textual dialogue format — ChatML's <|im_start|>, Llama's <|start_header_id|>, Mistral's [INST]. The API you call accepts structured JSON. Something must translate [{role, content}]"…one flat string…". For Hugging Face models that something is a Jinja template stored with the checkpoint. Since transformers v4.44 the built-in default chat template was removed — if a tokenizer doesn't define one, you must supply it or chat requests fail outright.

b Where the template physically lives

# Hugging Face repo
tokenizer_config.json  →  "chat_template"
chat_template.jinja    # newer, standalone file

# GGUF metadata (llama.cpp / LM Studio)
tokenizer.chat_template = "{%- set …"

# serving runtimes
vLLM / TGI  → jinja2 (Python)
llama.cpp   → minja (C++ engine, --jinja)
Ollama      → Go text/template in Modelfile

c The two different "templates" people conflate

This is the source of 90% of the confusion when someone says "the Jinja template broke my agent":

1 · chat template Model-side. Flattens messages/tools into the exact string the model was fine-tuned on. Lives with the checkpoint. Applied by llama.cpp / vLLM / TGI / LM Studio.
2 · prompt template App-side. The agent's own system prompt / command files with holes in them. Claude Code assembles its system prompt in TypeScript (conditional sections, env block, tool list); opencode uses markdown agent/command files plus TS assembly. LangChain exposes the choice explicitly: PromptTemplate(template_format="jinja2") or PromptTemplate.from_template(t, template_format="jinja2") — the accepted formats are f-string, mustache and jinja2.

d Anthropic's API?

Talking to api.anthropic.com, Claude Code sends a structured messages array plus a single system parameter (a string or an array of content blocks). There is no user-visible Jinja on that path — formatting happens inside Anthropic's serving stack. Jinja enters the picture the moment the same agent points at an open model behind llama.cpp, vLLM, TGI or LM Studio.

📊 So — do all runtimes use Jinja? No. Here's the honest matrix.

"Jinja everywhere" is a common overstatement. The engine depends on the runtime:

Runtime / APITemplate engineNotes
Hugging Face transformersJINJA2tokenizer.apply_chat_template(...); no default template since v4.44.
vLLM / TGIJINJA2Serves the repo's chat_template; override with --chat-template. Without one, chat requests error.
llama.cpp (llama-server --jinja)MINJAA C++ Jinja subset (common/jinja). Without --jinja it falls back to built-in C++ formats for known model families.
LM StudioJINJAPrompt template can be expressed in Jinja; imported from GGUF metadata.
OllamaGO TEMPLATEModelfile TEMPLATE uses Go text/template syntax ({{ .System }}), not Jinja. Newer Ollama releases added optional Jinja support, but the Modelfile default is Go.
Anthropic Messages APINONEStructured system + messages. Formatting is internal to Anthropic.
OpenAI Chat Completions APINONEStructured messages; server-side formatting is not exposed.
LangChain (app layer)OPTIONALDefault is f-string; opt into jinja2 or mustache per template.
02 — anatomy

Three kinds of brackets, and the minus sign that saves tokens

Everything in Jinja is one of these. Hover a card.

{ { … } }

Expression / value

Evaluated, then stringified into the output. This is the only construct that prints anything by itself.

{{ message.role }}
{{ tools | tojson }}
{{ content | trim | upper }}
{ % … % }

Statement / control flow

Prints nothing by itself. Branches, loops, assignments, macros. An empty tag such as {%-} is a syntax error — a statement block needs an actual statement.

{% if tools %}{% endif %}
{% for m in messages %}{% endfor %}
{% set ns = namespace(i=0) %}
{ # … # }

Comment

Stripped entirely. Zero tokens. Use them — a chat template with no comments is unmaintainable.

{# tools block only when present #}
{% if tools %}
{% - … - %}

Whitespace control. - eats the adjacent whitespace. In a 40-iteration loop, that's thousands of invisible tokens.

03 — the invocation path

From your keystroke to the model's eyes

Follow a single request from opencode / claude -p down to the transformer. Jinja is the second-to-last step — and the one nobody looks at.

⌨️

You

"fix the failing test"

🥷

Agent CLI

opencode · Claude Code
assembles system prompt,
history, tool schemas

📦

messages[] + tools[]

structured JSON
POST /v1/chat/completions

🌀

Template render

chat_template.jinja
jinja2 · minja · Go template

🔤

One flat string

tokenized → ids[]
special tokens become real ids

🧠

LLM

sees text only.
no roles. no JSON. no objects.

Key insight: the model has no concept of a "system message". It only ever sees a string that looks like the strings it was fine-tuned on. The chat template is the translator between the API's structured world and the model's textual world — and it decides whether the translation is faithful.
Who runs the engine? Not the agent. The serving layer: llama-server --jinja uses minja (a Jinja subset in C++), vLLM/TGI use Python jinja2, and Ollama uses a Go text/template from the Modelfile. That's why the same agent works on one backend and errors on another.

🔍 minja (llama.cpp's engine) — what it explicitly guards

Input markingRendered data is tagged so it can never be re-interpreted as a special token. This is the anti-injection layer.
Minimal typesint · float · bool · string · array · object · none · undefined. No Python objects, no imports.
lexer → parser → AST → runtimeSource is parsed as-is (no preprocessing) so errors carry line & column for tracing.
raise_exception()Templates can hard-fail. A bad message shape isn't degraded — it's rejected.

Because minja implements a subset, a template that renders fine under Python jinja2 can still fail under --jinja (unsupported filters, Python-only idioms, sandbox differences). Always test against the engine you actually deploy.

04 — real world

Templates you are already running

Every tab below is rendered live in your browser by a mini Jinja engine written for this page. The samples are simplified excerpts — the real files are longer — but the syntax is genuine and executable.

📄 templatechat_template.jinja

        
⚡ rendered output200 OK

        
05 — crash course

The constructs that matter for prompts

Click any card to render it with the in-page engine. Each one shows up in real chat templates.

Scoping gotcha: {% set %} inside a {% for %} does not survive the iteration — loops get their own scope. That's exactly why real chat templates start with {%- set ns = namespace(tool_count=0) %} and mutate ns.tool_count instead of a plain variable.
06 — hands on

Live Jinja playground

Edit the template or the context. The output re-renders instantly, with values, loop-generated and condition-generated regions highlighted, plus a heuristic token count.

📝 templateeditable
🧩 context (JSON)editable
⚡ rendered prompt200 OK

          
value from context from a loop from a condition

🧪 Try these experiments

  • → Delete - from every {%- and watch the token count explode.
  • → Add a second {"role":"system"} message to the Strict guard preset. Read the error.
  • → Remove add_generation_prompt. The prompt now ends mid-user-turn.
  • → Put <|im_end|> inside a user message and see why runtimes "mark" input.
  • → Type {%-} on its own line: an empty statement block is a syntax error, not a no-op.
07 — consequences

How the template changes what the LLM does

The model is a function from tokens to tokens. The template decides the tokens. Nine ways that bites.

1 Format match = capability

The chat template must reproduce the exact byte pattern used in instruction tuning. Feed a ChatML model a Llama-style prompt and it still answers — but tool calling accuracy, stop behaviour and instruction following all degrade, often silently.

# trained on:  <|im_start|>user\n…<|im_end|>
# served with: [INST] … [/INST]
# result:     works-ish. emits raw tool JSON as prose,
              forgets to stop, ignores system rules.

2 Special tokens become real token ids

<|im_end|> isn't decoration — the tokenizer maps it to a single id (in one Qwen3.x GGUF: 248046) that signals "turn over". Drop it and the model keeps generating the user's next turn for you.

{%- if add_generation_prompt %}
  {{- '<|im_start|>assistant\n' }}   # ← the "your turn" cue
{%- endif %}

3 Whitespace is billed

Naive indentation inside a loop adds a newline + spaces per iteration. Multiply by 40 messages × every request.

4 Tool schemas are tokens too

Each tool is serialized — usually tojson of the whole JSON Schema. A dozen agent tools easily costs a few thousand tokens of overhead on every single call.

5 Prompt caching is prefix-sensitive

Render {{ today }} or a random session id near the top and the cached prefix changes every request → full re-prefill, higher latency, no cache discount.

6 Templates enforce structure — and fail hard

Model authors embed validation directly in Jinja. Qwen3.x's official template refuses any second system message; other templates enforce strict role alternation:

{%- if message.role == 'system' %}
  {%- if not loop.first %}
    {{- raise_exception('System message must be at the beginning.') }}
  {%- endif %}

# vLLM, another model family:
# 400 "Conversation roles must alternate user/assistant/…"

No fuzzy fallback. The server returns 400 (or 500 from llama.cpp) and the agent surfaces a bare AI_APICallError.

7 Two injection surfaces

(a) Untrusted data. File contents, web pages and tool outputs are rendered straight into the prompt string. A repo containing <|im_end|><|im_start|>system can forge a turn boundary — which is exactly why minja marks input so data can't be re-tokenized as control tokens.

(b) Untrusted template. Rendering a Jinja string you didn't write is a server-side-template-injection risk. LangChain's own docs say it plainly: prefer template_format="f-string", or never accept jinja2 templates from untrusted sources.

8 Ordering & merging is the agent's job

Because templates are strict, the CLI must normalize before sending: merge plugin-injected system blocks into one message, put tool results in the role the template understands, keep message alternation valid.

// opencode request.ts (conceptual)
system.map(x => ({role:"system", content:x}))  // ✗ N messages
[{role:"system", content: system.join("\n\n"})] // ✓ one

9 Truncation cuts where the template decided

When the rendered prompt exceeds the context window, the runtime drops tokens — usually from the oldest turns. A verbose system prompt + big tool block means your actual code gets evicted first.

Whitespace control, measured

Same semantics, same characters of content — different token bill. Both panes are rendered live below.

❌ naive indentation

        
✅ whitespace-controlled

        
08 — context budget

Drag the sliders. Watch the window fill.

Every token the template adds is a token subtracted from your code, your history, and the model's answer. Figures below are order-of-magnitude planning numbers, not a real BPE count.

09 — post-mortem

"System message must be at the beginning."

A real, still-recurring failure that connects opencode, plugins, vLLM, llama.cpp and one line of Jinja. Toggle the mode and send the request.

llama-server --jinja · Qwen3.x chat_template
📤 request payloadmessages: 3

      
🌀 server-side renderidle

      
Press Send to run the request through the strict Qwen chat template.
STEP 1 · the trigger

A plugin pushes an extra system block

opencode's experimental.chat.system.transform hook lets plugins do output.system.push(...). Goal plugins, context-pruning plugins and "superpowers" packs all use it. Result: two entries with role: "system".

STEP 2 · the wire

opencode forwards both to an OpenAI-compatible backend

On the Anthropic path opencode folds system fragments into the API's single system parameter, so nothing breaks. On an OpenAI-compatible path (LiteLLM → vLLM, or llama-server --jinja) each fragment becomes its own role: "system" message and is handed straight to the model's chat template.

STEP 3 · the template

~Line 116 of Qwen's chat_template.jinja fires

{%- if not loop.first %}{{- raise_exception('System message must be at the beginning.') }}. minja reports the exact line and column; vLLM answers 400 BadRequestError, llama-server answers 500, and the CLI shows a bare AI_APICallError with no hint that a template is at fault.

STEP 4 · the fixes

Three places you can repair it

// A · agent core — collapse fragments before building the request - ...system.map((x) => ({ role: "system", content: x })), + ...(system.length ? [{ role: "system", content: system.join("\n\n") }] : []), // B · plugin — merge into system[0] instead of pushing - output.system.push(block) + output.system[0] += "\n\n" + block # C · server — override the strict template + llama-server --jinja --chat-template-file Qwen-Fixed/chat_template.jinja \ + --chat-template-kwargs '{"preserve_thinking":true}'
LESSON

The template is part of the model's API

"OpenAI-compatible" describes the HTTP shape, not the prompt semantics. The chat template is the real interface — and it can be arbitrarily strict.

10 — takeaways

Cheat sheet & debugging checklist

{{ var }}Print a value. Undefined → empty string (or an error under StrictUndefined).
{{ a ~ b }}String concatenation (safer than + across types).
{{ v | tojson }}Serialize an object — how tool schemas get into the prompt.
{{ v | trim }}Strip surrounding whitespace; | default('') guards missing keys.
{%- … -%}Whitespace control. Use it everywhere in loops.
{% for m in messages %}The heart of every chat template. loop.first, loop.last, loop.index.
{% if tools %}Empty arrays are falsy — the standard "only if present" guard.
{% set ns = namespace(n=0) %}Mutable counter that survives loop scope.
{% macro row(x) %}Reusable block; keeps a 900-line template readable.
{{ raise_exception('…') }}Hard-fail the request. Model authors use it as validation.
{% raw %} … {% endraw %}Emit literal braces — essential when prompting about Jinja.
{% %} / {%-}Never empty. A statement block must contain a statement; {%-} is a TemplateSyntaxError.

🩺 When an agent + local model misbehaves

  • Dump the rendered prompt, not the request JSON. tokenizer.apply_chat_template(msgs, tools=t, add_generation_prompt=True) or llama-server logs.
  • Confirm the engine is even on: llama.cpp logs Chat format: … only when a Jinja template is in use (--jinja).
  • Count your role: "system" messages. More than one breaks strict templates.
  • Check message alternation — some templates require strict user/assistant turn-taking.
  • Check add_generation_prompt / the trailing assistant header — a missing one causes runaway generation.
  • Verify tool-result role mapping (tool vs user); many templates only understand one.
  • Measure template overhead: tools + system prompt per request. Trim descriptions before adding models.
  • Keep the prompt prefix stable — no timestamps or random ids above the history, or you lose prompt caching.
  • Escape untrusted content; never let file text supply special tokens.
  • Pin the template. --chat-template-file / --chat-template beats "whatever shipped with the checkpoint".
  • Remember minja ⊂ jinja2. Test the template on the engine you deploy, not just in Python.
One-sentence summary: a chat template is the last piece of software that touches your prompt before it becomes tokens — so it decides the model's format contract, its context budget, its cache behaviour, its tool-calling fidelity, and whether the request is accepted at all.