TanStack
Harness

Connect clients to a harness

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. The host reads the user of each request with its access, so no endpoint is open by accident.

ts
import { createHarnessHost, defineHarness, membersOnly } from '@tanstack/ai-harness'
import { createHarnessHandler } from '@tanstack/ai-harness/server'
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(),
  access: {
    getUser: ({ request }) =>
      request.headers.get('authorization') === `Bearer ${process.env.HARNESS_TOKEN}`
        ? { id: 'user-1' }
        : null,
    rules: membersOnly(),
  },
})

export const handler = createHarnessHandler({ host, harness: assistant })
  • getUser reads the user of each request. null answers 401.
  • isAllowed decides each action. A refused action answers 403. membersOnly() lets a user use the threads they opened and the threads shared with them.
  • A host with no access needs openAccess on the handler, else createHarnessHandler throws at the start. See Serve with no login.

Access and tenants has the full rules.

When more than one instance serves the same URL, also set media: { secret }. The secret signs media URLs, so every instance must use the same value. Without it, the handler signs with a random key of its own, so a signed URL works only on this instance, until it restarts.

On a host with ownership options, createHarnessHandler throws at the start when media is not set. On another host without media, the first handler logs one warning.

ts
const mediaKey = process.env.MEDIA_KEY
const oldMediaKey = process.env.OLD_MEDIA_KEY
if (!mediaKey || !oldMediaKey) {
  throw new Error('Set MEDIA_KEY and OLD_MEDIA_KEY.')
}

export const rotatingHandler = createHarnessHandler({
  host,
  harness: assistant,
  // The first key signs. Every key checks the URLs.
  media: { keys: [mediaKey, oldMediaKey] },
})
  • Each key must be at least 32 bytes. A shorter key throws at the start.
  • To change the key, put the new key first in keys and keep the old key after it. A URL that the old key signed works until it expires (1 hour). Then remove the old key.
  • A signed URL works for 1 hour. To change that, set media.urlTtlMs, for example { secret, urlTtlMs: 5 * 60_000 } for 5 minutes on private files.
  • A signed URL works only for this harness, in the tenant of the user who got it. Each request still needs a user from getUser that can read the thread, so send the login with it. For a request with no login, see Serve media to a request with no login. When the thread is deleted, its URLs answer 404.

A request body over media.maxBodyBytes (default 32 MiB) answers 413. See Files inside a client message.

The handler answers these paths under your route. For chat and events:

  • 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.
  • GET run?threadId=: the saved messages, the running turn, and the waiting approvals, for a useChat that loads the thread after a reload. pendingWaits has every wait of the thread, as GET snapshot has them, also a step.ask question.
  • GET run?runId=&threadId=: the turn with that run id as SSE, from its first event. A reloaded useChat joins a running turn with it. Another instance finds the turn only with threadId. See Join a turn after a reload.
  • 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.

For one session:

  • 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.
  • GET snapshot?threadId=: the status, running operations, and waiting approvals.
  • GET transcript?threadId= and GET describe?threadId=: the saved messages, and the commands and settings that a client can use.
  • GET agent-transcript?threadId=&subagentRunId=: the saved messages of one child agent of the thread. See Show what a child wrote after a reload.
  • POST media, GET media-url, and GET media: upload a file, get a signed URL, and read the bytes. A signed URL works for 1 hour (media.urlTtlMs), and only for a user who can read the thread. See Send and show media.

For a list of sessions:

  • GET sessions: one page of the sessions that this user can list, newest first. Add withArchived=true to also get the archived sessions.
  • POST sessions: change a session with { op, threadId }. The ops are rename, archive, unarchive, update, delete, fork, share, and unshare. client.sessions has one method for each op. A thread that is not there throws 404. See List, rename, and fork sessions and Share the thread.
  • GET host-events: the status changes and index changes of the sessions that this user can read, as SSE.

A read of a thread that does not exist answers 404. Only GET run?threadId= answers an empty thread, because useChat reads it before the first message.

