Files
Implement immutable transcript attachment providers.
Use files when threads need user attachments that should not live inline in messages.
import { createS3FileProvider } from "@your-org/pai-files-s3";
export const files = createS3FileProvider({
bucket: process.env.PAI_FILES_BUCKET!,
});Configure it on the server:
export const runtime = createAgentRuntime({
agent,
storage,
files,
scopeKey: (identity) => identity.workspaceId,
});Mental Model
Files are not a separate PAI thread collection. The message transcript records that a file was attached; the file provider owns how the underlying bytes are stored and loaded.
upload file
-> file provider stores bytes and metadata
-> provider returns fileId and file metadata
-> runtime creates an immutable attachment part
-> message persists that attachment part
-> model adapter reads the file and converts it to model inputThe thread transcript owns the fact that a user attached a file. The file provider owns everything behind the fileId.
That means the persisted thread stays small and portable:
await thread.send({
parts: [
{ type: "text", text: "Review this document." },
attachment,
],
});Only the attachment part is committed to the transcript. The provider may store the body in S3, R2, local disk, a database, content-addressed storage, or an external file service.
Provider Boundary
The provider stores and retrieves immutable files:
type FileProvider = {
save(input: SaveFileInput): Promise<UploadedFile>;
head(input: HeadFileInput): Promise<UploadedFile | null>;
read(input: ReadFileInput): Promise<StoredFileBody | null>;
list?(input: ListFilesInput): Promise<ListFilesResult>;
prepareForModel?(
input: PrepareForModelInput,
): Promise<PrepareForModelResult | null>;
createUrl?(input: CreateFileUrlInput): Promise<FileUrlResult | null>;
delete?(input: DeleteFileInput): Promise<void>;
close?(): Promise<void>;
};It does not create transcript parts or model-provider parts. PAI owns those translations.
Attachment Parts
The runtime stores the authorized snapshot as a reserved native data part:
type PaiStoredAttachmentPart = {
type: "data-pai-attachment";
id: string;
data: {
fileId: string;
mediaType: string;
filename?: string;
byteSize?: number;
metadata?: JsonObject;
};
};Attachment parts are immutable transcript content. PAI does not maintain a separate file table for attachments in core storage.
If application code changes or deletes the backing file after a message is committed, that is outside PAI's consistency model. The framework treats the transcript as the source of truth and expects provider-managed files to remain stable.
Public Projection
ThreadState.messages projects the reserved data part into one first-class
attachment part in place:
type PaiAttachmentPart = {
type: "attachment";
id: string;
fileId: string;
mediaType: string;
filename?: string;
byteSize?: number;
metadata?: JsonObject;
};URLs are not projected into the message. Call client.files.url(...) lazily
with part.fileId, or stream bytes through an application route, when preview
or download access is needed.
Provider Responsibilities
A file provider should:
- create stable
fileIdvalues; - store bytes and metadata however it wants;
- enforce scope isolation for reads, listings, and URLs;
- optionally enumerate paginated metadata inside one exact scope, including a runtime-derived creator and recorded origin-thread filter when supported;
- return enough metadata for PAI to create the transcript attachment part;
- optionally provide read or signed URL APIs for preview/download.
It may use S3, R2, local disk, database storage, content-addressed storage, external file ids, or signed URLs. That is private provider implementation.
Model Hydration
Before generation, the runtime walks context messages, reads attachment files from the provider, and lets the model adapter create provider-specific file parts:
const file = await files.read({
scopeKey,
fileId: part.fileId,
});
const modelPart = aiSdkAdapter.toFilePart(file);The AI SDK adapter maps stored files to AI SDK file parts. If a model cannot accept a file directly, the adapter may use a text fallback, such as filename and extracted text.
The important boundary is that persisted messages keep PAI's attachment part, file providers keep raw file storage, and provider-specific model file parts are created only at the model adapter boundary.
Non-Goals
The file provider does not own application resource storage.
attachment = user-supplied input in a message
resource = app-owned output such as a document, task, build, or reportApplication resources should live in your application data model. Expose them to the agent with normal tools and context. @pai/files only handles immutable files referenced by transcript attachment parts.
Provider Testing
File provider tests should cover:
- saving a file returns a stable
fileIdand metadata; - reads are isolated by exact
scopeKey; - listings are newest-first, isolated by exact scope, and honor optional
creator
userKeyand recorded-originthreadIdfilters; - listing rejects malformed continuation tokens and tokens reused with another explicit scope/user/thread query;
- missing files return
nullor a typed error consistently; - content type and filename metadata are preserved;
- signed URLs, when supported, are scoped and short-lived.