Attachments
Agents work with files, not just text. A run can start with uploaded files, generate new files mid-workflow (an image from a prompt_call, a report from a sub-agent), and deliver files at the end (email an attachment, write to S3, render in the UI). This page is the high-level guide to how files move through an agent and how you wire them between steps. For the exhaustive field-by-field reference, see Attachments references field.
The two channels: binary and text
Every attachment travels through the agent in two parallel channels:
- Native binary — capable LLMs (Claude, Gemini, Nova, GPT-4o-class) receive the raw bytes as a media content block, so the model truly "sees" the image / hears the audio / reads the PDF natively.
- Extracted text — every file is also OCR'd or transcribed. That text is what a text-only model sees, and what the prompt scanner and governance evaluators inspect.
You don't choose between them — the runtime picks the right channel per step based on the model's capabilities (see multi-modal routing). The thing you control is which files reach which step. That is what attachment references do.
Where attachments come from
There are exactly two origins for an attachment inside a run:
| Origin | How it's produced | How to reference it |
|---|---|---|
| Agent input (uploads) | A user (or the API / MCP) uploads files when triggering a run. Requires a dynamic_input trigger. | {{agent.attachments[…]}} — available from any step |
| Step-generated | A prompt_call emits images/media (image-generation models or the image_generation tool), the dedicated generate_image / generate_audio / generate_video steps always emit one, or a call_agent sub-agent returns files. A for_each loop whose body generates media aggregates every per-iteration asset, so a post-loop step can attach them all via {{step.<for_each_id>.attachments}}. A read_cloud_drive_file step emits one when the file it reads is an image / audio / video / PDF / DOCX / XLSX — the binary rides as an attachment with its extracted or transcribed text alongside. Web Fetch also produces one conditionally — only when its media_types field is set and at least one asset is actually downloaded (otherwise it stays text-only). | {{step.<id>.attachments[…]}} — references that step's output |
The file that lands on a step's input (from whichever upstream step fed it) is also reachable with the bare {{attachments[…]}} form.
Sending text with the files
"A photo and a sentence about it" is one run, not two fields you have to choose between. Send input together with input_upload_id / input_upload_ids — they are not mutually exclusive:
curl -X POST https://api.seclai.com/agents/{agent_id}/runs \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input": "Is this listing OK?", "input_upload_ids": ["<upload-id>"]}'
The prompt text leads and each file's extracted text follows under its # {filename} heading, so {{agent.input}} reads as one document:
Is this listing OK?
# photo.jpg
<OCR / transcript of the photo>
The binaries are unaffected — they still reach multi-modal steps natively as attachments, addressable with the usual {{agent.attachments[…]}} selectors. In the Run Agent modal, the message box stays visible on the Attach files tab for the same reason.
Send the file with no text, and a retrieval step whose query is left blank will search its knowledge base with the file itself — "which of these pages is in my photo?". A file with no extracted text, such as a raw photo, still contributes its # {filename} heading, so {{agent.input}} on such a run is "# photo.jpg" rather than empty; the retrieval step knows that heading is a label it wrote and does not treat it as your query.
The only pair that is mutually exclusive is input_upload_id and input_upload_ids — two spellings of the same batch. And you no longer need the old workaround of uploading the prompt as a synthetic input.txt text attachment; that added an upload round-trip and a spurious # input.txt heading.
There is no length limit on the combined text — the run is accepted whatever its size, and an input too large for the model surfaces as an error on the first step that calls one, not at submit time. With 20 files of extracted text that is a real possibility, so keep an eye on which files you attach for a text-only model; a multi-modal model reads the binaries natively and leans on the text far less.
What does not produce attachments
- Web Fetch returns text (markdown / HTML / plain text) by default. The page content flows as
{{step.<id>.output}}— feed it to aprompt_callas text. Exception: when itsmedia_typesfield selects images, video, audio, or documents, Web Fetch can produce attachments (a multi-asset manifest) — but only when at least one asset is actually downloaded. If none are found, or every asset is skipped (over a size cap, failed fetch), the output stays text-only, so don't assume{{step.<id>.attachments}}exists just becausemedia_typeswas set; see Web Fetch. Web Search always returns text, not binary files. text,merge,extract_content,extract_data,regex_replace, gates, and other transforms operate on text and pass attachments through untouched if their input carried any; they never create new ones.
If you need a remote file as a real binary attachment, fetch it inside a prompt_call using the web tools, or upload it as agent input.
The reference grammar
An attachment reference is a {{…}} expression that selects zero or more files from one source. The same grammar works everywhere — inside a text field or in a step's dedicated Attachments list.
| Reference | Selects |
|---|---|
{{attachments}} | All attachments on this step's input |
{{attachments[0]}} | The input attachment at zero-based index 0 (out-of-range ⇒ no match) |
{{attachments[*.pdf]}} | Input attachments whose filename matches the glob (* = any run, ? = one char) |
{{attachments[invoice.pdf]}} | Input attachment by exact filename |
{{agent.attachments[…]}} | The original trigger uploads — available from any step, same index / glob forms |
{{step.<id>.attachments[…]}} | Files emitted by a specific upstream prompt_call / call_agent / generate_* step, or aggregated from a for_each loop's body ({{step.<for_each_id>.attachments}}) |
{{step.<for_each_id>.item.attachment}} | Inside a for_each in iterate_attachments mode, the current iteration's file |
Multiple references union their matches in list order and dedupe (first-seen wins). A reference that matches nothing is a no-op (logged at INFO), never an error.
Implicit vs explicit visibility
A step only "sees" an attachment when its template declares the dependency — this keeps uploads from silently leaking into intermediate steps that don't want them.
- Implicit — referencing
{{input}},{{agent.input}}, or{{step.<id>.input|output}}pulls in all attachments carried on that source. Existing templates keep working unchanged. - Explicit — any
{{attachments[…]}}selector narrows the set. As soon as one explicit selector targets a source, it replaces the implicit "all" for that source.
Two ways to use a reference
-
Inline, in a text field — drop the placeholder into a
prompt_callprompt, an email HTML body, an S3 object key, or a webhook payload. The file is routed to that exact spot. This is how LLM-call steps (prompt_call,evaluate_step,extract_data) receive files — there is no separate field; the placeholder is the trigger.Summarize {{attachments[0]}} in three bullet points. -
The per-step Attachments field — manifest-consuming steps (
send_email,write_aws_s3_object,webhook_call,call_agent,publish_content,write_content_attachment,for_each,display_result,streaming_result) expose a dedicated Attachments list of references that selects which files the step ingests. The editor builds it row-by-row with a live match-count preview.["{{attachments[*.pdf]}}", "{{step.report.attachments[0]}}"]Leaving the list empty defaults to "every attachment on the step's input" — except the result steps (
display_result/streaming_result), where the default is text only (attach nothing unless a reference is set).
What the model sees
A model that receives several files with nothing to tell them apart can only refer to them by position — "the second image" — so your prompt has to describe the ordering, and stays correct only for as long as that ordering does. To avoid that, each file an LLM-call step sends is announced by a short label immediately before it, naming the kind and the filename:
Which of these pages matches the photo?
[image: photo.jpg]
<the photo>
[image: page_7_image_0.png]
<the page image>
[image: page_9_image_0.png]
<the page image>
This is what lets you ask for a citation and get a reliable answer: "tell me which image you used" works because the model can name it.
The name comes from wherever the file came from: the original filename for an upload, the extracted asset's filename for a retrieval match, the generated filename for step-produced media.
Retrieval labels also carry the step's name and the match's position, because neither a document title nor an extracted image's filename is unique on its own — the title is shared by every match from that document, and an extracted image is usually named after its page (page_7_image_0.png) or its alt text, so two different documents can produce the same one:
[image: Annual Report.pdf (Research, match 1)]
[image: Annual Report.pdf (Research, match 2)]
[image: page_7_image_0.png (Research, page 7, match 3)]
[image: Budget.pdf (Finance, match 1)]
The step name matters because two retrieval steps feeding the same LLM call each number their own matches from 1. Search Memory steps label their results the same way. These are exactly the names the step writes into its own results, so a citation the model gives you can be looked up in the step output.
Three things to know about what appears in the label. A file with no name is sent unlabeled rather than labelled with a placeholder. A very long name is shortened to 120 characters in the label only, with the middle replaced by … so the end survives — that is where a filename carries its extension. A retrieval match's (step, page, match) suffix is never shortened away: the name in front of it is trimmed to make room instead, since the suffix is the part that tells two matches apart. The file itself keeps its full name, so reference patterns like {{attachments[*.pdf]}} and the filename on an emailed attachment are unaffected. And because filenames can come from outside your account (an uploaded file, a file dropped in a shared cloud-drive folder, a page title), line breaks and square brackets in a name are neutralised so a filename cannot forge a label for a file that was never sent. That applies everywhere a name is shown in brackets: the label, the citation a retrieval step writes into its results, the [attachment: …] marker a {{attachments[…]}} reference expands to, the text a file is replaced by when the model cannot read its type, and — in that last case — the file's type itself.
Labels are added where Seclai builds the message for you — an attachment reference on a Prompt Call, chat history replay, and the media a Prompt Call hands back to the model for review when generation tools run with iterate on. A Prompt Call using use_step_input_as_messages receives a message array you built yourself and is sent through unchanged, so if you hand-build content blocks there, label them yourself if you want citations by name.
Worked examples
- Summarize an uploaded PDF
A dynamic_input trigger accepts one PDF; a prompt_call reads it inline.
Trigger: dynamic_input
→ Prompt Call:
prompt_template: "Summarize {{attachments[0]}} in 200 words."
→ Streaming Result
The bare {{attachments[0]}} resolves to the first file on the prompt step's input — the uploaded PDF. No Attachments field needed; the inline placeholder routes the binary.
- Analyze each uploaded image
Fan out over every uploaded file, one iteration each, with a for_each in iterate_attachments mode. This is the right shape for "do X to each attached image / file" — and it works as the first step, drawing directly from the trigger uploads.
Trigger: dynamic_input
→ For Each (id: image-loop, iterate_attachments: true) # omit input_template; omit the attachments filter to take ALL uploads
body:
→ Prompt Call (id: describe):
prompt_template: "Describe {{step.image-loop.item.attachment}} and list any text you can read."
child_steps: # run ONCE, after every iteration finishes
→ Prompt Call (id: digest):
prompt_template: |
Combine these per-image descriptions into one summary, grouping similar images:
{{step.image-loop.output}}
→ Display Result
Each iteration's item carries the current file's metadata (storage_key, mime, name); {{step.<for_each_id>.item.attachment}} hands the real file to the body prompt. Inside the body, the bare {{attachments[0]}} resolves to the same current file. Add an attachments filter (e.g. ["{{agent.attachments[*.pdf]}}"]) only when the user explicitly restricts the set — never narrow "image" to a single extension.
Collecting the results. When every iteration has finished, the loop's own output is the aggregated array of its body outputs, addressable as {{step.<for_each_id>.output}} — for a single-step body it's a JSON array of strings (one per image); for a multi-step body it's an array of objects keyed by body-step id. Put the steps that consume it in the for_each's child_steps (the post-loop continuation that runs once): here a digest Prompt Call folds the per-image descriptions into one summary, which a Display Result then surfaces. Swap in Send Email or Write to S3 to deliver the digest instead. See the For Each step reference.
- Generate an image and deliver it
A prompt_call on an image-generation model produces a file; downstream steps reference it via {{step.<id>.attachments[…]}}.
Prompt Call (id: gen, image-generation model):
prompt_template: "A watercolor fox in a snowy forest."
→ Send Email:
html_body: '<p>Here is your image:</p><img src="{{step.gen.attachments[0]}}" />'
In an email HTML body the placeholder is CID-embedded so it renders inline (see inline images). To store it instead, point a Write to S3 step at the same reference:
→ Write to S3:
object_key: "fox-{{index}}-{{name}}"
attachments: ["{{step.gen.attachments}}"]
Because image generation can emit several files, include a per-attachment placeholder ({{name}} / {{index}} / {{ext}}) in object_key so each file writes to a distinct object. To surface the generated image in the UI, give a Display Result step the reference ["{{step.gen.attachments}}"] (result steps attach nothing by default).
- Forward uploads to a sub-agent
call_agent is a manifest consumer — its Attachments field chooses which files to forward to the sub-agent, which receives them as if they were its own dynamic_input uploads.
Trigger: dynamic_input
→ Call Agent (target: "invoice-parser"):
attachments: ["{{agent.attachments[*.pdf]}}"]
Only the PDFs among the uploads are propagated to the sub-agent's first prompt step. See the Call Agent step.
- Route one specific file
References compose, so you can pick exactly one file out of a mixed batch and send it somewhere specific — e.g. email just the contract PDF while archiving everything to S3:
Prompt Call → (manifest flows downstream)
→ Send Email:
attachments: ["{{attachments[contract.pdf]}}"]
→ Write to S3:
object_key: "archive/{{index}}-{{name}}"
attachments: ["{{attachments}}"]
- Chatbot that iterates on a generated image
A conversational agent that generates an image and lets the user refine it turn by turn ("make the moon bigger", "warmer palette"). Each turn is one run; conversation memory carries the running thread, and the Add Chat Turn step returns it as a messages array that a use_step_input_as_messages Prompt Call feeds straight to the model. The key is the attachments field on the assistant turn — it persists the generated image with that turn so it replays into the next turn as a native media block the model can actually see.
Trigger: chat (conversational)
→ Add Chat Turn (conversation, key: "{{metadata.user_id}}", speaker: "user", content: "{{agent.input}}")
→ Prompt Call (id: gen, image-generation model, use_step_input_as_messages: true)
→ Display Result (attachments: ["{{step.gen.attachments}}"])
→ Add Chat Turn (conversation, key: "{{metadata.user_id}}", speaker: "assistant",
content: "Here's the image.", attachments: ["{{step.gen.attachments}}"])
On turn 2, the loaded history is replayed into the Prompt Call as a messages array in which the assistant turn carries the image as a media block. The model sees the picture it drew, so "make the moon bigger" now refers to something concrete — it can iterate instead of regenerating blind.
How memory carries attachments. When an Add Chat Turn step has an
attachmentsreference, each matched file is copied into memory-owned storage (so it outlives the run) and recorded with the turn. On replay — aprompt_callwithuse_step_input_as_messages: truereading that history — turns that carried files are rendered as multi-block messages (text + native media), exactly as if the file had been uploaded on that turn. Notes: only conversation memory (Add Chat Turn) persists attachments — not general memory (Add Memory); a turn keeps up to 10 attachments of 20 MB each (oversized files are skipped); the model must support image input natively, and remembered images add to each turn's token cost, so persist deliberately (typically just the latest generated image). Leaveattachmentsempty for a text-only turn.
Per-step cheat sheet
| Step | Role | How it handles attachments |
|---|---|---|
| Prompt Call | Both | Consumes via inline {{attachments[…]}} in the prompt/system template; produces files on image-gen models / the image_generation tool |
| Extract Data / Evaluate Step | Consumes | Inline {{attachments[…]}} placeholders in the prompt, same as Prompt Call |
| For Each | Consumes | iterate_attachments: true loops once per file; body uses {{step.<id>.item.attachment}} |
| Send Email | Consumes | Attachments field → RFC-5322 file attachments; inline <img src> placeholders → CID-embedded images |
| Write to S3 | Consumes | Attachments field → one S3 object per match (use {{name}}/{{index}}/{{ext}} in object_key) |
| Webhook Call | Consumes | Attachments field → encoded into the request body per payload_shape |
| Call Agent | Both | Attachments field forwards files to the sub-agent; sub-agent outputs may carry files back |
| Publish Content / Write Content Attachment | Consumes | Attachments field — single-file consumers (reject zero or >1 match; wrap in a for_each to fan out) |
| Display Result / Streaming Result | Consumes | Attachments field — default is text only; attach files only when a reference is set |
| Web Fetch | Produces | Text by default; produces a manifest only when media_types is set and ≥1 asset downloads — else text-only (no attachments). No cap on asset count; a page that needs too many separate downloads stops partway rather than dropping the lot |
| Web Search | Neither | Returns text, not attachments — consume via {{step.<id>.output}} |
| Add Chat Turn | Consumes | Attachments field persists files with the turn; they replay as native media when the history is fed to a use_step_input_as_messages prompt |
| Add / Load / Search Memory (general) | Neither | Text only — general memory stores text; for files use Add Chat Turn (conversation) or store the binary in S3 and keep its URL as text |
Validation, uploads, and limits
Saved agents carry a static attachment-reference contract derived from every step's templates, which drives three safety gates:
- Editor save rejects a reference on a step type that can't route binaries, or a
{{step.<id>.attachments}}against a step that doesn't produce a manifest. - Upload time rejects files when the agent declares no attachment references at all (nothing would consume them).
- Run time requires the uploaded batch to satisfy the references: every exact filename must be present, the highest declared index must be in range, and every glob must match at least one file.
Uploaded files are also prompt-scanned in parallel — an unsafe attachment (e.g. a jailbreak embedded in a PDF) fails the run's input scan closed. Per-file limits: 200 MB per uploaded file; 25 MB total per email (SES); 5 MB for base64 webhook payloads. See File Attachments on the Agents page for the full upload / API / MCP flow and the contract-introspection endpoints.
Input file retention
An uploaded input binary is part of the run's trace, so it is kept only as long as the run trace itself — i.e. your account's agent-trace retention (the free default is 7 days; set the retention on the system-managed agent-traces source connection, or keep traces forever). After that window the binary is swept and only its extracted text remains. Practically: replaying a run (the "run again with the same files" affordance) re-attaches the original uploads while they're still present — and each replay refreshes the retention clock on the files it reuses, so a file stays available as long as it keeps getting replayed (a blob is only swept once no run has referenced it within the retention window). Replay a run whose files have already been swept and any lost image falls back to its extracted text (documents stay readable as text; raw images are dropped). The replayed files are re-resolved server-side from the original run, so this is automatic and safe — there's nothing to re-upload. Replay is available from the UI, the API, and MCP.
Further reading
- Attachments references field — the complete reference table and per-consumer behaviour.
- File Attachments (Agent Steps) — selector syntax and multi-modal routing internals.
- File Attachments (Agents) — uploading files to a run, the attachment contract, and downloading run outputs.
- For Each step — iterating over attachments.