The read paths read the thread without a session, so they set up no plugins and claim nothing. GET describe opens the session, because the plugins give the commands and the settings. For a read route of your own, use host.observe. See Read a thread without a session.

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.

A cursor is a position in the log of the thread. It works on every instance that reads the same log store, and after a restart when the store keeps the log. With the memory log of memoryPersistence(), only this process knows the log.

To run more than one instance behind one URL, give every instance the same log store.

Sometimes the events after a cursor are gone. A host with keepLogRecords: 'since-snapshot' drops old records (see Drop the records before a snapshot), and before its log opens, a session keeps only its newest events in memory. A reader whose cursor is in the lost part gets one CUSTOM event first:

  • Its name is harness.gap (HARNESS_EVENTS.gap).
  • Its value is an EventGap: { from, resumeAt, reason }. reason is truncated or evicted. Its cursor is resumeAt.
  • Then the stream goes on from resumeAt.

The session view loads the snapshot and the transcript again when it sees this event. A custom client must do the same. See When events are missing.

Keep the stream open

Every transport sends the same frames: each event is an AG-UI wire chunk in a harness.event frame. A long stream stays usable through proxies and slow networks:

  • The first frame of GET events and of the socket is harness.hello, with the protocol version v. A client can ask for a version with ?v=2 or v: 2.
  • When the stream is idle, the server sends a heartbeat every 20 s. Set the idle timeout of your proxy to more than that.
  • A client that does not read for 30 s gets a slow_consumer error frame, and the stream closes. Connect again from the last cursor.
  • Every answer of the handler has an X-Request-Id header, and every error frame has a code and a requestId. Log the id, to find the request in the server logs.
  • To know who has a stream open, pass presence: { onConnect, onDisconnect } to createHarnessHost. See Show who is here.

To change the heartbeat or the limits, pass stream to createHarnessHandler and handleHarnessSocket. The harness wire protocol lists every frame, the error codes, and the stream values, for a client in any language.

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
export const team = defineHarness({
  name: 'acme/team-assistant',
  adapter: openaiText('gpt-6.1-sol'),
  expose: { settings: ['instructions'], commands: ['undo'] },
})
FieldWhat a client can do
agentsRun these agents, for example with client.agents.<name>.start(input).
settingsChange these thread settings with client.updateThreadSettings({ settings }).
configSet these plugin config keys with client.setPluginSetting({ key, value }).
commandsRun these commands with client.runCommand({ name }), or with /name in a session view on a client.
toolsRun these harness tools with no model turn, with client.runTool({ name, args }). See Run a tool without a model.
recordsAdd records of these types to the thread with client.appendRecords({ records }). See Let a client add records.
  • Any other input of these kinds gets { status: 'rejected', reason: 'not_exposed' }.
  • To check what an input says, for example its context, use acceptInput on the host. See Check an input on the server.
  • 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.setPluginSetting() and session.runCommand() work for every key and command, and session.describe() lists all of them.
  • defineHarness checks the names in agents and tools, and TypeScript suggests them. 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
import { createHarnessClient } from '@tanstack/ai-harness/client'
import type { assistant } from './harness'

const client = createHarnessClient<typeof assistant>({
  url: '/api/harness',
  threadId: 'user-1-thread',
  headers: { authorization: 'Bearer my-token' },
})

const { cursor } = await client.snapshot()
await client.send('Summarize my open tickets.')

for await (const entry of client.events({ from: cursor })) {
  if (entry.event.type === 'TEXT_MESSAGE_CONTENT') {
    console.log(entry.event.delta)
  }
}

events() reconnects after a network error and continues from the last cursor. It also reconnects when no bytes come for 60 s, so a dead connection (a laptop that slept) does not stay open. To change that time, pass idleTimeoutMs to createHarnessClient. Keep it larger than the heartbeat of the server, or the client reconnects again and again. A second tab, a phone, or a reload all see the same session.

