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.
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.
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 })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.
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] },
})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:
For one session:
For a list of sessions:
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.
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:
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.
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:
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.
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:
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.<name>.start(input). |
| settings | Change these thread settings with client.updateThreadSettings({ settings }). |
| config | Set these plugin config keys with client.setPluginSetting({ key, value }). |
| commands | Run these commands with client.runCommand({ name }), or with /name in a session view on a client. |
| tools | Run these harness tools with no model turn, with client.runTool({ name, args }). See Run a tool without a model. |
| records | Add records of these types to the thread with client.appendRecords({ records }). See Let a client add records. |
createHarnessClient wraps those endpoints. Import the harness with import type, so no server code reaches the browser.
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 upload files and show the media that your agents make, read Send and show media.
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 }).
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 })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.
send with to posts an agentMessage input to POST control. It runs as the user that getUser returned. The input looks like this:
{ "op": "agentMessage", "operationId": "op-agent-m1x2-3", "message": "Add a title.", "mode": "followUp" }The session rejects the message with a reason in these cases:
For how steers and follow-ups run on the server, see Message a running agent.
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.
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() }),
})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.
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:
To keep the thread after a reload, set persistence: true in useChat. Put the threadId in the URL of the connection too:
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 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.
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 })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}
</>
)
}For one connection that carries events and inputs, hand the socket and its upgrade request to handleHarnessSocket:
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.
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:
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'] }).
Next: run the same harness in a terminal with the CLI.