A community adapter is an npm package that maps one AI service onto chat(), generateImage(), and the other TanStack AI functions.
You build, publish, and maintain the adapter. The TanStack AI core team does not.
Create an npm package, for example @acme/tanstack-ai-adapter. Add @tanstack/ai as a peer dependency, so your adapter uses the same copy as the app.
For reference, read the adapters in the TanStack AI repository. The OpenAI adapter has the most features.
A text adapter extends BaseTextAdapter and implements two methods:
This sample calls a JSON API that returns the whole answer at once:
// src/index.ts
import { EventType } from "@tanstack/ai";
import { BaseTextAdapter } from "@tanstack/ai/adapters";
import type {
AdapterYieldChunk,
DefaultMessageMetadataByModality,
ModelMessage,
Modality,
TextOptions,
} from "@tanstack/ai";
import type {
StructuredOutputOptions,
StructuredOutputResult,
} from "@tanstack/ai/adapters";
export const ACME_MODELS = ["acme-small", "acme-large"] as const;
export type AcmeModel = (typeof ACME_MODELS)[number];
type AcmeOptions = Record<string, never>;
async function callAcme(
apiKey: string,
body: Record<string, unknown>,
signal: AbortSignal | undefined,
): Promise<string> {
const response = await fetch("https://api.acme.example/v1/chat", {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
signal,
});
const json: unknown = await response.json();
if (
typeof json === "object" &&
json !== null &&
"text" in json &&
typeof json.text === "string"
) {
return json.text;
}
throw new Error(`Acme returned no text (status ${response.status})`);
}
function toAcmeMessages(messages: Array<ModelMessage>) {
return messages.map((message) => ({
role: message.role,
content: typeof message.content === "string" ? message.content : "",
}));
}
export class AcmeTextAdapter<TModel extends AcmeModel> extends BaseTextAdapter<
TModel,
AcmeOptions,
ReadonlyArray<Modality>,
DefaultMessageMetadataByModality
> {
readonly name = "acme";
private readonly apiKey: string;
constructor(model: TModel, apiKey: string) {
super({}, model);
this.apiKey = apiKey;
}
async *chatStream(
options: TextOptions<AcmeOptions>,
): AsyncIterable<AdapterYieldChunk> {
const runId = options.runId ?? this.generateId();
const threadId = options.threadId;
const messageId = this.generateId();
yield { type: EventType.RUN_STARTED, runId, threadId, timestamp: Date.now() };
const text = await callAcme(
this.apiKey,
{ model: this.model, messages: toAcmeMessages(options.messages) },
options.abortController?.signal,
);
yield {
type: EventType.TEXT_MESSAGE_START,
messageId,
role: "assistant",
timestamp: Date.now(),
};
yield {
type: EventType.TEXT_MESSAGE_CONTENT,
messageId,
delta: text,
timestamp: Date.now(),
};
yield { type: EventType.TEXT_MESSAGE_END, messageId, timestamp: Date.now() };
yield {
type: EventType.RUN_FINISHED,
runId,
threadId,
finishReason: "stop",
timestamp: Date.now(),
};
}
async structuredOutput(
options: StructuredOutputOptions<AcmeOptions>,
): Promise<StructuredOutputResult<unknown>> {
const rawText = await callAcme(
this.apiKey,
{
model: this.model,
messages: toAcmeMessages(options.chatOptions.messages),
schema: options.outputSchema,
},
options.chatOptions.abortController?.signal,
);
const data: unknown = JSON.parse(rawText);
return { data, rawText };
}
}
export function acmeText<TModel extends AcmeModel>(
model: TModel,
apiKey: string = process.env.ACME_API_KEY ?? "",
) {
return new AcmeTextAdapter(model, apiKey);
}// src/demo.ts
import { chat } from "@tanstack/ai";
import { acmeText } from "./index";
const stream = chat({
adapter: acmeText("acme-large"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
});
for await (const chunk of stream) {
if (chunk.type === "TEXT_MESSAGE_CONTENT") {
process.stdout.write(chunk.delta);
}
}The answer of your service prints in the terminal. Your adapter now works with chat(), useChat, and the rest of TanStack AI.
The minimal adapter skips tools, system prompts, and images. A full adapter maps them the same way. For a full example, see the OpenAI text adapter.
Adapters are split by capability. Implement only what your service supports:
You are responsible for the adapter after you publish it:
These steps make your adapter type-safe per model, like the built-in adapters.
Model metadata describes what each model can do. TanStack AI uses it for compatibility checks and feature selection. For each model, define at least:
For a concrete example, see the OpenAI model metadata.
Group the models by what they support, in exported arrays. Put a model in an array only when it fully supports that capability:
export const OPENAI_CHAT_MODELS = [
// Frontier models
GPT5_2.name,
GPT5_2_PRO.name,
GPT5_2_CHAT.name,
GPT5_1.name,
GPT5_1_CODEX.name,
GPT5.name,
GPT5_MINI.name,
GPT5_NANO.name,
GPT5_PRO.name,
GPT5_CODEX.name,
// ...other models
] as const
export const OPENAI_IMAGE_MODELS = [
GPT_IMAGE_1.name,
GPT_IMAGE_1_MINI.name,
] as const
export const OPENAI_VIDEO_MODELS = [SORA2.name, SORA2_PRO.name] as constModels take different options. Build the options from small reusable fragments, not one copy per model:
Based on the OpenAI options:
export interface OpenAIBaseOptions {
// base options that every chat model supports
}
// Feature fragments that can be stitched per-model
/**
* Tools options for models.
*/
export interface OpenAIToolsOptions {
//...
}
/**
* Structured output options for models.
*/
export interface OpenAIStructuredOutputOptions {
//...
}There is no single correct split. Match the features of your service.
Type the options by model name, so users see only the options of the selected model. A model gets only the fragments it supports:
export type OpenAIChatModelProviderOptionsByName = {
[GPT5_2.name]: OpenAIBaseOptions &
OpenAIStructuredOutputOptions &
OpenAIToolsOptions &
OpenAIStreamingOptions &
OpenAIMetadataOptions
[GPT5_2_CHAT.name]: OpenAIBaseOptions &
OpenAIStructuredOutputOptions &
OpenAIToolsOptions &
OpenAIStreamingOptions &
OpenAIMetadataOptions
// ... repeat for each model
}Define the input kinds (text, images, audio) of each model, so invalid input is a type error:
export type OpenAIModelInputModalitiesByName = {
[GPT5_2.name]: typeof GPT5_2.supports.input
[GPT5_2_PRO.name]: typeof GPT5_2_PRO.supports.input
[GPT5_2_CHAT.name]: typeof GPT5_2_CHAT.supports.input
// ... repeat for each model
}To also set inputModalities at run time, see Set inputModalities in your own adapter.
Does your service have a thinking model? Users set it with chat({ reasoning }), and the types take only the levels of the selected model. Put a reasoning field on the metadata of each model, then build two maps from it:
import type { ModelReasoning } from '@tanstack/ai'
import type { ModelReasoningCapability } from '@tanstack/ai/adapter-internals'
const EFFORT_NONE_LOW_TO_XHIGH = {
levels: { off: 'none', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh' },
budget: false,
} as const
const GPT5_2 = {
name: 'gpt-5.2',
// ...supports, pricing
reasoning: EFFORT_NONE_LOW_TO_XHIGH,
} as const
// One object lists each model once.
const OPENAI_REASONING = {
[GPT5_2.name]: GPT5_2.reasoning,
}
// The levels per model, for the types.
export type OpenAIModelReasoningByName = {
[K in keyof typeof OPENAI_REASONING]: ModelReasoningCapability<
(typeof OPENAI_REASONING)[K]
>
}
// The same data at run time.
export const OPENAI_MODEL_REASONING: Readonly<Record<string, ModelReasoning>> =
OPENAI_REASONINGThen send options.reasoning as the thinking field of your service. Pass OpenAIModelReasoningByName[TModel] as the last type parameter of the adapter class, so chat({ reasoning }) is typed. See Add reasoning to your adapter.
chat() also passes two optional fields to a text adapter. Your adapter works without them, but they make repeated requests cheaper:
A user can switch to your adapter in the middle of a thread. The history then holds signatures and tool call IDs from another provider, and your API rejects them.
import { hashToolCallId, transformMessagesForReplay } from '@tanstack/ai/adapter-internals'
import type { ModelMessage } from '@tanstack/ai'
export function prepareMessages(messages: ReadonlyArray<ModelMessage>, model: string) {
const { messages: replay } = transformMessagesForReplay(
messages,
{ provider: 'example-provider', api: 'example-messages', model },
(id, { attempt }) => {
const clean = id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64)
return attempt === 0 ? clean : `${clean.slice(0, 40)}_${hashToolCallId(id)}_${attempt}`
},
)
return replay
}For history from another source, the helper:
chat() already drops failed answers and adds No result provided for unanswered tool calls before it calls your adapter. Your saved history does not change.
Can users paste an API key for your adapter? Then export a defineByokProvider object from a /byok subpath (@scope/ai-acme/byok):
import { defineByokProvider } from "@tanstack/ai/byok";
export const acmeByok = defineByokProvider({
id: "acme",
label: "Acme",
env: "ACME_API_KEY",
});Apps import { acmeByok } from "@scope/ai-acme/byok". A relay calls getByokKey(request, acmeByok) from @tanstack/ai/byok/server to read the x-byok-acme header.