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.

1agent:
2 build:
3 context: .
4 dockerfile: Dockerfile
5 interfaces:
6 messaging: true
7
8dev:
9 interfaces:
10 messaging:
11 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.

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

In local development, open the chat URL printed by ast dev (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.
1import { readFile } from "node:fs/promises";
2import type { AgentAdapter } from "@astropods/adapter-core";
3
4const adapter: AgentAdapter = {
5 name: "Document analyst",
6
7 async stream(prompt, hooks, options) {
8 try {
9 for (const attachment of options.attachments ?? []) {
10 if (!attachment.path) {
11 throw new Error(
12 `Cannot resolve ${attachment.name}; update the messaging and adapter packages`,
13 );
14 }
15
16 const bytes = await readFile(attachment.path);
17 await analyzeFile({
18 bytes,
19 name: attachment.name,
20 mimeType: attachment.mimeType,
21 prompt,
22 });
23 }
24
25 hooks.onChunk("I finished analyzing the attached file.");
26 hooks.onFinish();
27 } catch (error) {
28 hooks.onError(error as Error);
29 }
30 },
31
32 getConfig() {
33 return { systemPrompt: "Analyze user-provided documents.", tools: [], supportsFiles: true };
34 },
35};

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.

1import { randomUUID } from "node:crypto";
2import { mkdir, stat, writeFile } from "node:fs/promises";
3import { join } from "node:path";
4import type { StreamHooks } from "@astropods/adapter-core";
5
6const filesDir = process.env.AGENT_FILES_DIR ?? "/data/files";
7
8async function attachCsv(hooks: StreamHooks, csv: string) {
9 const name = `${randomUUID()}-analysis.csv`;
10 await mkdir(filesDir, { recursive: true });
11 await writeFile(join(filesDir, name), csv, { encoding: "utf8", flag: "wx" });
12
13 const file = await stat(join(filesDir, name));
14 hooks.onFile({ name, mimeType: "text/csv", size: file.size });
15 hooks.onChunk("Your analysis is ready.");
16 hooks.onFinish();
17}

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.

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.

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.
1import { randomUUID } from "node:crypto";
2import { readFile, writeFile } from "node:fs/promises";
3import { join } from "node:path";
4
5const filesDir = process.env.AGENT_FILES_DIR ?? "/data/files";
6const input = message.attachments?.find((a) => a.type === "FILE");
7if (input?.storageKey) {
8 const inputPath = join(filesDir, `${input.storageKey}.blob`);
9 const bytes = await readFile(inputPath);
10 // Process bytes...
11}
12
13const outputName = `${randomUUID()}-analysis.csv`;
14const outputBytes = new TextEncoder().encode(await buildCsv());
15await writeFile(join(filesDir, outputName), outputBytes, { flag: "wx" });
16conversation.sendContentChunk(message.conversationId, {
17 type: "END",
18 content: "Your export is ready.",
19 attachments: [
20 {
21 file: {
22 filename: outputName,
23 mimeType: "text/csv",
24 sizeBytes: outputBytes.byteLength,
25 },
26 },
27 ],
28});

See the Node messaging SDK or Python messaging SDK 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.

1{
2 "name": "quarterly-results.csv",
3 "content_type": "text/csv",
4 "size": 18420
5}

The response contains an opaque key and an upload descriptor:

1{
2 "key": "2df6a4ac-cb4a-4ef0-8b8d-833d635b91cf",
3 "file": {
4 "key": "2df6a4ac-cb4a-4ef0-8b8d-833d635b91cf",
5 "name": "quarterly-results.csv",
6 "content_type": "text/csv",
7 "size": 18420,
8 "updated_at": "2026-07-16T16:00:00Z"
9 },
10 "upload": {
11 "url": "2df6a4ac-cb4a-4ef0-8b8d-833d635b91cf/content",
12 "method": "PUT"
13 }
14}
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.

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

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.