Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3906d0a
feat(ai-harness): serve sessions over HTTP and WebSocket, with a type…
AlemTuzlak Oct 13, 2026
cac83c0
Merge remote-tracking branch 'origin/feat/harness-core' into feat/har…
AlemTuzlak Oct 13, 2026
178a4d6
fix(ai-harness): add back sessionOfTurn for the run route, and the OA…
AlemTuzlak Oct 13, 2026
effa81f
fix(ai-harness): import ./session/index, so the built types resolve H…
AlemTuzlak Oct 13, 2026
10c9cba
Merge remote-tracking branch 'origin/feat/harness-core' into feat/har…
AlemTuzlak Oct 13, 2026
9242847
test(ai-harness): startLoopbackReceiver cases from #1555
AlemTuzlak Oct 13, 2026
c051417
Merge branch 'feat/harness-core' into feat/harness-transport
AlemTuzlak Oct 14, 2026
cbc4184
feat(ai-harness, ai-mcp): MCP servers in a harness, and a harness as …
AlemTuzlak Oct 14, 2026
5d9ccf9
feat(ai-harness): GET agent-transcript and client.agentTranscript
AlemTuzlak Oct 14, 2026
28d86c6
Merge branch 'feat/harness-core' into feat/harness-transport
AlemTuzlak Oct 14, 2026
9e88d6c
docs(ai-harness): list GET agent-transcript in the handler routes
AlemTuzlak Oct 14, 2026
fb14c5d
Merge branch 'feat/harness-core' into feat/harness-transport
AlemTuzlak Oct 14, 2026
6b15ac2
Merge branch 'feat/harness-core' into feat/harness-transport
AlemTuzlak Oct 14, 2026
f6561e4
docs(ai-harness): describe the card in client.agentTranscript
AlemTuzlak Oct 14, 2026
9f5ae29
Merge branch 'feat/harness-core' into feat/harness-transport
AlemTuzlak Oct 14, 2026
1b255c8
docs(ai-harness): read a child's messages from the browser
AlemTuzlak Oct 14, 2026
8264c81
Merge remote-tracking branch 'origin/feat/harness-transport' into fea…
AlemTuzlak Oct 14, 2026
0c62cdf
feat(ai-harness): createSessionView, one live store for a session in …
AlemTuzlak Oct 14, 2026
4348362
Merge remote-tracking branch 'origin/feat/harness-core' into feat/har…
AlemTuzlak Oct 14, 2026
18ad485
fix(ai-harness, ai-mcp): adopt object params from #1779
AlemTuzlak Oct 14, 2026
322b81b
docs(ai-harness): object params in the transport, view, MCP, and auth…
AlemTuzlak Oct 14, 2026
1e85a42
test(ai-harness): rename, update, and fork a session through the client
AlemTuzlak Oct 14, 2026
e5af9da
fix(ai-harness): join and resume a run on any instance, and no insecu…
jherr Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/ai-harness-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/ai-harness': minor
---

Serve a harness session to clients.

- `createHarnessHandler()` serves a session over HTTP. `POST run` is standard AG-UI, so `useChat` with `fetchServerSentEvents` works on a harness, with approvals, client tools, and a reload that joins the running turn. `POST control`, `GET events`, `GET transcript`, `GET agent-transcript` (the messages of a child agent run), `GET snapshot`, `GET describe`, `sessions`, and the media routes cover the rest.
- `handleHarnessSocket()` serves the same session over a WebSocket.
- `expose` on `defineHarness()` names the agents, settings, config keys, and commands that a client can use. Any other input of those kinds gets `rejected` with `not_exposed`.
- `createHarnessClient()` from `@tanstack/ai-harness/client` is a typed, browser-safe client. It sends inputs, reads the session, and streams events that resume from a cursor.
- `oauthConnector()` and the OAuth helpers (`loopbackLogin`, `deviceLogin`, `exchangeCode`, `refreshCredential`, and more) keep a credential per sender. `token({ wait: true })` stops the turn with an `auth_required` interrupt until the user signs in.
10 changes: 10 additions & 0 deletions .changeset/ai-harness-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/ai-harness': minor
---

