Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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:

carbon
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{{ 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 for the full grammar and the reason.

Step 2 — Register#

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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
1
2
3
4
5
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
1
2
3
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
1
2
3
4
5
6
7
8
9
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 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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 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:

django
1
2
3
4
{{ 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:

django
1
2
3
4
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 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).

Where to next#

Updated 2026-07-03 13:00:10 View source (.md) rev 10