defineByok keeps each user's provider key in the browser. The server reads one key with getByokKey and does not store it.
Each send puts the key on an x-byok-* header.
This setup takes about 10 minutes. You need a chat that already works with useChat.
To save the key on your server instead, see Keep Keys on the Server.
Import BYOK from the /byok subpath of your framework package. Vue, Solid, Svelte, Preact, Angular, Octane, and Remix have the same subpath.
import { defineByok, defaultByokStorage } from "@tanstack/ai-react/byok";
export const byok = defineByok({
storage: defaultByokStorage(),
});If the browser supports passkeys, defaultByokStorage() keeps the key encrypted with a passkey. If not, the key stays in memory for this tab only.
Call byok.update("openai", value) from your own form. The library does not ship a dialog. useByok(byok) gives you the status of each saved key.
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 that you can copy.
Pass the same store to useChat. Set forwardedProps.provider to "openai", so the client 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"),
threadId: "support-chat",
byok,
forwardedProps: { provider: "openai" },
});
return (
<button
type="button"
disabled={isLoading}
onClick={() => {
void sendMessage("Hello");
}}
>
Send
</button>
);
}Read the header with getByokKey, then build the adapter with that key:
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-6.1-sol", apiKey),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
});
return toServerSentEventsResponse(stream);
}Import openaiByok from @tanstack/ai-openai/byok. That file is safe in the browser. The main entry of the adapter loads the provider SDK.
Warning
Do not log the raw key. Use maskKey on error strings.
Paste a key, send a message, and your server calls OpenAI with the key of the user.
Your server uses the key for one call, then forgets it.
Passkey storage runs a WebAuthn prompt to save a key and to unlock it. Some browsers (Safari, Dia) show that prompt only right after a click.
You want to set up your models in one place, for example a list that users pick from. But the key comes in only with each request. Wrap the adapter in keyedAdapter, and the route builds it when the key comes in.
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),
);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 is the same as in step 3.
Your users pick OpenAI or Anthropic, and each user brings a key. Put both providers in keyedAdapters. The map key is the provider id, so a key goes only 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 the key that 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"),
threadId: "support-chat",
byok,
forwardedProps: { provider },
});
return (
<button type="button" onClick={() => void sendMessage("Hello")}>
Send
</button>
);
}How keyedAdapterFromRequest picks:
Some more rules:
By default, a send with no browser key does not POST. If your server has env keys, call byok.setServerCoverage(true):
byok.setServerCoverage(true);Then a send with no pasted key still POSTs, and the server uses the env key. If that key is also empty, the server returns byokMissing (401), and the client sets snapshot.prompt.
Your subagents call models too, and those calls need the key of the user. 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.