diff --git a/.changeset/ai-harness-transport.md b/.changeset/ai-harness-transport.md new file mode 100644 index 0000000000..9f0d8ebd40 --- /dev/null +++ b/.changeset/ai-harness-transport.md @@ -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. diff --git a/.changeset/ai-harness-view.md b/.changeset/ai-harness-view.md new file mode 100644 index 0000000000..303679dbe7 --- /dev/null +++ b/.changeset/ai-harness-view.md @@ -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. diff --git a/.changeset/ai-mcp-harness.md b/.changeset/ai-mcp-harness.md new file mode 100644 index 0000000000..e3f5f39956 --- /dev/null +++ b/.changeset/ai-mcp-harness.md @@ -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_` for each exposed agent and `command_` 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 `_`, and adds a `/mcp` status command. `oauth: true` signs in to a server with `/connect `. +- `@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. diff --git a/.changeset/harness-join-any-instance.md b/.changeset/harness-join-any-instance.md new file mode 100644 index 0000000000..d7089c0ae3 --- /dev/null +++ b/.changeset/harness-join-any-instance.md @@ -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. diff --git a/.changeset/harness-secure-defaults.md b/.changeset/harness-secure-defaults.md new file mode 100644 index 0000000000..d8f7ec8847 --- /dev/null +++ b/.changeset/harness-secure-defaults.md @@ -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. diff --git a/.changeset/harness-view-experimental.md b/.changeset/harness-view-experimental.md new file mode 100644 index 0000000000..cdb14f7fa5 --- /dev/null +++ b/.changeset/harness-view-experimental.md @@ -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. diff --git a/docs/config.json b/docs/config.json index 111ac54010..3090110a65 100644 --- a/docs/config.json +++ b/docs/config.json @@ -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", @@ -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", @@ -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", diff --git a/docs/harness/auth.md b/docs/harness/auth.md new file mode 100644 index 0000000000..d825439b85 --- /dev/null +++ b/docs/harness/auth.md @@ -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() +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. diff --git a/docs/harness/connect.md b/docs/harness/connect.md new file mode 100644 index 0000000000..dfd4fe46ba --- /dev/null +++ b/docs/harness/connect.md @@ -0,0 +1,359 @@ +--- +title: Connect clients to a harness +id: harness-connect +order: 2 +description: "Serve a harness session over HTTP, SSE, or WebSocket. Talk to it from a web app or another chat() call." +keywords: + - tanstack ai + - harness + - AG-UI + - websocket + - harnessText +--- + +Your harness runs on a server, but the people who use it are in a browser or another agent. `createHarnessHandler` serves a session over HTTP. A web app talks to it with `createHarnessClient`, and another `chat()` call with `harnessText`. + +## Serve the session over HTTP + +Mount one fetch handler on a route. `authorize` is required, so no endpoint is open by accident. + +```ts group=harness-connect +import { defineHarness, createHarnessHandler, createHarnessHost } from '@tanstack/ai-harness' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { openaiText } from '@tanstack/ai-openai' + +export const assistant = defineHarness({ + name: 'acme/assistant', + adapter: openaiText('gpt-6.1-sol'), +}) + +const host = createHarnessHost({ persistence: memoryPersistence() }) + +export const handler = createHarnessHandler({ + host, + harness: assistant, + authorize: (request) => + request.headers.get('authorization') === `Bearer ${process.env.HARNESS_TOKEN}` + ? { id: 'user-1' } + : null, + canAccess: (principal, threadId) => threadId.startsWith(`${principal.id}-`), +}) +``` + +`canAccess` is required. It decides which threads each principal can use. To let every principal use every thread, pass `canAccess: () => true`. + +On a durable host (`stores.log`), also set `mediaSecret`. It signs media URLs, so every instance must use the same value. Without a log store, the default is a random key, and signed URLs stop working after a restart. + +The handler answers these paths under your route: + +- `GET capabilities`: the AG-UI capabilities, with the agents in `expose.agents`. +- `POST run`: standard AG-UI. One request runs one prompt and streams it as SSE. The prompt keeps every content part of the last user message, for example an image. `useChat` works with it. See [Use it from useChat](#use-it-from-usechat). +- `GET run?threadId=`: the saved messages, the running turn, and the waiting approvals, for a `useChat` that loads the thread after a reload. +- `GET run?runId=`: the turn with that run id as SSE, from its first event. A reloaded `useChat` joins a running turn with it. +- `GET events?threadId=`: every event of the session as SSE. Each event id is a cursor, so a reconnect with `Last-Event-ID` continues where it stopped. +- `POST control`: send `{ threadId, input }`, for example `{ op: 'prompt', message }`. You get a receipt back. Agents, settings, config keys, and commands need `expose`. See [Choose what clients can change](#choose-what-clients-can-change). +- `GET snapshot?threadId=`: the status, running operations, and waiting approvals. +- `GET agent-transcript?threadId=&subagentRunId=`: the saved messages of one child agent of the thread. See [Show what a child wrote after a reload](./subagents#show-what-a-child-wrote-after-a-reload). + +## Cursors + +Each event id of `GET events` and `GET run`, and the `cursor` of each `harness.event` frame, is an opaque string. Send it back as `Last-Event-ID` or `from`, as it is. Do not parse it. + +- On a durable host (`stores.log`), a cursor is a position in the log of the thread. It works on every instance and after a restart. +- Without a log store, a cursor is a counter in the memory of one process. Another instance or a restart does not know it. + +To run more than one instance behind one URL, use `stores.log`. + +## Choose what clients can change + +A client can send a prompt and answer questions. Other inputs can change how the session works, so a client can use only what the harness exposes. Nothing is exposed by default. + +Name what a client can use in `expose`: + +```ts group=harness-connect +export const team = defineHarness({ + name: 'acme/team-assistant', + adapter: openaiText('gpt-6.1-sol'), + expose: { settings: ['instructions'], commands: ['undo'] }, +}) +``` + +| Field | What a client can do | +| --- | --- | +| `agents` | Run these agents, for example with `client.agents..start(input)`. | +| `settings` | Change these [thread settings](./thread-settings) with `client.configure(settings)`. | +| `config` | Set these plugin config keys with `client.setConfig({ key, value })`. | +| `commands` | Run these commands with `client.command(name)`, or with `/name` in a [session view](./custom-ui) on a client. | + +- Any other input of these kinds gets `{ status: 'rejected', reason: 'not_exposed' }`. +- `GET describe` and `client.describe()` list only the exposed commands and config keys. A UI built from them shows only what a client can use. +- Server code that calls the session is not limited. `session.setConfig()` and `session.command()` work for every key and command, and `session.describe()` lists all of them. +- `defineHarness` checks the names in `agents`. Plugins add their config keys and commands when a session opens, so it does not check those names. + +## Talk to it from a web app + +`createHarnessClient` wraps those endpoints. Import the harness with `import type`, so no server code reaches the browser. + +```ts group=harness-connect-client +import { createHarnessClient } from '@tanstack/ai-harness/client' +import type { assistant } from './harness' + +const client = createHarnessClient({ + url: '/api/harness', + threadId: 'user-1-thread', + headers: { authorization: 'Bearer my-token' }, +}) + +await client.prompt('Summarize my open tickets.') + +for await (const entry of client.events()) { + if (entry.event.type === 'TEXT_MESSAGE_CONTENT') { + console.log(entry.event.delta) + } +} +``` + +`events()` reconnects after a network error and continues from the last cursor. A second tab, a phone, or a reload all see the same session. + +To upload files and show the media that your agents make, read [Send and show media](./media). + +## Send a message to a running agent + +A background agent runs for minutes, and the user wants to correct it from the page, or ask for one more change after it ends. The browser sends the run a message with `client.sendToAgent`. + +1. On the server, list the agent in `expose.agents`. A client can start and message only those agents: + +```ts group=harness-connect-agents +import { defineAgent } from '@tanstack/ai' +import { createHarnessHandler, createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { openaiText } from '@tanstack/ai-openai' + +const drafter = defineAgent({ + name: 'drafter', + description: 'Drafts a blog post', + run: (ctx) => ctx.chat({ adapter: openaiText('gpt-6.1-sol'), stream: false }), +}) + +export const studio = defineHarness({ + name: 'acme/studio', + adapter: openaiText('gpt-6.1-sol'), + agents: [drafter], + expose: { agents: ['drafter'] }, +}) + +export const handler = createHarnessHandler({ + host: createHarnessHost({ persistence: memoryPersistence() }), + harness: studio, + authorize: (request) => + request.headers.get('authorization') === `Bearer ${process.env.HARNESS_TOKEN}` + ? { id: 'user-1' } + : null, + canAccess: (principal, threadId) => threadId.startsWith(`${principal.id}-`), +}) +``` + +2. In the browser, send the message to the id of the run: + +```ts group=harness-connect-agents-client +import { createHarnessClient } from '@tanstack/ai-harness/client' +import type { studio } from './harness' + +const client = createHarnessClient({ + url: '/api/harness', + threadId: 'user-1-thread', + headers: { authorization: 'Bearer my-token' }, +}) + +export async function correct(operationId: string) { + const receipt = await client.sendToAgent({ + operationId, + message: 'Cite papers, not blogs.', + }) + if (receipt.status === 'rejected') { + console.warn(receipt.reason) + } +} + +export function addTitle(operationId: string) { + return client.sendToAgent({ + operationId, + message: 'Add a title.', + mode: 'followUp', + }) +} +``` + +The id of the run is the `operationId` in the receipt of `client.agents.drafter.start()`. A [session view](./custom-ui#3-act-on-the-session) also lists the running agents with their ids. + +- `mode: 'steer'` (the default): the message joins the next model call of the run. +- `mode: 'followUp'`: the agent runs again after the run ends. `receipt.operationId` is the id of the new run. + +`sendToAgent` posts an `agentMessage` input to `POST control`. It runs as the user that `authorize` returned. The input looks like this: + +```json +{ "op": "agentMessage", "operationId": "op-agent-m1x2-3", "message": "Add a title.", "mode": "followUp" } +``` + +The session rejects the message with a `reason` in these cases: + +- `not_running`: the session does not know the run. Another host can run it, or the id is wrong. +- `not_exposed`: the agent of the run is not in `expose.agents`. + +For how steers and follow-ups run on the server, see [Message a running agent](./subagents#message-a-running-agent). + +## Use it from useChat + +Your app already uses `useChat`. You want the same chat screen on a harness, with approvals, tools that run in the browser, and the thread after a reload. Point `useChat` at the `run` path of the handler. + +1. Put the tools in a file that the server and the browser both import: + +```ts group=harness-connect-tools +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +export const deploy = toolDefinition({ + name: 'deploy', + description: 'Deploy the app. A person must approve it.', + needsApproval: true, + inputSchema: z.object({ env: z.string() }), + outputSchema: z.object({ ok: z.boolean() }), +}) +``` + +2. On the server, give the harness the tool with its implementation: + +```ts group=harness-connect-tools-server +import { defineHarness } from '@tanstack/ai-harness' +import { openaiText } from '@tanstack/ai-openai' +import { deploy } from './tools' + +export const deployer = defineHarness({ + name: 'acme/deployer', + adapter: openaiText('gpt-6.1-sol'), + tools: [ + deploy.server(async () => { + // Your deploy code runs here. + return { ok: true } + }), + ], +}) +``` + +Serve `deployer` with `createHarnessHandler`, as in [Serve the session over HTTP](#serve-the-session-over-http). + +3. In the browser, give `useChat` the same tool definitions: + +```tsx group=harness-connect-tools-client +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { deploy } from './tools' + +export function Chat() { + const { messages, interrupts, sendMessage } = useChat({ + threadId: 'user-1-thread', + connection: fetchServerSentEvents('/api/harness/run'), + tools: [deploy.client()] as const, + // The input's context: tools and plugins read it. + forwardedProps: { page: '/releases' }, + }) + + return ( + <> +

