@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 inputThread 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.