Add `createSessionView()` in `@tanstack/ai-harness/view`. It turns a session (local, or remote through `createHarnessClient()`) into one live TanStack Store state that works with any UI library.

- The state: messages with their parts, tool calls, approvals, client tools, other interrupts, questions, sign-ins, child agents with their results, media with signed URLs, waiting inputs, usage, models, settings, commands, and plugin state.
- Actions: `send` (a prompt, a steer, or a `/command`), `followUp`, `continue`, `upload`, `cancel`, `cancelInput`, `setDelivery`, `configure`, `reset`, `revert`, and the answers to approvals (with edited arguments), client tools, interrupts, and questions.
- Typed events: `view.on('approval' | 'interrupt' | 'turnEnd' | ..., handler)` and plugin events.
- A view that opens while a turn runs shows the turn from its start. After a reload, each child agent shows its own messages again.
12 changes: 12 additions & 0 deletions .changeset/ai-mcp-harness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@tanstack/ai-mcp': minor
---

Use MCP in a TanStack AI harness.

- `@tanstack/ai-mcp/harness` adds `createHarnessMcpServer`. It serves a harness as an MCP server, so Claude Code, Claude Desktop, Cursor, or another agent can use it. The tools are `chat`, `steer`, `cancel`, `approve`, `reject`, `resolve`, `answer`, and `status`, plus `agent_<name>` for each exposed agent and `command_<name>` for each exposed command. Every tool takes an optional `threadId`. With `approvals: 'ask'` (the default), the client asks the user about each approval when it supports elicitation. Otherwise the approvals come back in the result. `approvals: 'auto'` approves every tool call. `resolve` answers every kind of interrupt in one call.
- `@tanstack/ai-mcp/harness` also adds the `mcp()` harness plugin. It connects stdio and HTTP servers by name, gives the model their tools as `<server>_<tool>`, and adds a `/mcp` status command. `oauth: true` signs in to a server with `/connect <name>`.
- `@tanstack/ai-mcp/connector` adds `mcpConnector`: one OAuth MCP server as a harness plugin, with `needsApproval`, `prefix`, `scopes`, and `clientName`. The sign-in passes the `iss` of the callback on (RFC 9207).
- `mcp()` and `mcpConnector` answer the `elicitation/create` requests of MCP spec 2025 servers, and the input requests of spec 2026-07-28 servers, as session questions. One tool call can ask up to 5 times.

`@tanstack/ai-harness` is an optional peer dependency. Only the `./harness` and `./connector` entries need it.
9 changes: 9 additions & 0 deletions .changeset/harness-join-any-instance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/ai-harness': patch
---

On a durable host (`stores.log` and `stores.runs`), any server instance can serve a run.

- `POST run` sends the log cursor of each event as its SSE `id`. A client that is cut off sends the same request with `Last-Event-ID`, and any instance continues the stream from there. The turn does not run again.
- `GET run?runId=` finds a turn that another instance runs. It streams the turn from the shared log, from its start or from `Last-Event-ID`, until the turn ends.
- `GET run?threadId=` shows a turn that another instance runs as `activeRun`, so a reloaded `useChat` joins it.
9 changes: 9 additions & 0 deletions .changeset/harness-secure-defaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/ai-harness': patch
---

Make the transport defaults safe for a server with many instances.

- `createHarnessHandler` and `handleHarnessSocket` need `canAccess`. To let every principal use every thread, pass `canAccess: () => true`.
- On a durable host (`stores.log`), `createHarnessHandler` throws without `mediaSecret`, so a media URL that one instance signs works on every instance.
- `oauthConnector` needs `login`. Use `'device'` on a server, and `'loopback'` only for a harness on the user's own machine, such as a CLI.
5 changes: 5 additions & 0 deletions .changeset/harness-view-experimental.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-harness': patch
---

