Skip to main content

Attach Files to an Agent

This tutorial walks through the full flow for letting an agent read a document you provide at runtime:

  1. Upload the file once with POST /v1/files.
  2. Reference its file_id from a Responses API request via an input_file content part.
  3. The agent automatically calls the right read tool (docx_read, xlsx_read, or pdf_read) on its own.

The wire shape is the same as OpenAI's Files API — if you already point an OpenAI SDK at our backend, file uploads Just Work.

When should I use this vs. artifact_id or s3_key?
  • You want the agent to read a file you have on hand → use file_id (this tutorial). It's the only flow that gives you org-scoped, audited storage.
  • The file was emitted by the agent earlier in the same run → use artifact_id. Zero config, the agent's read tools already know about it.
  • You have a raw S3 key from the legacy templates/ flow → still works but deprecated for one release cycle. Migrate to file_id.

Prerequisites

  • A platform API token with file:write and file:read abilities (any api-secret-scope token has both).
  • An agent on the platform whose tool list includes docx_read_tool / xlsx_read_tool / pdf_read_tool. See Wiring an Artifact-Capable Workflow if you need to add them.

End-to-end flow

The signed URL never travels through SQS and is never written to disk on the worker — it is minted just-in-time and consumed once. SQS redelivery on retry gets a fresh URL automatically.

1. Upload the file

POST /v1/files is a standard multipart upload. Two form fields are required: file (the bytes) and purpose (always user_data in v1).

Python

import httpx

with open("quarterly-report.docx", "rb") as f:
response = httpx.post(
"https://api.alpha.alien.club/v1/files",
headers={"Authorization": "Bearer oat_YOUR_TOKEN"},
files={"file": (
"quarterly-report.docx",
f.read(),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)},
data={"purpose": "user_data"},
)

response.raise_for_status()
file_id = response.json()["id"]
print(file_id) # file_8pauo0KQA-g_Bi7LHx5RA

TypeScript

const formData = new FormData()
formData.append("file", await fileFromPath("./quarterly-report.docx"))
formData.append("purpose", "user_data")

