When a workflow block emits a file, it emits a UserFile — a small JSON shape that other blocks can consume. Knowing the shape lets you pass files end-to-end through a workflow without bouncing through the upload API.
The UserFile shape
UserFile lives in lib/uploads/utils/file-utils.ts:
| Field | Notes |
|---|---|
name | Display name. Same as workspace_files.originalName. |
key | Object-storage key. The unambiguous identity of the file. |
context | One of the file contexts — workspace, chat, execution, etc. |
mimeType | From workspace_files.contentType. |
size | Bytes. |
path | Optional. Local path inside a sandbox or worker. |
url | Optional. Pre-resolved serve URL. |
Most blocks accept any of path, url, or key and resolve to a serve URL via resolveFilePathFromInput:
// blocks/blocks/file.ts
if (typeof record.key === 'string' && record.key.trim() !== '') {
const key = record.key.trim()
const context = typeof record.context === 'string'
? record.context
: inferContextFromKey(key)
return `/api/files/serve/${encodeURIComponent(key)}?context=${context}`
}Resolution order: path → url → key (with context either explicit or inferred from the key prefix).
Producing a UserFile from a block
Two patterns:
Block input (basic / advanced mode)
// Basic — file-upload UI
{ id: 'uploadFile', type: 'file-upload', canonicalParamId: 'file', mode: 'basic' }
// Advanced — reference an upstream block's file output
{ id: 'fileRef', type: 'short-input', canonicalParamId: 'file', mode: 'advanced' }Inside tools.config, normalize the two inputs into a single canonical param:
import { normalizeFileInput } from '@/blocks/utils'
const file = normalizeFileInput(params.uploadFile || params.fileRef, { single: true })
if (file) params.file = fileTool output (file producers)
Tools that produce files use FileToolProcessor in transformResponse to materialize bytes into the file store and return a UserFile:
import { FileToolProcessor } from '@/executor/utils/file-tool-processor'
const processor = new FileToolProcessor(context)
const file = await processor.processFileData({
data: base64Content,
mimeType: 'application/pdf',
filename: 'doc.pdf',
})
// file is a UserFile — return it from transformResponse.The processor inserts a workspace_files row with the right context (typically execution) and returns the resulting UserFile.
Reading file bytes inside a server route
Server-side code that needs the file contents (parsing, sending to an external API) calls downloadFileFromStorage:
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
const buffer = await downloadFileFromStorage(userFile)
// buffer: Buffer — feed to a parser, hash, or external API.When an integration block accepts a file, the recommended pattern is:
- Accept the file via the basic/advanced input pair above.
- Route the tool call to an internal API route (
/api/tools/{service}/upload) instead of calling the external API directly. - In the internal route, use
downloadFileFromStorageto load bytes, then forward to the external API.
This keeps storage credentials server-side and avoids bouncing the file through the browser.
Inferring context from a key
Files written through the upload API typically encode the context into the storage prefix (e.g. chat/{chatId}/..., execution/{runId}/...). inferContextFromKey parses the key and returns the matching context, so callers that only have the key can still serve the file correctly.
Never construct a serve URL by hand from a key without setting context. The serve route uses context to authorize the read; an unknown context returns 403 even for valid keys.
Source
apps/actana/lib/uploads/utils/file-utils.ts—UserFile,inferContextFromKeyapps/actana/lib/uploads/utils/file-utils.server.ts—downloadFileFromStorageapps/actana/blocks/utils.ts—normalizeFileInputapps/actana/executor/utils/file-tool-processor.ts—FileToolProcessorapps/actana/blocks/blocks/file.ts— reference resolver