You do not want to pay for provider credits for every user. Let them bring their own API key.
The key stays in the browser, so you do not have to worry about storing user keys on the server.
Your relay uses it for one call, then forgets it. Each send puts it on an x-byok-* header, not in the JSON body.
Do these four steps:
If you use a framework package, import BYOK from its /byok subpath. You do not need @tanstack/ai-client. The same subpath exists on Vue, Solid, Svelte, Preact, Angular, Octane, and Remix.
import { defineByok, defaultByokStorage } from "@tanstack/ai-react/byok";
export const byok = defineByok({
storage: defaultByokStorage(),
});If the browser supports passkeys, defaultByokStorage() uses a passkey. If not, keys stay in memory for this tab only.
Passkey storage runs a WebAuthn prompt to save and to unlock a key. Some browsers (Safari, Dia) only show that prompt right after a click. So call byok.update(), byok.unlock(), or byok.prepare() straight from the click handler, before you await other work. If you run the unlock deep in a send flow, the prompt may never appear and the call throws with a "needs a fresh user action" message instead. Unlock on the click, then send.
This activation check applies to existing keyrings. First-time registration can require a second PRF prompt, which the browser handles even if registration consumes activation.
Passkey storage accepts 32-byte PRF arrays from password managers such as 1Password and converts them to binary data without changing the encryption key.
Call byok.update("openai", value) from your own UI. The library does not ship a dialog.
useByok(byok) gives the status for saved keys.
import { useState } from "react";
import { useByok } from "@tanstack/ai-react";
import { byok } from "./byok";
export function KeyForm() {
const snapshot = useByok(byok);
const [value, setValue] = useState("");
const [error, setError] = useState("");
const status = snapshot.status.openai;
const last4 = status && "masked" in status ? status.masked : "";
return (
<form
onSubmit={(event) => {
event.preventDefault();
const next = value.trim();
if (!next) return;
void byok
.update("openai", next)
.then(() => {
setValue("");
setError("");
})
.catch((caught: unknown) => {
setError(
caught instanceof Error ? caught.message : "Could not save key",
);
});
}}
>
<input
type="password"
autoComplete="off"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={last4 ? `Saved ${last4}` : "Paste a key"}
/>
<button type="submit" disabled={!value.trim()}>
Save
</button>
{error ? <p>{error}</p> : null}
</form>
);
}The ts-react-chat example has a key-icon popup you can copy.
Pass the same store. Set forwardedProps.provider to "openai". The client then sends only that key.
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { byok } from "./byok";
export function Chat() {
const { sendMessage, isLoading } = useChat({
connection: fetchServerSentEvents("/api/chat"),
byok,
forwardedProps: { provider: "openai", model: "gpt-5.6" },
});
return (
<button
type="button"
disabled={isLoading}
onClick={() => {
void sendMessage("Hello");
}}
>
Send
</button>
);
}If no provider is set, the send throws. The client does not attach every stored key.
Built-in fetch and XHR adapters copy the headers onto the POST.
If you write a custom connect, copy runContext.headers yourself. See Custom Transports.
Use this in any API route.
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { createOpenaiChat } from "@tanstack/ai-openai";
import { openaiByok } from "@tanstack/ai-openai/byok";
import { byokMissing, getByokKey } from "@tanstack/ai/byok/server";
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request);
const apiKey = getByokKey(request, openaiByok);
if (!apiKey) return byokMissing(openaiByok);
const stream = chat({
adapter: createOpenaiChat("gpt-5.6", apiKey),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
});
return toServerSentEventsResponse(stream);
}Import openaiByok from @tanstack/ai-openai/byok, not from the adapter main entry. The /byok file is safe in the browser. The main entry pulls in the provider SDK.
The header wins. If the header is empty, getByokKey reads OPENAI_API_KEY from the environment. If both are empty, byokMissing returns a 401. If one credential is made of several values, read them together with getByokKeys.
CAUTION: Do not log the raw key. Use maskKey on error strings.
You can paste a key, send a message, and the relay calls OpenAI with that key.
You want to set up your models in one place, for example a list that users pick from. But you only get the key with each request. Wrap the adapter in keyedAdapter. The route builds it when the key comes in.
On the server, define the model once:
import { keyedAdapter } from "@tanstack/ai/byok";
import { createOpenaiChat } from "@tanstack/ai-openai";
import { openaiByok } from "@tanstack/ai-openai/byok";
export const gpt = keyedAdapter(openaiByok, (key) =>
createOpenaiChat("gpt-6.1-sol", key),
);Then build it in the route with the key from the request:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { byokMissing, getByokKey } from "@tanstack/ai/byok/server";
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request);
const apiKey = getByokKey(request, gpt.provider);
if (!apiKey) return byokMissing(gpt.provider);
const stream = chat({
adapter: gpt.create(apiKey),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
});
return toServerSentEventsResponse(stream);
}The client does not change. It sends the key the same way as in step 3:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { byok } from "./byok";
export function Chat() {
const { sendMessage } = useChat({
connection: fetchServerSentEvents("/api/chat"),
byok,
forwardedProps: { provider: "openai" },
});
return (
<button type="button" onClick={() => void sendMessage("Hello")}>
Send
</button>
);
}Good to know:
Your users pick OpenAI or Anthropic, and each one brings their own key. Put both in keyedAdapters. The map key is the provider id, so a key only goes to its own provider:
import { keyedAdapter, keyedAdapters } from "@tanstack/ai/byok";
import { createAnthropicChat } from "@tanstack/ai-anthropic";
import { anthropicByok } from "@tanstack/ai-anthropic/byok";
import { createOpenaiChat } from "@tanstack/ai-openai";
export const models = keyedAdapters({
openai: (key) => createOpenaiChat("gpt-6.1-sol", key),
anthropic: keyedAdapter(anthropicByok, (key) =>
createAnthropicChat("claude-sonnet-5-5", key),
),
});In the route, keyedAdapterFromRequest builds the adapter of the provider that has a key:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import {
byokMissing,
keyedAdapterFromRequest,
} from "@tanstack/ai/byok/server";
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request);
const adapter = keyedAdapterFromRequest(request, models);
if (!adapter) return byokMissing("openai");
const stream = chat({
adapter,
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
});
return toServerSentEventsResponse(stream);
}On the client, forwardedProps.provider picks which key goes out:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { byok } from "./byok";
export function Chat({ provider }: { provider: "openai" | "anthropic" }) {
const { sendMessage } = useChat({
connection: fetchServerSentEvents("/api/chat"),
byok,
forwardedProps: { provider },
});
return (
<button type="button" onClick={() => void sendMessage("Hello")}>
Send
</button>
);
}How it picks:
An entry like anthropic: keyedAdapter(openaiByok, ...) throws at startup, so a key cannot reach the wrong provider.
The adapter you get back is a union of your providers. Need the exact model types, for example for modelOptions? Call models.openai.create(key) yourself.
By default, a send with no browser key does not POST. If your relay has env keys, call byok.setServerCoverage(true):
byok.setServerCoverage(true);Then a send with no pasted key still POSTs. The relay uses the env key. If that is also empty, the relay returns byokMissing (401). The client sets snapshot.prompt.
Your subagents call models too, and those calls need the user's key. Pass the key on subagents.keys. Each agent builds its adapter with ctx.keys.
import { defineAgent, keyedAdapter } from "@tanstack/ai";
import { createOpenaiChat } from "@tanstack/ai-openai";
import { openaiByok } from "@tanstack/ai-openai/byok";
const gpt = keyedAdapter(openaiByok, (key) =>
createOpenaiChat("gpt-6.1-sol", key),
);
export const writer = defineAgent({
name: "writer",
description: "Writes a short draft",
run: async (ctx) =>
ctx.chat({
adapter: await ctx.keys.adapter(gpt),
messages: [{ role: "user", content: "Write a short draft." }],
stream: false,
}),
});import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { byokMissing, getByokKey } from "@tanstack/ai/byok/server";
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request);
const apiKey = getByokKey(request, openaiByok);
if (!apiKey) return byokMissing(openaiByok);
const stream = chat({
adapter: gpt.create(apiKey),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
subagents: {
agents: [writer],
keys: (provider) => getByokKey(request, provider),
},
});
return toServerSentEventsResponse(stream);
}How ctx.keys finds a key:
The writer now calls OpenAI with the key that the user pasted.
For other cases: