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.
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.
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}`)
})
}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:
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.
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.
Your plugin reads the credentials of the user of each run:
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:
Where the session replaces them:
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:
To scrub a text of your own, use scrubSecrets(text, secrets) from @tanstack/ai/byok. It uses the same rules.
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.'
},
}),
},
}),
})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:
refreshCredential keeps the OAuth client of the old credential, so the next refresh has it too.