PAIPAI

@pai/files

File provider contract for immutable transcript attachments.

@pai/files defines the optional file provider contract used for immutable user attachments.

Applications pass a file provider through @pai/core:

const runtime = createAgentRuntime({
  agent,
  files,
  scopeKey: (identity) => identity.workspaceId,
});

The core idea is intentionally small:

runtime uploads file
  -> file provider saves immutable bytes and returns fileId + metadata
  -> runtime creates an attachment part in the transcript
  -> model adapter reads the file and converts it to model input

Thread storage persists the attachment part. The file provider only stores and retrieves immutable files; it does not create message parts and it does not create model-provider parts.

FileProvider

type FileProvider = {
  /** Persist bytes and return a stable `fileId`. */
  save(input: SaveFileInput): Promise<UploadedFile>;

  /** Cheap metadata-only lookup without reading the file body. */
  head(input: HeadFileInput): Promise<UploadedFile | null>;

  /** Fetch bytes for a previously saved file. */
  read(input: ReadFileInput): Promise<StoredFileBody | null>;

  /** List file metadata in one exact scope, optionally by creator and origin. */
  list?(input: ListFilesInput): Promise<ListFilesResult>;

  /** Produce the cheapest representation to hand to a model provider. */
  prepareForModel?(
    input: PrepareForModelInput,
  ): Promise<PrepareForModelResult | null>;

  /** Mint a short-lived URL the consumer can use directly. */
  createUrl?(input: CreateFileUrlInput): Promise<FileUrlResult | null>;

  /** Delete a file. The transcript is immutable, so use sparingly. */
  delete?(input: DeleteFileInput): Promise<void>;

  /** Optional cleanup hook for long-lived processes. */
  close?(): Promise<void>;
};

The provider owns how files are stored. It may use S3, R2, local disk, database storage, signed URLs, external file ids, content-addressed storage, or anything else.

save, head, and read are required. list, prepareForModel, createUrl, delete, and close are optional capabilities.

Listing Files

Enumerable providers can expose a paginated scope catalogue:

const first = await files.list?.({
  scopeKey,
  userKey,
  threadId,
  limit: 50,
});

const second = first?.nextPageToken
  ? await files.list?.({
      scopeKey,
      userKey,
      threadId,
      limit: 50,
      pageToken: first.nextPageToken,
    })
  : undefined;

Results are ordered newest saved file first. scopeKey is always an exact authorization partition. The optional userKey filter selects the runtime-derived creator recorded when the file was saved; it is provenance, not ownership or an authorization rule. Omitting it includes every creator and files without attribution. The optional threadId filter selects the association recorded when a file was created for a thread; it is not a reverse index of every transcript that later referenced the same fileId. The transcript remains the canonical source for attachment references.

Page tokens are opaque continuation positions. Repeat the same scopeKey and userKey and threadId filters on every page; providers reject malformed tokens and tokens reused with another query. One page may contain at most FILE_PROVIDER_MAX_PAGE_SIZE (100) files.

Provider Constructor Hooks

First-party provider constructors share common upload hooks. Use beforeSave for synchronous validation or normalization such as MIME sniffing, size policy, or virus scanning:

const files = createMongoFileProvider({
  url: process.env.MONGO_URL!,
  beforeSave: async (input) => {
    const bytes = await collectFileBody(input.body);
    const scan = await scanFile(bytes, input.filename ?? "attachment");

    if (scan.status === "failed") {
      throw new FileRejectedError(scan.error.message, scan.error.code);
    }

    return {
      ...input,
      body: bytes,
      mediaType: scan.data.mimeType,
    };
  },
});

beforeSave runs after trusted scope and creator resolution and before bytes are persisted. It may return a replacement SaveFileInput, or return nothing to continue with the original input. Trusted scopeKey and userKey, plus the boundary's original threadId and cancellation fields, are restored before the provider saves. A direct upload's thread id is still only caller-supplied origin metadata, never authorization. If the hook reads input.body and that body is a stream, it must return a fresh body because streams are one-shot.

Throw FileRejectedError for user-facing upload rejection. Operational failures, such as an unavailable scanning service, should normally throw their original error so clients can show a retryable upload failure rather than "this file is invalid."

type FileProviderBeforeSaveHook = (
  input: SaveFileInput,
) => SaveFileInput | undefined | Promise<SaveFileInput | undefined>;

