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.

Agent-readyMCP

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.

REST API

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.

MCPsees the page

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.

That image is the whole point. A model cannot judge a layout from base64 bytes. Give it eyes and it stops producing documents that validate fine but print wrong.

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

EndpointPOST https://pdfex.io/mcp
AuthAuthorization: Bearer pdfex_… — same key as REST
TransportStreamable HTTP, stateless
ResponsesPlain JSON (no long-lived SSE stream)
GET / DELETE405 by design — there is no session to hold open
Plan quotaOnly generating a PDF counts — designing and previewing do not
One key, both surfaces. REST calls and MCP tool calls are authenticated by the same credential path, so a single key covers both — and revoking it at API Keys switches both off at once.

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.

  1. 1
    Read the contractget_capabilities

    The 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.

  2. 2
    Start from a working exampleget_samples

    Invoices, certificates and pay stubs, in both engines. Agents design far better by adapting a real template than by inventing JSON from a description.

  3. 3
    Write the template

    pdfme coordinates in millimetres, or plain HTML and CSS with {{variables}} for the data.

  4. 4
    Check it cheaplyvalidate_template

    Catches malformed markup and unsupported field types without rendering anything. Fast, and free of side effects.

  5. 5
    Look at itpreview_template

    The 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.

  6. 6
    Fix and look again

    The agent sees the picture, adjusts, and previews once more. Repeat until the page looks right. Nothing has been saved yet.

  7. 7
    Save itcreate_template

    Only 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.

  8. 8
    Print real PDFs

    The saved template now works everywhere: the dashboard, the REST API, and anything else that can call POST /api/pdf/token.

Steps 1–6 write nothing. An agent can iterate as long as it likes at no cost and with no risk to your data. The only tool that creates a template is the one you ask for.

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.

ToolKindWhat it does
get_capabilitiesreadEngines, field types, units, page sizes, fonts, your limits.
get_samplesreadLists the sample catalogue, or returns one sample in full (kind + engine).
list_templatesreadSummaries of every template you can see. Never the schema.
get_templatereadOne template. detail: "summary" (default) omits the schema; "full" includes it.
validate_templatereadIs this template valid? Never renders, never saves.
preview_templatereadRenders and returns page images. Nothing is saved.
create_templatewriteSaves a new template. Counts against your template limit.
update_templatewriteEdits a template, optionally guarded by expected_version.
delete_templatewritePermanently 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.

The field-naming trap. The preview tool takes 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”.

resource

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.

prompt

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.

htmlrecommended for agents

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.

pdfmepixel-perfect

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…UseBecause
Invoices, receipts, statements, reportshtmlTables and text need to flow and grow with the data.
Anything with long or variable texthtmlThe browser wraps and paginates it for you.
Certificates, awards, diplomashtmlCentring and decorative CSS are trivial.
Labels, forms, ID cardspdfmeThe paper has a fixed grid; you need exact positions.
Overlaying a scan or pre-printed formpdfmeFields land on known coordinates.
Barcodes, seals and precise line artpdfmesvg, line, rectangle and ellipse fields at exact sizes.
An agent designing from scratchhtmlFar fewer ways to get it subtly wrong.
engine

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
body

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 }
    ]
  ]
}
  • schemas is 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 — plus type, position, width and height.
  • Omit basePdf and you get A4 portrait, no padding.
  • Fields marked readOnly are treated as fixed decoration and are not counted as variables.

Field types

TypeUse it forOptions
textAny string of charactersfontSize, alignment, verticalAlignment, fontColor, backgroundColor, lineHeight, characterSpacing
tableRows and columns of texthead, headWidthPercentages
imageLogos and picturesvalue is a data: URI, or an http(s) URL fetched server-side
svgVector artworkplaced like any other field
lineRules and underline strokesposition and size define the stroke
rectangleBoxes, fills and bordersposition and size define the box
ellipseCircles, seals, ovalsposition and size define the bounds
The table trap. A table's column count comes from the field's 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.

enginerecommended for agents

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

RestrictionWhat to do instead
JavaScript does not runEverything is static. Compute values before you send them.
No external network requestsInline images and fonts as data: URIs.
<iframe> and meta-refresh do not navigateNothing can pull in a remote page.
No cookies, no local storageTemplates are self-contained documents.
Why so strict? Templates can come from an agent, so a template must never be able to reach your private network or the metadata service inside your cloud. Blocking navigation and subresource requests is what makes it safe to let an agent author templates unattended. Inline your images and nothing is lost.

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

NameWidthHeightTypical use
A4210 mm297 mmInternational default
Letter215.9 mm279.4 mmUS default
Legal215.9 mm355.6 mmLong US documents
A3297 mm420 mmPosters, large labels
A5148 mm210 mmNotices, 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.

html engine

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.

pdfme engine

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.

Two font limits worth knowing.
  1. Custom fonts arrays apply to the pdfme engine only. The HTML engine renders with Chromium's own fonts — use a font stack there and inline a @font-face rule with a data: URI if you need something specific.
  2. If a pdfme field names a font that is not loaded, PDFEx prints it in the fallback font and records a FONT_NOT_AVAILABLE warning — and the preview image will show you the substitution before you save anything.
imagesthe silent failure

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.

inline data URI

Preferred

A value of data:image/png;base64,... is used exactly as given. No network, nothing to block, and the same output every time.

http(s) URL

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.

The failure that used to be invisible. An unusable image is replaced by a transparent 1x1 PNG and the request still succeeds. It used to be logged server-side only, so the caller saw an empty box and nothing else. Every preview now reports 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.

kindWhat you getEngines
invoiceBilling with line items and totals

A clean header, a bill-to block, a repeating line-item table, a total row.

html pdfme
certificateCourse completion award

Centred display type and two signature rules — landscape.

html pdfme
paycheck-stubEarnings, 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.

Pro move: ask your agent to fetch the 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.

LimitValueApplies to
Request body10 MBAny preview with inlined images or fonts
Pages per preview10How many pages one preview returns
Raster scale4×How much a preview image is enlarged
Image long side2400 pxPreview images are capped, whatever the scale
Templatesper planHow 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.

Images are rasterised from the real production PDF, not a separate rendering path — so the picture your agent approves is the document your customer receives.

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:

BlockContents
text“Rendered page 1 of 1 (html, 216×279 mm)”, the variables found, and any warnings.
imageThe page as a PNG — what the model looks at.
resourceThe 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.

1

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.

2

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/:token needs 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.
Want the whole thing as one agent workflow? Templates can also be moved between environments as portable export bundles — see Export & Import Templates above — so an agent can design on staging, and you can promote the finished template to production without copying your database.

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:

CodeMeansFix
FIELD_OUT_OF_BOUNDSA pdfme field extends past the page edge and will be clipped or cut off.Move it, or shrink its width/height.
FONT_NOT_AVAILABLEA 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_DATAThe template uses a variable your sample data does not provide.Add the value — the preview renders that spot empty.
IMAGE_SUBSTITUTEDAn 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_ENGINEFonts 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.
Repeating blocks are understood. A variable used inside {{#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.