const res = await fetch("https://api.alpha.alien.club/v1/files", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.PLATFORM_TOKEN}` },
body: formData,
})

const { id: fileId } = await res.json()

cURL

curl -X POST https://api.alpha.alien.club/v1/files \
-H "Authorization: Bearer oat_YOUR_TOKEN" \
-F "file=@quarterly-report.docx;type=application/vnd.openxmlformats-officedocument.wordprocessingml.document" \
-F "purpose=user_data"

Response shape

{
"id": "file_8pauo0KQA-g_Bi7LHx5RA",
"object": "file",
"bytes": 36682,
"created_at": 1781702181,
"filename": "quarterly-report.docx",
"purpose": "user_data",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
}

id is what you pass to the Responses API. created_at is unix seconds (OpenAI convention).

Limits and allowlist

ConstraintValue
Max file size50 MB
Allowed MIME typesapplication/pdf, …wordprocessingml.document (.docx), …spreadsheetml.sheet (.xlsx), text/markdown, text/plain
Allowed extensionspdf, docx, xlsx, md, txt
MIME detectionFrom bytes (magic number), not the Content-Type header
MIME header is untrusted

The backend sniffs the file's magic number to determine the real MIME type — relabelling a binary as application/pdf does not bypass the allowlist. Uploads whose detected MIME falls outside the list are rejected with 422.

2. Reference the file in a Responses API request

Use OpenAI's standard input_file content part. Mix it with input_text parts in the same content array:

POST /agent/{agent_id}/responses
Authorization: Bearer oat_YOUR_TOKEN
Content-Type: application/json

{
"model": "claude-sonnet-4-6",
"input": [{
"role": "user",
"content": [
{ "type": "input_file", "file_id": "file_8pauo0KQA-g_Bi7LHx5RA" },
{ "type": "input_text", "text": "Summarise the attached quarterly report in one paragraph." }
]
}],
"stream": true
}

That's it. The agent's prompt is automatically prefixed with a structured preamble listing each attached file (file_id, filename, mime_type) so the model can call the matching read tool directly.

What the agent sees

Behind the scenes the model receives something like:

The following files have been attached to this request and can be read via the artifact read tools
(docx_read / xlsx_read / pdf_read) by passing the matching `file_id` argument:
- file_id=file_8pauo0KQA-g_Bi7LHx5RA (filename=quarterly-report.docx, mime_type=application/vnd.openxmlformats-officedocument.wordprocessingml.document)

User request:
Summarise the attached quarterly report in one paragraph.

The agent issues docx_read_tool({"file_id": "file_8pauo0KQA-g_Bi7LHx5RA"}) on its own — you do not have to instruct it explicitly.

Multiple files in a single turn

Pass as many input_file parts as you need:

"content": [
{ "type": "input_file", "file_id": "file_aaa..." },
{ "type": "input_file", "file_id": "file_bbb..." },
{ "type": "input_file", "file_id": "file_ccc..." },
{ "type": "input_text", "text": "Compare the three reports and find the common KPIs." }
]

The agent typically issues the read tool calls in parallel.

Unsupported reference modes

OpenAI's full surface also accepts file_url (remote URL) and file_data (inline base64). Both are rejected by the backend today with a 400 and the OpenAI-shaped error code:

{
"error": {
"message": "input_file 'file_url' mode is not supported, use file_id with a /v1/files upload",
"type": "invalid_request_error",
"code": "unsupported_file_reference_mode"
}
}

The wire shape is kept spec-compatible so SDKs that emit those modes get a clean, parseable error.

3. Stream the response

The Responses API streams Server-Sent Events. The interesting frames for this flow:

EventWhen
response.createdStream opened, the backend committed Job.inputFileIds
response.output_item.added (type: function_call)Agent decided to call a read tool
response.function_call_arguments.doneArguments finalised, e.g. {"file_id": "file_…"}
response.tool_call.outputRead tool returned (text + headings for docx, sheets for xlsx, markdown for pdf)
response.output_text.deltaModel's response tokens
response.completedTerminal — full response in payload.response

Minimal Python consumer:

import httpx
import json

with httpx.stream(
"POST",
"https://api.alpha.alien.club/agent/231/responses",
headers={"Authorization": "Bearer oat_YOUR_TOKEN", "Content-Type": "application/json"},
json={
"model": "claude-sonnet-4-6",
"input": [{
"role": "user",
"content": [
{"type": "input_file", "file_id": file_id},
{"type": "input_text", "text": "Summarise this."},
],
}],
"stream": True,
},
timeout=httpx.Timeout(connect=10.0, read=240.0),
) as response:
for line in response.iter_lines():
if line.startswith("data:"):
payload = json.loads(line[5:].strip())
if payload.get("type") == "response.completed":
final = payload["response"]
print(final["output"][-1]["content"][0]["text"])
break

Managing uploaded files

The Files API endpoints mirror OpenAI's full surface:

EndpointDescription
POST /v1/filesUpload a new file
GET /v1/filesList the calling org's files (filter with ?purpose=user_data)
GET /v1/files/{file_id}Get a single file's metadata
GET /v1/files/{file_id}/content302-redirect to a freshly-signed S3 URL for downloading the bytes
DELETE /v1/files/{file_id}Hard-delete (S3 object first, then DB row)
# List
files = httpx.get(
"https://api.alpha.alien.club/v1/files",
headers={"Authorization": "Bearer oat_YOUR_TOKEN"},
).json()["data"]

# Download
download = httpx.get(
f"https://api.alpha.alien.club/v1/files/{file_id}/content",
headers={"Authorization": "Bearer oat_YOUR_TOKEN"},
follow_redirects=True,
)
# Bytes available on download.content

# Delete
httpx.delete(
f"https://api.alpha.alien.club/v1/files/{file_id}",
headers={"Authorization": "Bearer oat_YOUR_TOKEN"},
)

Lifecycle

  • Files persist until you call DELETE or until the owning organization is deleted (cascade).
  • There is no automatic TTL. If you only need a file for a single run, delete it after the response completes.
  • Re-uploading the same bytes returns a new file_id — there is no automatic dedup. If you need stable references, cache the file_id on your side.

Org scoping

Files are scoped to the uploader's current organization. A file uploaded while you have org A active is invisible to org B, even for the same user. Cross-org access returns 404 (not 403) to prevent existence enumeration.

Security model

The properties you can rely on:

  • You never hand the agent platform-level credentials. The backend mints a single-use, short-lived presigned URL for the worker; the model only ever sees the opaque file_id.
  • MIME is detected from bytes, not the Content-Type header — a malicious client cannot smuggle a non-allowlisted format past the upload boundary.
  • Filename sanitisation strips path separators before constructing the S3 key. The key is always files/{org_id}/{file_id}/{filename}; a client cannot influence the {org_id} segment.
  • Two authorisation checks per file, one at /v1/responses creation and one at worker dispatch — a file revoked or deleted between the two returns the worker a clean error rather than a dangling URL.
  • Read tools are extraction-only: macros in .docx, formulas in .xlsx, and JavaScript in .pdf are not evaluated.

Troubleshooting

The agent says it doesn't have docx_read available. The target agent's workflow needs to include the artifact read tools. See Wiring an Artifact-Capable Workflow.

POST /v1/files returns 422. Either the file exceeded 50 MB, the extension is not on the allowlist, or the magic-number sniff detected something outside the allowed MIME types. The error body lists which validator failed.

POST /agent/:id/responses returns 404 with code file_not_found. The file_id does not exist for your current organization. Switch organizations or re-upload.

POST /agent/:id/responses returns 400 with code unsupported_file_reference_mode. You sent an input_file part with file_url or file_data. Switch to file_id (the only supported mode in v1).

The worker raises Backend failed to resolve N input file(s). The file was deleted (or org membership revoked) between job creation and SQS pickup. The job fails fast rather than running with a partial input set — re-upload and retry.

See also