Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .changeset/ai-harness-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ Add `@tanstack/ai-harness-cli`: `runCli(harness)` runs a harness from the termin
- `-p "prompt"` runs one prompt for scripts and CI, and `--output ndjson` prints every AG-UI event.
- `--serve` serves the session protocol over HTTP behind a bearer token.
- `--acp`, `--mcp`, and `--dashboard` load `@tanstack/ai-acp`, `@tanstack/ai-mcp`, and `@tanstack/ai-dashboard` only when you use the flag. A missing package, or one without harness support, fails that flag with a clear message.
- In every mode, the first SIGINT or SIGTERM cancels the running turn. A second one, or one while no turn runs, stops the CLI, and `runCli` resolves to 130 (SIGINT) or 143 (SIGTERM). The CLI closes a host that it made with `recoverable: true`, or releases its session handle on your own host. The `signals` option takes another signal source, for example an `EventEmitter` in a test.
- `host` and `principal` options let the CLI use your durable host and run as your user. Every mode except `--dashboard` opens its sessions as that principal. On a host with `access`, `--serve` reads the user of each request with `identify`, and `--mcp` does not start, because stdio has no login.
8 changes: 6 additions & 2 deletions .changeset/ai-harness-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
Add `@tanstack/ai-harness`: build an agent harness on TanStack AI. A harness keeps one conversation open across many turns.

- `defineHarness()` names an agent: its adapter, tools, system prompts, middleware, agents, subagents, routing, plugins, turn hooks, and durability options.
- `createHarnessHost()` opens sessions. A session takes `send` (a prompt, a steer into the running turn, a follow-up, or a message to an agent run with `to`), `continue`, `reset`, `resolve`, `cancel`, and `background` inputs. Each returns an operation with a receipt, and the session streams typed events. `host.isOpen(harness, { threadId })` says whether this host holds that session open.
- `createHarnessHost()` opens sessions. Each `host.open` gives its own handle to the live session of the thread. `session.close()`, or `await using`, releases the handle. A dropped handle is released by the garbage collector, with one warning. A call on a handle after its session closed opens the session again.
- `lifecycle` on `createHarnessHost()` sets how sessions live. A session that no handle holds closes after it was idle for `idleCloseMs` (default 60 000). `maxOpen` (default 500) first closes the least recently used idle sessions. `retention` (`settledOperations`, `settledAgentRuns`, `eventBytes`) bounds the settled operations, agent runs, receipts, events, and log tail that each session keeps in memory. `host.close({ recoverable })` stops the work of every session and refuses later opens.
- `host.observe(harness, { threadId, principal })` reads a thread with no session: its events, transcript, snapshot, media, usage, and entry. It sets up no plugins, writes nothing, and claims nothing.
- `host.sessions.delete` closes the sessions of a thread and of its child threads. Then it deletes their logs or messages, index entries, uploaded media, generated artifacts, settings, config, interrupted turn, usage, and revert data.
- A session takes `send` (a prompt, a steer into the running turn, a follow-up, or a message to an agent run with `to`), `continue`, `reset`, `resolve`, `cancel`, and `background` inputs. Each returns an operation with a receipt, and the session streams typed events. `host.isOpen(harness, { threadId })` says whether this host holds that session open.
- `createHarnessHost({ access })` decides who can use each session. `identify({ request })` reads the user of a request, and `authorize({ principal, action, session, run })` decides each action. The principal type comes from what `identify` returns. The host keeps each tenant apart, needs a `principal` on each call, and `host.open` returns a handle that acts only as its principal. A refused action throws `AccessDeniedError`. Without `access`, one user runs the host, with no checks. With `access`, keyed adapters and provider keys read env vars only when `access.envKeys` is `true`.
- `membersOnly()` is a ready `authorize` with the roles owner, member, and viewer. `host.sessions.share` and `host.sessions.unshare` change the members, and `host.sessions.fork` takes `members: 'self' | 'all'`. For apps with their own access tables, `host.revoke` takes the access of a user to a thread. `host.identify` reads the user of a request for your own routes. Each `host.sessions` method takes one object.
- A principal with a `tenantId` keeps its sessions in its tenant: one thread id in two tenants is two sessions, with two index entries and two logs. `logIdOf({ tenantId, logId })` gives the id of a log in the store.
- Every run has a run context with its user, and that user's credentials and provider keys: `ctx.context.run` in tools and middleware (type them with `HarnessRunContext`), `ctx.turn.run` in a turn plugin, and `run` in a command. The `ctx.credentials` and `ctx.keys` of a session plugin have no user, so each call throws. `ctx.agents.start(agent, input, { principal })` starts an agent for a user. The log keeps the sender of every input.
- `definePlugin()` adds tools, prompts, middleware, agents, commands, config options, state, and extension points to a session or to one turn.
- `definePlugin()` adds tools, prompts, middleware, agents, commands, config options, state, and extension points to a session or to one turn. With `lifetime: 'host'`, the host sets a plugin up once per harness and shares it with every session. It is cleaned up when the last session that uses it closes, or at `host.close()`.
- Agents run from code with `session.agents.<name>.run()` and `.start()`. In agent code, `ctx.harness` gives `step.do()` (a finished step returns its stored value when the run runs again) and `agents.start()` (a child that counts against the subagent budget).
- With `stores.log` from `@tanstack/ai-persistence`, a session is durable: inputs, tool results, and steps are in the log, and the next host recovers a turn that a crash stopped. `durableTool()` runs side effects once. `durability.truncatedToolResult` sets the text of a tool call cut at the output limit. Without it, a cut answer ends the turn.
- `turn.onModelError`, `retryTransientErrors()`, `turn.beforeFinish`, and `turn.canJoin` control how a turn retries, continues, and ends.
Expand Down
4 changes: 2 additions & 2 deletions .changeset/ai-harness-transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

