---
audience: designers, developers
summary: "Complete template grammar \u2014 every command ScaiScribe's docx-templates\
  \ pipeline supports."
title: Templates
path: reference/templates
status: published
---


# Template reference

ScaiScribe templates support **DOCX, PPTX, and XLSX**. The three paths differ in grammar:

- **DOCX** — full grammar via [`docx-templates`](https://github.com/guigrpa/docx-templates) v4 (FOR, IF, ALIAS, IMAGE, LINK, HTML, EXEC, plus JS expression evaluation).
- **PPTX** — Tier 1 substitution only via the hand-rolled engine in `workers/node/lib/ooxml-template-fill.js`. **No FOR/IF/commands; no JS evaluation.** Dotted paths only.
- **XLSX** — same Tier 1 engine as PPTX. Inline strings AND shared strings are substituted.

Most of this page describes the DOCX grammar (the broadest surface). The "PPTX & XLSX (Tier 1)" section at the end covers what's supported on the other two formats.

If you just want the concept overview, see [Concepts → Templates](/docs/scaiscribe/concepts/templates). For an end-to-end walkthrough, see [Tutorials → Templates](/docs/scaiscribe/tutorials/templates).

## Delimiter

ScaiScribe configures the engine with `cmdDelimiter: ["{{", "}}"]`. **Every** command lives inside `{{ ... }}` — there is no `{% ... %}` syntax, no Jinja-style block markers, no Mustache sections. One pair of double braces wraps everything.

```
{{ variable }}                                   ← insert
{{ INS variable }}                               ← insert (explicit form)
{{ = variable }}                                 ← insert (shorthand)
{{ FOR item IN items }} … {{ END-FOR item }}     ← loop
{{ IF condition }} … {{ END-IF }}                ← conditional
{{ ALIAS x INS something }} … {{ *x }}           ← alias
{{ IMAGE jsExpr }}                               ← image (see caveats below)
{{ LINK ({ url, label }) }}                      ← hyperlink
{{ HTML expression }}                            ← raw HTML (Word-only)
{{ EXEC something }}                             ← side-effect (no output)
```

The JavaScript expression inside any command has full access to the variables passed in `variables` at instantiation time.

## Insertion

### Three ways to spell it

```
{{ recipient.name }}             ← shorthand (most common)
{{ = recipient.name }}           ← shorthand with explicit `=`
{{ INS recipient.name }}         ← explicit
```

All three produce identical output. The "shorthand" form is what you'll use 95% of the time.

### Expressions, not just paths

The content between the delimiters is a JavaScript expression. The last evaluated statement gets inserted:

```
{{ recipient.name.toUpperCase() }}
{{ items.length }}
{{ amount > 1000 ? 'High value' : 'Standard' }}
{{ new Date(deadline).toLocaleDateString('en-GB') }}
{{ items.filter(x => x.urgent).map(x => x.name).join(', ') }}
```

Multi-line expressions work too — the last value is what gets inserted:

```
{{ INS
const total = items.reduce((sum, item) => sum + item.amount, 0);
`Total: €${total.toFixed(2)}`
}}
```

### Nested paths

`{{ user.address.street }}` resolves through the structure. Missing intermediate keys produce an error at render time (see [Error handling](#error-handling)).

If you want optional access, use JS optional chaining: `{{ user?.address?.street ?? '—' }}`.

## Loops — `FOR` / `END-FOR`

Iterate over arrays:

```
{{ FOR item IN line_items }}
- {{ $item.name }}: {{ $item.amount }}
{{ END-FOR item }}
```

Inside the loop, the current item is named with a `$` prefix. Without the prefix, `item` would shadow… actually it just doesn't resolve cleanly. **Always use the `$` prefix inside loop bodies.**

### Loop variables

- `{{ $idx }}` — current 0-based index of the innermost loop.
- `{{ $item }}` (or whatever you named it) — the current element.

### Nested loops

```
{{ FOR company IN companies }}
{{ $company.name }}
{{ FOR person IN $company.people }}
  - {{ $person.name }} (since {{ $person.since }})
{{ END-FOR person }}
{{ END-FOR company }}
```

`$idx` always refers to the innermost loop. Save the outer index to a variable if you need it:

```
{{ FOR company IN companies }}
{{ EXEC const companyIdx = $idx }}
  ...
{{ FOR person IN $company.people }}
  Person {{ companyIdx }}.{{ $idx }}: {{ $person.name }}
{{ END-FOR person }}
{{ END-FOR company }}
```

### Filtering and sorting inline

The expression after `IN` is JavaScript:

```
{{ FOR person IN project.people.filter(p => p.active).sort((a, b) => a.name.localeCompare(b.name)) }}
{{ $person.name }}
{{ END-FOR person }}
```

### Loops over table rows

Put the `FOR` marker in the **row above** the data row, and the `END-FOR` marker in the **row below**:

```
| Name                | Since              |
|---------------------|--------------------|
| {{ FOR p IN people }}                    |
| {{ $p.name }}       | {{ $p.since }}     |
| {{ END-FOR p }}                          |
```

docx-templates detects this and replicates the data row for each iteration. The marker rows themselves are removed from the output.

This works for arbitrary numbers of columns. It does NOT work for header rows (those aren't replicated).

### Dynamic columns

Loop horizontally too — `FOR` in the row above, `END-FOR` after the cell:

```
| {{ FOR col IN columns }} | {{ $col.label }} | {{ END-FOR col }} |
```

Combine row + column loops for fully dynamic tables. See the docx-templates README for the cell-template pattern.

### Empty arrays

`{{ FOR x IN [] }} … {{ END-FOR x }}` produces no output and no error. The block is skipped. If you want fallback text, wrap in IF:

```
{{ IF items.length > 0 }}
{{ FOR item IN items }}
- {{ $item.name }}
{{ END-FOR item }}
{{ END-IF }}
{{ IF items.length === 0 }}
_No items._
{{ END-IF }}
```

## Conditionals — `IF` / `END-IF`

```
{{ IF recipient.urgent }}
**URGENT** — please respond by {{ recipient.deadline }}.
{{ END-IF }}
```

The expression is plain JavaScript. Truthy values match — `0`, `""`, `null`, `undefined`, `false`, `NaN` are falsy.

There is **no `ELSE`**. Use two separate IFs (or a ternary in an INS) for if-else:

```
{{ IF status === 'paid' }}Thank you for your payment.{{ END-IF }}
{{ IF status !== 'paid' }}Please remit within 30 days.{{ END-IF }}
```

Or:

```
{{ status === 'paid' ? 'Thank you for your payment.' : 'Please remit within 30 days.' }}
```

### `IF` over table rows

Same row-marker pattern as `FOR`. Put `IF` in the row above the conditional content, `END-IF` in the row below.

### Nested IFs

```
{{ IF subscriber }}
{{ IF subscriber.tier === 'gold' }}**Gold member** — priority support.{{ END-IF }}
{{ IF subscriber.tier === 'silver' }}Silver member.{{ END-IF }}
{{ END-IF }}
```

Internally `IF` is a `FOR` that iterates 0 or 1 times depending on the condition. They compose freely.

## Aliases — `ALIAS` / `*name`

Long expressions used in many places get unwieldy:

```
| {{ FOR p IN people }}                                                     |
| {{ $p.name.toUpperCase() }}  | {{ $p.contracts.filter(c => c.active).length }} |
| {{ END-FOR p }}                                                            |
```

Define aliases once, reference by `*name`:

```
{{ ALIAS upperName INS $p.name.toUpperCase() }}
{{ ALIAS activeCount INS $p.contracts.filter(c => c.active).length }}

| {{ FOR p IN people }}        |              |
| {{ *upperName }}             | {{ *activeCount }} |
| {{ END-FOR p }}              |              |
```

Aliases are particularly valuable in table rows where Word forces line-wrapping of long commands across multiple visual lines.

## Images — `IMAGE`

```
{{ IMAGE qrCode(project.url) }}
```

The expression must return an *image object*:

```js
{
  width: 6,            // cm
  height: 6,           // cm
  data: ArrayBuffer | base64 string,
  extension: '.png' | '.jpg' | '.jpeg' | '.gif' | '.svg',
  alt?: string,
  rotation?: number,   // degrees, clockwise
  caption?: string,
  thumbnail?: { data, extension }   // SVG fallback for older Word
}
```

### **Important: ScaiScribe does not pass `additionalJsContext`**

The `IMAGE` command needs access to helper functions (like `qrCode()` above) via the `additionalJsContext` option to `createReport()`. **ScaiScribe currently does NOT pass that option**, which means `IMAGE` is only usable with literal/inline image objects:

```
{{ IMAGE ({ width: 5, height: 5, data: variables.logo_b64, extension: '.png' }) }}
```

Pass the image data into your `variables` payload as a base64 string, then reference it via the expression. You'll still need exact width/height in cm.

For dynamic image generation (QR codes, charts as images, etc.), the workaround is to render the image in your application before instantiation and pass the bytes through.

### Centering images

Center the `{{ IMAGE … }}` command itself in the template (use Word's centre alignment on that paragraph). The image inherits the paragraph alignment.

## Hyperlinks — `LINK`

```
{{ LINK ({ url: 'https://scaiscribe.scailabs.ai', label: 'ScaiScribe' }) }}
{{ LINK ({ url: project.url, label: project.name }) }}
{{ LINK ({ url: project.url }) }}                          ← label defaults to url
```

The expression must return an object with `url` (required) and `label` (optional).

## Raw HTML — `HTML`

```
{{ HTML `
<p>This is <strong>HTML</strong> inserted via altchunk.</p>
` }}
```

Word supports this; **LibreOffice and Google Docs do not** (the altchunk feature is Word-specific). The HTML appears as a placeholder in those clients.

Avoid `HTML` for cross-client compatibility. If you need rich formatting, do it in the docx body directly or generate the formatted content as multiple commands.

## Side effects — `EXEC`

Run JavaScript without inserting output. Useful for setting variables you'll use later:

```
{{ EXEC let runningTotal = 0 }}
{{ FOR item IN items }}
{{ EXEC runningTotal += $item.amount }}
- {{ $item.name }}: {{ $item.amount }}
{{ END-FOR item }}

**Total: {{ runningTotal }}**
```

`EXEC` is equivalent to `{{= undefined }}` — the expression evaluates, the output is empty.

## ScaiScribe-specific notes

### What counts as a "placeholder" in the API response

When you `POST /v1/templates`, the response includes a `placeholders` array — every `{{ … }}` token found in `word/document.xml`:

```json
{
  "template_id": "tpl_...",
  "name": "letterhead",
  "format": "docx",
  "byte_size": 24500,
  "placeholders": [
    "recipient.name",
    "FOR item IN items",
    "$item.name",
    "$item.amount",
    "END-FOR item",
    "IF urgent",
    "deadline",
    "END-IF"
  ]
}
```

The list reports **every token verbatim** — including `FOR`/`END-FOR`/`IF`/`END-IF`/`ALIAS` markers and `$`-prefixed loop variables. It's not a "variable catalogue"; it's a structural inventory.

To extract just the variable names a caller needs to provide, filter out anything starting with `FOR`/`END-FOR`/`IF`/`END-IF`/`ALIAS`/`EXEC`/`INS`/`=` and anything starting with `$`.

### Where placeholders are scanned

ScaiScribe currently scans only `word/document.xml` for the API response. **Placeholders in headers, footers, and side-zip parts ARE rendered correctly at instantiation time** — the engine processes everything — but they don't surface in the API's `placeholders` list. If your operator UI relies on the list to validate variable bindings, account for this gap.

### What ScaiScribe does NOT pass through

```js
createReport({
  template,
  data,
  cmdDelimiter: ["{{", "}}"],
})
```

That's the full call. So these docx-templates features are unavailable:

- `additionalJsContext` — no custom JS helpers; `IMAGE`/`EXEC`/`INS` snippets can only use built-in JS (Math, Date, Array, etc.) and the `data` payload.
- `processLineBreaks` — defaults to `true`; you can't disable it.
- `failFast` — defaults to `true`; the first error stops processing.
- `noSandbox` — sandboxed by default; can't disable.
- Custom error handlers — uses the default.

### Maximum template size

Templates are capped at the same per-file ceiling as other binary uploads (~32 MB). Most templates are well under 1 MB.

### Quotas

- Each registered template counts under `templates_count` quota (default 20 per tenant).
- Each instantiation produces a normal document, counted under `documents_count`.
- Instantiation doesn't have its own quota; just the per-day render budget via `renders_per_day`.

## Error handling

ScaiScribe uses docx-templates with `failFast: true` (the default). The first error stops instantiation; the API returns a structured envelope:

```json
{
  "error": {
    "code": "TEMPLATE_FAILED",
    "message": "docx-templates failed: TemplateError: Could not find ... in path \"recipient.name\"",
    "details": {},
    "request_id": "req_..."
  }
}
```

Common errors:

| Error | Cause | Fix |
|---|---|---|
| `Could not find … in path "X"` | Variable referenced but missing from `variables` payload. | Add it, or use optional chaining: `{{ X ?? '—' }}`. |
| `Unexpected token ...` | Invalid JS expression inside `{{ }}`. | Check the expression in a JS console; fix syntax. |
| `Unmatched END-FOR` / `Unmatched END-IF` | Missing or misplaced opening command. | Pair every `FOR` with `END-FOR x` (note the loop name). |
| `Maximum call stack size exceeded` | Infinite loop in an `EXEC` block. | Add bounds. |

`failFast` means one mistake in one place kills the whole render. Test templates with realistic data before relying on them in production.

## Limitations vs upstream docx-templates

| Feature | docx-templates | ScaiScribe |
|---|---|---|
| Custom `additionalJsContext` | ✓ | ✗ — only `data` and built-in JS |
| Custom command delimiters | ✓ | ✗ — fixed at `{{` / `}}` |
| `QUERY` command (GraphQL/SQL passthrough) | ✓ | ✗ — no `queryResolver` wired |
| `processLineBreaks` toggle | ✓ | ✗ — always on |
| `noSandbox` / VM sandbox toggle | ✓ | ✗ — always sandboxed |
| Custom error handlers | ✓ | ✗ — `failFast: true` always |
| PPTX templates | ✗ | ✓ Tier 1 — substitution only (no FOR/IF/JS) |
| XLSX templates | ✗ | ✓ Tier 1 — substitution only (no FOR/IF/JS) |
| XLSX templates | ✗ | ✗ — XLSX uses spec-based generation |

These are deliberate scope choices, not bugs. If your use case requires one of them, file a feature request.

## PPTX & XLSX (Tier 2)

PPTX and XLSX templates use a separate, smaller engine (`workers/node/lib/ooxml-template-fill.js`) rather than docx-templates. The reasons: no production-quality npm library covers PPTX templating; and XLSX templating libraries that exist are either tiny or unmaintained. Hand-rolled gives us the same `{{ … }}` syntax across all three formats with a predictable, debuggable code path.

### What's supported

| Feature | DOCX | PPTX | XLSX |
|---|---|---|---|
| `{{ var }}` insertion | ✓ | ✓ | ✓ |
| Dotted paths (`{{ user.address.street }}`) | ✓ | ✓ | ✓ |
| Array indexing (`{{ items.0.name }}`, `{{ items[0].name }}`) | ✓ | ✓ | ✓ |
| Method chains (`{{ name.toUpperCase() }}`, `{{ amount.toFixed(2) }}`) | ✓ (full JS) | ✓ (safe subset) | ✓ (safe subset) |
| Arithmetic + comparisons + logical ops + ternaries | ✓ (full JS) | ✓ | ✓ |
| Arrow functions / lambdas | ✓ | ✗ | ✗ |
| Arbitrary globals (`Math`, `Date`, `JSON`) | ✓ | ✗ | ✗ |
| Missing variable → empty (no error) | ✗ (errors) | ✓ | ✓ |
| Missing variable → fidelity warning | n/a | ✓ (`TEMPLATE_VARIABLE_MISSING`) | ✓ (same) |
| Expression error → empty + warning | n/a | ✓ (`TEMPLATE_EXPRESSION_ERROR`) | ✓ (same) |
| Run-span coalescing for split tokens | (handled by docx-templates) | ✓ | ✓ |
| `FOR`/`END-FOR` loops over text-frame paragraphs | ✓ | ✓ | n/a |
| `FOR`/`END-FOR` loops over table rows | ✓ | n/a | ✓ (with formula-ref rewriting) |
| `FOR`/`END-FOR` loops at slide level (whole-slide replication) | n/a | ✓ | n/a |
| `IF`/`END-IF` conditionals | ✓ | ✓ | ✓ |
| `IMAGE`/`LINK`/`HTML`/`EXEC` commands | ✓ | ✗ | ✗ |
| `ALIAS` | ✓ | ✗ | ✗ |

### What gets scanned for placeholders

The API's `placeholders` field reports tokens found in:

- **DOCX**: `word/document.xml`
- **PPTX**: every `ppt/slides/slideN.xml` + every `ppt/notesSlides/notesSlideN.xml`
- **XLSX**: `xl/sharedStrings.xml` (interned text) + every `xl/worksheets/sheetN.xml` (inline strings)

Tokens in headers/footers (DOCX), master slides (PPTX), or other side parts still RENDER correctly at instantiation time. The scanner just doesn't surface them in the API field. If your operator UI relies on the list to validate variable bindings, account for the gap.

### Expression language (PPTX/XLSX safe subset)

The expression inside `{{ … }}` is parsed by a recursive-descent parser in `workers/node/lib/template-expr.js`. There is no `eval`, no VM, no native dependency. The parser walks the AST against your `variables` payload, applying methods only from a curated allowlist per type.

**Supported syntax:**

```
{{ recipient.name }}                        # path
{{ recipient.name.toUpperCase() }}          # method chain
{{ amount.toFixed(2) }}                     # numeric formatting
{{ items.0.name }}                          # array index (legacy)
{{ items[0].name }}                         # array index (bracketed)
{{ items.length }}                          # property access
{{ count > 5 ? "high" : "low" }}            # ternary
{{ count > 3 && status === "paid" }}        # logical + comparison
{{ price * 1.21 }}                          # arithmetic
{{ name.slice(0, 3).toUpperCase() }}        # chains compose
```

**Allowed methods, by receiver type:**

| Receiver | Methods |
|---|---|
| **string** | `toUpperCase`, `toLowerCase`, `trim`, `trimStart`, `trimEnd`, `slice`, `substring`, `substr`, `replace`, `replaceAll`, `split`, `concat`, `padStart`, `padEnd`, `startsWith`, `endsWith`, `includes`, `indexOf`, `charAt`, `charCodeAt`, `toString` |
| **number** | `toFixed`, `toPrecision`, `toExponential`, `toLocaleString`, `toString` |
| **array** | `slice`, `join`, `concat`, `includes`, `indexOf`, `at`, `reverse`, `toString`, property `length` |
| **string** | property `length` |
| **object** | any property access (it's your data) |

Any method call outside the allowlist (e.g. `name.eval("…")`, `items.filter(...)`, `count.constructor`) is rejected at evaluation time and the token renders as empty + a `TEMPLATE_EXPRESSION_ERROR` warning lands in the result. This is the safety boundary — there is no path through the parser that lets a user expression access global state, allocate VMs, or escape the data scope.

### Loops — `FOR` / `END-FOR`

Three placement contexts:

#### PPTX text-frame loop

Markers occupy whole paragraphs within an `<a:txBody>`:

```
{{ heading }}
{{ FOR item IN items }}
{{ $item.label }}: {{ $item.value }}
{{ END-FOR item }}
```

Three items → three replicated lines. Inside the body, `$item` is the current element and `$idx` is the 0-based index of the innermost loop.

#### XLSX row loop

Markers occupy whole rows. The block between replicates per item; rows are renumbered to be contiguous; formula cell refs that point at the template row get rewritten per replica:

```
| A           | B            |
|-------------|--------------|
| Region      | Revenue      |   ← header (passthrough)
| {{ FOR row IN data }}      |   ← marker (removed)
| {{ $row.region }} | {{ $row.revenue }} |   ← data template (replicated)
| {{ END-FOR row }}          |   ← marker (removed)
| TOTAL       | =SUM(B2:B?)  |   ← totals (passthrough)
```

Three items → three data rows numbered 2, 3, 4; the totals row that started at row 5 in the template ends up at row 5 in the output. **Relative cell refs in formulas get rewritten** when their template row number equals the source row's number — absolute refs (`$B$3`) stay verbatim.

#### PPTX slide-level loop

A slide-level FOR signals "clone this whole slide per data item":

- The slide's **first** text content must be `{{ FOR x IN list }}` on its own
- The slide's **last** text content must be `{{ END-FOR x }}` on its own

The slide between them is cloned N times. Each clone gets its own scope binding `$x` to the current item. The presentation's relationships, content types, and `<p:sldIdLst>` are updated. Slides are renumbered 1..N (no gaps).

Empty input (`x = []`) drops the template slide entirely — no marker-only slide remains in the output.

### Conditionals — `IF` / `END-IF`

Same paragraph / row placement as `FOR`. Truthy → keep content, falsy → drop:

```
{{ IF urgent }}
**URGENT** — please respond by {{ deadline }}
{{ END-IF }}
```

`{{ END-IF }}` takes no name (vs. DOCX's `{{ END-IF }}` which also doesn't, despite the FOR naming difference).

### Authoring PPTX/XLSX templates

For PPTX, use PowerPoint or LibreOffice Impress. Type `{{ recipient.name }}` directly into text frames where you want substitution. **Avoid pasting** the placeholders — pasting from another app often splits the token across many runs and even our coalescing has limits.

For XLSX, use Excel or LibreOffice Calc. Type `{{ subject }}` into a cell. Both cell types (interned strings via `sharedStrings.xml` and inline strings via `<is>`) are handled.

For multi-row loops, put each marker on its own row. Mixing data and a `{{ FOR }}` marker in the same row is treated as substitution, not a loop start.

### Numbers, dates, and currency

You have two options now (Tier 2):

**Option 1 — pre-format in your application** (recommended for dates):

```python
variables = {
    "deadline": deadline.strftime("%d %B %Y"),   # "15 June 2026"
    "ratio_pct": f"{ratio:.1%}",                 # "8.5%"
}
```

Template: `Due {{ deadline }} ({{ ratio_pct }})`.

**Option 2 — use the expression evaluator** (good for currency / numeric formatting):

```
{{ amount.toFixed(2) }}                                    1234.57
{{ amount.toLocaleString("de-DE") }}                       1.234,567
{{ '€' + amount.toFixed(2) }}                              €1234.57
```

Dates aren't easy here — there's no `Date` global, so use Option 1 for date formatting. Currency works in-template with method chains.

## Where to next

- [Concepts → Templates](/docs/scaiscribe/concepts/templates) — the model.
- [Tutorials → Templates](/docs/scaiscribe/tutorials/templates) — end-to-end example.
- [Troubleshooting → Templates](/docs/scaiscribe/troubleshooting/templates) — error catalogue.
- [docx-templates upstream](https://github.com/guigrpa/docx-templates) — the underlying library's own docs for things ScaiScribe doesn't override.