type FileProviderPrepareForModelHook = (
  input: PrepareForModelInput,
  context: {
    read(input: ReadFileInput): Promise<StoredFileBody | null>;
  },
) => Promise<PrepareForModelResult | null>;

type FileProviderCommonOptions = {
  beforeSave?: FileProviderBeforeSaveHook;
  prepareForModel?: FileProviderPrepareForModelHook;
  maxFileBytes?: number | false;
};

Set maxFileBytes to a finite byte count to make first-party providers reject oversized direct/in-process saves while streaming or collecting file bodies. HTTP receivers also enforce their own default requestLimits before upload bytes reach the provider. Leave maxFileBytes unset, or set it to false, only when another trusted layer enforces the cap.

Provider authors can use applyFileProviderHooks(provider, options) from @pai/files to apply shared hooks consistently.

prepareForModel receives the current thread association, active run signal, and the attachment metadata snapshot. A hook can return one model-ready part, or a parts result when one document expands into ordered text and media parts. Returning null defers to the provider's native preparation or read-backed fallback.

Attachments

Upload first, then send the reference the upload returns:

const uploaded = await client.files.upload({
  body: file,
  mediaType: file.type,
  filename: file.name,
});

await thread.send({
  text: "Review this document.",
  attachments: [uploaded],
});

upload resolves trusted scope and creator, runs the beforeSave hook, and hands the bytes to the provider's save:

const stored = await files.save({
  scopeKey,
  userKey,
  threadId,
  body: new Uint8Array(await file.arrayBuffer()),
  filename: file.name,
  mediaType: file.type,
  metadata: { source: "review-flow" },
});

The send that follows carries only stored.fileId. The runtime, not the file provider and not the caller, creates the persisted message part — it reads the provider's record back and snapshots it:

type PaiAttachmentPart = {
  /** Marks this part as a PAI attachment part. */
  type: "attachment";

  /** Stable identity from the reserved native attachment data part. */
  id: string;

  /** Stable provider id used to read the file. */
  fileId: string;

  /** MIME type when known. */
  mediaType: string;

  /** Suggested display/download filename. */
  filename?: string;

  /** Size in bytes, measured by the provider when the body was stored. */
  byteSize?: number;

  /** Provider/application metadata safe to expose in thread state. */
  metadata?: Record<string, unknown>;
};

Attachment parts are immutable transcript content. If application code changes or deletes the backing file after the message is committed, that is outside PAI's consistency model.

The provider returns UploadedFile. PAI turns that reference into an attachment part. After that, the message transcript is the source of truth.

Public Projection

Clients render the first-class PaiAttachmentPart directly from state.messages[].parts. Access URLs are not projected because they may be scoped and short-lived. Call client.files.url(...) lazily with part.fileId when a preview or download is needed.

Model Hydration

Before a model call, the runtime asks the provider for the cheapest representation it can supply. Providers with externally fetchable storage can return a URL from prepareForModel. Local and in-memory providers usually return bytes.

const modelFile = await files.prepareForModel?.({
  scopeKey,
  threadId: thread.threadId,
  signal: run.signal,
  fileId: part.fileId,
  mediaType: part.mediaType,
  filename: part.filename,
  byteSize: part.byteSize,
  metadata: part.metadata,
});

const modelPart = aiSdkAdapter.toFilePart(modelFile);

The runtime supplies the trusted thread id, cancellation signal, and attachment metadata snapshot stored in the message. A hook can use that context to stage the file into a thread-scoped execution environment and return one or more text/file parts instead of sending the original bytes directly. threadId remains optional because hosts can also call a provider outside a thread.

When a provider does not implement prepareForModel, the runtime falls back to read() and passes bytes to the model adapter. This keeps provider implementations independent from AI SDK, OpenAI, Anthropic, or any future model adapter shape.

Inputs

type SaveFileInput = {
  /** Resolved trusted scope. */
  scopeKey: ScopeKey;

  /** Runtime-derived creator provenance; not an authorization boundary. */
  userKey?: UserKey;

  /** Creation-origin association; scopeKey remains the auth boundary. */
  threadId?: ThreadId;

  /** Active request cancellation for streamed or copied bodies. */
  signal?: AbortSignal;

  /** File body supplied by the receiver or host application. */
  body: Uint8Array | ReadableStream<Uint8Array>;

  /** MIME type used for attachment metadata and model routing. */
  mediaType: string;

  /** Suggested filename. */
  filename?: string;

  /** Provider/application metadata safe to expose in thread state. */
  metadata?: Record<string, unknown>;
};