{messages.length} messages

+ {interrupts.map((interrupt) => + interrupt.kind === 'tool-approval' ? ( + + ) : null, + )} + + + ) +} +``` + +The approval reaches the harness, and the tool runs on the server. What else `POST run` does for `useChat`: + +- Each turn runs as the `runId` of its request. A retry with the same `runId` runs once. The same `runId` with another message gets `409`. +- A tool with only a `.client()` implementation runs in the browser, and the same turn gets its result. +- A question from `ctx.session.ask` arrives on the stream of the turn that waits for it. +- The `forwardedProps` of the request are the input's [context](./inputs#send-context-with-a-message). +- With `persistence: true`, `useChat` loads the thread from `GET run?threadId=`. +- If a turn still runs after a reload, `useChat` joins it with `GET run?runId=`. The turn shows from its first event, and its answer continues to stream. +- On a durable host (`stores.log` and `stores.runs`), each event of `POST run` has its log cursor as its id. If the connection drops, `useChat` sends the request again with `Last-Event-ID`. Any server continues the stream from there, and the turn does not run again. + +To keep the thread after a reload, set `persistence: true` in `useChat`: + +```tsx group=harness-connect-tools-client +export function ReloadableChat() { + const { messages } = useChat({ + threadId: 'user-1-thread', + connection: fetchServerSentEvents('/api/harness/run'), + tools: [deploy.client()] as const, + persistence: true, + }) + return

{messages.length} messages

+} +``` + +The join has these rules: + +- `canAccess` decides who can join, the same as for every other path. +- On a durable host, any server can answer the join. It reads the turn from the shared log. +- Without a log store, the turn must run on the host that answers the request. On another host, the join gets `404`. +- A turn that ended gives the events that the session still keeps, and then the stream ends. + +## Use a WebSocket + +For one connection that carries events and inputs, authorize the upgrade, then hand the socket to `handleHarnessSocket`: + +```ts group=harness-connect +import { handleHarnessSocket } from '@tanstack/ai-harness' +import type { WebSocketLike } from '@tanstack/ai' + +export function onUpgrade(socket: WebSocketLike) { + handleHarnessSocket({ + host, + harness: assistant, + socket, + principal: { id: 'user-1' }, + canAccess: (principal, threadId) => threadId.startsWith(`${principal.id}-`), + }) +} +``` + +The client sends `{ type: 'harness.subscribe', threadId }` first. After that, each `harness.input` frame gets a `harness.receipt`, and every event arrives as a `harness.event` frame with its cursor. + +## Use it as the model of another chat + +`harnessText` turns a harness into a text adapter. The outer chat sends a message, and the harness runs a full turn with its own tools, plugins, and agents: + +```ts group=harness-connect +import { chat } from '@tanstack/ai' +import { harnessText } from '@tanstack/ai-harness' + +const stream = chat({ + adapter: harnessText(assistant, { host }), + messages: [{ role: 'user', content: 'Fix the failing test.' }], + threadId: 'outer-thread', +}) +``` + +Each outer thread gets its own inner session, so the harness keeps its own history. The inner session gets every content part of the last user message, so images and files go through too. + +If a plugin picks the model of the harness, the harness has no adapter to read its input kinds from. Pass them with `harnessText(assistant, { host, inputModalities: ['text', 'image'] })`. + +## What you have now + +- One handler that serves standard AG-UI and the session stream. +- Clients that can change only what you expose. +- A `useChat` screen on the harness, with approvals, tools that run in the browser, and a reload that joins the running turn. +- A typed web client that reconnects and resumes from a cursor. +- Messages from the browser to a running agent. +- A WebSocket, and a harness you can call from `chat()`. + +Next: send more than a prompt with [inputs](./inputs). diff --git a/docs/harness/custom-ui.md b/docs/harness/custom-ui.md new file mode 100644 index 0000000000..1052b1c6e5 --- /dev/null +++ b/docs/harness/custom-ui.md @@ -0,0 +1,608 @@ +--- +title: Build your own UI +id: harness-custom-ui +order: 13 +description: "Show a harness session in your own terminal screen or web app. One live store holds the messages, tool calls, approvals, and plugin state, and works with any UI library." +keywords: + - tanstack ai + - harness + - session view + - tanstack store + - ink + - custom ui +--- + +You want your own screen for your harness: your own layout in a terminal, or a page in your web app. `session.events()` gives you raw AG-UI events. To show them, you must join text deltas, follow each tool call, and keep a list of open approvals. That is a reducer, and you must keep it correct. + +`createSessionView` does that work. It keeps one live state in a TanStack Store, and it gives you actions and typed events. You show the state with any UI library. + +At the end of this page you have a terminal screen in about 20 lines, and the same view in a web app. + +## Install + +Install the harness and the TanStack Store adapter for your UI library. With no UI library, the harness is enough. + + + +react: @tanstack/ai-harness @tanstack/react-store +vue: @tanstack/ai-harness @tanstack/vue-store +solid: @tanstack/ai-harness @tanstack/solid-store +svelte: @tanstack/ai-harness @tanstack/svelte-store +preact: @tanstack/ai-harness @tanstack/preact-store +angular: @tanstack/ai-harness @tanstack/angular-store +vanilla: @tanstack/ai-harness +octane: @tanstack/ai-harness + + + +The terminal screen on this page uses Ink, which needs Node 22 or later. Install Ink and React too: + + + +react: ink react + + + +## 1. Make a view + +Open a session, then give it to `createSessionView`: + +```ts group=harness-custom-ui +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createSessionView } from '@tanstack/ai-harness/view' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { openaiText } from '@tanstack/ai-openai' + +const assistant = defineHarness({ + name: 'acme/assistant', + adapter: openaiText('gpt-6.1-sol'), +}) + +const host = createHarnessHost({ persistence: memoryPersistence() }) +const session = await host.open(assistant, { threadId: 'thread-1' }) + +const view = createSessionView(session) +await view.ready +``` + +`view.ready` resolves when the view has the saved messages, the status, and the commands and settings of the session. If you open a thread again, its history is in the view immediately. And if a turn is still running when you open the view, you see that turn from its start, not only from the moment you opened it. + +## 2. Show the state + +`view.store` is a TanStack Store. `useSelector` reads one part of the state, and your component updates only when that part changes. This Ink screen shows the messages and the status: + +```tsx ignore +import { Box, Text, render } from 'ink' +import { useSelector } from '@tanstack/react-store' +import type { SessionView } from '@tanstack/ai-harness/view' + +function Screen({ view }: { view: SessionView }) { + const messages = useSelector(view.store, (state) => state.messages) + const status = useSelector(view.store, (state) => state.status) + return ( + + {messages.map((message) => + message.role === 'assistant' ? ( + + {message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')} + + ) : ( + + {message.role === 'user' ? `> ${message.text}` : message.text} + + ), + )} + {status} + + ) +} + +render() +await view.send('Summarize the README.') +``` + +Put the code of steps 1 and 2 in one `.tsx` file, then run it. The screen shows your prompt, then the answer while it streams, then the status `idle`. Press Ctrl+C to quit. + +Each TanStack Store adapter reads the store the same way. Vue, Solid, Svelte, and Preact have `useSelector`. Angular has `injectSelector`. With no UI library, subscribe to the store: + +```ts group=harness-custom-ui +const subscription = view.store.subscribe((state) => { + console.log(`${state.status}: ${state.messages.length} messages`) +}) +``` + +Call `subscription.unsubscribe()` to stop. + +### What the state holds + +The conversation: + +- `messages`: the lines to show. Each message has a `role`: `user`, `assistant`, or `notice`. +- `status`: `idle`, `running`, or `requires_action`. +- `connection`: `open`, `reconnecting`, or `closed`. + +What waits for the user: + +- `approvals`: tool calls that wait for a yes or a no. +- `clientTools`: tool calls that run in your UI and wait for their output. See [Run a tool in the browser](#run-a-tool-in-the-browser). +- `interrupts`: everything else a turn can wait for, for example a value that a tool asks for. See [Answer other interrupts](#answer-other-interrupts). +- `questions`: questions from a command, a plugin, or an MCP server. See [Show a question](#show-a-question). +- `signIns`: connectors that need a sign-in, with a `url` and a `userCode` when the connector gives them. + +The session: + +- `threadId`: the id of the conversation. +- `agents`: the background agents that run now. Each one has an `id`, a `name`, `send`, and `cancel`. +- `queuedTurns` and `waitingInputs`: how many messages wait for their turn, and which ones, in the order they run. +- `plugins`: the saved state of each plugin, by plugin name. + +Settings and usage, for a settings panel or a status bar: + +- `commands`, `config`, and `tools`: what the session has, for a help screen. +- `models`: the model names from `defineHarness({ models })`, for a model menu. +- `settings`: the stored settings of the thread, for example the model it uses now. +- `usage`: the token usage of the thread. It updates after each model call. + +An assistant message has `parts`. Each part is one of these: + +- `text` or `reasoning`: the text, which grows while it streams. +- `tool-call`: a tool call with its `name`, `args`, and `status` (`running`, `done`, `failed`, or `needs-approval`). +- `agent`: a child agent, with its own `parts`, its `status`, and its `result` when it gives one. See [Show subagents](#show-subagents). +- `media`: a file that an agent made. See [Show media](#show-media). + +A notice has a `kind`: + +- `info`: the session continues a turn that a crash stopped, or a [reset](./fork-and-reset#reset-with-a-handoff-note) started a fresh context. +- `error`: a turn or an action failed. +- `rejected`: the session did not accept a message. +- `command`: the text result of a command. +- `ui`: a line that you added with `view.notice(text)`. + +## 3. Act on the session + +Send work: + +- `view.send(text)`: sends a prompt. While a turn runs, the text steers that turn. Text that starts with `/` runs a command, for example `/goal all tests pass`. +- `view.followUp(text)`: the message waits, and runs as its own turn after the current work. +- `view.continue()`: starts a turn from the saved messages, with no new message. +- `view.command(name, input)`: runs a command, for example from a button. +- `view.upload(bytes, { name, mimeType })`: stores a file. Send the record you get back with `view.send(text, [record])`. See [Show media](#show-media). + +Change the session: + +- `view.setConfig({ key, value })`: changes a config value, for example `view.setConfig({ key: 'mode', value: 'plan' })`. +- `view.configure(settings)`: changes the stored settings of the thread, for example `view.configure({ model: 'strong' })`. +- `view.reset(note)`: the model starts fresh from the next turn, with your `note` first. See [Reset with a handoff note](./fork-and-reset#reset-with-a-handoff-note). +- `view.revert(messageId)` and `view.unrevert()`: go back to a message, and undo that. + +Stop work: + +- `view.cancel()`: cancels the running turn. `view.cancel(operationId)` cancels one operation, for example a background agent. +- `view.cancelInput(inputId)`: drops a message from `waitingInputs` before it runs. `view.setDelivery({ inputId, delivery: 'steer' })` moves it into the running turn instead. +- `view.dispose()`: stops the view when your screen closes. After that, each action throws an error. + +On a view with a [`HarnessClient`](#a-ui-in-the-browser), two actions need `expose` in the harness: + +- `view.command`, and a `/command` in `view.send`, run only the commands in `expose.commands`. +- `view.setConfig` sets only the keys in `expose.config`. + +For any other command or key, the view adds a `rejected` notice with the text `Not accepted: not_exposed`. It also calls your `'error'` handler with that text. On a client, `commands` and `config` in the state list only the exposed items. + +See [Choose what clients can change](./connect#choose-what-clients-can-change). + +Answer what waits: + +- `approval.approve()` and `approval.reject()`: answer one approval. `view.approve(id)` and `view.reject(id)` do the same by id. +- `view.approveAll()` and `view.rejectAll()`: answer all open approvals. +- `call.resolve(output)` and `call.fail(message)`: answer one client tool call. +- `request.resolve(value)` and `request.cancel()`: answer one item of `interrupts`. +- `question.answer(value)`: answers a question. + +Want the tool to run with different arguments than the model picked? Pass them to `approve`: + +```ts group=harness-custom-ui +const [waiting] = view.store.get().approvals +waiting?.approve({ editedArgs: { path: 'notes/draft.md' } }) +``` + +`reject(payload)` sends data with a no, when the tool's approval schema takes some. + +If a turn waits for more than one approval, client tool, or interrupt, the turn continues after you answer all of them. + +Message a background agent: + +- `agent.send(text, { mode })`: sends a message to one item of `state.agents`. `mode` is `'steer'` (the default) or `'followUp'`. See [Message a running agent](./subagents#message-a-running-agent). + +```ts group=harness-custom-ui +const [agent] = view.store.get().agents +if (agent) { + await agent.send('Add a title.', { mode: 'followUp' }) +} +``` + +`agent.cancel()` stops the run, and the runs it started. + +A session and a `HarnessClient` can do all of this. If you give `createSessionView` your own `SessionViewSource`, some members are optional. Without `sendToAgent`, `agent.send` rejects with an error. Without `followUp`, `reset`, or another optional member, its action adds an `error` notice instead. + +## 4. Listen for events + +Some items need attention when they arrive. `view.on` calls your handler for each one. When a tool call waits for approval, this handler rings the terminal bell and adds a line to the screen: + +```ts group=harness-custom-ui +view.on('approval', (approval) => { + process.stdout.write('\u0007') + view.notice(`${approval.tool} waits for your answer.`) +}) +``` + +Items that wait for the user: + +- `'approval'`: an approval. Call `approve()` or `reject()` on it. +- `'clientTool'`: a client tool call. Call `resolve(output)` or `fail(message)` on it. +- `'interrupt'`: an item of `interrupts`. Call `resolve(value)` or `cancel()` on it. +- `'question'`: a question with a `message`, a `schema`, a `url` when the user must open a page, and `answer(value)`. +- `'signIn'`: a connector that needs a sign-in, with `connector`, `url`, and `userCode`. + +Progress: + +- `'toolCall'`: `{ id, name }`, when a tool call starts. +- `'agent'`: `{ id, name, status }`, when a child agent starts, finishes, or fails. +- `'error'`: the error text. The view also adds it to `messages` as a notice. +- `'turnEnd'`: `{ operationId }`, when a chat turn ends. A turn that stops for an approval has not ended, so it does not fire then. + +The view calls your handler after the item is in the state, so the handler can read `view.store.get()` and find the item. `view.on` returns a function that removes the handler. + +## Show a question + +A command or a plugin can ask the user a question. Each item of `state.questions` has: + +- `id` and `message`. +- `schema`: the JSON Schema of the answer, when the question has one. Without a schema, the answer is text. +- `secret`: the answer is a key or a password. Hide what the user types. +- `url`: a page that the user must open before they answer, for example a payment page. +- `answer(value)`: sends the answer. + +When a question has a `url`, show the link. The user opens it, then answers. The session does not open the page. This handler adds the link to the screen: + +```ts group=harness-custom-ui +view.on('question', (question) => { + if (question.url) view.notice(`Open ${question.url}, then answer.`) +}) +``` + +### Show a field only when it applies + +A schema can make a field apply only when another field has a value. JSON Schema writes this with `if` and `then`. In this schema, `address` applies only when `delivery` is `mail`: + +```json +{ + "type": "object", + "properties": { + "delivery": { "type": "string", "enum": ["pickup", "mail"] }, + "address": { "type": "string" } + }, + "required": ["delivery"], + "if": { "properties": { "delivery": { "const": "mail" } } }, + "then": { "required": ["address"] } +} +``` + +The view gives you the schema as is. Your UI reads the condition. This function returns the fields that `then` adds for the answer so far: + +```ts group=harness-custom-ui +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function fieldsThatApply(schema: unknown, answer: Record) { + if (!isRecord(schema) || !isRecord(schema.if) || !isRecord(schema.then)) { + return [] + } + const rules = isRecord(schema.if.properties) + ? Object.entries(schema.if.properties) + : [] + const matches = rules.every( + ([field, rule]) => isRecord(rule) && answer[field] === rule.const, + ) + const required = schema.then.required + return matches && Array.isArray(required) ? required : [] +} +``` + +Call it each time the user changes a field. Show a field from the result, and hide it again when the result no longer has it: + +```ts group=harness-custom-ui +const [question] = view.store.get().questions +const extra = fieldsThatApply(question?.schema, { delivery: 'mail' }) +// With the schema above, `extra` is ['address']. +``` + +## Answer other interrupts + +Some turns wait for something that is not a yes or a no. A tool can stop the turn to ask for a value, or your own code can stop it for a form. The view puts these in `state.interrupts`. Each item has: + +- `id`, `reason`, and `message`: what the turn waits for. `reason` is the name the interrupt gave itself, for example `pick_region`. +- `payload`: the data the interrupt sent for your UI, for example the fields of a form. +- `responseSchema`: the JSON Schema of the answer, when it has one. [Show a field only when it applies](#show-a-field-only-when-it-applies) works for it too. +- `resolve(value)`: sends the answer. +- `cancel()`: answers with no value, and the turn continues without one. + +This handler shows each one, and answers the region picker right away: + +```ts group=harness-custom-ui +view.on('interrupt', (request) => { + view.notice(request.message ?? `The turn waits for ${request.reason}.`) + if (request.reason === 'pick_region') request.resolve({ region: 'eu-west-1' }) +}) +``` + +## Show media + +A user sends a screenshot, and an agent draws an image. The view gives you each file as a media part, with a URL that works in ``, `