PAIPAI

@pai/storage

ThreadStore contract. Apps pick Mongo or in-memory.

ThreadStore is the durable thread provider. You do not implement this unless you are writing a storage adapter.

CallableWhen
createMongoThreadStore()Production
createInMemoryThreadStore()Tests, first app (createPai default)

Pass the store as createPai({ storage }). Call initialize() on Mongo at boot.

Listing a thread's attachments

listThreadAttachments() returns the files users sent in one thread without paging the transcript. Each item is the stored attachment payload (fileId, mediaType, optional filename, byteSize, metadata) plus the messageId that carried it.

// Every file sent in the thread.
const { items } = await storage.listThreadAttachments({ scopeKey, threadId });

// Or a page at a time.
const page = await storage.listThreadAttachments({
  scopeKey,
  threadId,
  limit: 20, // 1–100 attachments
  pageToken, // from the previous page's nextPageToken
});
  • Without limit, every match (after pageToken, if you pass one) comes back in one result with no nextPageToken.
  • Only attachments on user messages that are visible in the transcript. Assistant files and hidden messages are excluded.
  • Newest message first; attachments within one message keep their order. A file sent twice appears once per message.
  • A page can end partway through a message; nextPageToken resumes at its next attachment. Tokens are bound to the scope and thread, and reusing one elsewhere throws ThreadStorePageTokenError.
  • A missing thread returns an empty page.

Listing a thread's tool calls

listThreadToolCalls() returns one tool's calls in one thread without paging the transcript. Each item is the stored native tool part (the AI SDK ToolUIPart shape: type: "tool-<name>", toolCallId, state, input, output or errorText) plus the messageId that carried it.

// Every call of one tool in the thread.
const { items } = await storage.listThreadToolCalls({
  scopeKey,
  threadId,
  toolName: "publish_file",
});
// A preliminary output is still streaming, so it is not a finished call.
const done = items.filter(
  ({ part }) => part.state === "output-available" && part.preliminary !== true,
);

// Or a page at a time: add `limit` (1–100 tool calls) and pass back `pageToken`.
  • Without limit, every match (after pageToken, if you pass one) comes back in one result with no nextPageToken. Each item carries the tool's whole output, so pass a limit for a tool with large outputs on a long thread.
  • toolName is the key the agent's tools registers the tool under, which names its stored parts; it is not the tool's id where the two differ.
  • Only tool-<toolName> parts on assistant messages that are visible in the transcript. dynamic-tool parts are not matched; PAI stores a registered tool's calls as static parts.
  • Every state is returned; filter on part.state yourself.
  • Newest message first; calls within one message keep their order.
  • A page can end partway through a message; nextPageToken resumes at its next call. Tokens are bound to the scope, thread, and tool name, and reusing one elsewhere throws ThreadStorePageTokenError.
  • A missing thread returns an empty page.

Both listings read live data, not a snapshot: a message added after one page is never returned in part by the next, while parts appended to the boundary message are. A store may reject a token whose boundary message has since been deleted (for example by a regeneration) with ThreadStorePageTokenError; list again from the start.

Custom ThreadStore adapters must implement both listings; createThreadStoreConformanceSuite from @pai/storage/test covers their behaviour.

Provider utilities

@pai/storage/thread-store exports canonical record parsers, commit-input validation, helpers for applying write sets and validating record relationships, and transcriptPartsOf with the threadAttachmentListing and threadToolCallListing() rules that decide which stored parts the two listings return. Storage providers own their read queries, filtering, ordering, and pagination-token formats.

Learn more

On this page