Mark `createSessionView`, `SessionViewState`, and `SessionViewEvents` as `@experimental`. Document that event cursors are opaque strings: log positions on a durable host, and process-local counters without a log store.
41 changes: 41 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,18 @@
"addedAt": "2026-10-13",
"updatedAt": "2026-10-14"
},
{
"label": "Connect clients",
"to": "harness/connect",
"addedAt": "2026-10-13",
"updatedAt": "2026-10-14"
},
{
"label": "Send inputs safely",
"to": "harness/inputs",
"addedAt": "2026-10-13",
"updatedAt": "2026-10-14"
},
{
"label": "Durable sessions",
"to": "harness/durable-sessions",
Expand Down Expand Up @@ -994,6 +1006,24 @@
"addedAt": "2026-10-13",
"updatedAt": "2026-10-14"
},
{
"label": "Auth and connectors",
"to": "harness/auth",
"addedAt": "2026-10-13",
"updatedAt": "2026-10-14"
},
{
"label": "Share a thread between people",
"to": "harness/shared-threads",
"addedAt": "2026-10-13",
"updatedAt": "2026-10-14"
},
{
"label": "Build your own UI",
"to": "harness/custom-ui",
"addedAt": "2026-10-13",
"updatedAt": "2026-10-14"
},
{
"label": "Run agents from a harness",
"to": "harness/subagents",
Expand All @@ -1005,6 +1035,17 @@
"to": "harness/skills",
"addedAt": "2026-10-13"
},
{
"label": "Use MCP servers",
"to": "harness/mcp",
"addedAt": "2026-10-13",
"updatedAt": "2026-10-14"
},
{
"label": "Use from any MCP client",
"to": "harness/mcp-server",
"addedAt": "2026-10-13"
},
{
"label": "Use a harness from an editor (ACP)",
"to": "harness/acp",
Expand Down
177 changes: 177 additions & 0 deletions docs/harness/auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
---
title: Auth and connectors
id: harness-auth
order: 7
description: "Let users sign in to GitHub and other OAuth services from the harness. Tokens stay in your credential store and never reach the model."
keywords:
- tanstack ai
- harness
- oauth
- connectors
- credentials
---

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

```ts group=harness-auth
import { toolDefinition } from '@tanstack/ai'
import { defineHarness, oauthConnector } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'

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(
async () => {
const response = await fetch('https://api.github.com/issues', {
headers: { authorization: `Bearer ${await token()}` },
})
return response.json()
},
),
],
})

export const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
plugins: () => [github],
})
```

`token()` returns a fresh access token. It refreshes an expired token when the service gave a refresh token.

## 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`.
- The token goes into the credential store. `/disconnect github` deletes it.

`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.

## 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.

```ts group=harness-auth-wait
import { toolDefinition } from '@tanstack/ai'
import { defineHarness, oauthConnector } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'

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(
async () => {
const response = await fetch('https://api.github.com/issues', {
headers: { authorization: `Bearer ${await token({ 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 your own plugin, use `ctx.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 `ctx.credentials.set`.
- 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 (`ctx.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](./connect#choose-what-clients-can-change)):

```ts group=harness-auth-wait-client
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.command('connect:github')
```

After the sign-in, the turn finishes its work with no new message.

## Keep credentials

The host reads credentials from `stores.credentials`, keyed by the sender of the running turn. Without it, credentials live in memory until the process stops. When several people write in one thread, see [Share a thread between people](./shared-threads).

```ts group=harness-auth
import { defineCredentialStore } from '@tanstack/ai-persistence'
import type { Credential } from '@tanstack/ai-persistence'

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 })),
})
```

- Encrypt tokens at rest in a real store.
- `list` returns ids and types only, never the secret values.
- 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](./shared-threads#share-one-credential-with-the-whole-organization).

## Read credentials in your own plugin

`ctx.credentials.require('github')` returns the credential, or stops with `auth_required` when the user has not signed in. Plugins read only the credentials of the sender of the running turn. Outside a turn, they read the credentials of the person who opened the session.

## What you have now

- Browser and device-code sign-in for any OAuth service.
- Tools that get a fresh token without the model seeing it.
- Turns that wait for a sign-in and then finish their work.
Loading
Loading