Files in chat

Read user uploads and return downloadable files from a messaging agent
View as Markdown

Astro AI’s web chat can send files to an agent and render files from the agent as download chips. The transfer has two parts:

  • The Files API moves bytes between the browser and the deployment’s persistent file store.
  • The messaging API associates an uploaded file key with a user or assistant message.

The browser never puts file bytes in a chat message. The agent normally does not call the Files API either: Astro AI mounts the same file store into the agent container and sets AGENT_FILES_DIR to its location (normally /data/files).

Enable file-enabled chat

File-enabled chat needs two things: a messaging deployment with the web adapter (which provisions and mounts the file store automatically), and an agent that declares it consumes files.

First, declare a messaging agent. The file store is mounted automatically; there is no separate storage setting.

agent:
build:
context: .
dockerfile: Dockerfile
interfaces:
messaging: true
dev:
interfaces:
messaging:
adapters: [web]

Then set supportsFiles in the config your agent sends at startup. The chat’s upload controls (the paperclip, drag-and-drop, paste, and the Files tab) appear only for agents that set this flag, so an agent that ignores uploads never shows controls that do nothing. The flag defaults to off; having the file store mounted is not enough on its own. It gates only the upload affordances; agent-produced download chips render regardless.

getConfig() {
return { systemPrompt: "Analyze user-provided documents.", tools: [], supportsFiles: true };
}