Without from, events() starts at the newest event, so it sends no old events. That is why the example reads the snapshot cursor before it sends.

  • To read every event from the start of the session, pass from: '0'. Do this when you rebuild a transcript from the events.
  • If the snapshot answer has no cursor (the URL is not a harness handler), events() throws and does not try again.

To upload files and show the media that your agents make, read Send and show 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.send(message, { to: operationId }).

  1. On the server, list the agent in expose.agents. A client can start and message only those agents:
ts
import { defineAgent } from '@tanstack/ai'
import { createHarnessHost, defineHarness, membersOnly } from '@tanstack/ai-harness'
import { createHarnessHandler } from '@tanstack/ai-harness/server'
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'] },
})

const host = createHarnessHost({
  persistence: memoryPersistence(),
  access: {
    getUser: ({ request }) =>
      request.headers.get('authorization') === `Bearer ${process.env.HARNESS_TOKEN}`
        ? { id: 'user-1' }
        : null,
    rules: membersOnly(),
  },
})

export const handler = createHarnessHandler({ host, harness: studio })
  1. In the browser, send the message to the id of the run:
ts
import { createHarnessClient } from '@tanstack/ai-harness/client'
import type { studio } from './harness'

const client = createHarnessClient<typeof studio>({
  url: '/api/harness',
  threadId: 'user-1-thread',
  headers: { authorization: 'Bearer my-token' },
})

export async function correct(operationId: string) {
  const receipt = await client.send('Cite papers, not blogs.', {
    to: operationId,
  })
  if (receipt.status === 'rejected') {
    console.warn(receipt.reason)
  }
}

export function addTitle(operationId: string) {
  return client.send('Add a title.', { to: operationId, mode: 'followUp' })
}

The id of the run is the operationId in the receipt of client.agents.drafter.start(). A session view 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.

send with to posts an agentMessage input to POST control. It runs as the user that getUser 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.

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
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() }),
})
  1. On the server, give the harness the tool with its implementation:
ts
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.

  1. In the browser, give useChat the same tool definitions:
tsx
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 (
    <>
      <p>{messages.length} messages</p>
      {interrupts.map((interrupt) =>
        interrupt.kind === 'tool-approval' ? (
          <button
            key={interrupt.id}
            onClick={() => interrupt.resolveInterrupt(true)}
          >
            Approve {interrupt.toolName}
          </button>
        ) : null,
      )}
      <button onClick={() => sendMessage('Deploy to staging.')}>Deploy</button>
    </>
  )
}

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 of step.ask or of an MCP server arrives on the stream of the turn that waits for it. See Wait for a person.
  • The forwardedProps of the request are the input's context.
  • 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.
  • 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.

Join a turn after a reload

To keep the thread after a reload, set persistence: true in useChat. Put the threadId in the URL of the connection too:

tsx
export function ReloadableChat() {
  const { messages } = useChat({
    threadId: 'user-1-thread',
    connection: fetchServerSentEvents('/api/harness/run?threadId=user-1-thread'),
    tools: [deploy.client()] as const,
    persistence: true,
  })
  return <p>{messages.length} messages</p>
}

The join has these rules:

  • The host checks that the user can read the thread, the same as for every other path.
  • The join request of useChat sends only the run id. With threadId in the URL, any instance that reads the same log store can answer the join. It reads the turn from the log of that thread.
  • Without threadId, only the instance that runs the turn finds it. Another instance answers 400.
  • A turn that ended gives the events that the session still keeps, and then the stream ends.

Stop a turn from useChat

The stop() of useChat closes the request, but the turn continues on the server. A closed connection never cancels work, because another tab or a reload can still read the turn. To stop the turn too, send a cancel input for its run.

  1. On the server, serve the harness with one handler. POST run streams the turn to useChat, and POST control takes the cancel:
ts
import { createHarnessHost, defineHarness, membersOnly } from '@tanstack/ai-harness'
import { createHarnessHandler } from '@tanstack/ai-harness/server'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'

export const writer = defineHarness({
  name: 'acme/writer',
  adapter: openaiText('gpt-6.1-sol'),
})

