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 the harness and the TanStack Store adapter for your UI library. With no UI library, the harness is enough.
npm i @tanstack/ai-harness @tanstack/react-storepnpm add @tanstack/ai-harness @tanstack/react-storeyarn add @tanstack/ai-harness @tanstack/react-storebun add @tanstack/ai-harness @tanstack/react-storeThe terminal screen on this page uses Ink, which needs Node 22 or later. Install Ink and React too:
npm i ink reactpnpm add ink reactyarn add ink reactbun add ink reactOpen a session, then give it to createSessionView:
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.readyview.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.
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:
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 (
<Box flexDirection="column">
{messages.map((message) =>
message.role === 'assistant' ? (
<Text key={message.id}>
{message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')}
</Text>
) : (
<Text key={message.id} color="gray">
{message.role === 'user' ? `> ${message.text}` : message.text}
</Text>
),
)}
<Text color="gray">{status}</Text>
</Box>
)
}
render(<Screen view={view} />)
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.
An Ink screen with approvals, questions, sign-ins, and child agents is in examples/harness-cli/src/tui.tsx. It runs from runCli({ ui }).
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:
const subscription = view.store.subscribe((state) => {
console.log(`${state.status}: ${state.messages.length} messages`)
})Call subscription.unsubscribe() to stop.
The conversation:
What waits for the user. Each item is a wait of the session:
waits lists every open wait in one array, as snapshot().pendingWaits does, with the kind of each one. Use it for a badge or a count. Answer each wait through its item in the lists above.
The session:
Settings and usage, for a settings panel or a status bar:
In a harness with routing, an assistant message also has agent: the name of the root agent that wrote it, or undefined for the main model. After a handoff, the next agent writes a new message in the same turn.
An assistant message has parts. Each part is one of these:
A notice has a kind:
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. With { as: 'operator' }, it sends the reply of an operator, and no model runs.
view.followUp(text): the message waits, and runs as its own turn after the current work.
view.continueWithoutMessage(): starts a turn from the saved messages, with no new message.
view.runCommand({ name, input }): runs a command, for example from a button.
view.putMedia({ body, mimeType, name }): stores a file. Send the record you get back with view.send(text, [record]). See Show media. Change the session:
view.setPluginSetting({ key, value }): changes a config value, for example view.setPluginSetting({ key: 'mode', value: 'plan' }).
view.updateThreadSettings({ settings }): changes the stored settings of the thread, for example view.updateThreadSettings({ settings: { model: 'strong' } }).
view.reset({ note }): the model starts fresh from the next turn, with your note first. See Reset with a handoff note.
view.revert({ messageId }) and view.unrevert(): go back to a message, and undo that.
Stop work:
On a view with a HarnessClient, two actions need expose in the harness:
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.
Answer what waits:
Want the tool to run with different arguments than the model picked? Pass them to approve:
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.
An approval of permissions() reads payload as the answer of the user:
The user types once, always, or a message for the model. This function sends the answer:
import type { Approval } from '@tanstack/ai-harness/view'
function answerPermission(approval: Approval, reply: string) {
if (reply === 'once') approval.approve()
else if (reply === 'always') approval.approve({ payload: 'always' })
else approval.reject({ payload: reply })
}If a turn waits for more than one approval, client tool, or interrupt, the turn continues after you answer all of them.
A background agent run can wait too, for example for an approval of permissions(). Its waits are in the same approvals, clientTools, and interrupts lists. The view sends the answer to each of them at once, and the run continues. See When a background agent waits.
Message a background agent:
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. You can also give createSessionView your own SessionViewSource. Its methods take the same arguments as the session methods, for example resolve({ resume }) and cancel({ operationId }). send takes mode for a steer or a follow-up, and to for a message to an agent run.
A source must have these members:
These members are optional:
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:
view.on('approval', (approval) => {
process.stdout.write('\u0007')
view.notice({ text: `${approval.tool} waits for your answer.` })
})Items that wait for the user:
Progress:
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.
The view also keeps its state right on its own:
A command, a durable tool, or an agent run can ask the user with step.ask. An MCP server can ask during a tool call. Each item of state.questions has:
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:
view.on('question', (question) => {
if (question.url) view.notice({ text: `Open ${question.url}, then answer.` })
})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:
{
"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:
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function fieldsThatApply(schema: unknown, answer: Record<string, unknown>) {
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:
const [question] = view.store.get().questions
const extra = fieldsThatApply(question?.schema, { delivery: 'mail' })
// With the schema above, `extra` is ['address'].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: the waits of kind question (for example a call of the question tool) and human. Each item has:
This handler shows each one, and answers the region picker right away:
view.on('interrupt', (request) => {
view.notice({ text: request.message ?? `The turn waits for ${request.reason}.` })
if (request.reason === 'pick_region') request.resolve({ region: 'eu-west-1' })
})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 <img>, <audio>, and <video>.
The media parts are in these places:
This component shows the parts of a message, with the parts of each child agent:
import type { MediaPart, ViewPart } from '@tanstack/ai-harness/view'
function Media({ part }: { part: MediaPart }) {
if (part.url === undefined) return <span>{part.name}</span>
if (part.kind === 'image') return <img src={part.url} alt={part.name} />
if (part.kind === 'audio') return <audio controls src={part.url} />
if (part.kind === 'video') return <video controls src={part.url} />
return (
<a href={part.url} download={part.name}>
{part.name}
</a>
)
}
export function Parts({ parts }: { parts: Array<ViewPart> }) {
return (
<>
{parts.map((part, index) => {
if (part.type === 'text') return <p key={index}>{part.text}</p>
if (part.type === 'media') return <Media key={part.id} part={part} />
if (part.type === 'agent') return <Parts key={part.id} parts={part.parts} />
return null
})}
</>
)
}A media part has:
Where url comes from:
To send files with a message, give their records to view.send. This code sends a screenshot from the disk:
import { readFile } from 'node:fs/promises'
const screenshot = await session.putMedia({
body: await readFile('./bug.png'),
mimeType: 'image/png',
name: 'bug.png',
})
await view.send('What is wrong in this screenshot?', [screenshot])In a browser, get the record from client.putMedia({ body: file, mimeType: file.type, name: file.name }). The user message then shows the file in media.
A child agent is an agent part in the assistant message of the turn that started it. The component in Show media already draws one, with its own parts inside. On top of parts, each agent part has:
After a reload, each saved child shows only its card: name, status, error, and result, with no parts. To show what the child wrote, call view.loadAgent({ id }), for example when the user opens the child. The view reads the saved messages of the child (see Show what a child wrote after a reload) and fills its part:
const last = view.store.get().messages.at(-1)
const child =
last?.role === 'assistant'
? last.parts.find((part) => part.type === 'agent')
: undefined
if (child?.type === 'agent') await view.loadAgent({ id: child.id })A nested child in that part loads the same way, with its own loadAgent call. A background child that still runs keeps getting its new text in the same part.
A terminal screen that prints the old turns only one time (for example Ink Static) must load the saved children before its first render. examples/harness-cli/src/tui.tsx does this in loadSavedAgents.
To show a line when a child ends, listen for 'agent':
view.on('agent', (child) => {
if (child.status !== 'running') view.notice({ text: `${child.name} is ${child.status}.` })
})Plugins show their work through state and events. state.plugins holds the saved state of each plugin, by plugin name.
view.on takes any plugin event from createPluginEvent, and the handler gets its typed value:
import { createPluginEvent } from '@tanstack/ai-harness'
const Deployed = createPluginEvent<{ env: string }>('acme/deploy:done')
view.on(Deployed, (done) => view.notice({ text: `Deployed to ${done.env}` }))state.plugins is plugin data, so check its shape before you use it.
The agent changes several files in one turn, and your user wants to see each change as it happens. The file tools of workspaceTools() send a FileChanged event for each file that they change, with a unified diff. This handler prints each diff in color:
import { FileChanged } from '@tanstack/ai-harness/plugins/coding'
function colorLine(line: string) {
if (line.startsWith('+++') || line.startsWith('---')) return line
if (line.startsWith('+')) return `\u001b[32m${line}\u001b[0m`
if (line.startsWith('-')) return `\u001b[31m${line}\u001b[0m`
return line
}
view.on(FileChanged, (file) => {
console.log(`${file.change} ${file.path}`)
if (file.patch) console.log(file.patch.split('\n').map(colorLine).join('\n'))
})When the agent edits a file, the terminal shows updated /work/repo/src/app.ts, then the diff. Added lines are green, and deleted lines are red.
Each event has:
When the events come:
An event has no patch, and 0 for both counts, in these cases:
Like every plugin event, each FileChanged goes into the session log, with its patch.
@tanstack/ai-harness/plugins/coding runs only in Node. In a browser, import only the FileChange type from it. Then make the event from its name:
import { createPluginEvent } from '@tanstack/ai-harness'
import type { FileChange } from '@tanstack/ai-harness/plugins/coding'
export const FileChanged = createPluginEvent<FileChange>('tanstack/workspace-tools:file-changed')Your harness runs on a server, and your UI is a web page. Give createSessionView a HarnessClient in place of a session. The state, the actions, and the events stay the same.
Serve the session with createHarnessHandler, and mount the handler on /api/harness:
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 })membersOnly() lets each user use the threads that they opened, and the threads shared with them. See Access and tenants.
The handler also serves GET transcript and GET describe. The view reads them when it starts. For the other routes, see Connect clients.
GET transcript, GET snapshot, GET events, and the other read paths read the thread without a session, so they start no plugins on the server. GET describe opens the session, because the plugins give the commands and the settings. To read a thread in a route of your own, use host.observe. See Read a thread without a session.
Make the view one time, in code that runs in the browser. Then read it in your components:
import { useState } from 'react'
import { useSelector } from '@tanstack/react-store'
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',
headers: { authorization: 'Bearer my-token' },
}),
)
export function Chat() {
const messages = useSelector(view.store, (state) => state.messages)
const approvals = useSelector(view.store, (state) => state.approvals)
const connection = useSelector(view.store, (state) => state.connection)
const [draft, setDraft] = useState('')
return (
<main>
<p role="status">{connection === 'reconnecting' ? 'Reconnecting...' : ''}</p>
{messages.map((message) => (
<p key={message.id}>
{message.role === 'assistant'
? message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')
: message.text}
</p>
))}
{approvals.map((approval) => (
<p key={approval.id}>
Run {approval.tool}?{' '}
<button type="button" onClick={approval.approve}>
Yes
</button>
<button type="button" onClick={approval.reject}>
No
</button>
</p>
))}
<form
onSubmit={(event) => {
event.preventDefault()
void view.send(draft)
setDraft('')
}}
>
<input aria-label="Message" value={draft} onChange={(event) => setDraft(event.target.value)} />
<button type="submit">Send</button>
</form>
</main>
)
}connection tells the user about the network:
Some tools must run where the user is, for example a tool that opens a screen of your app. The server cannot run them. Give the harness the tool with no .server() implementation. The turn then waits, and the view lists the call in clientTools. Your page runs the tool and sends the output.
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
export const openScreenInput = z.object({ route: z.string() })
export const openScreen = toolDefinition({
name: 'openScreen',
description: 'Open a screen of the app for the user.',
inputSchema: openScreenInput,
outputSchema: z.object({ opened: z.boolean() }),
})import { defineHarness } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'
import { openScreen } from './tools'
export const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
tools: [openScreen],
})import { createHarnessClient } from '@tanstack/ai-harness/client'
import { createSessionView } from '@tanstack/ai-harness/view'
import { openScreenInput } from './tools'
const view = createSessionView(
createHarnessClient({ url: '/api/harness', threadId: 'user-1-thread' }),
)
view.on('clientTool', (call) => {
if (call.tool !== 'openScreen') return
const input = openScreenInput.safeParse(call.args)
if (!input.success) {
call.fail('The route is not a string.')
return
}
history.pushState({}, '', input.data.route)
call.resolve({ opened: true })
})A client tool call has:
A turn can wait for approvals and client tools at the same time. When each item has an answer, the view sends all the answers together. Then the turn continues. To show the calls in a component, read state.clientTools with useSelector, the same as approvals.