---
audience: developers
summary: Register a .docx template once; instantiate per-request with structured data.
title: Templates
path: tutorials/templates
status: published
---


# Tutorial: Templates

You'll: design a letterhead template in Word, register it with ScaiScribe, instantiate it ten times for ten different recipients, then finalize to PDF for each. Total wall-clock with a warm cache: under a second per letter.

## Step 1 — Author the template

Open Word (or LibreOffice). Create a normal letterhead. Wherever you want a value substituted, write `{{ variable_name }}`. Use the docx-templates grammar for loops and conditionals:

```
{{ company.name }}
{{ recipient.address.street }}
{{ recipient.address.city }} {{ recipient.address.postal_code }}

Dear {{ recipient.name }},

Subject: {{ subject }}

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

{{ body_paragraph }}

{{ FOR item IN line_items }}
- {{ $item.name }}: €{{ $item.amount.toFixed(2) }}
{{ END-FOR item }}

Sincerely,
{{ sender.name }}
{{ sender.title }}
```

Save as `letterhead.docx`. ScaiScribe doesn't care about styling beyond what your design needs — the template IS the styling.

Note three things about the grammar:
- All commands use `{{ ... }}` — no `{% ... %}` block markers.
- Inside `FOR` loops, the loop variable is `$`-prefixed (`$item`, not `item`).
- Number formatting is done inline via JS (`amount.toFixed(2)`, `amount.toLocaleString('de-DE')`). There's no `numberFormat` filter. See the [Templates reference](/docs/scaiscribe/reference/templates) for the full grammar and the reason.

## Step 2 — Register

```python
import os
from scaiscribe import Client, ScaiKeyAuth

client = Client(
    "https://scaiscribe.scailabs.ai",
    auth=ScaiKeyAuth(
        client_id=os.environ["SCAIKEY_CLIENT_ID"],
        client_secret=os.environ["SCAIKEY_CLIENT_SECRET"],
    ),
    default_tenant_id=os.environ["SCAISCRIBE_TENANT_ID"],
)

with open("letterhead.docx", "rb") as f:
    template = client.upload_template(
        file=f.read(),
        name="letterhead",
        format="docx",
    )

print("Registered:", template.template_id)
print("Placeholders:", template.placeholders)
# → ['company.name', 'recipient.address.street', 'recipient.address.city',
#    'recipient.address.postal_code', 'recipient.name', 'subject', 'urgent',
#    'deadline', 'body_paragraph', 'line_items', 'sender.name', 'sender.title']
```

The placeholder list is what ScaiScribe parsed out. If anything's missing, your variable wasn't recognised — check the marker syntax.

## Step 3 — Instantiate per recipient

```python
recipients = [
    {
        "name": "Marketing Department",
        "address": {"street": "Main St 1", "city": "Heerlen", "postal_code": "6411 CR"},
    },
    {
        "name": "Finance Department",
        "address": {"street": "Side Rd 2", "city": "Heerlen", "postal_code": "6411 CR"},
    },
    # ... 8 more
]

for r in recipients:
    doc = client.from_template(
        template_id=template.template_id,
        variables={
            "company": {"name": "ScaiLabs B.V."},
            "recipient": r,
            "subject": "Q4 review highlights",
            "urgent": False,
            "body_paragraph": "Attached is the Q4 review.",
            "line_items": [
                {"name": "Revenue", "amount": 145000.00},
                {"name": "Costs", "amount": 92000.00},
            ],
            "sender": {"name": "Marcel", "title": "CEO"},
        },
    )
    print(f"Letter for {r['name']}: {doc.doc_id}")
```

Each call mints a fresh `doc_id` and applies the variables. The instantiated document is a normal spec — you could PATCH it further if you wanted, but you'll usually want to finalize directly.

## Step 4 — Finalize to PDF

```python
for r in recipients:
    doc = client.from_template(template_id=template.template_id, variables=r_vars(r))
    result = client.finalize(doc.doc_id, formats=["pdf"])
    pdf = result.outputs[0]
    print(f"{r['name']}: {pdf.download_url}")  # presigned, ~1h validity
```

Or batch both formats:

```python
result = client.finalize(doc.doc_id, formats=["docx", "pdf"])
for r in result.outputs:
    print(f"{r.format}: {r.byte_size} bytes")
```

## Step 5 — Download

The `download_url` is a presigned S3 link. Stream it:

```python
import httpx

for r in recipients:
    doc = client.from_template(template_id=template.template_id, variables=r_vars(r))
    result = client.finalize(doc.doc_id, formats=["pdf"])
    with httpx.stream("GET", result.outputs[0].download_url) as resp:
        with open(f"letter_{r['id']}.pdf", "wb") as f:
            for chunk in resp.iter_bytes():
                f.write(chunk)
```

## What just happened

- The template binary lives once in object storage; ten instantiations don't duplicate it.
- Each instantiation is a tiny database row (a new doc_id + a Spec carrying just the variable bindings + a reference to the template).
- The actual `.docx` produced by `finalize` is generated by docx-templates filling the variables into the binary at render time. PDF goes through Gotenberg.
- Every instantiation counts under `documents_count` quota. The template itself counts under `templates_count`.

