PAIPAI

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 input

The 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 fileId values;
  • 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 report

Application 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 fileId and metadata;
  • reads are isolated by exact scopeKey;
  • listings are newest-first, isolated by exact scope, and honor optional creator userKey and recorded-origin threadId filters;
  • listing rejects malformed continuation tokens and tokens reused with another explicit scope/user/thread query;
  • missing files return null or a typed error consistently;
  • content type and filename metadata are preserved;
  • signed URLs, when supported, are scoped and short-lived.

On this page