You ship your harness to users: a CLI that they install, a desktop app, or a hosted app with accounts. openaiText('gpt-6.1-sol') reads OPENAI_API_KEY from the environment. Your users do not have your .env file, and they do not start your app from a terminal with a key set.
With providerKeys, each user runs /connect:openai in your app and pastes their own key. The harness saves the key for that user and uses it for every turn.
A keyed adapter waits for a key. keyedAdapter takes the provider and a function that makes the adapter from the key:
import { keyedAdapter } from '@tanstack/ai'
import { createAnthropicChat } from '@tanstack/ai-anthropic'
import { anthropicByok } from '@tanstack/ai-anthropic/byok'
import { createOpenaiChat } from '@tanstack/ai-openai'
import { openaiByok } from '@tanstack/ai-openai/byok'
const gpt = keyedAdapter(openaiByok, (key) => createOpenaiChat('gpt-6.1-sol', key))
const claude = keyedAdapter(anthropicByok, (key) =>
createAnthropicChat('claude-sonnet-5-5', key),
)Before each turn, the session calls your function with the key of the user who sent that turn.
Give the harness a keyed adapter as its adapter. Then list in providerKeys every provider that users can connect:
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { modelPicker, providerKeys } from '@tanstack/ai-harness/plugins'
import { falByok } from '@tanstack/ai-fal/byok'
import { grokByok } from '@tanstack/ai-grok/byok'
import { openrouterByok } from '@tanstack/ai-openrouter/byok'
import { openrouterSignIn } from '@tanstack/ai-openrouter/pkce'
import { memoryPersistence } from '@tanstack/ai-persistence'
export const assistant = defineHarness({
name: 'acme/assistant',
adapter: gpt,
plugins: () => [
providerKeys({
providers: [
{ ...openaiByok, keyUrl: 'https://platform.openai.com/api-keys' },
anthropicByok,
grokByok,
falByok,
{ ...openrouterByok, signIn: openrouterSignIn() },
],
}),
modelPicker({ choices: { gpt, claude }, default: 'gpt' }),
],
})
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(assistant, {
threadId: 'ada-chat',
user: { id: 'ada' },
})/connect:openai asks the user for the key. The question has secret: true, so show a password field for it:
const stopAsking = session.onEvent('CUSTOM', async ({ event }) => {
if (event.name !== 'harness.question') return
const [question] = session.snapshot().pendingWaits
// question.secret is true. Read the key without showing it.
if (question) await session.answer({ questionId: question.waitId, value: 'sk-proj-...' })
})
console.log(await session.runCommand({ name: 'connect:openai' })) // Connected to OpenAI (key ...abcd).
stopAsking()OpenAI: connected (key ...abcd)
Anthropic: from the ANTHROPIC_API_KEY env var
xAI Grok: missing. Run /connect grok.
fal.ai: missing. Run /connect fal.
OpenRouter: missing. Run /connect openrouter.In your own UI, read the providers from session.snapshot().plugins['tanstack/provider-keys'].providers. Each entry has an id, a label, and a state: env or missing. Every member of a thread reads this list, so it shows only the env vars that the session reads, not the keys of one user. A host with access reads no env vars without allowServerEnvKeys: true. To show the keys of the user, run /keys.
OpenRouter has a browser sign-in, so users do not copy a key. openrouterSignIn() in the providers list turns it on:
The sign-in uses PKCE. It listens on 127.0.0.1 on a random port for one callback, and it checks a random state. After 10 minutes it stops. To change the time, pass openrouterSignIn({ timeoutMs }).
The listener runs on the computer of the harness. So the sign-in works when the harness and the browser are on the same computer: a CLI or a desktop app. In a hosted app, remove signIn, and users paste their OpenRouter key.
OpenAI and Anthropic have no sign-in for other apps, so users paste a key.
If a turn needs a key that the user did not connect, the turn stops before the model call:
After /connect:openai, send the message again.
/connect:<id> saves the key in the credential store of the host, as an api_key credential with the provider id. The store keeps it for the user who runs the command:
To write a store, see Write a store for production. Your own routes can read the same keys with storedProviderKeys.
The key that a user pastes can be a Claude OAuth token (sk-ant-oat...). createAnthropicChat picks the auth mode from the token, so the keyed adapter above works for it too. See Bearer and OAuth tokens.
If the user has no saved key, the session reads the env var of the provider, for example OPENAI_API_KEY. A saved key wins over the env var. So you can develop with a .env file, and your users connect in the app.
On a host with access, the session does not read the env vars. Each user must connect their own key, so no user spends the key of your server. To let users fall back to the env var, set allowServerEnvKeys: true in access.
Your agent makes images with fal, so it needs the fal key of the user. Every agent gets ctx.keys. In a harness, ctx.keys reads the keys of the user who started the agent:
import { defineAgent } from '@tanstack/ai'
import { falImage } from '@tanstack/ai-fal'
import { z } from 'zod'
const flux = keyedAdapter(falByok, (key) => falImage('fal-ai/flux/dev', { apiKey: key }))
export const illustrator = defineAgent({
name: 'illustrator',
description: 'Draws an image from a prompt',
inputSchema: z.object({ prompt: z.string() }),
run: async (ctx) =>
ctx.generateImage({
adapter: await ctx.keys.adapter(flux),
prompt: ctx.input.prompt,
}),
})Put the agent in subagents.agents of the harness, and the model calls it as a tool.
Outside a harness, ctx.keys reads the env var. See Keys inside agents.