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:
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 / API | Template engine | Notes |
|---|---|---|
Hugging Face transformers | JINJA2 | tokenizer.apply_chat_template(...); no default template since v4.44. |
| vLLM / TGI | JINJA2 | Serves the repo's chat_template; override with --chat-template. Without one, chat requests error. |
llama.cpp (llama-server --jinja) | MINJA | A C++ Jinja subset (common/jinja). Without --jinja it falls back to built-in C++ formats for known model families. |
| LM Studio | JINJA | Prompt template can be expressed in Jinja; imported from GGUF metadata. |
| Ollama | GO TEMPLATE | Modelfile TEMPLATE uses Go text/template syntax ({{ .System }}), not Jinja. Newer Ollama releases added optional Jinja support, but the Modelfile default is Go. |
| Anthropic Messages API | NONE | Structured system + messages. Formatting is internal to Anthropic. |
| OpenAI Chat Completions API | NONE | Structured messages; server-side formatting is not exposed. |
| LangChain (app layer) | OPTIONAL | Default is f-string; opt into jinja2 or mustache per template. |
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.
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.
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
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.
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.
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.
{% 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.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.
🧪 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.
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.
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.
"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.
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".
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.
~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.
Three places you can repair it
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.
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)orllama-serverlogs. - ✓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/assistantturn-taking. - ✓Check
add_generation_prompt/ the trailing assistant header — a missing one causes runaway generation. - ✓Verify tool-result role mapping (
toolvsuser); 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-templatebeats "whatever shipped with the checkpoint". - ✓Remember minja ⊂ jinja2. Test the template on the engine you deploy, not just in Python.