Serve a harness session to clients.

- `createHarnessHandler()` from `@tanstack/ai-harness/server` serves a session over HTTP, as the user that the host `access` reads from each request. `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`, and `GET describe` cover one session. `sessions` (with the `share` and `unshare` ops), `GET host-events`, and the media routes cover the rest. A refused action answers 403. A read of a thread that does not exist answers 404 and starts no session.
- `createHarnessHandler()` from `@tanstack/ai-harness/server` serves a session over HTTP, as the user that the host `access` reads from each request. `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`, and `GET describe` cover one session. `sessions` (with the `share` and `unshare` ops), `GET host-events`, and the media routes cover the rest. A refused action answers 403. A read of a thread that does not exist answers 404 and starts no session. The read routes read a thread with `host.observe`, so they set up no plugins. `GET describe` opens the session.
- `handleHarnessSocket()`, from the same entry, serves the same session over a WebSocket. It takes the upgrade `request` and reads the user from it.
- `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 from `@tanstack/ai-harness/oauth` (`loopbackLogin`, `deviceLogin`, `exchangeCode`, `refreshCredential`, and more) keep a credential per user. A tool calls `token({ run: ctx.context.run })` for the token of the user of its run. `token({ run, wait: true })` stops the turn with an `auth_required` interrupt until the user signs in.
- `oauthConnector()` and the OAuth helpers from `@tanstack/ai-harness/oauth` (`loopbackLogin`, `deviceLogin`, `exchangeCode`, `refreshCredential`, and more) keep a credential per user. A tool calls `token({ run: ctx.context.run })` for the token of the user of its run. `token({ run, wait: true })` stops the turn with an `auth_required` interrupt until the user signs in. `deviceLogin`, `loopbackLogin`, `startLoopbackReceiver`, `exchangeCode`, and `refreshCredential(config, credential, { fetch, signal })` stop at once when their `signal` aborts, so a cancel or a session close stops a running `/connect`. `OAuthConfig.issuer` makes `loopbackLogin` refuse a code from another issuer (RFC 9207). The loopback deadline (`timeoutMs`) does not keep the process alive.
6 changes: 3 additions & 3 deletions .changeset/ai-mcp-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
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.
- On a host with `access`, `createHarnessMcpServer` reads the user of each HTTP request with `identify`. The host checks each tool call of that user. A host without `access` needs `openAccess: true` (no user) or `openAccess: { principal }` (one local user), else the server throws at the start.
- `@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).
- On a host with `access`, `createHarnessMcpServer` reads the user of each HTTP request with `identify`. The host checks each tool call of that user. A host without `access` needs `openAccess: true` (no user) or `openAccess: { principal }` (one local user), else the server throws at the start. Each tool call releases its session handle when it ends, so the session closes on idle. The `status` tool and the media reads read the thread with `host.observe`, so they set up no plugins.
- `@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. A stdio server starts once per host, and every session of the host shares it. `oauth: true` signs in to a server with `/connect <name>`, for each user.
- `@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). A cancel or a session close stops a running sign-in at once: the loopback stops listening, and the OAuth requests abort.
- `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.
2 changes: 1 addition & 1 deletion .changeset/harness-acp-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'@tanstack/ai-acp': minor
---

`@tanstack/ai-acp/agent` adds `createAcpAgent` and `serveAcp`, which serve a harness as an ACP v2 agent (experimental, like the draft protocol). Tool approvals become permission requests, cancel cancels the running turn, and image, audio, and embedded file blocks go to the media store of the session. `session/list` lists the sessions of the harness from the session index of the host. The `principal` option is the user of the editor: each session opens as that user, and the list shows the sessions of that user. It is required on a host with `access`. `@tanstack/ai-acp` now uses `@agentclientprotocol/sdk` 1.8, pinned to `~1.8.0` because the agent uses its experimental v2 entry point. The existing ACP client adapters keep working.
`@tanstack/ai-acp/agent` adds `createAcpAgent` and `serveAcp`, which serve a harness as an ACP v2 agent (experimental, like the draft protocol). Tool approvals become permission requests, cancel cancels the running turn, and image, audio, and embedded file blocks go to the media store of the session. `session/list` lists the sessions of the harness from the session index of the host. The `principal` option is the user of the editor: each session opens as that user, and the list shows the sessions of that user. It is required on a host with `access`. The agent holds one session handle per ACP session. `session/close` releases it, and the close of the connection releases them all, so the host closes each session on idle. `@tanstack/ai-acp` now uses `@agentclientprotocol/sdk` 1.8, pinned to `~1.8.0` because the agent uses its experimental v2 entry point. The existing ACP client adapters keep working.
9 changes: 7 additions & 2 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -973,14 +973,19 @@
"label": "Durable sessions",
"to": "harness/durable-sessions",
"addedAt": "2026-10-13",
"updatedAt": "2026-10-15"
"updatedAt": "2026-10-17"
},
{
"label": "List, rename, and fork sessions",
"to": "harness/sessions",
"addedAt": "2026-10-13",
"updatedAt": "2026-10-17"
},
{
"label": "Lifecycle and retention",
"to": "harness/lifecycle",
"addedAt": "2026-10-17"
},
{
"label": "Run side effects once",
"to": "harness/durable-tools",
Expand Down Expand Up @@ -1122,7 +1127,7 @@
"label": "Use MCP servers",
"to": "harness/mcp",
"addedAt": "2026-10-13",
"updatedAt": "2026-10-16"
"updatedAt": "2026-10-17"
},
{
"label": "Use from any MCP client",
Expand Down
71 changes: 71 additions & 0 deletions docs/harness/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ export const assistant = defineHarness({

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

Expand Down Expand Up @@ -198,8 +200,77 @@ Your plugin reads the credentials of the user of each run:

See [Act as the user of a run](./plugins#act-as-the-user-of-a-run).

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

```ts group=harness-auth-own
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.'
},
}),
},
}),
})
```

A tool refreshes an expired token with the `signal` of its run, so the refresh stops with the run:

```ts group=harness-auth-own
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)) {
credential = await refreshCredential(gitlab, credential, { signal })
await credentials.set({ id: 'gitlab', credential })
}
const token = credential.type === 'api_key' ? credential.value : credential.accessToken
const response = await fetch('https://gitlab.com/api/v4/projects?membership=true', {
headers: { authorization: `Bearer ${token}` },
signal,
})
return response.json()
})
```

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

## 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.
- Sign-ins of your own that stop at once on a cancel.
Loading
Loading