Handle files and binary data in n8n workflows
Keeps n8n binary files intact across JSON transforms, AI-agent tool boundaries, and chat-surface delivery, using the $binary/$json slot split.
Why it matters
Correctly read, transform, store, and transmit files and binary data through n8n workflows, especially when working with AI agents, chat attachments, and multimodal inputs that require staging files to storage and passing URLs instead of raw bytes.
Outcomes
What it gets done
Keep binary data alive through JSON-only transform nodes using pass-through or merge patterns
Stage uploaded files to storage and pass keys/URLs through AI agent tool boundaries
Download files from HTTP, storage, or email triggers into the correct $binary slot
Generate public URLs for chat surfaces that cannot render images from binary slots
Install
Add it to your toolbox
Run in your project directory:
curl -fsSL https://spark.entire.vc/get/ag-n8n-binary-and-data | bash Overview
n8n Binary and Data
Keeps n8n binary file data intact across JSON-only transforms, AI-agent tool boundaries, and chat-surface delivery, using the $json/$binary slot split and storage-key staging. Use whenever an n8n workflow reads, transforms, stores, or transmits files or binary fields, including AI-agent inputs and chat attachments.
What it does
This skill governs how n8n workflows handle files and binary data - reading, transforming, storing, uploading, downloading, or transmitting files and binary fields, including multimodal agent inputs and chat attachments. It treats uploaded files and generated URLs as potentially sensitive: get approval before sending data to a new external host, use the narrowest retention/access scope available, avoid logging bytes or base64 payloads, and never embed credentials in URLs or workflow fields.
Its foundation is that every n8n item carries two independent, side-by-side slots: $json for structured data and $binary for file bytes - file contents (a PDF, image, zip) live only in $binary, never in $json. Three rules prevent most binary bugs: file contents are in $binary, not $json (reading $json.data for file contents returns nothing); binary cannot cross the AI-agent tool boundary in either direction, since tool arguments and returns are JSON-only, so files must be pre-staged to storage and passed as a key or URL; and chat surfaces (Slack, Discord, Teams, Telegram, embedded webhook chat) render images by URL, never by reading $binary, so the file must live somewhere a URL can fetch it.
Each item's binary slot is keyed by a binary property name (default data) pointing to an object with base64 data, mimeType, fileName, and fileExtension; $json and $binary are separate namespaces that never mix, and a webhook receiving multipart/form-data puts the file in $binary and the accompanying fields in $json.body. Binary is usually produced by nodes rather than hand-built: HTTP Request with responseFormat: "file", Read/Write Files from Disk, storage downloads (S3, Google Drive, Dropbox, etc.), email-attachment triggers, or AI media-generation nodes via options.binaryPropertyOutput - leaving responseFormat at its JSON/string default is the classic reason a download ends up as garbled text instead of clean bytes. In a Code node, read raw bytes with the getBinaryDataBuffer helper (never hand-decode base64) and re-attach binary: $input.item.binary on return, since a Code node returning [{ json: {...} }] without it silently drops the file; write binary by building the slot with base64 data, mimeType, fileName, and fileExtension.
JSON-only nodes (Edit Fields/Set, Code, IF, etc.) can silently strip the $binary slot from their output with no validation error - fixed either by a pass-through option on the transforming node (Edit Fields' includeOtherFields, or a Code node explicitly returning the binary) or by fanning the source into both the transform and a bypass branch, then recombining with a Merge node in combineByPosition mode. The agent-tool binary boundary is the sharpest edge: for inbound files, split a chat trigger's files[] array, upload each to private storage under a hashed key, re-merge as a synchronization barrier before the agent runs with executeOnce: true, and inject both the human-readable name and the storage key into the agent's system prompt so the tool can fetch by key; for outbound files, a tool sub-workflow generates the binary, uploads it, and returns JSON like {ok, key, url, mimeType} for the agent to embed. passthroughBinaryImages: true only changes what the LLM sees for vision - it does not let tools receive files, and covers images only.
For chat surfaces, since clients render by URL rather than reading $binary and n8n ships no built-in CDN, the bytes must be uploaded to user-provided storage (object stores like S3/R2/GCS/Azure Blob/Backblaze B2/Supabase Storage, or drive-style services like Dropbox/Google Drive/OneDrive/Box, with Cloudflare R2 as the lowest-friction default) and a signed, expiring URL used for sensitive content. Explicitly unavailable: $fromAI() cannot carry binary (only strings/numbers/booleans/objects), tool arguments/returns have no binary parameter, n8n has no CDN or public file host, and getBinaryDataBuffer is a Code-node helper unavailable in the Custom Code Tool sandbox. Persistent tabular storage (reference-counting staged files, dedup) belongs to the separate n8n_manage_datatable surface, not this skill.
Nine documented anti-patterns cover reading file contents from $json, skipping responseFormat: "file" on HTTP downloads, a Code node dropping binary on return, a JSON transform eating binary, passing an uploaded file through $fromAI, misreading passthroughBinaryImages as a tool channel, a tool returning raw binary instead of a JSON key/URL, posting $binary directly to a chat surface, and hardcoding base64 in a Code node. Since a stripped binary slot is a silent failure invisible to validation, verification requires running n8n_test_workflow or a real trigger, then inspecting the execution via n8n_executions per-node to confirm the binary slot's presence and metadata at the node just before it disappears.
When to use - and when NOT to
Use whenever an n8n workflow reads, transforms, stores, uploads, downloads, or transmits files and binary fields, including multimodal agent inputs and chat attachments. It does not cover Data Tables (owned by n8n-mcp-tools-expert), Code-node sandbox/execution-mode detail (owned by n8n-code-javascript/n8n-code-python), or the Custom Code Tool contract itself (owned by n8n-code-tool).
Inputs and outputs
Input is an n8n item carrying $json and $binary slots, or a file arriving via HTTP download, disk read, storage download, email attachment, or AI media generation. Output is correctly preserved binary data through transforms, agent-tool boundaries, and chat-surface delivery - either passed through directly or staged to storage and referenced by key/URL.
// Code node, "Run Once for Each Item"
const buffer = await this.helpers.getBinaryDataBuffer(0, 'data'); // (itemIndex, propertyName)
const text = buffer.toString('utf-8');
const length = buffer.length;
return [{
json: { ...$json, length },
binary: $input.item.binary, // pass the binary through, or it's gone
}];
Integrations
n8n-code-javascript/n8n-code-python (Code-node sandbox and helpers), n8n-code-tool (Custom Code Tool's narrower sandbox with no $binary/getBinaryDataBuffer/$fromAI), n8n-workflow-patterns (the AI-Agent-with-tools shape), n8n-node-configuration (conditional fields like responseFormat, binaryPropertyName), n8n-expression-syntax ($binary.<key> vs $json.body addressing), n8n-validation-expert (validation can't catch a dropped binary slot), n8n-mcp-tools-expert (Data Tables and n8n_executions), n8n-error-handling (error branches for storage uploads/downloads), and using-n8n-mcp-skills as the overall skill index.
Who it's for
n8n workflow builders handling files - PDFs, images, attachments, AI-generated media - who need to keep binary data intact across JSON transforms, AI-agent tool calls, and chat-surface delivery instead of losing it to a silent strip.
Source README
n8n Binary and Data
When to Use
Use this skill when an n8n workflow reads, transforms, stores, uploads, downloads, or transmits files and binary fields, including multimodal agent inputs and chat attachments.
Treat uploaded files and generated URLs as potentially sensitive. Obtain approval before sending data to a new external host, use the narrowest retention and access scope available, avoid logging bytes or base64 payloads, and do not embed credentials in URLs or workflow fields.
Every n8n item carries two independent slots: $json for structured data and $binary for file bytes. They travel side by side through the workflow. File contents - the actual PDF, image, or zip - live in $binary, never in $json. Get that split wrong and you read an empty field, lose a file mid-flow, or hand an AI agent a tool input it can't use.
This skill covers where binary lives, how to read and write it, how to keep it from being silently stripped, the hard wall between binary and the AI-agent tool boundary, and why chat surfaces need a URL instead of raw bytes.
The three rules that prevent 90% of binary bugs
File contents are in
$binary, not$json. After an HTTP download, a "Read Files", or an email-attachment trigger, the bytes sit in$binary.<key>.$jsonholds metadata at most. Reading$json.datafor file contents gives you nothing.Binary cannot cross the AI-agent tool boundary - in either direction. Tool arguments and tool return values are JSON only. An uploaded image can't be passed into a tool as a file, and a tool can't return raw bytes. Pre-stage to storage and pass a key or URL through JSON instead. See
references/AGENT_TOOL_BINARY.md.Chat surfaces render images by URL, not by
$binary. Slack, Discord, Teams, Telegram, embedded webhook chat - none of them read the binary slot. The image has to live somewhere a URL can fetch it. Seereferences/CDN_REQUIREMENT.md.
The two slots
Each item is shaped like this:
{
"json": { "customerId": 42, "status": "sent" },
"binary": {
"invoice": {
"data": "<base64-encoded bytes>",
"mimeType": "application/pdf",
"fileName": "invoice-42.pdf",
"fileExtension": "pdf"
}
}
}
The key inside binary (invoice here) is the binary property name. Most file-handling nodes have a binaryPropertyName parameter that points at it - the producer names the slot, the consumer references it by that name. The default key across most nodes is data, so when nothing tells you otherwise, assume $binary.data.
$json and $binary are separate namespaces. An expression like {{ $binary.invoice.fileName }} reads file metadata; {{ $json.customerId }} reads data. They never mix.
This split also explains a webhook gotcha: a Webhook trigger receiving multipart/form-data puts the uploaded file in $binary and the accompanying form fields in $json.body - so an uploaded file is not somewhere under $json at all. (The $json.body nesting for webhooks is n8n-expression-syntax territory.)
See references/BINARY_BASICS.md for the full slot anatomy, mime types, and size limits.
Producing binary
You rarely build a $binary slot by hand - nodes populate it for you:
| Source | How binary appears |
|---|---|
HTTP Request with responseFormat: "file" |
Response body lands in $binary.data (or the name you set) |
| Read/Write Files from Disk | File contents read into $binary |
| Storage downloads (S3, Google Drive, Dropbox, etc.) | Downloaded file in $binary.<key> |
| Email triggers with attachments | Each attachment arrives in $binary |
| Provider AI media nodes (image/audio gen) | Set options.binaryPropertyOutput so the bytes land where the next node looks |
For an HTTP download, the one field that matters is responseFormat. Confirm it with get_node on nodes-base.httpRequest - leaving it as the default JSON/string format is the classic reason a downloaded file ends up as garbled text in $json instead of clean bytes in $binary.
Reading and writing binary in a Code node
Most workflows never need to crack open the bytes - they just pass binary through to a consumer (email attachment, file upload, Slack file). When you do need the raw bytes, do it in a Code node.
Read with getBinaryDataBuffer - do not try to base64-decode $binary.<key>.data by hand:
// Code node, "Run Once for Each Item"
const buffer = await this.helpers.getBinaryDataBuffer(0, 'data'); // (itemIndex, propertyName)
const text = buffer.toString('utf-8');
const length = buffer.length;
return [{
json: { ...$json, length },
binary: $input.item.binary, // pass the binary through, or it's gone
}];
Write by building the slot yourself - base64 the bytes plus a mime type and file name:
const text = 'Hello, world!';
return [{
json: { ok: true },
binary: {
report: {
data: Buffer.from(text).toString('base64'),
mimeType: 'text/plain',
fileName: 'report.txt',
fileExtension: 'txt',
},
},
}];
The Code-node sandbox, helpers, and execution modes are the domain of n8n-code-javascript (and n8n-code-python) - use those for the language-level detail. The one binary-specific thing to remember here: a Code node that returns [{ json: {...} }] without re-attaching binary silently drops the file. See references/BINARY_BASICS.md.
Keeping binary alive across transforms
JSON-only nodes - Edit Fields (Set), Code, IF, and others - can drop the $binary slot from their output. The workflow validates clean and runs without error; the file just isn't there downstream when the email node goes to attach it.
Two ways to keep it:
- Pass-through option on the transforming node. Edit Fields has
includeOtherFields; a Code node can returnbinary: $input.item.binaryexplicitly. Cheapest fix when it's available. - Fan out and Merge by position. Route the source into both the transform and a bypass branch, then recombine with a Merge in
combineByPositionmode. The JSON comes from the transform side, the binary survives on the bypass side.
[Source with binary] ─┬─→ [Edit Fields: change JSON] ─┐
│ (binary stripped here) ├─→ [Merge: combineByPosition] ─→ [Email: attach]
└──────────────────────────────────┘
(bypass — binary passes through untouched)
combineByPosition pairs item N from each input, so the field counts must line up. The connection wiring and the alternatives for many-strip-point chains (upload-early, sub-workflow) are in references/MERGE_FOR_CONTEXT.md.
The agent-tool binary boundary
This is the sharpest edge. An AI Agent talks to its tools (Custom Code Tool, Call n8n Workflow Tool, HTTP Request Tool, MCP tools) over JSON. Binary does not fit through that pipe in either direction. The fix is the same shape both ways: stage the bytes in storage, pass a key/URL through JSON, fetch on the other side.
Inbound - a user uploads a file the agent's tool must operate on:
- The chat trigger gives you a
files[]array. Split it out and upload each file to private storage under a hashed key. - Re-merge that branch before the agent runs (it's a synchronization barrier, not decoration), and set
executeOnce: trueon the agent so N files don't trigger N agent runs. - Inject the keys into the agent's system prompt, listing both the original name (human context) and the storage key (what the tool needs), with an explicit "use EXACTLY this key".
- The tool receives the key as a string argument and downloads the file from storage itself.
Outbound - a tool generates a file the agent must return:
- The tool sub-workflow generates the binary, uploads it to storage, and returns JSON like
{ "ok": true, "key": "...", "url": "https://...", "mimeType": "image/png" }. - The agent embeds the URL in its reply (or passes the key to another tool).
passthroughBinaryImages: true on the agent only changes what the LLM sees for vision - it does not let tools receive the file, and it's image-only (no PDFs, audio, or video). You still need the upload-and-pass-key pattern for any tool. Full patterns, hash strategy, storage choices, and the long-running-tool variant are in references/AGENT_TOOL_BINARY.md.
Building the tool itself? See n8n-code-tool for the Custom Code Tool contract and n8n-workflow-patterns for the AI-Agent-with-tools shape.
The CDN requirement for chat surfaces
When a workflow generates an image and the user wants it shown inside a chat message:
- Binary on the item isn't enough. The chat client renders messages that reference images by URL (or pushes bytes through the platform's own file-upload API). It never reads
$binary. - The bytes have to live somewhere a URL can fetch over HTTPS. Upload to an object store or drive first, then embed the returned URL.
- n8n has no built-in CDN. The user provides the storage.
Ask which storage they already use rather than defaulting to S3 - object storage (S3, R2, GCS, Azure Blob, Backblaze B2, Supabase Storage) and drive-style services (Dropbox, Google Drive, OneDrive, Box) all work and all change the URL shape. Cloudflare R2 is the lowest-friction starting point if they have nothing. For sensitive content, use a signed URL with an expiry rather than a permanently public one. See references/CDN_REQUIREMENT.md.
What's NOT available
$fromAI()cannot carry binary. It fills tool parameters with strings, numbers, booleans, and objects - never file bytes. Pass a storage key instead.- Tool arguments and returns are JSON only. There is no "binary parameter" on an agent tool, in or out.
- n8n ships no CDN or public file host. Serving a file over a URL is always something the user's storage does, not n8n.
getBinaryDataBufferis a Code-node helper. It isn't available in the Custom Code Tool sandbox (see n8n-code-tool).
Where Data Tables live
For persistent tabular storage - reference-counting staged files, tracking which keys are live, dedup - that's the n8n_manage_datatable surface, owned by n8n-mcp-tools-expert. This skill does not cover Data Tables.
Anti-patterns
| Anti-pattern | What goes wrong | Fix |
|---|---|---|
Reading file contents from $json |
Bytes live in $binary; $json is empty or metadata only |
Read $binary.<key>, or getBinaryDataBuffer in a Code node |
HTTP download without responseFormat: "file" |
Bytes arrive as mangled text in $json, not clean binary |
Set responseFormat: "file" on the HTTP Request node |
Code node returns [{json:{...}}], no binary |
The file is silently dropped downstream | Re-attach binary: $input.item.binary in the return |
| JSON transform (Edit Fields/IF) eats the binary | Email/upload node finds nothing to attach | Pass-through option, or fan out + Merge by position |
Passing an uploaded file into a tool via $fromAI |
$fromAI can't carry binary; the tool gets nothing |
Pre-stage to storage, inject the key in the system prompt, tool fetches by key |
Assuming passthroughBinaryImages lets tools see the file |
It only affects what the LLM sees, and only for images | Still need the upload-and-pass-key pattern for tools |
| Tool returns raw binary to the agent | Tool output is JSON; bytes don't survive (and bloat context) | Upload, return { key, url } in JSON |
Posting $binary to a chat surface and expecting an image |
Chat clients render by URL, not raw bytes | Upload to storage/CDN, embed the URL or use the platform file API |
| Hardcoding base64 in a Code node | Huge workflow JSON, slow, leaky | Reference via $binary, or upload and reference by URL |
Reference files
| File | Read when |
|---|---|
references/BINARY_BASICS.md |
First time handling binary, or reading/writing the $binary slot, mime types, size limits |
references/AGENT_TOOL_BINARY.md |
An agent tool needs an uploaded file, or produces one - the boundary in either direction |
references/MERGE_FOR_CONTEXT.md |
Binary disappears after a JSON transform and you need to re-attach it |
references/CDN_REQUIREMENT.md |
Showing images in a chat surface or anywhere that needs URL-referenced images |
Integration with Other Skills
n8n-code-javascript / n8n-code-python: the Code node is where you read/write raw bytes (getBinaryDataBuffer, Buffer.from(...).toString('base64')). Those skills own the sandbox, helpers, and execution-mode detail - this skill owns the rule that binary must be re-attached on return.
n8n-code-tool: the Custom Code Tool sandbox is narrower - no $binary, no getBinaryDataBuffer, no $fromAI. When a tool needs a file, this skill's storage-key pattern is how it gets one.
n8n-workflow-patterns: the agent-tool binary boundary sits inside the AI-Agent-with-tools pattern; the CDN flow is a generate → upload → reply chain.
n8n-node-configuration: responseFormat, binaryPropertyName, includeOtherFields, binaryPropertyOutput are all conditional fields - use get_node to confirm the exact names on the user's version.
n8n-expression-syntax: addressing $binary.<key>.fileName vs $json.body (webhook uploads in particular) is expression territory.
n8n-validation-expert: a dropped binary slot is a silent failure - validate_workflow won't flag it. Confirm presence by inspecting the execution.
n8n-mcp-tools-expert: owns n8n_manage_datatable (Data Tables) and n8n_executions - use the latter to confirm a binary slot actually survived a given node.
n8n-error-handling: storage uploads and downloads fail; the inbound/outbound staging steps need error branches so a missing key doesn't 404 silently.
using-n8n-mcp-skills: the index of how these skills fit together.
Verifying binary survived
Validation won't catch a stripped binary slot - it's a silent failure. Confirm it ran correctly:
n8n_test_workflow(or trigger a real run) to produce an execution.n8n_executionsto pull that execution, and inspect per-node output for thebinaryslot - it shows presence and metadata even if the base64 is too large to render.- The node where
binarylast appears is the node before the strip. That's where the pass-through or Merge goes.
Quick Reference Checklist
- File contents read from
$binary.<key>- never$json - HTTP downloads use
responseFormat: "file" - Code nodes re-attach
binaryon return when the file must continue - JSON transforms either pass binary through or Merge it back (
combineByPosition) - No attempt to pass binary into/out of an agent tool - keys/URLs through JSON instead
-
passthroughBinaryImagesused only for LLM vision, not as a tool channel - Chat-surface images uploaded to storage; the URL is embedded, not the bytes
- Storage backend chosen with the user (not defaulted to S3); signed URLs for sensitive content
- Binary presence confirmed by inspecting the execution, not by validation
Remember: two slots, side by side. Data rides in $json, files ride in $binary - and the moment a file has to cross an agent tool or reach a chat surface, it travels as a URL, not as bytes.
Limitations
- Storage limits, binary modes, and node-specific field names vary across n8n versions and hosting configurations.
- An n8n validation pass cannot prove that file bytes survived a live execution; inspect execution data with a safe sample.
- This skill does not choose a storage provider or authorize uploading sensitive data to one.
Discussion
Questions & comments · 0
Sign In Sign in to leave a comment.