const host = createHarnessHost({
  persistence: memoryPersistence(),
  access: {
    getUser: ({ request }) =>
      request.headers.get('authorization') === `Bearer ${process.env.HARNESS_TOKEN}`
        ? { id: 'user-1' }
        : null,
    rules: membersOnly(),
  },
})

// Mount it on `/api/harness/*`.
export const handler = createHarnessHandler({ host, harness: writer })
  1. In the browser, call client.cancel with the runId of useChat in your stop handler. The turn of POST run runs as that id:
tsx
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { createHarnessClient } from '@tanstack/ai-harness/client'
import type { writer } from './harness'

const headers = { authorization: 'Bearer my-token' }

const client = createHarnessClient<typeof writer>({
  url: '/api/harness',
  threadId: 'user-1-thread',
  headers,
})

export function StoppableChat() {
  const { messages, runId, isLoading, stop, sendMessage } = useChat({
    threadId: 'user-1-thread',
    connection: fetchServerSentEvents('/api/harness/run', { headers }),
  })

  const stopTurn = async () => {
    stop()
    if (!runId) return
    const receipt = await client.cancel({ operationId: runId })
    if (receipt.status === 'rejected') console.warn(receipt.reason)
  }

  return (
    <>
      <p>{messages.length} messages</p>
      <button onClick={() => sendMessage('Write a long story.')}>Send</button>
      {isLoading ? <button onClick={stopTurn}>Stop</button> : null}
    </>
  )
}
  • The cancel works on any server that reads the same log store. The input goes to the server that runs the turn.
  • If the turn already ended, the receipt can be rejected with the reason not_running.
  • The turn ends with the status cancelled.

Use a WebSocket

For one connection that carries events and inputs, hand the socket and its upgrade request to handleHarnessSocket:

ts
import { handleHarnessSocket } from '@tanstack/ai-harness/server'
import type { WebSocketLike } from '@tanstack/ai'

export function onUpgrade(socket: WebSocketLike, request: Request) {
  handleHarnessSocket({ host, harness: assistant, socket, request })
}

The client sends { type: 'harness.subscribe', threadId } first, and the host answers with a harness.hello frame. After that, each harness.input frame gets a harness.receipt, and every event arrives as a harness.event frame with its cursor.

  • The host reads the user from the upgrade request with getUser, the same as for HTTP. Every input on the socket runs as that user.
  • A user that getUser refuses gets an unauthorized error frame, and the socket closes.
  • The socket handles the frames of one connection in the order they came. So a prompt with a large image reaches the session before the frame after it.
  • A user who cannot read the thread, or who loses access later, gets a forbidden error frame, and the socket closes.
  • A frame over media.maxBodyBytes (default 32 MiB) gets an error frame, and the socket closes with code 1009. Your WebSocket server reads the whole frame before this check, so the check does not cap memory. Set the frame limit of your server too, for example maxPayload of ws.
  • On a host with no access, pass openAccess, the same as for createHarnessHandler.
  • Pass socket: true to createHarnessHandler too. Then GET capabilities says transport.websocket: true, so clients know that they can use the socket. The default is false.

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

  • Who runs the inner turn: when the outer chat() runs inside a harness turn, the inner session opens as the user of that turn. Else it opens as user, for example harnessText(assistant, { host, user: { id: 'ada' } }).
  • Access: the inner thread is <outer thread id>/inner/<harness name>. When host has access and also runs the outer thread, the inner session is checked through the outer thread, so every user who can read a shared outer thread can use its inner session. From app code, host.open, host.submit, host.fork, and host.transfer refuse any thread id with /inner/.
  • Usage: the outer run gets the token usage of every inner model call, so the usage totals of the outer session count them.
  • Cancel: an abort of the outer call cancels the inner turn. For a remote harness, harnessText({ url, token }) also tells the remote host to cancel the turn.

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, as the user of each request.
  • Clients that can change only what you expose.
  • A useChat screen on the harness, with approvals, tools that run in the browser, a reload that joins the running turn, and a stop button that cancels the 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: run the same harness in a terminal with the CLI.