- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- Build your first harnessUpdated
- Host optionsNew
- Send and show mediaUpdated
- Connect clientsUpdated
- Write a client in any languageNew
- Access and tenantsNew
- Run in the terminalUpdated
- Deploy a harnessUpdated
- Send inputs safelyUpdated
- Run a tool without a model
- Durable sessionsUpdated
- Run background work on workersNew
- List, rename, and fork sessionsUpdated
- Lifecycle and retentionNew
- Run side effects onceUpdated
- Wait for a personNew
- Build on the session logUpdated
- Control how a turn endsUpdated
- Store settings per threadUpdated
- Compact a long sessionUpdated
- Fork and reset a threadUpdated
- One log for a session treeUpdated
- Write a pluginUpdated
- Track usage and costUpdated
- Ask before risky tool callsUpdated
- Give your agent coding toolsUpdated
- Sandbox and extend the coding toolsUpdated
- Run the coding tools for many threadsNew
- Undo a turnUpdated
- Build a coding agentUpdated
- Delegate to coding agentsUpdated
- Switch between agent profilesUpdated
- Auth and connectorsUpdated
- Share a thread between peopleUpdated
- Connect model providersUpdated
- Build your own UIUpdated
- Code mode in a harnessUpdated
- Work until a goal is metUpdated
- Run agents from a harnessUpdated
- Hand off a conversationNew
- Add skillsUpdated
- Use MCP serversUpdated
- Give your agent a computerNew
- Use from any MCP clientUpdated
- Use a harness from an editor (ACP)Updated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Keep Keys on the ServerNew
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
Auth and connectors
Your agent needs to open a pull request, so it needs the user's GitHub token. The user should sign in once, in the browser, and the model should never see the token. oauthConnector does that: it adds /connect github, stores the token, and hands it to your tools.
1. Add a connector#
import { toolDefinition } from '@tanstack/ai'
import { defineHarness, oauthConnector } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'
import type { HarnessRunContext } from '@tanstack/ai-harness'
const github = oauthConnector({
id: 'github',
label: 'GitHub',
// The harness runs on the user's machine (a CLI), so open a browser here.
login: 'loopback',
oauth: {
authorizationUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
deviceUrl: 'https://github.com/login/device/code',
clientId: 'your-github-oauth-app-client-id',
scopes: ['repo'],
},
tools: (token) => [
toolDefinition({ name: 'list_issues', description: 'List my open issues' }).server<HarnessRunContext>(
async (_args, ctx) => {
const response = await fetch('https://api.github.com/issues', {
headers: { authorization: `Bearer ${await token({ run: ctx.context.run })}` },
})
return response.json()
},
),
],
})
export const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
plugins: () => [github],
})token({ run }) returns a fresh access token of the user of that run. If the token is expired and the service gave a refresh token, token refreshes it first, one time for all the calls that need it. See Refresh a token. Pass the run of the tool call, ctx.context.run, so each user gets their own token. See Act as the user of a run.
2. Sign in#
- The user runs /connect github.
- With login: 'loopback', the CLI opens the browser. The harness listens on 127.0.0.1 on a random port for one callback, with PKCE and a random state.
- With oauth.issuer set, the sign-in refuses a code that the service sends with another iss (RFC 9207).
- The token goes into the credential store. /disconnect github deletes it.
- A cancel of the command, or a close of the session, stops a sign-in that waits. The listener on 127.0.0.1 closes at once.
login is required. Use login: 'device' for a harness on a server, and for SSH sessions, containers, and CI. The user then gets a code to enter on the service's page. Device login needs oauth.deviceUrl.
If a tool runs before sign-in, it stops with a harness.auth_required event. The CLI shows which service to connect. In your own app, session.onAuthRequired tells you which service to show:
import type { HarnessSession } from '@tanstack/ai-harness'
export function showSignIns(session: HarnessSession) {
// Call the returned function to stop the calls.
return session.onAuthRequired(({ connector, url, userCode }) => {
console.log(`Sign in to ${connector}: ${url ?? `run /connect ${connector}`}`)
if (userCode) console.log(`Enter this code on that page: ${userCode}`)
})
}Wait for the sign-in#
A turn from a background agent or an email runs while nobody watches it. That turn can need a sign-in. Without a sign-in, the tool fails, and the work of that turn stops. Pass wait: true to token. Then the turn waits for the sign-in and continues after it.
import { toolDefinition } from '@tanstack/ai'
import { defineHarness, oauthConnector } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'
import type { HarnessRunContext } from '@tanstack/ai-harness'
const github = oauthConnector({
id: 'github',
label: 'GitHub',
// The server cannot open a browser for the user, so show a code.
login: 'device',
oauth: {
authorizationUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
deviceUrl: 'https://github.com/login/device/code',
clientId: 'your-github-oauth-app-client-id',
scopes: ['repo'],
},
tools: (token) => [
toolDefinition({ name: 'list_issues', description: 'List my open issues' }).server<HarnessRunContext>(
async (_args, ctx) => {
const response = await fetch('https://api.github.com/issues', {
headers: { authorization: `Bearer ${await token({ run: ctx.context.run, wait: true })}` },
})
return response.json()
},
),
],
})
export const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
plugins: () => [github],
// A web app starts the sign-in with this command.
expose: { commands: ['connect:github'] },
})In a tool of your own plugin, use ctx.context.run.credentials.require('github', { wait: true }).
When the credential is missing, the turn stops with an interrupt:
- The interrupt has reason: 'auth_required'. Its metadata['tanstack:interruptPayload'].request.connector is 'github'.
- The sign-in belongs to the sender of the stopped turn. When that person saves the credential, the same turn continues as that person, and the tool runs again. The save can be /connect github, the connect:github command, or run.credentials.set in a command of your own.
- A credential that another person saves never continues the turn.
- To stop the wait, resolve the interrupt with status: 'cancelled'. Then the tool fails, the same as without wait.
- On a durable host, the wait continues after a restart.
- wait is for a tool of the running chat turn. Do not pass it in a command, a background agent, or a hook. The credentials of a command never wait.
- Provider keys (run.keys.require) do not wait.
In a web app, the wait shows in state.signIns of the session view, also after a reload. When the user presses your connect button, run the connector command. Each sign-in then has the url and the userCode to show. A client can run only the commands in expose.commands, so the harness above exposes connect:github (see Choose what clients can change):
import { createHarnessClient } from '@tanstack/ai-harness/client'
import { createSessionView } from '@tanstack/ai-harness/view'
const view = createSessionView(
createHarnessClient({ url: '/api/harness', threadId: 'user-1-thread' }),
)
view.on('signIn', (signIn) => {
console.log(`Connect ${signIn.connector} to continue the turn.`)
})
export const connectGithub = () => view.runCommand({ name: 'connect:github' })After the sign-in, the turn finishes its work with no new message.
Keep credentials#
Pass a credential store to the host. The host reads credentials from it, keyed by the user of each run. Without one, credentials live in memory until the process stops. When several people write in one thread, see Share a thread between people.
import { defineCredentialStore } from '@tanstack/ai/byok'
import type { Credential } from '@tanstack/ai/byok'
const saved = new Map<string, Credential>()
const keyOf = (userId: string | undefined, id: string) => `${userId ?? 'tenant'}:${id}`
export const credentials = defineCredentialStore({
get: async ({ scope, id }) => saved.get(keyOf(scope.userId, id)) ?? null,
set: async ({ scope, id, credential }) => {
saved.set(keyOf(scope.userId, id), credential)
},
delete: async ({ scope, id }) => {
saved.delete(keyOf(scope.userId, id))
},
list: async (scope) =>
[...saved.entries()]
.filter(([key]) => key.startsWith(`${scope.userId ?? 'tenant'}:`))
.map(([key, credential]) => ({ id: key.split(':')[1] ?? key, type: credential.type })),
})import { createHarnessHost } from '@tanstack/ai-harness'
export const host = createHarnessHost({ credentials })The store can be a table in your own database or a secrets manager. See the rules in Write a store for production.
- Encrypt tokens at rest in a real store.
- list returns ids and types only, never the secret values.
- The harness keeps a secret answer in the store for a short time, with the type secret_answer. Leave that type out of a list of connected accounts. See Secret answers on another host.
- A credential saved without a userId belongs to the whole tenant. A user who has no credential of their own uses it. See Share one credential with the whole organization.
- If more than one host uses the store, add getVersioned and setIf. Then two hosts do not both save a refreshed token. See Add compare-and-set.
Read credentials in your own plugin#
Your plugin reads the credentials of the user of each run:
- In a tool or a middleware hook, ctx.context.run.credentials.require('github') returns the credential. Before the user signs in, it stops with auth_required.
- In a command, use run.credentials of the command context.
- The ctx.credentials of a session plugin have no user, so each call throws.
Keep tokens out of the log and the model#
A tool can show a token by mistake: an error message with the request URL, or a debug field in its result. The session replaces each secret that it gave out with [redacted] before it keeps or sends anything.
The secrets that the session knows:
- The provider keys of run.keys, also a key from an env var.
- The credentials of run.credentials and of the connectors: API keys, OAuth tokens, and the OAuth client secret.
- The answer to a question with secret: true. For an answer with fields, each string field.
Where the session replaces them:
- The session log: your records, the transcript, plugin state (also in a Map or a Set), notes, and tool results.
- The events that clients get.
- The model context, so the model never sees a secret.
- The error frames of the HTTP handler, the WebSocket, and the stdio worker.
A durable step value with a secret throws, so the log never keeps it. Read the secret outside step.do.
The session finds the raw value, its JSON-escaped form, and its URL-encoded form. It does not find:
- A value shorter than 6 characters. So a short value does not remove common words.
- A secret that a stream splits across two deltas, in those two events. The message in the transcript does not have the secret.
- A secret that is JSON-escaped two times.
- A secret in an instance of your own class, a Date, or a typed array in plugin state.
To scrub a text of your own, use scrubSecrets(text, secrets) from @tanstack/ai/byok. It uses the same rules.
Write your own sign-in command#
oauthConnector covers most services. For a sign-in of your own, use the helpers from @tanstack/ai-harness/oauth. Pass the signal of the command, so a cancel stops the sign-in at once:
import { defineCommand, definePlugin } from '@tanstack/ai-harness'
import { deviceLogin, isExpired, refreshCredential } from '@tanstack/ai-harness/oauth'
import type { OAuthConfig } from '@tanstack/ai-harness/oauth'
const gitlab: OAuthConfig = {
authorizationUrl: 'https://gitlab.com/oauth/authorize',
tokenUrl: 'https://gitlab.com/oauth/token',
deviceUrl: 'https://gitlab.com/oauth/authorize_device',
clientId: 'your-gitlab-app-client-id',
scopes: ['read_api'],
}
export const gitlabLogin = definePlugin({
name: 'acme/gitlab-login',
setup: (ctx) => ({
commands: {
'connect:gitlab': defineCommand({
description: 'Sign in to GitLab',
run: async (_input, { credentials, signal }) => {
const credential = await deviceLogin(gitlab, {
onCode: ({ userCode, verificationUri }) =>
ctx.session.authRequired({ connector: 'gitlab', url: verificationUri, userCode }),
signal,
})
await credentials.set({ id: 'gitlab', credential })
return 'Connected to GitLab.'
},
}),
},
}),
})- deviceLogin, loopbackLogin, exchangeCode, and refreshCredential take a signal. When it aborts, the call stops at once and rejects with the reason of the signal.
- The signal of a command aborts on a cancel of the command and on a close of the session.
- loopbackLogin takes timeoutMs. The default is 10 minutes. This timer does not keep the process alive.
- When another library runs the OAuth flow, startLoopbackReceiver({ timeoutMs, signal }) only waits for the callback. waitForCode(state) gives the code and the iss of the callback.
Refresh a token#
An access token expires, and several tool calls can need it at the same time. Some services rotate the refresh token: after one refresh, the old refresh token stops working. Then a second refresh at the same time fails, and the user must sign in again.
run.credentials.refresh(id, update, { signal }) runs one refresh, and every caller gets its result:
import { toolDefinition } from '@tanstack/ai'
import type { HarnessRunContext } from '@tanstack/ai-harness'
export const gitlabProjects = toolDefinition({
name: 'gitlab_projects',
description: 'List my GitLab projects',
}).server<HarnessRunContext>(async (_args, ctx) => {
const { credentials, signal } = ctx.context.run
let credential = await credentials.require('gitlab')
if (isExpired(credential)) {
const fresh = await credentials.refresh(
'gitlab',
// Another call can refresh first. Then keep its credential.
(current, hostSignal) =>
isExpired(current)
? refreshCredential(gitlab, current, { signal: hostSignal })
: Promise.resolve(current),
{ signal },
)
// The user disconnected meanwhile: ask for a sign-in.
credential = fresh ?? (await credentials.require('gitlab'))
}
const token = credential.type === 'oauth' ? credential.accessToken : credential.value
const response = await fetch('https://gitlab.com/api/v4/projects?membership=true', {
headers: { authorization: `Bearer ${token}` },
signal,
})
return response.json()
})update gets the saved credential and returns the new one. Return current to keep it. refresh resolves with the credential to use, or null when none is saved. oauthConnector refreshes with refresh too.
What refresh does:
- One host runs one update at a time for each saved credential. A call that comes while one runs waits for it, and gets the same credential.
- It saves the new credential to the scope where it found it. A tenant token stays the tenant's, so every user of the tenant gets the new token.
- If the store has getVersioned and setIf, the save is a compare-and-set. When another host saved first, the call uses that token and does not refresh again.
- Without that pair, the one-at-a-time rule is per host only. Two hosts can refresh at the same time, and the last save wins.
- update gets a signal of the host, which only host.close() aborts. An abort of your signal rejects only your call. The refresh goes on for the other calls.
refreshCredential keeps the OAuth client of the old credential, so the next refresh has it too.
What you have now#
- Browser and device-code sign-in for any OAuth service, and sign-ins of your own that stop at once on a cancel.
- Tools that get a fresh token without the model seeing it.
- One refresh for each expired token, also on many hosts.
- A log, events, and a model context with [redacted] in place of each secret.
- Turns that wait for a sign-in and then finish their work.