Foliant

Templates

Write the layout once in Typst, store it under a name, then render it with different values every time. The template stays on our side; each render sends only the data.

Why templates

An invoice looks the same every month; only the numbers change. Sending the full Typst source on every request works, but it means your application carries layout code, every change needs a deploy, and an agent generating documents has to reproduce 80 lines of formatting it does not care about.

With a stored template the render request becomes:

POST /v1/render
{ "template": "invoice.typ", "inputs": { "no": "2026-0917", "customer": "Meridian Studio GmbH", "total": "8496.60" } }

Templates live in your account, up to 50 of them, 512 KB each. They can include other templates and reference stored assets such as your logo or a corporate font. Rendering a template costs the same as rendering source: per output page.

Inputs

Values arrive in the document as sys.inputs, a Typst dictionary of strings. Read them with a default so the template still compiles when a field is missing:

#let input(key, default: "") = sys.inputs.at(key, default: default)

#input("customer")                       // "" if absent
#input("currency", default: "EUR")       // "EUR" if absent
#input("total", default: "0").replace(".", ",")   // strings, so format as you like

Inputs are strings. For numbers use float(input("total")); for lists or tables send JSON and decode it: #let items = json(bytes(input("items"))). Booleans: compare against "true".

Everything in inputs is passed as-is. Numbers are not localised, dates are not parsed. Do formatting in the template so it lives in one place, or pre-format in your application if you prefer the template to stay dumb. Pick one and stay with it.

Example: invoice

A complete A4 invoice with a line-item table from a JSON input, a logo from stored assets, totals computed in the template.

// templates/invoice.typ
#let input(k, default: "") = sys.inputs.at(k, default: default)
#let items = json(bytes(input("items", default: "[]")))
#let fmt(n) = {
  let s = str(calc.round(n, digits: 2))
  if not s.contains(".") { s + ".00" } else if s.split(".").at(1).len() == 1 { s + "0" } else { s }
}
#let net = items.map(i => i.qty * i.unit).sum(default: 0)
#let vat = net * 0.19

#set page(paper: "a4", margin: (x: 22mm, y: 20mm))
#set text(font: "Libertinus Serif", size: 10.5pt)

#grid(columns: (1fr, auto), align: (left, right),
  [#image("/assets/logo.png", height: 12mm)],
  [#text(size: 9pt, fill: luma(90))[Invoice #input("no")\ #input("date")]],
)
#v(14mm)

#input("customer")\
#input("address").replace(", ", "\n")
#v(10mm)

#table(
  columns: (1fr, auto, auto, auto),
  align: (left, right, right, right),
  stroke: (x, y) => if y == 0 { (bottom: 0.6pt) } else { (bottom: 0.3pt + luma(200)) },
  inset: (y: 6pt),
  [*Item*], [*Qty*], [*Unit*], [*Total*],
  ..items.map(i => (i.name, str(i.qty), fmt(i.unit), fmt(i.qty * i.unit))).flatten(),
)
#v(4mm)
#align(right)[
  #grid(columns: (auto, 28mm), align: (left, right), row-gutter: 4pt,
    [Net], [#fmt(net)],
    [VAT 19 %], [#fmt(vat)],
    [*Due by #input("due")*], [*EUR #fmt(net + vat)*],
  )
]
#v(1fr)
#text(size: 8.5pt, fill: luma(90))[#input("footer", default: "Thank you for your business.")]

Store it and render:

curl -X PUT https://api.foliant.dev/v1/templates/invoice.typ \
  -H "Authorization: Bearer $KEY" -H "Content-Type: text/plain" \
  --data-binary @invoice.typ

curl -X POST https://api.foliant.dev/v1/render \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -o invoice.pdf -d '{
  "template": "invoice.typ",
  "inputs": {
    "no": "2026-0917", "date": "26 September 2026", "due": "15 October 2026",
    "customer": "Meridian Studio GmbH", "address": "Rosenthaler Str. 41, 10178 Berlin",
    "items": "[{\"name\":\"Design system audit\",\"qty\":1,\"unit\":2400},{\"name\":\"Component library, phase 1\",\"qty\":32,\"unit\":120}]"
  }
}'

Note items is a JSON string inside the JSON body. That is the escape hatch for structured data until inputs accept nested values.

Example: certificate

Landscape A5, one page, PNG output for embedding in an email. Uses datetime.today() so the date is right without an input.

// templates/certificate.typ
#let input(k, default: "") = sys.inputs.at(k, default: default)
#set page(width: 210mm, height: 148mm, margin: 16mm)
#set text(font: "Libertinus Serif", size: 11pt)
#set align(center)

