Structured letter content (blocks)
blocks is the structured alternative to plain content: typed paragraphs, headings, key-value rows, tables with real column math, totals lines and named styles, so an agent can build an invoice or a price list instead of hand-formatting a wall of text. Every element stays inside bounded, validated rails, so a structured letter can never break DIN 5008 or the delivery clear zones.
- 1
content or blocks, never both
A letter carries its body either as content (plain text, as before) or as blocks (structured: tables, headings, totals lines). Exactly one of the two, never both and never neither. blocks is available as a field on letter_create_draft, order_send (inline) and letter_preview. An optional document-level styleDefs object names reusable styles that blocks and cells reference by name.
- 2
The vocabulary
Twelve block types, all available from day one: plain text, forms (checkboxRow, fillLine, keyValue with valueRule), the table workhorse, totals lines, layout containers (columns, box) and one single manual pagination block, pageBreak. Everything beyond that stays automatic: table/totals/box/checkboxRow/fillLine already keep themselves together across a page break. Style a whole paragraph, heading, cell or line via style/styleOverride (step 4); highlight one word inside a sentence with inline markup instead (step 5).
paragraph text Plain text, align left/center/right/justify, understands inline **bold**/*italic* heading text Heading, level 1..3, understands inline **bold**/*italic* keyValue rows Label/value rows (e.g. invoice number / date), up to 40 rows, valueRule draws a writing line under every value checkboxRow options Question with tick boxes, up to 6 options, optionsAlign right or inline, boxes drawn as vector geometry fillLine segments One to four writing lines side by side, labelPosition inline-left or below (e.g. "Ort, Datum") table columns, rows The workhorse: up to 20 columns, 100 data rows, colspan/rowspan, format eur/date/plain, header row repeats across page breaks, cell text understands inline **bold**/*italic* totals lines Label/amount lines, up to 20, emphasis for the grand total, kept together across a page break columns flows (2..3) Parallel flows of leaf blocks (+ box) box blocks (1..40) Bordered/filled container, e.g. a hint panel, may hold a columns block image attachmentId PNG/JPG, uploaded first via attachment_upload_image spacer lines Blank lines, 1..20 pageBreak (no fields) Hard page break, top level only
- 3
Money is always integer cents
Every amount is an integer in cents, never a float. 32.00 EUR is 3200, not 32 or 32.00. A table column with "format": "eur" renders the cell value (or a cell object's amountCents field) as formatted currency, e.g. 3,653.30 €. A table cell is a bare string (text), a bare integer (cents, only meaningful in an eur column), null (empty cell), or an object { text?, amountCents?, align?, colspan?, rowspan?, style?, styleOverride? } for anything beyond plain text.
- 4
styleDefs: named styles plus inline overrides
A document may carry a top-level styleDefs object, up to 24 entries: { "accent": { "font": "sans", "sizePt": 10, "color": "primary", "bold": true } }. Blocks and cells reference a style by name via the style field, or supply a small inline styleOverride with the same shape. Changing one styleDef restyles the whole document. Built-in styles (always available, even with no styleDefs object): default, bold, muted, small, accent, heading1, heading2, heading3, tableHeader, tableCell, totals, totalsEmphasis, caption. Bounds: font is default/serif/sans/mono (default is Times, matching plain-content letters), sizePt 7 to 16 (7 is deliberately one step below body small print, for form legends and captions), color is a palette key (primary/primaryDark/ink/muted or the grayscale gray0 to gray100) OR your own brand hex in "#rrggbb" form (six digits only, lower or upper case, no shorthand, no CSS color names). FrankKi checks every TEXT color, palette key or hex alike, for readability: at least 3.0:1 contrast on white paper, 4.5:1 below 9pt. An invisible style is rejected with the measured contrast ratio and a fix hint, never silently printed. Colors that carry no text (box.borderColor, box.background, table.borders.color, table.zebraColor) take the same palette keys or hex values but skip the contrast check: a pale fill is the point of a pale fill, it only has to be a valid color.
- 5
Inline markup: **bold** and *italic*
Body text understands a tiny markdown subset and nothing beyond it: **bold** becomes bold, *italic* becomes italic, ***both*** becomes bold and italic. This applies to paragraph.text, heading.text and table cell text (a string cell or { "text": "..." }); everywhere else (checkboxRow.text, fillLine labels, keyValue label/value, totals labels, image captions) the text prints exactly as written. A value formatted by an eur or date column also carries no markup, because that string was never authored by hand. Two rules keep this predictable: a marker only opens when a non-space follows it and only closes when a non-space precedes it, so "3 * 4 * 5" stays plain text; and an opener with no matching closer of the same length prints literally, "**bold*" is exactly "**bold*". There is no escape character and no other markdown (no links, no code, no lists, no underscore _italic_). Size, color and font role stay with style/styleOverride, inline markup only ever adds bold and italic.
{ "type": "paragraph", "text": "Please transfer the amount by **2026-03-15**, reference *RE-2026-014*." } - 6
Form blocks: checkboxRow, fillLine, keyValue with valueRule
Three blocks turn a letter into something a recipient can fill in by hand. Their form furniture is always vector geometry (rectangles and rules), never a font character, so a tick box stays a tick box on every printer instead of falling back to a tofu rectangle. checkboxRow draws a question with tick boxes (1 to 6 options); optionsAlign "right" (the default) right-aligns the boxes on the first text line, "inline" lets them run on directly behind the text; checked: true draws a crossed box, and a checkboxRow is never split across a page break. fillLine draws one to four writing lines side by side; labelPosition "inline-left" puts the label before the line on the same baseline, "below" draws the line first with a small label underneath, the classic "Ort, Datum" under a signature line; widthPercent follows the same all-or-none rule as columns. keyValue with valueRule: true additionally draws a writing line under every value column, for rows like "Land: ______ TIN: ______", without a second block type.
{ "type": "checkboxRow", "text": "Bist du mit der elektronischen Zustellung einverstanden?", "options": [ { "label": "Ja" }, { "label": "Nein", "checked": true } ] } { "type": "fillLine", "labelPosition": "below", "segments": [ { "label": "Ort, Datum" }, { "label": "Unterschrift" } ] } { "type": "keyValue", "valueRule": true, "rows": [ { "label": "Land" }, { "label": "Steuernummer (TIN)" } ] } - 7
pageBreak and nesting
pageBreak is the one manual pagination block: the next block starts on a new page. It is only allowed at the top level, never inside box or columns, a pageBreak there is a named rejection with that exact sentence in details, not a silent failure. It is ignored with no error (the letter still renders) when it is the first block of the document or directly follows another pageBreak, because an empty leading page is never what was meant. Nesting has exactly two legal shapes, both two levels deep: columns > box > leaf block, or box > columns > leaf block. A columns block inside a box may only carry leaf blocks, never another box; everything else is also a named rejection with the exact path.
{ "type": "box", "background": "#f5f0ea", "blocks": [ { "type": "columns", "flows": [ { "blocks": [ { "type": "paragraph", "text": "Sender" } ] }, { "blocks": [ { "type": "paragraph", "text": "Recipient" } ] } ] } ] } - 8
Limits, all in one place
Generous but hard-capped. Page count stays capped separately and is priced per page, dryRun (on order_send and letter_preview) shows the page count and price before a large table actually gets sent.
Blocks per document (nested children counted) 200 Table rows 100 Table columns 20 Characters per table cell 2000 Images per document 10 Serialized payload 256 KB Characters per paragraph 4000 Characters per heading 200 Characters per image caption 300 Label characters (keyValue/totals/table column) 120 Value characters (keyValue) 300 Rows per keyValue block 40 Lines per totals block 20 Options per checkboxRow 6 Characters, checkboxRow question text 600 Characters per checkbox label 60 Segments per fillLine 4 Width per fillLine segment (percent) 5 .. 100 Amount (cents) -1,000,000,000 .. 1,000,000,000 Font size (styleDef) 7 .. 16pt Named styles per document 24
- 9
Errors are machine-fixable
An invalid blocks payload never comes back as a bare Zod error. It is a VALIDATION_ERROR (400) with a details array of { path, code, message, hint } entries, e.g. "blocks[2].rows[4].sum: expected integer cents" with a hint that spells out the fix. The intent is a one-round repair loop: read the hint, fix the exact path, resend. The full description of VALIDATION_ERROR lives in the error catalog.
- 10
The crafting loop
The fastest start is not a blank page but a curated template. template_list marks them curated: true, and block-based ones additionally hasBlocks: true. template_get on one returns its full blocksTemplate plus styleDefs to copy and modify. Then call letter_preview with the modified blocks: it returns the first pages as an image directly in the result, plus a page count and a cost estimate, without charging or sending anything. Adjust blocks, call letter_preview again, until the layout is right. A round takes seconds. Then save it with template_save as a draft and release it with template_release, or for a one-off letter go straight to letter_create_draft and order_send. Letterhead, footer and colors come from your letter design and are set around your blocks, so do not redraw them yourself. Once a template is saved, template_preview checks it with sample values, the way any colleague sees it right now: for a not-yet-saved blocks layout letter_preview stays the right tool, for an already-saved template use template_preview instead.
letter_create_draft { "subject": "Rechnung Juni 2026", "blocks": [ { "type": "heading", "text": "Rechnung" }, { "type": "table", "columns": [ { "key": "text", "label": "Artikel", "width": "grow", "align": "left" }, { "key": "sum", "label": "Summe netto", "width": "auto", "align": "right", "format": "eur" } ], "rows": [ { "text": "Beratung Website Neugestaltung", "sum": 32000 } ] }, { "type": "totals", "lines": [ { "label": "Rechnungssumme", "amountCents": 32000, "emphasis": true } ] } ], "senderAddressId": "<senderAddressId>", "recipientAddressInline": { "name": "Mandant GmbH", "street": "Musterstr.", "houseNumber": "1", "zip": "12345", "city": "Musterstadt", "country": "DE" } } - 11
Block templates: the same blocks, saved
A template is not a second content model. blocksTemplate is the SAME blocks array, with {{tokens}} left in the text fields, plus the same styleDefs map. A template carries contentTemplate (plain text) OR blocksTemplate (structured), never both, mirroring the letter itself. The curated FrankKi library covers Rechnung klassisch, Rechnung modern, Mahnung, Zahlungserinnerung, Preisliste, Honorarnote, Angebot and Bericht mit Tabellen: every partner may read them, none may overwrite them, they exist to be copied. Merge fields are typed: text (the default), date (ISO YYYY-MM-DD), number, currency (integer cents) and rows. A rows field is the variable-length half of a template: the table block that draws it names it via rowsFrom instead of carrying fixed rows, and on apply you pass a list of objects keyed by the column keys of that table. Every rows field must be bound by exactly one table and vice versa; template_save rejects anything else with the exact path. Drafts are free: creating, overwriting and previewing them as often as you like costs nothing and mails nothing. Only template_release is gated, and only by the four-eyes rule: with more than one active user in your account a human releases it in the dashboard and the response carries the link.
// template_save -> blocksTemplate { "type": "table", "columns": [ { "key": "text", "label": "Leistung", "width": "grow" }, { "key": "sum", "label": "Betrag", "width": "auto", "align": "right", "format": "eur" } ], "rowsFrom": "positionen" } // template_save -> mergeFields [ { "key": "positionen", "type": "rows", "label": "Positionen", "required": true } ] // template_apply_with_merge_fields -> mergeValues { "positionen": [ { "text": "Beratung Juni", "sum": 32000 } ] } - 12
Three worked agent flows
First, "Rechnung fuer Juni an Mandant X" ("invoice for June to client X"): mandant_search finds the client, document_create turns the line items (integer cents) into the invoice, re-computes net, VAT rates and gross, checks the mandatory details under § 14 UStG and optionally draws the number from your sequence, order_send sends it. Instead of order_send, approval_submit puts the letter in front of a human first. Second, "Baue mir eine Preisliste im Stil meines Briefdesigns" ("build me a price list in the style of my letter design"): the crafting loop from step 10. Third, "Mahne Rechnung 2026-014 an" ("send a dunning letter for invoice 2026-014"): document_list finds the invoice, document_get returns a ready-made referencePrefill block that you paste unchanged into document.references of a document_create with documentType mahnung, then order_send. The dunning letter then carries the reference to the invoice and document_list finds both sides of the chain.
"Invoice for June to client X" mandant_search -> document_create (rechnung) -> order_send "Build me a price list in the style of my letter design" template_list -> template_get (curated: true) -> modify blocks -> letter_preview -> adjust -> letter_preview -> template_save -> template_release "Send a dunning letter for invoice 2026-014" document_list -> document_get (returns referencePrefill) -> document_create (mahnung, references) -> order_send
- 13
The full reference via MCP
The MCP resource frankki://blocks-guide serves the same reference machine-readably straight from the protocol (resources/list, resources/read): the vocabulary, all table options, the same limits, the template and rows rules, a compact example and the same three flows. An agent without browser access needs nothing from this page. For the document half (invoice, dunning letter, payment reminder, credit note, ZUGFeRD and XRechnung) the e-invoicing guide sits next to it.
See the tool reference →Documents and e-invoicing (ZUGFeRD) →See the error catalog →
Trust and live status
Machine-readable service facts and the current operational status of the partner interface are publicly available at all times, no login needed.