In local development, open the chat URL printed by ast project (normally http://localhost:3100). Users can attach a file with the paperclip, drag and drop, or paste. The chat uploads the bytes when the message is sent and passes the resulting key with that message.

Read files attached by a user

When you use an Astro AI adapter, each call to your agent receives the current turn’s files in StreamOptions.attachments. Treat this list as the authority for the turn. Do not enumerate the whole shared directory to decide which files belong to the current user.

Each attachment contains:

NodePythonMeaning
keykeyOpaque Files API key. Do not show it as the filename.
namenameOriginal display filename. Do not use it to construct an input path.
pathpathAbsolute path to the stored bytes. Read this path when present.
mimeTypemime_typeMIME type reported at upload time, if known.
sizesizeSize in bytes, if known.
import { readFile } from "node:fs/promises";
import type { AgentAdapter } from "@astropods/adapter-core";
const adapter: AgentAdapter = {
name: "Document analyst",
async stream(prompt, hooks, options) {
try {
for (const attachment of options.attachments ?? []) {
if (!attachment.path) {
throw new Error(
`Cannot resolve ${attachment.name}; update the messaging and adapter packages`,
);
}
const bytes = await readFile(attachment.path);
await analyzeFile({
bytes,
name: attachment.name,
mimeType: attachment.mimeType,
prompt,
});
}
hooks.onChunk("I finished analyzing the attached file.");
hooks.onFinish();
} catch (error) {
hooks.onError(error as Error);
}
},
getConfig() {
return { systemPrompt: "Analyze user-provided documents.", tools: [], supportsFiles: true };
},
};

path is absent only when an older sidecar or SDK did not carry the storage key. Never guess AGENT_FILES_DIR/<original filename>: API-managed uploads are stored by opaque key, not by their display name. Update the messaging and adapter packages before relying on file input.

Return a file to the user

Write the complete output as a regular file directly inside AGENT_FILES_DIR, then register it with the response hook before finishing the response. The bridge sends the file reference on the END chunk, and the chat renders a download chip.

Use a new, single-segment filename for every output. A UUID prefix prevents concurrent conversations or different users from colliding on a shared filename. Do not use subdirectories, symlinks, or names ending in .blob, .meta.json, or .tmp; those are reserved storage artifacts and are not exposed as agent outputs.

import { randomUUID } from "node:crypto";
import { mkdir, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { StreamHooks } from "@astropods/adapter-core";
const filesDir = process.env.AGENT_FILES_DIR ?? "/data/files";
async function attachCsv(hooks: StreamHooks, csv: string) {
const name = `${randomUUID()}-analysis.csv`;
await mkdir(filesDir, { recursive: true });
await writeFile(join(filesDir, name), csv, { encoding: "utf8", flag: "wx" });
const file = await stat(join(filesDir, name));
hooks.onFile({ name, mimeType: "text/csv", size: file.size });
hooks.onChunk("Your analysis is ready.");
hooks.onFinish();
}

The file must exist before onFile / on_file, and the hook’s name must exactly match the basename on disk. If the sidecar cannot find that regular file when it processes the terminal chunk, it omits the download chip rather than creating a broken link.

Read images attached by a user

Images arrive separately from files, in StreamOptions.images. The bytes travel inline as a data: URI on each image’s url, so a model can read them without a filesystem access.

NodePythonMeaning
namenameOriginal display filename.
urlurldata: URI holding the bytes. Pass this to the model.
mimeTypemime_typeMIME type detected from the bytes.
sizesizeSize in bytes.

StreamOptions is the same for every adapter. What differs is which fields an adapter consumes, because the adapter owns stream(). The Mastra and LangChain adapters read images and pass them to the model, so a vision-capable agent describes an uploaded image with no extra code. For a non-image attachment they append its name and path to the turn’s text, which a tool can then read. Pair that with a file-reading tool: a model handed a path it cannot open tends to describe a file it never read. In a custom adapter you write stream() yourself and read both lists directly.

An uploaded image also appears in attachments with a path, so an agent that reads files keeps working unchanged. Use attachments to process the bytes and images to show the image to the model.

A message inlines up to 2 MiB of image bytes in total. A larger image, or one past that budget, arrives in attachments only, so the model does not see it.

import type { AgentAdapter } from "@astropods/adapter-core";
const adapter: AgentAdapter = {
name: "Image describer",
async stream(prompt, hooks, options) {
const images = options.images ?? [];
if (images.length === 0) {
hooks.onChunk("Attach an image and I will describe it.");
hooks.onFinish();
return;
}
const content = [
...images.map((image) => ({
type: "image_url" as const,
image_url: { url: image.url },
})),
{ type: "text" as const, text: prompt },
];
// Send `content` to your vision model, then stream its reply.
hooks.onFinish();
},
getConfig() {
return { systemPrompt: "Describe user-provided images.", tools: [], supportsFiles: true };
},
};

Use the raw messaging SDK

If you manage the gRPC stream yourself, the same rules apply without the adapter conveniences.

For an inbound web attachment:

  • Select attachments with type FILE.
  • Use storageKey (Node) or storage_key (Python), not filename, to locate the bytes.
  • Filesystem-backed uploads are stored at AGENT_FILES_DIR/<storage key>.blob.
  • An uploaded image also arrives as a second attachment with type IMAGE, carrying the bytes in a data: URI on url. Select those to pass images to a model.

For an outbound file:

  1. Write a uniquely named regular file directly inside AGENT_FILES_DIR.
  2. Send a FileAttachment on the terminal ContentChunk.
  3. Set filename to the file’s basename. Leave url empty for a filesystem file.
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
const filesDir = process.env.AGENT_FILES_DIR ?? "/data/files";
const input = message.attachments?.find((a) => a.type === "FILE");
if (input?.storageKey) {
const inputPath = join(filesDir, `${input.storageKey}.blob`);
const bytes = await readFile(inputPath);
// Process bytes...
}
const outputName = `${randomUUID()}-analysis.csv`;
const outputBytes = new TextEncoder().encode(await buildCsv());
await writeFile(join(filesDir, outputName), outputBytes, { flag: "wx" });
conversation.sendContentChunk(message.conversationId, {
type: "END",
content: "Your export is ready.",
attachments: [
{
file: {
filename: outputName,
mimeType: "text/csv",
sizeBytes: outputBytes.byteLength,
},
},
],
});

See Inbound message anatomy for the full message and response shapes.

Build a custom chat client

Astro AI’s built-in chat already implements this flow. A custom client must use both APIs in order:

1

Reserve a file key

Call POST /api/v1/deployments/{deploymentId}/files with the display metadata and exact byte size.

{
"name": "quarterly-results.csv",
"content_type": "text/csv",
"size": 18420
}

The response contains an opaque key and an upload descriptor:

{
"key": "2df6a4ac-cb4a-4ef0-8b8d-833d635b91cf",
"file": {
"key": "2df6a4ac-cb4a-4ef0-8b8d-833d635b91cf",
"name": "quarterly-results.csv",
"content_type": "text/csv",
"size": 18420,
"updated_at": "2026-07-16T16:00:00Z"
},
"upload": {
"url": "2df6a4ac-cb4a-4ef0-8b8d-833d635b91cf/content",
"method": "PUT"
}
}
2

Upload the bytes

Resolve a relative upload.url against /api/v1/deployments/{deploymentId}/files/. Absolute URLs are presigned storage URLs and must be used unchanged. Send the bytes with the returned method and headers. Include the user’s Astro AI credentials only for a same-origin Astro AI URL; never forward them to a presigned cross-origin URL.

The upload is not attachable until this request succeeds. A 413 means the declared or received file is too large; 507 means the deployment volume cannot fit it.

3

Attach the key to a message

Send only the key with the user’s text. Up to 16 files can be attached to one message, and text may be empty when at least one attachment is present.

POST /api/v1/deployments/{deploymentId}/messaging/conversations/{conversationId}/messages
Content-Type: application/json
{
"content": "Summarize this report",
"attachments": [
{ "key": "2df6a4ac-cb4a-4ef0-8b8d-833d635b91cf" }
]
}

The server reloads the file’s authoritative metadata and rejects unknown, incomplete, or differently owned keys.

4

Render and download response files

Assistant attachments arrive on the terminal SSE chunk and are also stored in chat history:

event: chunk
data: {"type":"chunk","chunk_type":"end","content":"Your analysis is ready.","attachments":[{"key":"0d94b92d-analysis.csv","name":"0d94b92d-analysis.csv","content_type":"text/csv","size":9321}]}

Download bytes from GET /api/v1/deployments/{deploymentId}/files/{key}/content. Follow redirects so the same client works with filesystem and presigned-object storage.

Ownership and storage rules

Setting supportsFiles changes what your agent ingests: it starts accepting user uploads. Treat attachment bytes, display names, and MIME types as untrusted input, and apply the same parser limits and content validation you would use for any user upload. The agent container shares the underlying volume, so use only the current turn’s attachment paths for input — do not scan or expose unrelated files.

  • Files are scoped to the authenticated user, even when several users share a deployment. List, read, download, delete, and message-attachment checks all enforce that scope.
  • Agent-produced files are assigned to the conversation owner when the reply is finalized. A file already assigned to another user is not transferred or attached.
  • Upload limits depend on the active deployment path and storage backend. Clients should treat 413 Payload Too Large as authoritative and prompt the user to choose a smaller file. The store also reserves free space for chat data; clients should surface 507 Insufficient Storage and let the user delete files before retrying.
  • Agent code writes outputs directly to the shared volume, so it must bound output size and handle disk-full write errors; direct agent writes do not pass through the upload limit or capacity reservation.
  • Symlinks are never adopted or served. Only regular files directly inside AGENT_FILES_DIR can become agent-produced downloads.
  • File metadata and chat history persist, so upload and download chips survive a page reload. If a user later deletes a file, its old message metadata remains but its bytes are no longer downloadable.