You want a client for a harness that is not useChat or createHarnessClient: a Python service, a mobile app, or a proxy of your own. This page lists every route, frame, and harness.* event, so you can write that client in any language.
Send a prompt to a thread with curl:
curl -X POST https://example.com/api/harness/control \
-H 'authorization: Bearer my-token' \
-H 'content-type: application/json' \
-d '{"threadId":"thread-1","input":{"op":"prompt","message":"Hello"}}'The command prints the receipt, for example {"inputId":"in-1","status":"accepted","operationId":"op-1"}. The first prompt also makes the thread. Then open the session stream of the thread:
curl -N -H 'authorization: Bearer my-token' \
'https://example.com/api/harness/events?threadId=thread-1&v=2'With no cursor, the stream starts at the first event of the thread. It shows the hello frame, then one SSE event per session event, and a ping comment when the stream is idle:
data: {"type":"harness.hello","v":2,"threadId":"thread-1"}
id: 4
data: {"type":"harness.event","cursor":"4","threadId":"thread-1","operationId":"op-1","event":{"type":"TEXT_MESSAGE_CONTENT","messageId":"m-1","delta":"Hi"}}
: pingThe rest of this page is the reference for each part.
The server is the handler of Connect clients to a harness. One handler and one socket serve every transport on this page:
import { createHarnessHost, defineHarness, membersOnly } from '@tanstack/ai-harness'
import { createHarnessHandler, handleHarnessSocket } from '@tanstack/ai-harness/server'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
import type { WebSocketLike } from '@tanstack/ai'
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(),
},
})
// Mount it on `/api/harness/*`.
export const handler = createHarnessHandler({ host, harness: assistant })
// Call it from the upgrade handler of your server.
export function onUpgrade(socket: WebSocketLike, request: Request) {
handleHarnessSocket({ host, harness: assistant, socket, request })
}Pick the transport that your client can use:
| Transport | Events | Inputs | Use it when |
|---|---|---|---|
| HTTP | GET events (SSE) | POST control (JSON) | Your client can read SSE. This works through most proxies. |
| WebSocket | harness.event frames | harness.input frames | You want one connection for events and inputs. |
| Stdio worker | NDJSON on stdout | NDJSON on stdin | You start the harness as a child process. |
The socket and the stdio worker use the same frames. The HTTP routes send the same harness.* frames as SSE data.
These routes are enough for a full client. Connect clients to a harness lists the others (media, sessions, and more).
POST control answers with these statuses:
A rejected receipt is still 200. Its reason says why, for example not_exposed.
The first frame from the client must be harness.subscribe. The host reads the user from the upgrade request, with getUser. A browser cannot set headers on a WebSocket, so getUser must read a cookie or a token in the URL.
import type { ControlFrame, HostFrame } from '@tanstack/ai-harness/server'
function isFrame(value: unknown): value is HostFrame {
return (
typeof value === 'object' &&
value !== null &&
'type' in value &&
typeof value.type === 'string'
)
}
const socket = new WebSocket('wss://example.com/api/harness/socket')
const send = (frame: ControlFrame) => socket.send(JSON.stringify(frame))
socket.addEventListener('open', () => {
send({ type: 'harness.subscribe', threadId: 'thread-1', v: 2 })
})
socket.addEventListener('message', (message) => {
const frame: unknown = JSON.parse(String(message.data))
if (!isFrame(frame)) return
if (frame.type === 'harness.hello') {
send({
type: 'harness.input',
requestId: 'req-1',
input: { op: 'prompt', message: 'Hello' },
})
}
if (frame.type === 'harness.receipt') console.log(frame.requestId, frame.status)
if (frame.type === 'harness.event') console.log(frame.cursor, frame.event.type)
if (frame.type === 'harness.error') console.warn(frame.code, frame.message)
})The socket closes with these codes:
| Code | Why | What the client does |
|---|---|---|
| 4401 | getUser refused the user. | Sign in again. |
| 4403 | The user cannot read the thread, or lost access. | Stop. |
| 4400 | The server does not speak the v of the subscribe. | Stop. Use a client of the version of the server. |
| 4408 | The client read too slowly. See Stream values. | Subscribe again from the last cursor. |
| 1011 | The event feed of the thread ended. | Subscribe again from the last cursor. |
runHarnessWorker reads frames from stdin and writes frames to stdout, one JSON object per line. Put it in its own script:
import { defineHarness } from '@tanstack/ai-harness'
import { runHarnessWorker } from '@tanstack/ai-harness/worker'
import { openaiText } from '@tanstack/ai-openai'
const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
})
await runHarnessWorker(assistant)Start the script as a child process, then write frames to it:
import { spawn } from 'node:child_process'
import { createInterface } from 'node:readline'
import type { ControlFrame } from '@tanstack/ai-harness/server'
const worker = spawn('node', ['worker.js'], { stdio: ['pipe', 'pipe', 'inherit'] })
const send = (frame: ControlFrame) => worker.stdin.write(`${JSON.stringify(frame)}\n`)
send({ type: 'harness.subscribe', threadId: 'thread-1', v: 2 })
send({ type: 'harness.input', requestId: 'req-1', input: { op: 'prompt', message: 'Hello' } })
for await (const line of createInterface({ input: worker.stdout })) {
console.log(line)
}To ship a harness as a worker with a checked bundle, see Deploy a harness.
Each frame is one JSON object with a type. Ignore a frame with a type that you do not know. A later version of the server can add frames.
From the client to the host (socket and stdio worker):
| type | Fields | What it does |
|---|---|---|
| harness.subscribe | threadId, from?, v? | The first frame. Starts the event stream after the cursor from. |
| harness.input | requestId, input, traceparent? | Sends one input. input is the same object as the input of POST control. |
| harness.snapshot | none | Asks for the snapshot of the session. |
input is one of the ops of Send inputs to a session, for example { op: 'prompt', message } or { op: 'cancel', operationId }. Pick a new requestId for each input. The receipt and any error frame repeat it.
From the host to the client:
| type | Fields | When |
|---|---|---|
| harness.hello | v, threadId | First, after a subscribe that the host accepts. On SSE, the first event of GET events. |
| harness.receipt | requestId, status, inputId?, operationId?, reason? | The answer to one harness.input. |
| harness.event | cursor, threadId, operationId, event | One event of the session. |
| harness.snapshot | snapshot | The answer to a harness.snapshot frame. |
| harness.error | message, code, requestId | Something failed. See Errors. |
| harness.ping | none | A heartbeat on the socket. Ignore it. |
The stream of a thread has the events of every run of its tree, in one order. To rebuild the tree, keep the parentRunId of the RUN_STARTED of each operationId. For example, a lead agent that runs two helpers in a group gives these frames (the event is cut):
{"type":"harness.event","cursor":"9","threadId":"thread-1","operationId":"op-lead","event":{"type":"RUN_STARTED","runId":"op-lead",...}}
{"type":"harness.event","cursor":"12","threadId":"thread-1","operationId":"op-a","event":{"type":"RUN_STARTED","runId":"op-a","parentRunId":"op-lead",...}}
{"type":"harness.event","cursor":"13","threadId":"thread-1","operationId":"op-b","event":{"type":"RUN_STARTED","runId":"op-b","parentRunId":"op-lead",...}}op-a and op-b are children of op-lead.
The status of a receipt is accepted, queued, or rejected. A rejected receipt has a reason, for example not_exposed, not_running, or unknown_agent.
Give each input an inputId, and send the same id again after a lost answer. A prompt, command, agent, or append input with the same inputId runs once. The inputId of its receipt is the id that you sent.
The socket handles the frames of one connection in the order they came.
Each harness.event has a cursor. On SSE, the id of each event is the same cursor. A cursor is an opaque string. Store the last one, and do not parse it.
To continue after a disconnect, connect again with the last cursor:
curl -N -H 'authorization: Bearer my-token' -H 'Last-Event-ID: 4' \
'https://example.com/api/harness/events?threadId=thread-1&v=2'The stream starts with the first event after that cursor. A disconnect does not stop a running turn. To stop a turn, send a cancel input.
A cursor works on every server and after a restart only on a durable host (stores.log). See Cursors.
Sometimes the server no longer has the events after your cursor. A truncate of the log removes old records, for example. Then the stream sends one CUSTOM event with the name harness.gap, and continues from the first event that it has:
{
"type": "harness.event",
"cursor": "120",
"threadId": "thread-1",
"operationId": "session",
"event": {
"type": "CUSTOM",
"name": "harness.gap",
"value": { "from": "12", "resumeAt": "120", "reason": "truncated" }
}
}When you get harness.gap, do these steps:
A session view does these steps for you.
The protocol version is 2.
The harness.hello frame gives the version that the server uses. If the server does not speak your v, it sends one error frame and closes the stream:
{
"type": "harness.error",
"message": "Protocol version 3 is not supported. This host speaks version 2.",
"code": "unsupported_version",
"requestId": "req-1"
}The server speaks one version. Use a client of that version.
A harness.error frame has a code and a requestId. message is for people, so read code in your code.
| code | Why | Does the stream close? |
|---|---|---|
| unauthorized | getUser refused the user. | Yes. |
| forbidden | The user cannot read the thread, or lost access. On the socket, also when the host refuses one input. | Yes. When the host refuses one input, no. |
| invalid_frame | The frame is not valid JSON, has an unknown type or a bad input, comes before harness.subscribe, or is a second harness.subscribe. | No. |
| unsupported_version | The server does not speak the v that you sent. | Yes. |
| slow_consumer | The client read too slowly. | Yes. |
| internal | The server failed to read the events, or failed to apply one input. | When the read failed, yes: connect again from the last cursor. When one input failed, no. |
On HTTP, a refused login or action before the stream starts is the 401 or 403 status of the request, with no frame.
createHarnessClient reads these frames for you. After slow_consumer, events() and hostEvents() connect again. Any other code ends the read with a HarnessProtocolError, which has the code and the message.
The requestId connects the error to its request:
Log the requestId with your errors. Then you can find the request in the server logs.
A long stream passes through proxies and slow networks. The server keeps it open and protects itself with these values:
| Value | Option | Default | What happens |
|---|---|---|---|
| Heartbeat | heartbeatMs | 20 s | SSE gets a : ping comment. The socket gets {"type":"harness.ping"}. |
| SSE queue | sseQueueBytes | 1 MiB | The server stops reading the log while this much data waits for the client. |
| Socket buffer | socketBufferBytes | 8 MiB | Above this bufferedAmount, the server stops sending. It checks again every 50 ms. |
| Slow close | slowConsumerMs | 30 s | If the client does not read for this long, the server sends slow_consumer and closes. |
To change a value, pass stream to the handler and to the socket. A value that you do not set keeps its default:
// A proxy that closes idle streams after 15 s, and mobile clients that read slowly.
const stream = { heartbeatMs: 10_000, slowConsumerMs: 60_000 }
export const slowNetworkHandler = createHarnessHandler({ host, harness: assistant, stream })
export function onSlowNetworkUpgrade(socket: WebSocketLike, request: Request) {
handleHarnessSocket({ host, harness: assistant, socket, request, stream })
}Each value must be a finite number above 0. If a value is not, createHarnessHandler and handleHarnessSocket throw at the start, and the error names the field.
To join a turn to the trace of your app, send a W3C traceparent:
const response = await fetch('/api/harness/control', {
method: 'POST',
headers: {
'content-type': 'application/json',
traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
},
body: JSON.stringify({
threadId: 'thread-1',
input: { op: 'prompt', message: 'Hello' },
}),
})
console.log(response.headers.get('X-Request-Id'))What the harness does with it:
To give the host a tracer, see Trace runs and report operations.
A harness adds its own events to the stream as AG-UI CUSTOM events. name is the event name, and value has the fields below. Ignore a name that you do not know. A later version can add names.
In TypeScript, HarnessEventValues from @tanstack/ai-harness maps each name to its value type, and HARNESS_EVENTS has the names. A browser client imports HARNESS_EVENTS from @tanstack/ai-harness/client. harness.plugin.warning is the only name that is in neither.
| name | value |
|---|---|
| harness.operation.started | { operationId, kind, agent? }. kind is chat, agent, command, or compact. |
| harness.operation.finished | { operationId, status }. status is completed, failed, cancelled, or interrupted. Before it, a cancelled turn ends its open text message and sends RUN_FINISHED with outcome: { type: 'cancelled' }. A turn past its time limit sends RUN_ERROR with code: 'timeout'. |
| harness.operation.resumed | { operationId, resumedFrom? }. A host continued a run after another host stopped. |
| harness.turn.retry | { operationId, retries, error: { message, code?, retryAfterMs? }, continued }. continued is true when the model continues a partial answer. |
| harness.handoff | { from, to, note?, runId, by, waitId? }. from and to are an agent name, null for the main model, or { human: true }. by is tool, code, router, or operator. A handoff to a person has the waitId of its wait. |
| harness.usage | { model, sender?, source?, label?, usage, total }. usage is this model call, and total is the new total of the thread. source is plugin:<name>, router, or transfer. A turn or an agent run has no source. |
| name | value |
|---|---|
| harness.input.accepted | { inputId, op, message? }. message is the user message of a prompt, steer, or followUp, without the secrets of the session. |
| harness.input.queued | { inputId, delivery }. The owner of the log stored the input, and it waits behind the running turn or the turns before it. delivery is steer or queue. |
| harness.input.applied | { inputId, operationId } |
| harness.input.rejected | { inputId, reason } |
| harness.input.settled | { inputId, operationId?, outcome, error? }. outcome is completed, aborted, interrupted, or failed. Only failed has error: { message, code? }. |
| harness.input.delivery | { inputId, delivery }. A setDelivery input moved a waiting input. |
| name | value |
|---|---|
| harness.wait.opened | { waitId, kind, runId, message?, expiresAt? }. A wait opened in the log: a question, a step.ask, a handoff to a person, or a wait of a background agent run. kind is approval, question, sign-in, client-tool, or human. expiresAt is in epoch milliseconds. The interrupts of a turn come in its RUN_FINISHED. |
| harness.wait.settled | { waitId, outcome }. outcome is answered, cancelled, or expired. |
| harness.question | { questionId, message, schema?, secret?, url? }. Answer it with the answer input. |
| harness.question.answered | { questionId } |
| harness.auth_required | { connector, url?, userCode? }. A tool needs a sign-in. |
When you get one of these events, load the part of the session that it names again.
| name | value |
|---|---|
| harness.config.changed | { key, value } |
| harness.settings.changed | { settings } |
| harness.commands.changed | {}. Load GET describe again. |
| harness.reloaded | { error? }. The plugins started again. Load GET describe again. |
| harness.reset | { inputId, note? }. The next turn starts a fresh model context. |
| harness.revert | { messageId }. messageId is null when no revert stands. Load GET transcript again. |
| name | value |
|---|---|
| harness.media | A media record: { id, threadId, kind, mimeType, name, size, createdAt, source }. See Send and show media. |
| harness.plugin.event | { plugin, name, value }. An event that a plugin sent. |
| harness.plugin.warning | { plugin, message }. A plugin hook, a listener, or a write that no one waits for failed. The work goes on. |
| harness.ownership.changed | { epoch, reason }. A new host owns the log, from its first write. reason is taken_over when the ownership of the old owner expired with no release (it stopped), else acquired. |
| harness.stranded | { operationId?, hostId? }. The workspace that the log binds the thread to is gone, so the workspace tools fail. operationId is the run that tried it. hostId is the host of a folder. |
| harness.transferred | { transferId, from?, to? }. from and to are { threadId }. The conversation came from, or went to, another thread. |
| harness.gap | { from, resumeAt, reason }. See When events are missing. |
Next: send your first prompt with the two curl commands at the top of this page, and watch the events arrive.