#text(size: 9pt, fill: luma(110), tracking: 0.08em)[CERTIFICATE]
#v(6mm)
#text(size: 24pt, weight: 600)[#input("title", default: "Certificate of Completion")]
#v(2mm)
#line(length: 30%, stroke: 0.6pt)
#v(6mm)
This confirms that
#v(2mm)
#text(size: 18pt, style: "italic")[#input("name")]
#v(2mm)
completed #emph(input("course")) on
#datetime.today().display("[day] [month repr:long] [year]").
#v(1fr)
#grid(columns: (1fr, 1fr), gutter: 12mm,
  [#line(length: 100%, stroke: 0.5pt) #text(size: 8pt)[#input("signer1", default: "Instructor")]],
  [#line(length: 100%, stroke: 0.5pt) #text(size: 8pt)[#input("signer2", default: "Director")]],
)

Render call

{ "template": "certificate.typ",
  "format": "png", "ppi": 3,
  "inputs": { "name": "Ada Lovelace",
              "course": "Analytical Engines" } }

PNG needs a single page; this template has a fixed page size so it always is. ppi: 3 gives 216 dpi, enough for print or a retina screen.

Example: report with data

Multi-page, reads a CSV asset uploaded separately, so the template stays small and the data can be refreshed without touching layout.

// templates/monthly-report.typ
#let input(k, default: "") = sys.inputs.at(k, default: default)
#let rows = csv("/assets/" + input("data", default: "usage.csv"))
#let header = rows.first()
#let body = rows.slice(1)

#set page(paper: "a4", numbering: "1 / 1", header: [#h(1fr) #text(size: 8pt, fill: luma(110))[#input("title") · #input("period")]])
#set text(font: "Libertinus Serif", size: 10pt)
#set heading(numbering: "1.")

= #input("title")
#input("summary")

= Figures
#table(columns: header.len(), align: (left, ..range(header.len() - 1).map(_ => right)),
  ..header.map(h => strong(h)),
  ..body.flatten(),
)

= Notes
#input("notes", default: "No notes for this period.")
# upload the data once per period
curl -X PUT https://api.foliant.dev/v1/assets/usage-2026-09.csv \
  -H "Authorization: Bearer $KEY" -H "Content-Type: text/csv" --data-binary @usage.csv

# render
curl -X POST https://api.foliant.dev/v1/render -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -o report.pdf \
  -d '{"template":"monthly-report.typ","inputs":{"title":"Usage report","period":"September 2026","data":"usage-2026-09.csv","summary":"Renders grew 40 % month over month."}}'

The template builds the asset path from an input. That works because "/assets/" appears as a literal in the source, so the loader knows to fetch from your assets; what follows it can be dynamic.

Logos and fonts

Stored assets are addressed as /assets/<name> (leading slash: root of your virtual project, so the path works from inside included templates too) from any document or template:

Upload from the dashboard (Files section), or via the API with raw bytes, base64 JSON, or a URL:

curl -X PUT https://api.foliant.dev/v1/assets/Inter-Regular.ttf -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"url":"https://github.com/rsms/inter/raw/master/docs/font-files/Inter-Regular.ttf"}'

Composing templates

Templates can include other templates. Put shared pieces (letterhead, footer, helper functions) in one file and include it:

// templates/_base.typ
#let input(k, default: "") = sys.inputs.at(k, default: default)
#let letterhead() = grid(columns: (1fr, auto),
  image("/assets/logo.png", height: 12mm),
  text(size: 8.5pt, fill: luma(90))[Meridian Studio GmbH\ Rosenthaler Str. 41\ 10178 Berlin])
#let money(n) = "EUR " + str(calc.round(n, digits: 2))

// templates/quote.typ
#import "/templates/_base.typ": *
#set page(paper: "a4", margin: 22mm)
#letterhead()
#v(12mm)
= Quote #input("no")
Total: #money(float(input("total", default: "0")))

Use #import "/templates/x.typ": * for functions and variables, #include "/templates/x.typ" to paste content. Includes are resolved recursively, up to 40 stored files per render.

Managing templates

TaskDashboardAPIMCP tool
ListFiles sectionGET /v1/templateslist_templates
ReadEdit buttonGET /v1/templates/{name}get_template
Create or updateSave templatePUT /v1/templates/{name}save_template
DeleteDelete buttonDELETE /v1/templates/{name}not exposed
Test without renderingnot yetPOST /v1/render with check: truecheck_source
Rendernot from the dashboardPOST /v1/render with templaterender_document

Template names end in .typ (assets must not): letters, digits, -, _, .. Saving over an existing name replaces it immediately; there is no draft state.

Versioning

There is no built-in version history. Two patterns work well:

Either way, check: true against the new template with realistic inputs before switching production traffic. Checks are free.

Pitfalls