Full agent reference
The complete agent-facing material: both engines in depth, the field reference, the sandbox rules, the token lifecycle and the failures worth knowing about.
Let an AI agent design your PDFs
PDFEx speaks MCP — the open protocol that lets an agent use a service as a set of tools. Point Claude, Cursor, VS Code or any MCP host at https://pdfex.io/mcp and your agent can design a template, look at a picture of the result, fix what is wrong, save it, and print real PDFs from it. No SDK to install, no glue code, no curl.
It is the same API you already use by hand, and the same API key — just handed over in a shape an agent can drive by itself.
The agent sends JSON and gets JSON back. When it asks for a preview it receives a pdf_base64 string — a blob of bytes it cannot look at.
It has to trust that the layout came out right.
preview_template returns the page as an image — a picture the model can actually look at — plus the measurements, the variables, and any warnings.
It critiques its own design and iterates until the page is right.
Connect in one minute
Create a key at API Keys (the same key your REST integration uses) and add this to your MCP host's config:
{
"mcpServers": {
"pdfex": {
"type": "http",
"url": "https://pdfex.io/mcp",
"headers": { "Authorization": "Bearer pdfex_your_key_here" }
}
}
}Most hosts let you read the key from an environment variable instead of pasting it — set the variable name PDFEX_API_KEY in your host's environment where it supports that. The header must be on the connection, not on individual tool calls.
What you are connecting to
The authoring loop
This is the workflow the server itself recommends (it ships as a prompt, see below). Nine tools, but only one habit matters: look before you save.
- 1Read the contract
get_capabilitiesThe engines, the field types, the units, the page sizes, your plan limits. One call, so the agent never guesses a field shape or a page size.
- 2Start from a working example
get_samplesInvoices, certificates and pay stubs, in both engines. Agents design far better by adapting a real template than by inventing JSON from a description.
- 3Write the template
pdfme coordinates in millimetres, or plain HTML and CSS with
{{variables}}for the data. - 4Check it cheaply
validate_templateCatches malformed markup and unsupported field types without rendering anything. Fast, and free of side effects.
- 5Look at it
preview_templateThe important one. A real render through the production pipeline, returned as an image. This catches what validation cannot: a field hanging off the page, a font that will be substituted, data the sample never fills.
- 6Fix and look again
The agent sees the picture, adjusts, and previews once more. Repeat until the page looks right. Nothing has been saved yet.
- 7Save it
create_templateOnly now, when the preview looks correct. This is the first step that writes anything — and the first that counts against your plan's template limit.
- 8Print real PDFs
The saved template now works everywhere: the dashboard, the REST API, and anything else that can call
POST /api/pdf/token.
The nine tools
Every tool is a thin wrapper over the same service your REST calls use. There is no second implementation to drift out of sync, and no second contract to learn — including the field names.
| Tool | Kind | What it does |
|---|---|---|
| get_capabilities | read | Engines, field types, units, page sizes, fonts, your limits. |
| get_samples | read | Lists the sample catalogue, or returns one sample in full (kind + engine). |
| list_templates | read | Summaries of every template you can see. Never the schema. |
| get_template | read | One template. detail: "summary" (default) omits the schema; "full" includes it. |
| validate_template | read | Is this template valid? Never renders, never saves. |
| preview_template | read | Renders and returns page images. Nothing is saved. |
| create_template | write | Saves a new template. Counts against your template limit. |
| update_template | write | Edits a template, optionally guarded by expected_version. |
| delete_template | write | Permanently deletes. Requires confirm: true. |
Why the read/write split is worth understanding
Each tool declares readOnlyHint, destructiveHint and idempotentHint to the host. A good MCP client uses them: six read tools can be auto-approved, delete_template can be made to ask first. That means you can hand an agent your keys without handing it the ability to quietly delete your work.
template and sampleData, while validate_template takes template content only (template or html_template) — sample data cannot change whether a template is valid. The tools that save — create_template and update_template — use pdfme_schema and sample_data. The preview and save names match the REST contract exactly, on purpose. See Troubleshooting if a call returns “nothing to render”.A resource and a prompt
Alongside the tools, the server publishes reading material and a ready-made workflow. Hosts surface these as “resources” and “prompts”.
pdfex://guide/agent-template-guide
The complete authoring guide — engines, field reference, units, validation errors, concurrency, and the REST contract — as markdown the agent can read directly. Roughly 16,700 characters. When a template is unusual, an agent should read this instead of guessing.
design_template
Takes a goal — “a freelance invoice in euros” — and hands back the recommended loop as instructions, together with the conventions that are easy to get wrong. A good host shows it as a slash-command.
Two engines, two philosophies
Every template is built with one of two engines. They produce the same kind of file from the same API — but you think in completely different units.
Write a web page
HTML and CSS, rendered by the same headless Chromium that prints web pages. Flowing text, tables that size themselves, flexbox, grid, gradients — all of it works, because it is a browser.
You describe a document. The browser decides where things go.
Place every field by hand
Each field gets an exact position and size in millimetres. Total control, no reflow: what you place is what prints. Ideal for forms, labels, certificates and anything on a pre-printed background.
You describe a coordinate plane. Nothing moves unless you move it.
Which should you pick?
| If you are building… | Use | Because |
|---|---|---|
| Invoices, receipts, statements, reports | html | Tables and text need to flow and grow with the data. |
| Anything with long or variable text | html | The browser wraps and paginates it for you. |
| Certificates, awards, diplomas | html | Centring and decorative CSS are trivial. |
| Labels, forms, ID cards | pdfme | The paper has a fixed grid; you need exact positions. |
| Overlaying a scan or pre-printed form | pdfme | Fields land on known coordinates. |
| Barcodes, seals and precise line art | pdfme | svg, line, rectangle and ellipse fields at exact sizes. |
| An agent designing from scratch | html | Far fewer ways to get it subtly wrong. |
The pdfme engine, in detail
Everything in a pdfme template is measured in millimetres, with the origin at the top-left of the page. Font sizes are the one exception: those are in points, because that is how type is measured.
What the coordinates mean
This is a real A4 page — 210 × 297 mm — with the two fields from the example below drawn in place. Left and top are the position; width and height are the box the text may occupy.
title — position { x: 20, y: 20 }, size 170 × 12, centred, 24pt.
body — position { x: 20, y: 40 }, size 170 × 60, 11pt.
Because A4 is 210 mm wide and both fields start at x = 20 with a width of 170, they end at 190 mm — a 20 mm margin on each side. That mirrors the page padding exactly.
Template structure
{
"basePdf": { "width": 210, "height": 297, "padding": [20, 20, 20, 20] },
"schemas": [
[
{ "name": "title", "type": "text", "position": { "x": 20, "y": 20 },
"width": 170, "height": 12, "fontSize": 24, "alignment": "center" },
{ "name": "body", "type": "text", "position": { "x": 20, "y": 40 },
"width": 170, "height": 60, "fontSize": 11 }
]
]
}schemasis an array of pages. Each page is an array of fields. One page here.- Every field needs a unique
name— that name is the variable your data fills — plustype,position,widthandheight. - Omit
basePdfand you get A4 portrait, no padding. - Fields marked
readOnlyare treated as fixed decoration and are not counted as variables.
Field types
| Type | Use it for | Options |
|---|---|---|
| text | Any string of characters | fontSize, alignment, verticalAlignment, fontColor, backgroundColor, lineHeight, characterSpacing |
| table | Rows and columns of text | head, headWidthPercentages |
| image | Logos and pictures | value is a data: URI, or an http(s) URL fetched server-side |
| svg | Vector artwork | placed like any other field |
| line | Rules and underline strokes | position and size define the stroke |
| rectangle | Boxes, fills and borders | position and size define the box |
| ellipse | Circles, seals, ovals | position and size define the bounds |
head array — not from the rows in your data. If head has three entries, every row must have three values, and headWidthPercentages needs one entry per column too. Data with four values will lose the fourth silently.A table field, in full
{
"name": "line_items",
"type": "table",
"position": { "x": 20, "y": 60 },
"width": 170,
"height": 80,
"head": ["Description", "Qty", "Amount"],
"headWidthPercentages": [60, 15, 25],
"fontSize": 10
}
// the value is an array of arrays of strings — one inner array per row:
"line_items": [
["Consulting", "10", "$800.00"],
["Platform licence", "1", "$450.00"]
]Older templates that store each page as an object keyed by field name instead of an array are still accepted — they are normalised automatically, so nothing you built before breaks.
The HTML engine, in detail
HTML templates are ordinary web pages with Handlebars placeholders. They are rendered by headless Chromium using its print-to-PDF pipeline — the same route your browser's “Save as PDF” takes.
How open it is
This is the part people underestimate. It is not a template markup language with a subset of CSS bolted on — it is a real browser engine. All of the following work:
- Flexbox and CSS grid
- Absolute and relative positioning
- Linear and radial gradients
- Borders, radius, box-shadow
- Web-safe font stacks
- Custom @font-face with base64
- Tables that size to content
- @page size and margins
- page-break-before / after / inside
- Repeating headers via thead
- Generated content (:before, :after)
- Counters and CSS counters
- Multi-column text layout
- writing-mode (vertical text)
- Transforms and rotation
- SVG inline in the markup
Data injection
<!-- a single value -->
<h1>Invoice {{invoice_number}}</h1>
<!-- a repeating block: one row per item in the array -->
<table>
<thead><tr><th>Description</th><th>Amount</th></tr></thead>
<tbody>
{{#each items}}
<tr><td>{{description}}</td><td>{{amount}}</td></tr>
{{/each}}
</tbody>
</table>
<!-- conditional content -->
{{#if is_paid}}<p class="paid">PAID</p>{{/if}}Variables are extracted from your placeholders automatically — you never list them by hand. Nested data works the natural way: inside {{#each items}}, each field name is read from the current item, so one array builds a whole table.
The sandbox: what is deliberately switched off
| Restriction | What to do instead |
|---|---|
| JavaScript does not run | Everything is static. Compute values before you send them. |
| No external network requests | Inline images and fonts as data: URIs. |
| <iframe> and meta-refresh do not navigate | Nothing can pull in a remote page. |
| No cookies, no local storage | Templates are self-contained documents. |
Page size and pagination
PDFEx reads your CSS @page rule to work out the paper size. If there is no such rule it falls back to US Letter, and you can always override it explicitly with page_format.
Let the CSS decide
@page { size: a4; margin: 12mm; }Or force it from the API
{ "engine": "html", "page_format": "A3" }Supported page sizes
| Name | Width | Height | Typical use |
|---|---|---|---|
| A4 | 210 mm | 297 mm | International default |
| Letter | 215.9 mm | 279.4 mm | US default |
| Legal | 215.9 mm | 355.6 mm | Long US documents |
| A3 | 297 mm | 420 mm | Posters, large labels |
| A5 | 148 mm | 210 mm | Notices, small booklets |
CSS shorthand is understood too — size: letter, 8.5in 11in, and landscape all resolve to the right format. A certificate often just declares @page { size: landscape }.
Fonts and typography
Type is where documents are won or lost, so it is worth knowing exactly what each engine does.
Every font the browser knows
Chromium brings real font rendering. Use a font stack and the document adapts to whatever is available:
body { font-family: Georgia, "Times New Roman", serif; }
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }You also get real italics, weights, small caps, tabular figures and letter spacing — the typographic features people expect from a browser.
Roboto, plus your own
pdfme embeds fonts into the file itself, so the PDF looks identical everywhere. Roboto is always loaded as the fallback. Add your own with a per-template fonts array:
"fonts": [
{ "name": "Inter", "data": "AAEAAAATAQAABAAw…" }
]data is the raw TTF base64-encoded (no data: prefix). Fonts are stored per template, not per account.
- Custom
fontsarrays apply to the pdfme engine only. The HTML engine renders with Chromium's own fonts — use a font stack there and inline a@font-facerule with a data: URI if you need something specific. - If a pdfme field names a font that is not loaded, PDFEx prints it in the fallback font and records a
FONT_NOT_AVAILABLEwarning — and the preview image will show you the substitution before you save anything.
Images
An image lives in the field's value, not in the schema — it is data you pass in, not something you place in the layout.
Preferred
A value of data:image/png;base64,... is used exactly as given. No network, nothing to block, and the same output every time.
Fetched server-side
Fetched during generation and embedded. Subject to the target policy below, and a failure is silent unless you read the preview.
What is blocked
Loopback, link-local (including cloud metadata at 169.254.169.254), carrier-grade NAT, multicast and reserved addresses are refused before any request is made — as are localhost and the known metadata hostnames. Private ranges (10/8, 172.16/12, 192.168/16) and IPv6 ULA are intentionally allowed, so a self-hosted deployment can pull images from its own network.
The HTML engine fetches nothing
Only data: and about: requests are permitted; every other one is aborted. A <meta http-equiv="refresh"> or <iframe src> is refused too, because those reach the network without any JavaScript. Inline your images as data: URIs.
IMAGE_SUBSTITUTED with the field's location and the reason.Ask what you are allowed to write
get_capabilities is the contract for your account, generated from the same list the validator enforces — so it can never advertise a field type that would then be rejected. Here is a real response:
{
"engines": ["pdfme", "html"],
"units": "millimetres, origin top-left of the page",
"field_types": {
"text": {
"properties": ["fontSize", "alignment", "verticalAlignment", "fontColor",
"backgroundColor", "lineHeight", "characterSpacing"],
"required": ["name", "type", "position", "width", "height"]
},
"image": { "value_format": "data:image/...;base64,... or http(s) URL fetched server-side" },
"svg": {}, "line": {}, "rectangle": {}, "ellipse": {},
"table": { "notes": "value is an array of string arrays; head defines the column
count; headWidthPercentages must have one entry per head column" }
},
"page_formats": {
"Letter": { "width_mm": 215.9, "height_mm": 279.4 },
"Legal": { "width_mm": 215.9, "height_mm": 355.6 },
"A4": { "width_mm": 210, "height_mm": 297 },
"A3": { "width_mm": 297, "height_mm": 420 },
"A5": { "width_mm": 148, "height_mm": 210 }
},
"default_page": { "width_mm": 210, "height_mm": 297 },
"fonts": ["Roboto"],
"fonts_note": "per-template fonts arrays are accepted on create and preview;
this is the default fallback font",
"template_limit": { "tier": "pro", "max_templates": 15, "used": 3 },
"limits": {
"preview_max_body_bytes": 10485760,
"preview_max_pages": 10,
"preview_max_scale": 4
}
}Note template_limit: it is your tier and your current usage, so an agent can tell whether it has room to save another template before trying and failing. Nothing here is guesswork.
Start from a working example
Three document types, each available in both engines. Fetching one gives you the complete template and its sample data, ready to adapt — which is a far better starting point than a blank schema.
| kind | What you get | Engines |
|---|---|---|
| invoice | Billing with line items and totals A clean header, a bill-to block, a repeating line-item table, a total row. | html pdfme |
| certificate | Course completion award Centred display type and two signature rules — landscape. | html pdfme |
| paycheck-stub | Earnings, deductions and net pay An info grid, two side-by-side tables and a highlighted net-pay panel. | html pdfme |
Call get_samples with no arguments for the catalogue. Pass both kind and engine to get one in full — one without the other returns a helpful error rather than guessing at what you meant.
invoice sample, preview it, then change one thing and preview again. You will see the whole loop work in under a minute.Limits and guardrails
These are the ceilings that keep a runaway agent from costing you anything. The numbers are published by get_capabilities too, so an agent can size its work correctly instead of discovering them by failing.
| Limit | Value | Applies to |
|---|---|---|
| Request body | 10 MB | Any preview with inlined images or fonts |
| Pages per preview | 10 | How many pages one preview returns |
| Raster scale | 4× | How much a preview image is enlarged |
| Image long side | 2400 px | Preview images are capped, whatever the scale |
| Templates | per plan | How many templates you may save |
Preview images default to 1.5× — crisp enough for a model to read, small enough not to flood its context. Ask for more pages with pages and more detail with scale when you need them, but start small.page_count tells you whether content spilled onto a second page.
A worked example, start to finish
This is the loop above, executed for real. An invoice template, checked, rendered, and saved.
1 — Validate before spending a render
validate_template({
engine: "html",
html_template: "<html><body><h1>Invoice {{invoice_number}}</h1>" +
"<p>Billed to: {{customer_name}}</p>" +
"<table>{{#each items}}<tr><td>{{description}}</td>" +
"<td>{{amount}}</td></tr>{{/each}}</table></body></html>"
})An invalid template is a successful answer, not an error — you get valid: false with the reasons:
{
"valid": false,
"engine": "html",
"errors": ["html_template failed to compile: Parse error on line 1: …"]
}2 — Preview, and actually look at the page
preview_template({
engine: "html",
html_template: "<html><body><h1>Invoice {{invoice_number}}</h1>…",
sample_data: { // ← note: preview uses sampleData, see the trap above
"invoice_number": "INV-2024-001",
"customer_name": "Acme Corp",
"items": [ { "description": "Consulting", "amount": "$800.00" } ]
},
pages: 1,
scale: 2
})The result is a text summary, the page image, and — only if you ask with include_pdf: true — the PDF itself as a resource block:
| Block | Contents |
|---|---|
| text | “Rendered page 1 of 1 (html, 216×279 mm)”, the variables found, and any warnings. |
| image | The page as a PNG — what the model looks at. |
| resource | The full PDF as application/pdf. Only present when include_pdf is true. |
3 — Save it
create_template({
name: "Acme Invoice",
engine: "html",
html_template: "<html>…",
sample_data: { "invoice_number": "INV-2024-001" } // ← pdfme_schema / sample_data here
})You get back the template id and its version. Keep both — you need the id to print, and the version to edit safely.
From a template to a live PDF
MCP is for authoring. Printing the finished document is a REST call, and it is deliberately simple: one call to request, one to fetch.
Request the document
POST /api/pdf/token
{
"template_id": "651…",
"data": { "invoice_number": "INV-2024-001",
"customer_name": "Acme Corp" }
}Returns instantly — the work is not done yet, so this is safe to call from a request handler.
Fetch the PDF
{ "success": true,
"token": "65eb…",
"url": "/api/pdf/65eb…" }Then GET that URL to receive application/pdf. Links stay valid for 15 minutes, then return 410.
Things that will bite you here
- The URL is the credential.
GET /api/pdf/:tokenneeds no API key — anyone holding the link can fetch the document. Treat it like a signed URL: don't log it, don't put it in a page your users can inspect, and don't email it around. - Fetch it promptly. The 15 minutes run from when the token was created, not from when you first fetch it. After that you get a 410 and have to request a new one.
- It re-renders on each fetch. The PDF is not stored — it is generated on demand. Fetching twice can legitimately produce two slightly different files if the template changed in between.
- Same data, same look. Generation runs through exactly the pipeline your per-template preview used — same fonts, same page size, same pagination. If it looked right in the preview, it prints right.
Two agents, one template
If an agent might be editing a template that a human is also editing, tell it to guard its writes. Every template carries a version that goes up with each successful change. Read it, then send it back:
update_template({
id: "651…",
name: "Invoice v2",
expected_version: 3
})
// → 200: the template is now version 4
// → conflict: nothing is written, and you are told the real version
{
"error": "Version conflict — someone else changed this template since you read it.",
"details": [
"expected_version 3 does not match the current version 4; re-fetch the template and retry",
"Re-read it with get_template, reapply your change, and pass the new version."
]
}expected_version is optional, so an agent that prefers to just overwrite still can. But for anything you care about, use it — it turns a silent overwrite into a clear error you can recover from. Otherwise the loser of the race simply disappears.
Warnings: the things that render anyway
A preview can succeed and still be wrong — a field hanging off the page renders clipped, a missing font gets silently substituted. Rather than let that pass, every preview reports it:
| Code | Means | Fix |
|---|---|---|
| FIELD_OUT_OF_BOUNDS | A pdfme field extends past the page edge and will be clipped or cut off. | Move it, or shrink its width/height. |
| FONT_NOT_AVAILABLE | A field names a font that is not loaded; the fallback font is used. | Add the font to the template's fonts array, or name a loaded one. |
| VARIABLE_MISSING_SAMPLE_DATA | The template uses a variable your sample data does not provide. | Add the value — the preview renders that spot empty. |
| IMAGE_SUBSTITUTED | An image value was replaced by a transparent placeholder, so the image is absent. | Pass a data: URI, or an http(s) URL that is neither blocked nor broken. |
| FONTS_IGNORED_BY_ENGINE | Fonts were supplied to the html engine, which cannot load them. | Embed the font in the template as a self-contained @font-face with a data: URI. |
{{#each items}} belongs to the item, not to your data contract, so it is never reported as missing — while {{../total}} and {{@root.customer}} are, because those really are read from the root. If something is reported as missing and your data does not have it at the top level, the warning is right.When something goes wrong
Errors are written to be acted on, and they say what to do next. The usual suspects:
“nothing to render” / an empty preview
The draft tools use template and sampleData; the save tools use pdfme_schema and sample_data. If you sent the wrong pair, the tool sees no content. Check the names before anything else.
401 — Invalid or missing API key
The Authorization header has to be set on the MCP connection, not on an individual call. Confirm the key is live and the host is actually sending headers.
405 on a GET
Expected. This endpoint is stateless and offers no SSE stream; the official client handles the 405 fine.
“unsupported type”
Only the seven listed field types exist — there is no qrcode or barcode field. Use an svg field and render the code as vector art.
An image is missing from the PDF
The render sandbox has no network access. Inline it as a data: URI (the http(s) form works for pdfme image fields, which are fetched server-side, but nothing else can reach the network).
“has no value for …” in the preview
Your sample data is missing a variable — add it. If the variables are inside {{#each}}, see the known quirk above and believe the image.
A pdfme field vanished
Check the warnings for FIELD_OUT_OF_BOUNDS — a field placed beyond the page edge is clipped, not resized.
409 conflict on update
Someone edited the template since you read it. Re-fetch with get_template, reapply your change, and pass the new version as expected_version.
Paste this into your agent
Don't want to explain any of it? Add the MCP server to your host and hand the model this:
Connect to the PDFEx MCP server and design a PDF template for:
<describe your document>
Use get_capabilities first, then adapt the closest sample from get_samples.
Validate, then preview and LOOK AT THE RETURNED IMAGE — measure it against the
description above and fix what looks wrong. Preview again until it is right.
Only then call create_template. Tell me the template id when you are done.
Remember: geometry in pdfme is in millimetres from the top-left; in HTML use
{{variables}} and data: URIs for images.That is the whole trick: an agent with eyes on the page, and nothing written until it likes what it sees.