PDF Export Node
The pdf_export node turns content held in a run variable into a PDF document, in one of three fixed layouts, and stores the file as an artifact of your workspace. It writes only the new artifact’s id, { "artifactId": 123 }, to its outputVar. Your app reads the artifact and downloads the file through the Artifacts API. The node calls no language model: no tokens, no credits.
A typical graph has an llm node write the content as JSON (for example a five-day trip plan), then a pdf_export node print it, then a response node tell the user the document is ready.
See Workflow Overview for the node/edge envelope this fits into.
Config fields
Section titled “Config fields”| Field | Type | Required | Description |
|---|---|---|---|
layout | string | Yes | One of brochure, itinerary or slides (see Layouts). No other value is accepted: you cannot supply your own HTML, CSS or template. |
titleTemplate | string | Yes | The document title, a Handlebars template over the run variables (for example Your trip to {{city}}), at most 500 characters. Rendered as plain text, then flattened to one line and cut to 200 characters. Printed on the document and stored as the artifact’s title. An empty result becomes Document. |
sectionsVar | string | Yes | Name of the run variable that holds the content (see Sections). A plain variable name: letters, digits and underscores, starting with a letter, at most 64 characters, no dotted paths. |
outputVar | string | No | Variable that receives { "artifactId": <number> } once the PDF is stored. Never a URL or a storage path. Same naming rule as sectionsVar; reserved names are refused. |
brandFromTenant | boolean | No | true prints your workspace name on the document. No other brand field (colour, logo) exists yet. |
Every rule above is checked when you save or publish: a missing or unknown layout, a missing titleTemplate or one that does not parse, a missing or badly named sectionsVar, or a brandFromTenant that is not true/false is refused with a BAD_USER_INPUT error naming the node and the field. The node also takes the common maxVisits field, like every node.
{ "id": "export_plan", "type": "pdf_export", "config": { "layout": "itinerary", "titleTemplate": "{{days}}-day plan for {{city}}", "sectionsVar": "plan", "outputVar": "planPdf", "brandFromTenant": true }}Layouts
Section titled “Layouts”All three are fixed templates kept by the platform. Every value is printed as text: HTML in your content is shown literally, never rendered.
| Layout | Page | Shape |
|---|---|---|
brochure | A4 portrait | A cover page with the title (and workspace name), then each section as a block. |
itinerary | A4 portrait | The title, then the sections as a timeline: one section per day or stop. |
slides | A4 landscape | A title slide, then one slide (page) per section. |
Text in scripts other than Latin depends on the fonts installed on the server.
Sections
Section titled “Sections”ctx[sectionsVar] must hold one of:
- an array of sections;
- an object
{ "sections": [...] }; - either of those as JSON text, optionally wrapped in one
```jsonfence. This is what anllmnode told to answer with JSON only stores, so itsoutputVarcan be thesectionsVardirectly. Models do not always obey “JSON only”, so the text is also accepted when the JSON is wrapped in a markdown code fence, has a sentence of prose before or after it, or has raw line breaks inside its strings: the node takes the first complete JSON value in the text that parses and is an array of sections or a{ sections }object, and checks it against the same rules and limits. Text with no such value still fails as invalid JSON.
A section is an object with three optional fields; at least one must be non-empty:
| Field | Type | Description |
|---|---|---|
heading | text or number | The section heading. |
body | text or number | A paragraph. |
items | array of text or numbers | A bulleted list. |
A bare string in the array is read as a section with only a body. Other keys are ignored. Control characters and invisible text-direction characters are removed before the length checks (they could make a printed price or date read differently from what it is).
[ { "heading": "Day 1: Arrival", "body": "Check in, then an easy evening walk by the river.", "items": ["Hotel check-in from 14:00", "Dinner at 19:30"] }, { "heading": "Day 2: Old town", "items": ["Museum", "Market", "Cooking class"] }]Limits
Section titled “Limits”Limits are strict: content over a limit fails the node with a message naming the field and the limit. Nothing is silently shortened.
| Limit | brochure, itinerary | slides |
|---|---|---|
| Sections | 40 | 30 |
heading characters | 200 | 120 |
body characters | 4,000 | 1,200 |
Entries in items | 30 | 8 |
| Characters per item | 500 | 200 |
| Characters in all sections together | 60,000 | 60,000 |
| Pages in the rendered PDF | 60 | 60 |
Also: JSON text in the variable may be at most 524,288 characters, a render may take at most 30 seconds, and the finished file may be at most 10 MB.
What the node writes
Section titled “What the node writes”On success the node stores the file, marks the artifact READY and sets ctx[outputVar] = { "artifactId": <number> } (when outputVar is set). The artifact also records the session and the workflow run that produced it, so you can find it with artifacts(sessionId: ...) even without the id.
To give the user the file, your app calls artifactDownloadUrl(id) when they click download. Do not put a download link in a reply: the link is short-lived and is a credential for the file.
Draft test runs create real artifacts. A run of the draft from the dashboard renders and stores a real PDF, with kind pdf_document_draft instead of pdf_document, so a documents list can leave test output out (artifacts(kind: "pdf_document")).
Failures
Section titled “Failures”The node is failure-routable: give it a success and a failure edge and every failure below takes the failure edge (trace row FAILED_HANDLED, the message in _lastNodeError) instead of failing the run.
Content and config errors fail before anything is created, so no artifact exists: sectionsVar not set in this run, content that is not an array or { sections } or valid JSON, an empty section, or a value over a limit.
One render at a time per server. A server renders one document at a time. A pdf_export that starts while another document is rendering fails at once, with no artifact, and is not retried: the message says the renderer is busy and to try again in a moment. Add a failure edge to a response that asks the user to try again; asking again usually works.
Render and storage failures happen after the artifact row exists. The artifact is marked FAILED with a fixed errorCode, and the node fails with a message such as PDF_EXPORT node "export_plan": rendering the PDF failed (RENDER_FAILED). The underlying error text is never returned.
errorCode | Meaning |
|---|---|
RENDER_FAILED | The PDF could not be rendered. |
TIMEOUT | Rendering took longer than 30 seconds. |
TOO_LARGE | The document has more than 60 pages or is larger than 10 MB. |
UPLOAD_FAILED | The file could not be stored. |
INTERNAL | Anything else, for example the finished file could not be recorded. |
If the artifact row itself cannot be created, the node fails with (INTERNAL) and no artifact exists. INVALID_INPUT is also a defined errorCode, but this node never writes it.
In a mock-mode test
Section titled “In a mock-mode test”Live in production since 2026-10-24. In a mock-mode draft test or regression replay, the node checks its config, sections and title as live (so an authoring error still fails the test), then renders nothing: no PDF, no artifact, no file stored. outputVar gets the test’s draftMocks entry, else the node’s mockResponse (any JSON value up to 16 KB), else {"artifactId": null, "mocked": true}. See Testing a Workflow Safely.
Next steps
Section titled “Next steps”- Artifacts API: read the artifact, list a session’s files, mint a download link.
- Failure routing: the
success/failureedges this node supports.