Persistence (Advanced)
- Build your first harness
- Host options
- Send and show media
- Connect clients
- Write a client in any language
- Access and tenants
- Run in the terminal
- Deploy a harness
- Send inputs safely
- Run a tool without a model
- Durable sessions
- Run background work on workers
- List, rename, and fork sessions
- Lifecycle and retention
- Run side effects once
- Wait for a person
- Build on the session log
- Control how a turn ends
- Store settings per thread
- Compact a long session
- Fork and reset a thread
- One log for a session tree
- Write a plugin
- Track usage and cost
- Ask before risky tool calls
- Give your agent coding tools
- Sandbox and extend the coding tools
- Run the coding tools for many threads
- Undo a turn
- Build a coding agent
- Delegate to coding agents
- Switch between agent profiles
- Auth and connectors
- Share a thread between people
- Connect model providers
- Build your own UI
- Code mode in a harness
- Work until a goal is met
- Run agents from a harness
- Hand off a conversation
- Add skills
- Use MCP servers
- Give your agent a computer
- Use from any MCP client
- Use a harness from an editor (ACP)
- Overview
- Quick Start
- Providers
- Harnesses
- Harness Auth
- Workspace
- Tools
- Policy
- Lifecycle & Snapshots
- Portable Snapshots
- Keep Files After Reload
- Save a Named Version
- Branch From a Version
- Send a Frozen File
- Let the Agent Save and Fork
- Pick Which Files to Keep
- What a Snapshot Stores
- Instance Durability
- Durable Runs
- Events
Sandboxes (Advanced)
- Runtime Context
- Bring Your Own Key
- Keep Keys on the Server
- Debug Logging
- Multimodal Content
- Files API
- Per-Model Type Safety
- Runtime Adapter Switching
- Tree-Shaking
- Extend Adapter
- Extend Adapter: Advanced
- Typed Pre-Configured Options
- Test with a Fake Model
- Prompt Caching
- Mid-Conversation Changes
- Approval Flow Processing
Bring Your Own Key (BYOK)
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.
Quick setup#
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.
1. Create a store#
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.
2. Save a key from your UI#
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.
3. Send the key with useChat#
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>
);
}4. Read the key on the server#
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.
How it works#
- defineByok keeps the keys in the browser, encrypted when the browser has passkeys.
- On each send, the client reads the key for forwardedProps.provider. It puts the key on the x-byok-<id> header, never in the JSON body.
- getByokKey reads that header. If the header is empty, it reads the env var of the provider, here OPENAI_API_KEY.
- If both are empty, byokMissing returns a 401.
Your server uses the key for one call, then forgets it.
Advanced#
Passkey prompts#
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.
- Call byok.update(), byok.unlock(), or byok.prepare() directly in the click handler, before you await other work.
- If the unlock runs deep in a send flow, the prompt does not appear. The call throws with a "needs a fresh user action" message.
- This activation check applies to existing keyrings. The first registration can need a second PRF prompt, and the browser handles it.
- Passkey storage accepts 32-byte PRF arrays from password managers such as 1Password. It converts them to binary data and the encryption key does not change.
Send rules#
- If forwardedProps.provider is not set, the send throws. The client does not attach every stored key.
- The 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.
- If one credential has several values, read them together with getByokKeys.
Pick the model before the key shows up#
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.
- Define the model once on the server:
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),
);- 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 is the same as in step 3.
- keyedAdapter works for every adapter kind: text, image, speech, and the rest.
- Your list can mix plain and keyed adapters. isKeyedAdapter(adapter) tells you which ones need a key.
More than one provider#
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:
- The key of the user wins. That is the x-byok-<id> header, checked in map order.
- Then the env keys, in map order. Only entries made with a descriptor (like anthropicByok) have env names. A plain factory reads the header only.
- If there is no key at all, you get null. Answer with byokMissing.
Some more rules:
- An entry like anthropic: keyedAdapter(openaiByok, ...) throws at startup, so a key cannot go to the wrong provider.
- The adapter that you get back is a union of your providers. If you need the exact model types, for example for modelOptions, call models.openai.create(key) yourself.
If the server already has an env key#
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.
Keys inside agents#
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.
- Define the agent. Wrap its adapter in keyedAdapter, then build it with ctx.keys.adapter:
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,
}),
});- In the route, give subagents.keys a function. getByokKey reads the key that the client sends for each provider:
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:
- It calls the subagents.keys function with the provider of the adapter, here openaiByok. The function can read any store, for example your database.
- subagents.keys can also be an object by provider id, for example { openai: apiKey }.
- If there is no key, it reads the env var of the provider, here OPENAI_API_KEY. If that var is empty too, the call throws Missing OpenAI API key. Set OPENAI_API_KEY.
- A child that calls ctx.chat({ subagents }) passes the keys on to its own children. Keys that the child sets itself win for those providers.
- ctx.keys.get(provider) returns the key or null. ctx.keys.require(provider) throws when there is no key.
The writer now calls OpenAI with the key that the user pasted.
Image, audio, and other providers#
- Image and audio POSTs use the same store. See Generation Hooks.
- OpenAI users can sign in with ChatGPT and skip the key. See Sign in with ChatGPT.
- OpenRouter can mint a key with OAuth. See Sign in with OpenRouter.
- Lovable uses lovableByok from @tanstack/ai-lovable/byok. See Lovable AI Gateway.
- Cloudflare needs a token and an account id. cloudflareByok from @tanstack/ai-cloudflare/byok declares cloudflareAccountByok as a companion. Pass both to defineByok({ providers }), and a send for cloudflare carries both headers. See Cloudflare.