Let the agent write the data, not the HTML
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
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.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.