AI report generator, in JavaScript
You want a model to produce documents. Think of an invoice laid out the way a customer asked, or a summary of last month’s orders nobody had to design by hand. The obvious route is to have it write HTML and print that. The route here has it write a report definition instead: JSON that describes the document, which quario checks and renders. Nothing the model writes can execute, and your code keeps the data.
The approach in one line
The model writes a definition → plan() checks it → your code renders it
with your data. A definition that doesn’t compile goes back to the model as
a list of problems. Each sits at its own path, so the model can fix it.
Do it with quario
Install the engine and the target you render to:
npm install quario @quario/pdfDescribe the tool. Its input is a report definition, and quario/schema.json
says what one may hold, so hand that file over as the input schema. Tell the
model the shape of the data it reports on, because it never sees the data
itself:
import schema from 'quario/schema.json' with { type: 'json' }
export const draftInvoice = {
name: 'draft_invoice',
description:
'Write a quario report definition for an invoice. It renders against ' +
'{ invoice: { number, issued, lines: [{ item, qty, unitPrice }] } }.',
input_schema: schema,
}The field name belongs to your SDK: Anthropic calls it input_schema, and
OpenAI calls it parameters. The schema is the same file.
The schema proves the shape of each value. It can’t prove that two values agree,
that a name isn’t reserved, or that an expression compiles. So plan() still
decides whether the document renders.
Handle the call. Compile the definition with plan(). While it has problems,
return them as the tool result. Once it compiles, render it against the data
your own code fetched:
import { writeFile } from 'node:fs/promises'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
const q = quario({ locale: 'en-IE', currency: 'EUR' })
export async function runDraftInvoice(definition: unknown) {
const { report, problems } = q.plan(definition)
if (!report) return { ok: false, problems } // the model reads these and retries
const bytes = await report.render(pdf(), await getInvoice())
await writeFile('invoice.pdf', bytes)
return { ok: true }
}A problem names where it sits and what’s wrong, which is exactly what a model needs to correct itself:
{
"ok": false,
"problems": [
{
"path": "detail.columns[1].style.format",
"message": "detail.columns[1].style.format: expected number, currency, percent, or date"
}
]
}Your agent loop does the rest. It sends the tool result back, the model sends
a corrected definition, and the handler runs again until ok is true.
What the model can’t do
A definition is untrusted input, the same as data, so it doesn’t matter who wrote it. Whatever the model sends:
- It can’t execute anything. Expressions parse into closures. There’s no
eval, nonew Function, and an expression has no loops to spin in. - It can’t emit markup. Every target reads a definition’s own text as
text, so a
<script>in a header reaches the reader as characters. - It can’t reach past your configuration. It calls only functions you registered, and it can’t raise the query budgets you set.
- It doesn’t supply the data. The handler renders against the record your code fetched. The definition only describes the document.
Where the numbers come from
The model writes the formula for each number, not the number. A total is an
aggregate, sum:[email protected] * @.unitPrice, and a line amount is an expression,
{{ round(@.qty * @.unitPrice, 2) }}. quario evaluates both against the record
your code fetched, so the figures come from your data.
Compare that with a model writing the page itself:
<tr><td>Total</td><td>$1,760.00</td></tr>{ "aggregates": { "total": "sum:[email protected] * @.unitPrice" } }The first total is text the model typed. It can look right and be wrong, and nothing checks it. The second is a formula, and quario computes the total, so the model can’t miscount a line or mistype the total.
What can still go wrong is the formula itself. A model can sum @.unitPrice
where it meant @.qty * @.unitPrice, read the wrong field, or drop the wrong
rows. It can also type a figure into a text cell. None of that hides in the
output: each is a line in the definition, readable in a diff. plan() catches
a definition that doesn’t fit the schema, not a formula that’s valid and wrong.
So review what the model wrote the way you’d review any change. Keep the
definitions it gets right, because a definition is JSON you can store, version,
and diff.
The claim holds when the data comes from your systems, as it does in the handler above. If you let the model pass the data itself, quario computes faithfully over whatever it sent.
Prefer MCP?
If your agent runs in a client such as Claude Code or Claude Desktop, you can
skip the handler. @quario/mcp exposes the same check and render as two tools,
and the host settings stay in its launch configuration.
quario over MCP →
FAQ
Can the model get the totals wrong?
It can’t get the arithmetic wrong, because it doesn’t do the arithmetic: quario does, from the data your code supplies. It can declare the wrong formula, or type a figure into a text cell. Both sit in the definition, where you or a reviewer can catch them.
Which models does this work with?
Any model that takes a tool with a JSON Schema input, in the ordinary mode. The
strict structured-output modes of OpenAI and Anthropic don’t accept the report
schema. OpenAI’s strict mode requires every key, and a report leaves most of
them out. The strict mode of Anthropic allows 24 optional keys, and the report
document has more than 290. plan() is the check either way, so a model
without tools can return the definition as JSON in its reply instead.
Why not let the model write HTML?
HTML from a model is code you have to sandbox, and it’s different every run. A definition is data, and quario validates it before it renders. The same one renders to a page, a PDF, a workbook, or a Word document.
How big is it?
The engine is under 5 kB minified and compressed. Each render target adds a little of its own on top — around 1 kB for HTML, 5 kB for PDF. The PDF target also leans on a file writer. The JSON Schema is about 7.5 kB compressed, and only the code that describes the tool imports it.
Can I use it for free?
Evaluating it’s free and has no time limit — the full engine, no key to request — and evaluation output carries a watermark. Anything past evaluating it, including shipping it in an application or running it in your business, needs a license for each production deployment. Its users, agents, and renders are never counted, and replicas of one service count once. An agent is never licensed on its own: it’s part of the deployment that runs it. See pricing →