## Gotchas

- **Numeric formatting** — there is no `numberFormat` filter. Format inline with JS built-ins: `{{ amount.toFixed(2) }}` or `{{ amount.toLocaleString('de-DE', {minimumFractionDigits: 2}) }}`.
- **Loop scope** — inside a `{{ FOR }}` block, the loop variable is `$`-prefixed: write `{{ $person.name }}`, not `{{ person.name }}`. If you need an outer-scope variable in a loop, just reference it normally (`{{ outer.name }}`).
- **Conditionals on empty arrays** — `{{ IF items }}` is truthy for empty arrays. Use `{{ IF items.length > 0 }}` if you mean "items has at least one".
- **Image insertion** — docx-templates supports `IMAGE` for dynamic images. The expression must return an image object — for ScaiScribe specifically, pass pre-rendered image bytes in the variables payload (we don't expose `additionalJsContext`). See the [reference](/docs/scaiscribe/reference/templates#images-image) for the exact shape.

## PPTX and XLSX templates

The same flow works for `.pptx` and `.xlsx` templates with **simple substitution only** (no FOR/IF, no JS expressions). Author the template in PowerPoint/Excel, type `{{ recipient.name }}` directly where you want the value, register, instantiate:

```python
# PPTX
with open("title_slide.pptx", "rb") as f:
    template = client.upload_template(
        file=f.read(),
        name="title-slide",
        format="pptx",
    )

doc = client.from_template(
    template_id=template.template_id,
    variables={
        "recipient": {"name": "Marketing Department"},
        "subject": "Q4 review",
    },
)
result = client.finalize(doc.doc_id, formats=["pptx", "pdf"])
```

```python
# XLSX
with open("report_template.xlsx", "rb") as f:
    template = client.upload_template(
        file=f.read(), name="q4-report", format="xlsx",
    )

doc = client.from_template(
    template_id=template.template_id,
    variables={
        "recipient": {"name": "Finance"},
        "fy": "FY26",
        "amount_eur": f"€{145_000.00:,.2f}",
    },
)
result = client.finalize(doc.doc_id, formats=["xlsx"])
```

**Differences vs. DOCX:**

1. **Method allowlist** — `{{ amount.toFixed(2) }}` works for both numbers and strings, but only methods from the per-type allowlist are callable. No arrow functions, no `new`, no `Math`/`Date` globals.
2. **Missing variables render as empty** (with a warning in the result), not as a render error. By design — partial fills are common in PPTX/XLSX templates.
3. **`IMAGE`/`LINK`/`HTML`/`EXEC` commands** aren't available in PPTX/XLSX. DOCX still has them via docx-templates.

### Looping in PPTX/XLSX

Both PPTX and XLSX support **FOR/IF**, but with different placement rules:

**PPTX text-frame loop** — markers occupy whole paragraphs in one text frame:

```
{{ heading }}                        ← cover line
{{ FOR item IN items }}
{{ $item.label }}: {{ $item.value }}  ← replicated per item
{{ END-FOR item }}
```

**PPTX slide-level loop** — the FOR marker is the first text on a slide, END-FOR is the last. The whole slide is cloned per item:

```
Slide 2 (template):
  • Title: {{ FOR company IN companies }}          ← first text content
  • Body:  {{ $company.name }} — {{ $company.revenue }}
  • Footer: {{ END-FOR company }}                  ← last text content
```

Three companies → slide 2 becomes slides 2, 3, 4. The cover slide and any tail slides pass through unchanged.

**XLSX row loop** — markers occupy whole rows; the block between replicates with row renumbering and formula-ref rewriting:

```python
# Template:
# Row 1: | Region   | Revenue   |
# Row 2: | {{ FOR row IN data }}
# Row 3: | {{ $row.region }} | {{ $row.revenue }} |
# Row 4: | {{ END-FOR row }}
# Row 5: | TOTAL    | =SUM($B$2:$B$4) |

doc = client.from_template(template_id=tpl_id, variables={
    "data": [
        {"region": "EMEA", "revenue": 1450},
        {"region": "AMER", "revenue": 1100},
        {"region": "APAC", "revenue":  800},
    ],
})
# Output rows 2–4 carry the three data points; the TOTAL row stays at row 5
# (formula uses absolute refs so it doesn't get rewritten).
```

For full grammar + the method allowlist + edge cases, see [Reference → Templates → PPTX & XLSX (Tier 2)](/docs/scaiscribe/reference/templates#pptx--xlsx-tier-2).

## Where to next

- [Templates concept](/docs/scaiscribe/concepts/templates) — the model behind it.
- [Tutorials → Ingest + edit](/docs/scaiscribe/tutorials/ingest-and-edit) — for the reverse flow.
- [Reference → API](/docs/scaiscribe/reference/api) — `/v1/templates` and `/v1/documents/from_template` shapes.
