There are two ways to let an AI agent produce an invoice. You can ask it for the invoice: it writes HTML, something prints it, and a PDF comes out. Or you can hand it an invoice template someone has already reviewed and ask it only for the facts — who, what, how many, at what rate — as JSON.
The first way works in a demo. The second one is the one that survives a second run.
A document is a promise to look the same
A model writes by sampling. Ask it twice for "an invoice for these two line items" and nothing ties the second answer to the first: the column order, the wording of the VAT line, whether the payment terms mention a due date, whether the legal footer is there at all. Each run is a new draft that nobody reviewed. For a birthday card that is charming. For an invoice, which in Germany has to carry a list of mandatory details, it means reviewing every single one the agent sends — at which point the agent saved nobody any work.
A template turns that around. The layout, the legal lines, the arithmetic and the payment code are written once, looked at by a person, versioned and published. What varies between runs is the data, and the data is the part a model is actually good at: pulling a customer name out of an email, the quantities out of a spreadsheet, the rate out of a contract.
The shape that works has four parts, and our MCP server has a tool for each:
| Step | Tool | Costs |
|---|---|---|
| Find the template | list_templates |
nothing |
| Learn what it expects |
get_template_schema — the JSON Schema of the data, plus the sample data it was designed with |
nothing |
| Check the data | validate_template |
nothing: the API checks a stored template without rendering it; ad-hoc HTML is checked inside the MCP server |
| Render | render |
nothing with a test key; with a live key one unit per started five PDF pages |
Test keys (ff_test_…) render the same templates through the same pipeline and are never metered; their outputs are kept for 24 hours, and on the Free plan they carry a watermark.
Where the data still goes wrong
Giving the model the data to write does not make the data right. So we took a short invoice template and fed validate_template the data an agent tends to produce, then rendered each version. We passed the template itself as HTML, which the tool checks offline, inside the MCP server:
<table>
{% for line in invoice.lines %}
<tr><td>{{ line.description }}</td><td>{{ line.qty }}</td><td>{{ line.price | money }}</td><td>{{ line.total | money }}</td></tr>
{% endfor %}
</table>
{% set net = invoice.lines | sum('total') %}
{% set vat = net * invoice.vat_rate / 100 %}
<p>Gesamt {{ (net + vat) | money }}</p>
With the data it was written for, validation reports nothing and the invoice ends in Gesamt 3.760,40 €. Then the agent's versions.
The agent leaves out the line totals, expecting the template to multiply quantity by price. This one does not. validate_template answers:
{
"ok": true,
"diagnostics": [
{
"severity": "warning",
"code": "unknown-variable",
"message": "\"line.total\" is not present in the sample data",
"line": 3,
"column": 101
}
]
}
and the rendered invoice ends in Gesamt 0,00 €.
The agent calls the list items, because that reads as well as lines to a model that did not look at the schema. Validation warns five times, once for invoice.lines and once for each field under it, again with "ok": true. The rendered invoice has no lines and totals 0,00 €.
The agent writes the rate as "19 %", the way it appears on the contract it read. The offline check reports nothing: it knows which names the template reads, not what kind of value belongs in them. When we first ran this, the rendered invoice ended in Gesamt NaN. That was a bug in our money helper, and it is fixed: money, number and numToWords now stop the render with money: got NaN, the result of a calculation with a value that is missing or not a number rather than print NaN on a document. Text that is not a number, such as "on request", still prints as it is.
The check that knows the values
An agent rarely brings its own HTML. It fills a template stored in Formfeed, and for a stored template validate_template does not check offline: it asks the API, free and without rendering, to check the version a render would use. That check runs the template once with the data, so the "19 %" above comes back as an error before anything is printed, and it compares the data with the template's stored JSON Schema:
{
"severity": "error",
"code": "data-validation",
"path": "data.invoice.vat_rate",
"message": "data.invoice.vat_rate: must be number"
}
The schema check needs a stored schema. Every template has one inferred from its sample data, which is what get_template_schema hands the agent, but only a schema you stored decides what counts as wrong — an inferred one would reject data for leaving out a field the sample happened to have. In the editor, Store as contract in the data and schema panel stores one; with the CLI, a schema.json next to the template does. Store one for every template an agent fills.
Two things follow either way. First, ok: true means nothing will fail, not the data is right: missing data is a warning, because a template may deliberately leave optional fields out. An agent should treat every warning as a question to answer before it renders. Second, a check only knows what it has been told. While you set an agent up, connect it with a test key: every render costs nothing, and reading a handful of results checks what no validation can, such as whether the right customer ended up on the invoice.
The offline results above come from a test in the MCP server's own repository, which runs the real tools with a network stub that fails on any request, so it also shows that ad-hoc HTML and its data never leave the machine the server runs on while they are checked: validate-agent-data.spec.ts.
What to tell the agent
The MCP server already tells a connected agent the order: list, read the schema, validate, render. What it cannot know is how strict to be with your documents. A few lines in the agent's instructions close that gap:
Before rendering a document:
1. Read the template's schema and sample data with get_template_schema, and use the same field names.
2. Call validate_template with your data. Fix every diagnostic, warnings included.
3. Numbers are numbers: 19, not "19 %"; 1200.5, not "1.200,50".
4. Before handing a document on, compare its totals with the data you sent.
The model still writes the part that changes. The part that must not change was written by a person, once, and the agent never gets to touch it.
The agents page has the setup for Claude Code, Cursor, VS Code, Codex and a dozen more clients and frameworks, and the MCP documentation lists every tool with its arguments.
Checked on 5 October 2026. The schema check in validate_template needs @formfeed/mcp 0.3.6 or later.
Top comments (0)