- OpenAIUpdated
- OpenAI: AdvancedNew
- AnthropicUpdated
- Anthropic: AdvancedNew
- Google GeminiUpdated
- Google Gemini: AdvancedNew
- Google Vertex AIUpdated
- OllamaUpdated
- Grok (xAI)Updated
- GroqUpdated
- MistralUpdated
- CohereUpdated
- TypeSafeUpdated
- OllayaUpdated
- ElevenLabsUpdated
- fal.aiUpdated
- ReactorUpdated
- World LabsUpdated
- OpenRouter AdapterUpdated
- OpenRouter: AdvancedNew
- Perplexity SearchUpdated
- Vercel AI GatewayUpdated
- Lovable AI GatewayUpdated
- OpenAI-CompatibleUpdated
- OpenAI-Compatible: AdvancedNew
- LLM GatewayUpdated
- CloudflareUpdated
- Claude CodeUpdated
- CodexUpdated
- OpenCodeUpdated
- Grok BuildUpdated
- ACP-CompatibleUpdated
- Amazon BedrockUpdated
- BytePlusUpdated
- BytePlus: AdvancedNew
Community Adapters Guide
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.
1. Create the package#
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.
2. Write a minimal text adapter#
A text adapter extends BaseTextAdapter and implements two methods:
- chatStream(): calls your service and yields AG-UI events.
- structuredOutput(): returns JSON for a schema.
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);
}3. Call it with chat()#
// 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.
How it works#
- chat() calls chatStream() with the messages, tools, and options of the call.
- chatStream() yields AG-UI events in order: RUN_STARTED, then TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, and TEXT_MESSAGE_END, then RUN_FINISHED.
- If your service streams, yield one TEXT_MESSAGE_CONTENT for each piece. For tool calls, yield TOOL_CALL_START, TOOL_CALL_ARGS, and TOOL_CALL_END.
- When a call sets outputSchema, chat() calls structuredOutput() with the JSON Schema.
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:
- Text (chat)
- Image
- Embeddings
- Video
Publish and list your adapter#
- Publish the package to npm.
- Add a page for your adapter to docs/community-adapters in the TanStack AI repository.
- Run pnpm run sync-docs-config in the root of the monorepo. This puts your page in the docs navigation.
- Open a pull request with the page and the generated changes.
Maintain your adapter#
You are responsible for the adapter after you publish it:
- Follow API changes of your service.
- Stay compatible with new TanStack AI releases.
- Answer issues and feedback from users.
- Update your docs page when features change. Open a follow-up pull request for it.
Advanced#
These steps make your adapter type-safe per model, like the built-in adapters.
Model metadata#
Model metadata describes what each model can do. TanStack AI uses it for compatibility checks and feature selection. For each model, define at least:
- The model name and id.
- The input and output modalities.
- The features, for example streaming, tools, and structured output.
- The price, if you know it.
- Notes and limits that are special to your service.
For a concrete example, see the OpenAI model metadata.
Model lists per capability#
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,
DALL_E_3.name,
DALL_E_2.name,
] as const
export const OPENAI_VIDEO_MODELS = [SORA2.name, SORA2_PRO.name] as constOption fragments#
Models take different options. Build the options from small reusable fragments, not one copy per model:
- Base options that every model takes.
- Feature fragments that you combine for each 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.
Typed options per model#
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
}Input modalities per 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.
Reasoning levels#
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_REASONING- levels lists only the levels that the model has, with the value your service takes for each. A level that is not in the list is not supported.
- budget is true when the model takes a thinking token budget.
- Many models share the same levels. Define each shape once, and point each model at it.
- A model that does not reason gets no reasoning field.
Then 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.
Prompt cache and mid-conversation changes#
chat() also passes two optional fields to a text adapter. Your adapter works without them, but they make repeated requests cheaper:
- options.promptCache: map it to the cache fields of your service. See Send the prompt cache.
- options.midConversationChanges: send added tools and prompts inside the conversation. See Send mid-conversation changes.
Keep history valid across models#
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.
- Name the source of your text adapter. chat() records it on each answer as metadata.tanstack.source:
- name: the adapter name.
- provider: optional. Defaults to name.
- api: optional, the wire API. Defaults to the adapter kind.
- Before you build the request, run the history through transformMessagesForReplay. Pass your source and a rule for tool call IDs:
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:
- Drops signatures and redacted thinking. Turns readable thinking into text.
- Calls your ID rule for each tool call, and gives each result the new ID of its call. If the new ID is taken, it calls the rule again with a higher attempt.
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.
Export a BYOK provider#
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",
});- Do not export it from the main entry of the package. That pulls your provider SDK into the browser.
- id is required. It is the slug in the x-byok-<id> header.
- env is the name of the environment variable that your adapter already reads. Put only names here, because the client imports this object.
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.