type UploadedFile = {
  /** Stable provider id. */
  fileId: string;

  /** MIME type stored in the transcript attachment. */
  mediaType: string;

  /** Suggested display/download filename. */
  filename?: string;

  /** File size in bytes when known. */
  byteSize?: number;

  /** Provider/application metadata safe to expose in thread state. */
  metadata?: Record<string, unknown>;
};

type ReadFileInput = {
  scopeKey: ScopeKey;
  fileId: string;
};

type ListFilesInput = {
  scopeKey: ScopeKey;
  /** Exact creator-provenance filter. Omit to include every creator. */
  userKey?: UserKey;
  /** Origin association recorded by SaveFileInput.threadId. */
  threadId?: ThreadId;
  limit: number;
  pageToken?: PageToken;
};

type FileListItem = UploadedFile & {
  /** Runtime-derived creator recorded at save time, when known. */
  userKey?: UserKey;
  /** Origin thread recorded at save time, when present. */
  threadId?: ThreadId;
};

type ListFilesResult = Page<FileListItem>;

type HeadFileInput = ReadFileInput;

type DeleteFileInput = ReadFileInput;

type PrepareForModelInput = {
  scopeKey: ScopeKey;
  /** Thread whose model request is resolving this attachment, when known. */
  threadId?: ThreadId;

  /** Active run cancellation, when model preparation happens inside a run. */
  signal?: AbortSignal;

  fileId: string;

  /** Hint for the provider, typically matching the stored mediaType. */
  mediaType: string;

  /** Stable attachment metadata copied into the persisted message. */
  filename?: string;
  byteSize?: number;
  metadata?: Record<string, unknown>;
};

type PrepareForModelResult =
  | {
      kind: "bytes";
      bytes: Uint8Array;
      mediaType: string;
      filename?: string;
    }
  | {
      kind: "url";
      url: string;
      mediaType: string;
      filename?: string;
    }
  | {
      kind: "parts";
      parts: PrepareForModelPart[];
    };

type PrepareForModelPart =
  | {
      kind: "text";
      text: string;
    }
  | {
      kind: "bytes";
      bytes: Uint8Array;
      mediaType: string;
      filename?: string;
    }
  | {
      kind: "url";
      url: string;
      mediaType: string;
      filename?: string;
    };

type StoredFileBody = UploadedFile & {
  body: Uint8Array | ReadableStream<Uint8Array>;
};

type FileUrlIntent = "view" | "download";

type CreateFileUrlInput = {
  scopeKey: ScopeKey;
  fileId: string;

  /** How the URL will be used by the caller. */
  intent: FileUrlIntent;

  /** Requested URL lifetime, when supported by the provider. */
  ttlSeconds?: number;
};

type FileUrlResult = {
  url: string;
  expiresAt?: string;
};

The runtime resolves trusted scope before calling the file provider. Providers must not read files across scopes.

Client Surface

Application clients use client.files rather than importing @pai/files directly:

const uploaded = await client.files.upload({
  body: file,
  mediaType: file.type,
  filename: file.name,
  threadId: thread.threadId,
});
const preview = await client.files.url({ fileId: uploaded.fileId });

await thread.send({
  parts: [
    { type: "text", text: "Summarize this image." },
    { type: "attachment", fileId: uploaded.fileId },
  ],
});

upload() stores immutable file input and returns its provider metadata. Sending an attachment reference creates the transcript part. read() and url() expose server capabilities for previews and downloads; they do not mutate the transcript.

Errors

class FileProviderUnavailableError extends Error {}
class FileNotFoundError extends Error {
  readonly fileId: string;
}
class FileRejectedError extends Error {
  readonly reason?: string;
}
class FileProviderPageTokenError extends Error {}

FileRejectedError is for provider or host validation failures during upload, such as MIME validation, size policy, or virus scanning. HTTP receivers map it to a client error instead of treating it as an internal failure.

Application resource storage is intentionally not part of @pai/files. This package only handles immutable files referenced by transcript attachment parts.

On this page