Image and Document Attachments
Send images and PDF documents to an agent, how Harness stores them, which model APIs read them natively, and what happens everywhere else.
A user message can carry images and PDF documents. Harness stores the bytes in a content-addressed attachment store, keeps only references in the session, and sends the bytes to the model in its native format where the model API accepts them. Where it does not, the model sees a short text placeholder instead of silently missing the file.
Attach files to a prompt
Pass images and documents to session.prompt(), session.skill() or session.task().
Both take base64 bytes without a data: prefix. The options are plain JSON.
import { readFile } from 'node:fs/promises';
const pdf = (await readFile('q3-report.pdf')).toString('base64');
const chart = (await readFile('chart.png')).toString('base64');
const answer = await session.prompt('Compare the chart with the report summary.', {
images: [{ data: chart, mimeType: 'image/png', filename: 'chart.png' }],
documents: [{ data: pdf, mimeType: 'application/pdf', filename: 'q3-report.pdf' }],
});task() forwards the attachments to the child session. Validation runs before anything is
recorded and throws InvalidPromptAttachmentError (code: 'INVALID_PROMPT_ATTACHMENT')
when:
- an image's
mimeTypeisn't a well-formedimage/*type, or a PDF is passed inimages - a document's
mimeTypeisn'tapplication/pdf(DOCUMENT_MIME_TYPES) datais empty or longer thanMAX_ATTACHMENT_DATA_LENGTHcharacters
Bytes that aren't valid base64, a file over 10 MiB, more than 25 MiB in total, or more than
20 attachments throw AttachmentLimitError (code: 'ATTACHMENT_LIMIT_EXCEEDED') or a
base64 error, also before the prompt is recorded. The whole batch is checked before any
file is stored, so a rejected prompt leaves nothing behind in the attachment store.
Attach files to a delivered message
Over HTTP, from @fabric-harness/client or from @fabric-harness/react, attachments ride
a kind: 'user' DeliveredMessage:
{
"message": {
"kind": "user",
"body": "Summarize the attached report.",
"attachments": [
{ "type": "document", "mimeType": "application/pdf", "filename": "q3.pdf", "data": "JVBERi0xLjQK..." },
{ "type": "image", "mimeType": "image/png", "data": "iVBORw0KGgo..." }
]
}
}DeliveredAttachment is DeliveredImageAttachment | DeliveredDocumentAttachment. Parsing
rejects with HTTP 400 an unknown type, a type that doesn't match its MIME type (an
image with application/pdf, or a document with image/png), a malformed ref, or
oversized data. Filenames are display metadata only: Harness keeps the last path
segment, removes control characters and quotes, replaces lone UTF-16 surrogates (text that
isn't valid Unicode) with U+FFFD, and truncates them to 255 characters.
Storage and persistence
When the host has an attachment store (every persistence preset in fabricharness.config.ts
provides one, and startDevServer({ attachmentStore }) accepts one), admission decodes the
inline bytes, stores them under the instance session, and replaces them with
{ id, digest, sizeBytes } refs before the submission is persisted. Admission decodes and
checks the whole message first (count, 10 MiB per file, 25 MiB in total, unless the runner's
attachments option sets other limits) and writes to the store only when every attachment
passes. Attachments that arrive as refs count toward the total with the size the store
recorded for them. A caller-declared sizeBytes is never trusted for that. The session then:
- records the
user_promptentry withattachments: [{ type, mimeType, filename?, ref }], so entries, checkpoints, conversation streams and traces never contain the bytes - loads the bytes for the current model request from the store
- loads them again from the store whenever later turns rebuild model context from history, including after threshold compaction and on the single retry after a provider context-overflow error. If overflow compaction summarizes away the prompt that carried the attachments, Harness re-adds that prompt with its images and documents so the retry still sends the bytes
Direct session.prompt() calls follow the same rules. The store is chosen in this order:
| Source | Used when | Survives a restart |
|---|---|---|
init({ attachmentStore }) or agent.session(id, { attachmentStore }) | You pass one | Yes, if the store is durable |
| The submission runner's store | The session runs inside a host submission | Yes, if the store is durable |
| A bounded, process-local in-memory store | Neither is configured | No |
A submission runner without an attachment store accepts inline attachments only when its
submission store is the process-local InMemorySubmissionStore. With any other submission
store, such as SQLite, Postgres or a snapshot-backed preset, admitting a message with inline
bytes throws AttachmentStoreRequiredError (code: 'ATTACHMENT_STORE_REQUIRED'), because
the bytes would otherwise be written into the persisted submission. Every persistence
preset supplies an attachment store, so this only affects hand-wired runners and servers
that pass their own submissionStore. Pass attachments: { store } to
createSubmissionRunner(), or attachmentStore to the server, to accept attachments.
The process-local fallback store belongs to the session object and is bounded: it keeps at most
64 MiB of attachment bytes and 256 attachments. When a new upload would exceed either limit, it
evicts the least recently stored attachment first (uploading the same content again refreshes
it), and it does not keep a single attachment larger than 64 MiB. It is released with the
session object. Configure an attachmentStore when a long-lived session must keep every
attachment it received.
If the bytes are gone when history is rebuilt, for example after a restart without a
durable store or after the fallback store evicted them, the attachment is still listed but reaches the model as
(document "q3.pdf" omitted: attachment bytes are not available). Harness never fails a
turn because an old attachment is missing. Each model request rehydrates at most 50 MiB of
historical attachment bytes, newest first. The budget is charged with the stored byte length.
When the store reports sizes without reading the bytes (AttachmentStore.stat, which the
in-memory store implements), an attachment that can't fit is skipped without being loaded.
Otherwise the loaded length is checked before the bytes are encoded for the request.
The 50 MiB covers the whole request. Messages that join a running response through a
durable submission share the allowance with the rebuilt transcript, so a joined input whose
bytes no longer fit reaches the model as the placeholder.
Provider error text never carries attachment bytes. Some providers quote the rejected
request in a 4xx body, including the image_url data URL. Before an error message is
thrown, stored on a model_attempt entry, or emitted as an event, Harness replaces every
attachment payload the failing request submitted with [attachment data redacted], however
short it is. A payload matches in its standard, URL-safe, unpadded, JSON-escaped (\/) and
percent-encoded spellings, and so does any echoed fragment of 16 or more base64 characters
(a truncated or line-wrapped quote). As a backstop, the payload of every
data:<mime>;base64, URL and every bare base64 run of 256 or more characters is replaced
too. This applies on the native loop and the Pi runtime, to retry and error records, and to
the rejected error and its cause; ProviderHttpError.body is redacted the same way. The
public generateWithRuntime() helper also redacts the error it rejects with, in place, so the
error keeps its class, status, retryAfterMs and retry classification.
The Pi runtime reuses one agent per session between prompts. After each prompt, including
one that fails or is cancelled, it replaces the image and PDF bytes in that agent's transcript
with the "not available" placeholder and detaches the prompt's hooks, tools and queued
steering, so the cached agent no longer references the prompt's messages or attachments. It
keeps at most maxCachedAgents agents (default 256), releasing the least recently used idle
one first. Agents that are still running are never released; the bound is re-applied as each
prompt settles, so a burst of concurrent sessions shrinks back to the limit once it finishes.
A durable session runtime such as Temporal receives only refs. Passing inline images or
documents to a session with a sessionRuntime throws INVALID_CONFIG rather than putting
the bytes into workflow history; send them through a submission with an attachment store
instead.
Model support
| Model path | Images | PDF documents |
|---|---|---|
Pi runtime, anthropic-messages | Native image block | Native document block, title = filename |
Pi runtime, openai-responses, azure-openai-responses | input_image | input_file with file_data |
Pi runtime, google-generative-ai, google-vertex | inlineData | inlineData |
| Pi runtime, other APIs (Bedrock, Mistral, chat completions, …) | Pi's own mapping, or Pi's placeholder when the model lacks image input | Placeholder |
AnthropicModelProvider | Native image block | Native document block |
GeminiModelProvider, VertexAIModelProvider | inlineData | inlineData |
OpenAICompatibleModelProvider | image_url data URL (imageInput: false to disable) | file part on api.openai.com, placeholder elsewhere (documentInput to override) |
AzureOpenAIModelProvider (SDK and @fabric-harness/azure) | image_url data URL | Placeholder |
BedrockModelProvider, CohereModelProvider, Workers AI | Placeholder | Placeholder |
Native image blocks accept PNG, JPEG, GIF and WebP (IMAGE_MIME_TYPES). Other image types
are admitted but reach those adapters as a placeholder naming the type. The Pi runtime applies
the same rule for every model API, so a BMP or SVG image never reaches the provider.
The placeholder replaces the attachment inside the user message:
(document "q3.pdf" omitted: model API "bedrock-converse" does not support document input)The first time an API drops a kind of attachment, Harness logs one console.warn naming
the API. The warning never includes bytes or filenames. After 1,000 distinct APIs Harness
logs one suppression notice and stops warning, so a host with many custom provider names
can't grow the set without limit. Custom Pi models declared through
customModels default to text-only input; set input: ['text', 'image'] on a
vision-capable model so Pi forwards images and PDFs.
Show attachments in a UI
The conversation projection emits a file part for each attachment on a user message:
{ type: 'file', mediaType: 'application/pdf', id: 'att_…_0', digest: '9f86…', size: 48213, filename: 'q3.pdf' }Download the bytes from GET /agents/:name/:id/attachments/:digest?session=. The Node
server, the generated Cloudflare worker, and createFabricAgentRouter (through the backend's
optional readAttachment) all serve this route. It answers with
content-disposition: attachment, x-content-type-options: nosniff,
cache-control: private, no-store and a sandboxing content-security-policy, so browsers
download uploaded files instead of rendering them on your origin. The download filename is
sanitized again when the header is built, so a name stored by an older release, lone
surrogates included, can't make the response fail. An unknown digest, a digest
stored under another session, or a tenant that doesn't own the instance gets 404. See the
React guide for sending attachments from a browser.
Request size limits
Inline attachments travel as base64 inside the JSON body, which makes them about a third
larger than the file. The Node server and the generated Cloudflare worker default to a
1 MiB body limit. A larger request fails with 413 and a message that names the limit:
{ "error": { "type": "body_too_large", "message": "Request body too large. Limit is 1048576 bytes. …" } }Raise it with startDevServer({ maxBodyBytes }), or set FABRIC_HARNESS_MAX_BODY_BYTES
for fh dev, generated Node servers and Cloudflare workers. Attachments over the per-file
or per-message limit return 413 with type: 'attachment_too_large'.
The OpenAI Responses-compatible POST /responses endpoint rejects input_image,
input_file, image_url, file and input_audio parts with 400 instead of dropping them.
Send files to that agent through POST /agents/:name/:id or /dispatch.
Security notes
- Treat attachment content as untrusted model input, like any user text. A PDF can carry prompt-injection instructions.
- Attachment bytes stay out of session entries, events, logs and durable workflow inputs.
Don't log provider payloads from a Pi
onPayloadhook, because they contain the bytes. - Attachments are scoped to the instance session that received them. Another instance can't download them, and deleting the instance deletes them.