- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- Build your first harnessUpdated
- Host optionsNew
- Send and show mediaUpdated
- Connect clientsUpdated
- Write a client in any languageNew
- Access and tenantsNew
- Run in the terminalUpdated
- Deploy a harnessUpdated
- Send inputs safelyUpdated
- Run a tool without a model
- Durable sessionsUpdated
- Run background work on workersNew
- List, rename, and fork sessionsUpdated
- Lifecycle and retentionNew
- Run side effects onceUpdated
- Wait for a personNew
- Build on the session logUpdated
- Control how a turn endsUpdated
- Store settings per threadUpdated
- Compact a long sessionUpdated
- Fork and reset a threadUpdated
- One log for a session treeUpdated
- Write a pluginUpdated
- Track usage and costUpdated
- Ask before risky tool callsUpdated
- Give your agent coding toolsUpdated
- Sandbox and extend the coding toolsUpdated
- Run the coding tools for many threadsNew
- Undo a turnUpdated
- Build a coding agentUpdated
- Delegate to coding agentsUpdated
- Switch between agent profilesUpdated
- Auth and connectorsUpdated
- Share a thread between peopleUpdated
- Connect model providersUpdated
- Build your own UIUpdated
- Code mode in a harnessUpdated
- Work until a goal is metUpdated
- Run agents from a harnessUpdated
- Hand off a conversationNew
- Add skillsUpdated
- Use MCP serversUpdated
- Give your agent a computerNew
- Use from any MCP clientUpdated
- Use a harness from an editor (ACP)Updated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Keep Keys on the ServerNew
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
Write a harness plugin
You built a feature for one harness, a todo list or a GitHub integration, and now you want it in every harness you ship. A plugin packages it: tools and prompts for the model, commands for the user, settings, and state that survives restarts.
The smallest plugin#
import { definePlugin } from '@tanstack/ai-harness'
export const today = definePlugin({
name: 'acme/today',
setup: () => ({ prompts: [`Today is ${new Date().toDateString()}.`] }),
})Add it with plugins: () => [today] in defineHarness. setup runs once per session. To share one setup between all the sessions of a host, see Share one setup between sessions.
What setup can return#
For the model:
- tools: tools for the model, made with toolDefinition.
- prompts: text for the system prompt. A function runs for each turn, so it can show current state. For text that changes often, see Put changing text last.
- middleware: chat middleware for the lead turn, the same type as chat({ middleware }). agentMiddleware is for agent runs, see Middleware in every agent.
- generationMiddleware: middleware for the activities agents call.
- adapter: a picker for the model of each turn, see Pick the model of a turn.
For agents and users:
- agents: agents added to session.agents. routing.router can send a turn to them too, see Route a turn to an agent.
- subagents: agents the model can call as tools. They are also added to session.agents.
- commands: user actions, see below.
- config: session settings, see below.
- contribute: items for another plugin's extension point.
If two plugins add the same tool, command, setting, or prompt id, host.open fails and names both plugins.
Return these from setup, not from the top level of definePlugin. A key like adapter or tools at the top level is a type error, and definePlugin throws for it. Other keys at the top level stay on the plugin object, so a plugin can give your code its own functions.
Put changing text last#
Set placement: 'context' on a prompt whose text changes often:
let openTickets = 0
export const tickets = definePlugin({
name: 'acme/tickets',
setup: () => ({
prompts: [
{
id: 'acme/tickets:count',
text: () => `Open tickets: ${openTickets}.`,
placement: 'context',
},
],
}),
})A provider caches the start of each request: the system prompts, then the history. A change of a system prompt makes the cache miss from that prompt on. A 'context' prompt is the last system prompt, so the system prompts before it stay in the cache.
- The model gets all 'context' text as one system prompt, after the other system prompts. The harness makes it again at each model call.
- The text has the authority of a system prompt, not of a user message.
- The default is 'system': the text goes in the system prompts, in plugin order.
- The log does not keep 'context' text.
- The first-party todos, goal, and plan mode prompts use 'context'.
Refresh state at the start of a turn#
A prompt function must return at once, so it cannot read a file or call a server. Do that work in onTurnStart. The harness calls it once at the start of each turn, before it reads the prompts:
import { readFile } from 'node:fs/promises'
let rules = ''
export const projectRules = definePlugin({
name: 'acme/project-rules',
setup: () => ({
onTurnStart: async () => {
rules = await readFile('RULES.md', 'utf8')
},
prompts: [() => rules],
}),
})- onTurnStart gets the turn. turn.run has the user of the turn and the credentials of that user.
- It runs one time for each turn, also when the turn makes many model calls.
- If it throws, the turn continues, and clients get a harness.plugin.warning event.
Add commands and settings#
Commands are actions for the user, not the model: slash commands and buttons in your app. Settings are typed, saved per session, and apply at the next turn.
import { configOption, defineCommand } from '@tanstack/ai-harness'
import { z } from 'zod'
export const greeter = definePlugin({
name: 'acme/greeter',
setup: (ctx) => ({
config: {
tone: configOption.select({ options: ['plain', 'warm'], default: 'plain' }),
},
commands: {
greet: defineCommand({
description: 'Greet someone',
input: z.object({ name: z.string() }),
run: async ({ name }, command) => {
const sure = await command.step.ask({
name: 'confirm',
message: `Greet ${name}?`,
schema: z.boolean(),
})
if (!sure) return 'Skipped.'
return ctx.config.get('tone') === 'warm' ? `Hello, dear ${name}!` : `Hello, ${name}.`
},
}),
},
}),
})- session.runCommand({ name: 'greet', input: { name: 'Ada' } }) runs the command. A Standard Schema input (zod here) is checked, and the command gets the parsed value. A JSON Schema input is not checked, the same as a JSON Schema tool input.
- session.setPluginSetting({ key: 'tone', value: 'warm' }) changes the setting. A value the option does not accept is rejected.
- step.ask waits for session.answer({ questionId, value }). The log keeps the question as a wait, so a user on any server can answer it. The question goes to session.onQuestion listeners, and it stays in session.snapshot().pendingWaits until someone answers.
- When the host stops while the command waits, the next host runs the command again from the top. Put each side effect before the ask in command.step.do, so it does not run twice.
- ctx.session.ask is only for an MCP server that asks during a tool call. Anywhere else it throws. Use step.ask, the question tool, or a permission rule.
Change commands while the session runs#
Some commands come from data that changes: a folder of prompts, saved shortcuts, a list from a server. ctx.commands adds and removes them while the session runs. Here /macro saves a new command that sends a message:
export const macros = definePlugin({
name: 'acme/macros',
setup: (ctx) => ({
commands: {
macro: defineCommand({
description: 'Save a command that sends a message: /macro <name> <message>',
run: (input: unknown) => {
const [name = '', ...words] = String(input ?? '').trim().split(' ')
if (ctx.commands.has(name)) return `/${name} is taken.`
ctx.commands.set(
name,
defineCommand({
description: `Send: ${words.join(' ')}`,
run: () => {
void ctx.session.send(words.join(' '))
},
}),
)
return `Saved /${name}.`
},
}),
},
}),
})- ctx.commands.set(name, command) adds a command of this plugin, or replaces it. A name that another plugin owns throws.
- ctx.commands.delete(name) removes a command of this plugin. Another plugin's name does nothing.
- Each change sends a harness.commands.changed event, so a client can read session.commands() again. A session view reads the new list by itself.
- During setup, wait for ctx.commands.ready before you call has or set. The plugins after this one add their commands later, so a name can look free and still be taken.
export const greetings = definePlugin({
name: 'acme/greetings',
setup: (ctx) => {
void ctx.commands.ready.then(() => {
if (!ctx.commands.has('hi')) {
ctx.commands.set('hi', defineCommand({ description: 'Say hi', run: () => 'Hi!' }))
}
})
},
})The skills plugin works this way: one command for each skill in a folder.
Change agents while the session runs#
Agents can come from data that changes too. ctx.agents.set and ctx.agents.delete work like ctx.commands. Here /helper adds an agent that the session can run at once:
import { defineAgent } from '@tanstack/ai'
export const helpers = definePlugin({
name: 'acme/helpers',
setup: (ctx) => ({
commands: {
helper: defineCommand({
description: 'Add an agent: /helper <name>',
run: (input: unknown) => {
const name = String(input ?? '').trim()
ctx.agents.set(
defineAgent({ name, description: `The ${name} agent`, run: async () => 'Done.' }),
)
return `Added the ${name} agent.`
},
}),
},
}),
})- ctx.agents.set(agent) adds an agent of this plugin, or replaces the one with the same name. A name that another plugin or the harness owns throws.
- ctx.agents.set(agent, { subagent: true }) also gives the model a tool that runs the agent as a subagent, from the next turn. The tool has the name of the agent.
- ctx.agents.delete(name) removes an agent of this plugin, and its subagent tool. Another owner's name does nothing.
- session.agent(name), ctx.agents.list(), and routing see the change at once.
ctx.agents also runs agents: start, run, and group, with g.start for each child of a group. It has the same type, AgentActions, as ctx.harness.agents in agent code. See Run agents from a plugin.
Keep state#
ctx.state(initial) gives a plugin its own state for the thread. The session log keeps each change as a record, so any host that opens the thread reads the state back, also after a restart. Clients see each change as an AG-UI STATE_SNAPSHOT event.
export const counter = definePlugin({
name: 'acme/counter',
setup: (ctx) => {
const state = ctx.state({ count: 0 })
return {
commands: {
bump: defineCommand({
description: 'Add one',
run: async () => (await state.update((current) => ({ count: current.count + 1 }))).count,
}),
},
}
},
})- The state is JSON.
- A key or a token of the session in the state goes into the log as [redacted]. update resolves with that same value. See Keep tokens out of the log and the model.
- The updates of one plugin run one at a time. Each update function gets the value that the update before it wrote.
- host.sessions.delete deletes the state with the log. A fork does not copy it.
- For a long list of records, such as one record for each step, append records to the log and set keepRecords. Read them with ctx.session.keptRecords(type). See Keep records after a truncate.
- Agents get a state with the same get and update from ctx.harness.state({ key, initial }). See Share state between agents.
Store a file from a tool#
A tool of your plugin makes an image or a PDF. Inline bytes in a tool result stay in the transcript and the log, and each later model call sends them again. Store the bytes with ctx.session.putMedia, and return mediaPart(record):
import { mediaPart } from '@tanstack/ai-harness/media'
declare function drawChart(values: Array<number>): Promise<Uint8Array>
export const charts = definePlugin({
name: 'acme/charts',
setup: (ctx) => ({
tools: [
toolDefinition({
name: 'draw_chart',
description: 'Draw a bar chart of the values',
inputSchema: z.object({ values: z.array(z.number()) }),
}).server(async ({ values }) => {
const record = await ctx.session.putMedia({
body: await drawChart(values),
mimeType: 'image/png',
name: 'chart.png',
})
return [mediaPart(record)]
}),
],
}),
})- The transcript and the log keep a small harness-media: reference. The model gets the bytes.
- putMedia throws a MediaError when the file is bigger than media.maxBytes, or when its kind is not in media.kinds. See Limit files, and choose what the model reads.
Give your agent a computer stores each screenshot this way.
Middleware in every agent#
You want to track token cost, or apply a policy, for every model call. But middleware runs only in the lead turn. The agents that the lead model calls, the agents you start in the background, and their own children each run a separate chat. Put the same middleware in agentMiddleware too. Then it runs in each of those chats.
import type { ChatMiddleware } from '@tanstack/ai'
export const usageByAgent = definePlugin({
name: 'acme/usage-by-agent',
setup: () => {
const tokens = new Map<string, number>()
const tracker: ChatMiddleware = {
name: 'acme/usage-by-agent',
onUsage: (ctx, usage) => {
const agent = ctx.subagentName ?? 'lead'
tokens.set(agent, (tokens.get(agent) ?? 0) + usage.totalTokens)
},
}
return {
middleware: [tracker],
agentMiddleware: [tracker],
commands: {
tokens: defineCommand({
description: 'Show the tokens of each agent',
run: () => Object.fromEntries(tokens),
}),
},
}
},
})- ctx.subagentName is the name of the agent. In the lead turn, it is undefined.
- ctx.subagentRunId is different for each run of an agent. For a nested child, ctx.parentSubagentRunId is the id of the child that started it.
- Each model call goes to onUsage one time, in the run that made the call. The lead turn does not count the usage of a child again.
- A background run that a turn starts keeps the plugins of that turn. So the agentMiddleware of a plugin with lifetime: 'turn' runs in it too. The turn plugins are cleaned up when the turn and its runs have settled.
- A tool result that your onAfterToolCall replaces is the result that the session log keeps. After a crash, the model gets that result again. See The other tools of a cut batch.
Act as the user of a run#
Several people can write in one thread, and a background agent can run after its turn ended. A tool must use the GitHub token of the user who started its own run, not of the user who sent the latest message. Each run carries its user, and that user's credentials and keys, as the run context:
import { toolDefinition } from '@tanstack/ai'
import type { HarnessRunContext } from '@tanstack/ai-harness'
export const github = definePlugin({
name: 'acme/github',
setup: () => ({
tools: [
toolDefinition({ name: 'my_repos', description: 'List my GitHub repos' }).server<HarnessRunContext>(
async (_args, ctx) => {
const credential = await ctx.context.run.credentials.require('github')
const token = credential.type === 'oauth' ? credential.accessToken : credential.value
const response = await fetch('https://api.github.com/user/repos', {
headers: { authorization: `Bearer ${token}` },
})
return response.json()
},
),
],
}),
})Where each part of a plugin reads the run:
- A tool or a middleware hook: ctx.context.run. Type it with HarnessRunContext, for example .server<HarnessRunContext>(...) or ChatMiddleware<HarnessRunContext>.
- A command: run of the command context. Its credentials is the same as run.credentials.
- A plugin with lifetime: 'turn': ctx.turn.run. Its ctx.credentials and ctx.keys belong to the same user.
- A session plugin (the default lifetime): ctx.credentials and ctx.keys have no user, so each call throws. ctx.session.user is the owner of the session.
- A plugin with lifetime: 'host': its setup has no user and no session. Its tools read the user of each call from ctx.context.run.
prepareTools gets run too: the run of the turn, with its id and its user. onTurnStart gets the turn, with its run in turn.run.
A plugin that starts a turn picks its user. ctx.session.send(text, { user }) and ctx.session.note(text, { wake: true, user }) run that turn as user. Without it, the turn runs as ctx.session.user. To continue the work of a run, pass the user of that run.
The run has these fields:
| Field | What it is |
|---|---|
| user | The user who started the run: { id, tenantId }. undefined in a session with no user. |
| tenantId | The tenant of the session. undefined in the default tenant. |
| threadId, runId, parentRunId | The thread, this run, and the run that started this one. |
| logId | The id of the log of the session tree in the store, with its tenant. |
| epoch | The ownership epoch of the log when the run started. See Fence your own side effects. |
| idempotencyKey(step, scope?) | A key for one side effect of the run. A run again on any host gets the same key. |
| signal | Aborts when the run stops. |
| source, label | Who made the model call: 'turn', or the source and the label of a ctx.model.chat call. See Tell plugin calls from turn calls in middleware. |
| credentials, keys | The credentials and the model provider keys of the user of the run. The session replaces their values with [redacted] in what it keeps and sends. To refresh an OAuth token, use credentials.refresh. See Auth and connectors. |
Call a model from a plugin#
Your plugin needs a model call of its own: a judge, a title, or a summary. A plain chat() call does not count in session.usage(), and it keeps running after the user cancels the turn. Call the model with ctx.model.chat instead:
export const recap = definePlugin({
name: 'acme/recap',
setup: (ctx) => ({
commands: {
recap: defineCommand({
description: 'Sum up the conversation in three lines',
run: async (_input, { run }) => {
const transcript = await ctx.session.transcript()
const { text } = await ctx.model.chat({
run,
label: 'recap',
options: {
messages: [...transcript, { role: 'user', content: 'Sum up this conversation in three lines.' }],
},
})
return text
},
}),
},
}),
})The options:
- run: the run that the call is for. The call uses the keys of its user, and the budget of the run applies. A plugin with lifetime: 'turn' can leave it out. Then the call uses the run of its turn.
- adapter: the model. Without it, the call uses the model of the session: the thread setting model, else the harness adapter. A keyedAdapter(...) gets the key of the user of the run.
- options: messages, systemPrompts, and outputSchema. With outputSchema, the call resolves to the checked value. Without it, the call resolves to { text, chunks }.
- label: a name for the call in its usage record.
- timeoutMs: the time limit of the call. The default is 2 minutes.
The call stops when its run stops, for example on a cancel, and when the time limit ends. Then it rejects. Its usage counts in the session under plugin:<name>, here plugin:acme/recap. See Usage of plugins. The call runs the harness middleware, as a turn call does, so your cost ledger and traces see it. The middleware gets the run in ctx.context.run. The goal(), title(), and compact() plugins call their models this way.
Tell plugin calls from turn calls in middleware#
Your cost ledger in the harness middleware sees the model calls of each turn and of each plugin. Read ctx.context.run.source to know who made the call:
const costs = new Map<string, number>()
const ledger: ChatMiddleware<HarnessRunContext> = {
name: 'acme/ledger',
onUsage: (ctx, usage) => {
const { source, label } = ctx.context.run
const key = label ? `${source}:${label}` : String(source)
costs.set(key, (costs.get(key) ?? 0) + usage.totalTokens)
},
}
export const ledgered = defineHarness({
name: 'acme/ledgered',
adapter: anthropicText('claude-sonnet-5-5'),
middleware: [ledger],
plugins: () => [recap],
})source has one of these values:
- 'turn': a model call of a chat turn.
- 'plugin:<name>': a ctx.model.chat call of a plugin, here 'plugin:acme/recap'. label is the label of the call, here 'recap'.
- 'router': a model.chat call of routing.router.
- 'transfer': the summary call of host.transfer. Its label is 'summary'.
source and label are the same names that the usage records use. See Usage of plugins.
Write warnings to the host logger#
Your plugin has a warning for the developer of the app, for example a service that does not answer. A console.warn line does not go where the app sends its logs, and it breaks a full-screen terminal UI. Write the warning to ctx.logger, the host logger:
export const prices = definePlugin({
name: 'acme/prices',
setup: (ctx) => {
let latest = 'unknown'
return {
onTurnStart: async () => {
try {
const response = await fetch('https://prices.example.com/today')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
latest = await response.text()
} catch (error) {
ctx.logger.warn('[acme/prices] Cannot load the prices. The turn uses the old prices.', { error })
}
},
prompts: [() => `Prices: ${latest}`],
}
},
})- ctx.logger has debug, info, warn, and error. Put an error in the meta as { error }, as the harness does.
- The logger does not add the name of your plugin. Start each message with it.
- A call to ctx.logger never throws. A logger that fails loses the line, and your plugin goes on.
- Every plugin gets ctx.logger: a session plugin, a turn plugin, and the setup of a lifetime: 'host' plugin.
The mcp(), codeMode(), and skills() plugins write their warnings this way.
Pick the model of a turn#
Some turns need another model: a short question does not need your strongest model, and an organization can allow only some models. Return an adapter picker from setup. When each turn starts, the session calls it with the message, context, and sender of that turn:
import { anthropicText } from '@tanstack/ai-anthropic'
export const cheapForShortTurns = definePlugin({
name: 'acme/cheap-for-short-turns',
setup: () => ({
adapter: (turn) =>
typeof turn.message === 'string' && turn.message.length < 200
? anthropicText('claude-haiku-5-5')
: anthropicText('claude-sonnet-5-5'),
}),
})A short message now runs on Claude Haiku, and a long one on Claude Sonnet.
The turn has these fields:
| Field | What it is |
|---|---|
| operationId | The id of the operation that runs the turn. |
| inputId | The id of the input, when the turn has one. |
| message | The user message of the input. undefined for a resolve and a continue. |
| context | The context of the input. It is client data, so do not trust it. |
| user | Who the turn runs as. Its tools get this user's credentials and keys. It is the sender of the input, and for a resolve the sender of the stopped turn. Default: the user that opened the session. See Share a thread. |
| sender | Who sent the input. The same as user, except on a resolve: there it is the user who resolved. |
| overrides | The overrides of the prompt, when it has some. |
| run | The run of the turn: its user, and that user's credentials and keys. See Act as the user of a run. |
| model | The model of the turn, as provider/model. The turn sets it when it picks its adapter, so setup, onTurnStart, and the picker do not see it yet. |
| attempt | How many times the input ran: 1 the first time. A turn that recovery runs again after a host stopped has a larger number. |
The session uses the picks like this:
- undefined keeps the harness adapter.
- If more than one plugin returns an adapter, the last plugin wins.
- The overrides.adapter of the turn wins over every pick.
- A model that the thread stored with session.updateThreadSettings wins over a plain pick. See Store settings per thread.
- A pick with override: true wins over the stored model. See below.
- A keyed adapter gets the provider key of the sender.
If the picker does not need the turn, it can take no parameter.
Win over the stored model#
Return { adapter, override: true } when your pick must win over the model that the thread stored. For example, a plugin that moves every turn to a smaller model when an organization is over its budget:
// Your check, for example a usage query.
function overBudget(tenantId: string | undefined) {
return tenantId === 'trial'
}
export const budgetGuard = definePlugin({
name: 'acme/budget-guard',
setup: () => ({
adapter: (turn) =>
overBudget(turn.user?.tenantId)
? { adapter: anthropicText('claude-haiku-5-5'), override: true }
: undefined,
}),
})- The overrides.adapter of the prompt still wins over this pick.
- If more than one plugin returns an override: true pick, the last one wins. A later plain pick does not change that.
Read the model from your data#
The picker is synchronous. To read a database first, give the plugin lifetime: 'turn'. The session then runs its setup for each turn and gives it the same turn as ctx.turn. An async setup can read the model from your data:
// Your lookup, for example a database query.
async function planOf(tenantId: string | undefined) {
return tenantId === 'acme' ? 'strong' : 'fast'
}
export const modelOfTheOrg = definePlugin({
name: 'acme/model-of-the-org',
lifetime: 'turn',
setup: async (ctx) => {
const plan = await planOf(ctx.turn?.user?.tenantId)
const adapter =
plan === 'strong'
? anthropicText('claude-sonnet-5-5')
: anthropicText('claude-haiku-5-5')
return { adapter: () => adapter }
},
})A plugin with the default lifetime has no turn at setup, so its ctx.turn is undefined.
A lifetime: 'turn' plugin lives for one turn, so it cannot add commands or config options. If it tries, the turn fails with an error. Put them in a plugin with the default lifetime.
Leave out the harness model#
When a plugin picks the model of every turn, defineHarness needs no adapter:
import { defineHarness } from '@tanstack/ai-harness'
export const assistant = defineHarness({
name: 'acme/assistant',
plugins: () => [modelOfTheOrg],
})If no plugin picks a model and the turn has no overrides.adapter, the turn fails with this error:
This turn has no model. Set `adapter` in defineHarness, return one from a plugin `adapter()`, or pass `overrides.adapter` to the turn.Let plugins work together#
Three ways, from simple to loose:
- Capabilities. One plugin declares provides: [cap] and calls ctx.provide(cap, value). Another declares requires: [cap] and reads ctx.get(cap). The provider must come first.
- Extension points. One plugin reads a list, and others add to it in any order.
- Events. ctx.emit(event, value) reaches every ctx.on(event, handler). A listener stops when its plugin is cleaned up, for example on a reload.
import { createExtensionPoint, createPluginEvent } from '@tanstack/ai-harness'
export const Checks = createExtensionPoint<{ name: string }>('acme/checks')
export const checked = createPluginEvent<{ name: string }>('acme/checked')
export const runner = definePlugin({
name: 'acme/check-runner',
setup: (ctx) => {
const checks = ctx.collect(Checks)
return {
commands: {
check: defineCommand({
description: 'Run every check',
run: () => {
for (const check of checks) ctx.emit(checked, check)
return checks.map((check) => check.name)
},
}),
},
}
},
})
export const lint = definePlugin({
name: 'acme/lint',
setup: () => ({ contribute: [Checks.item({ name: 'lint' })] }),
})Read ctx.collect at run time (in a command, a tool, or a middleware hook). During setup, later plugins have not added their items yet.
Clean up resources#
Open resources with ctx.resources.acquire(open, close). They close when the plugin is cleaned up, newest first: when the session closes, or for lifetime: 'turn', after the turn and the background runs that it started. If a later plugin fails in setup, every plugin set up so far is cleaned up, and no model request is sent.
Set lifetime: 'turn' to set a plugin up again for each turn.
Share one setup between sessions#
A plugin that opens a database pool, or starts a process, does that work again for each session. A host with 300 open sessions then holds 300 pools. Set lifetime: 'host', and the host sets the plugin up one time and shares it with every session:
import { toolDefinition } from '@tanstack/ai'
import { definePlugin } from '@tanstack/ai-harness'
// Your shared client, for example a database pool.
async function connect() {
return { countOrders: async () => 42, end: async () => {} }
}
export const orders = definePlugin({
name: 'acme/orders',
lifetime: 'host',
setup: async (ctx) => {
const db = await ctx.resources.acquire(connect, (pool) => pool.end())
return {
tools: [
toolDefinition({ name: 'count_orders', description: 'Count the orders' }).server(
async () => db.countOrders(),
),
],
}
},
})- The host sets the plugin up one time for each harness and plugin object. Every session of that harness that uses the same object shares this setup.
- Create the plugin one time, outside the plugins function, as orders is above. Then use it with plugins: () => [orders]. A plugins function that calls definePlugin or mcp() for each session gives each session its own setup.
- The host cleans it up when the last session that uses it closes, or at host.close().
- Its setup context has no session, no credentials, no keys, and no commands. It has harness (the name of the harness), resources, and logger. See Write warnings to the host logger.
- A tool of the plugin reads the user of its call from ctx.context.run. See Act as the user of a run.
Some parts need the session: a command, a setting, plugin state, or a question to the user. Return them from session. It runs for each session, with the same context as a session plugin:
import { defineCommand } from '@tanstack/ai-harness'
export const pool = definePlugin({
name: 'acme/pool',
lifetime: 'host',
setup: async (ctx) => {
const db = await ctx.resources.acquire(connect, (shared) => shared.end())
return {
session: (session) => ({
commands: {
orders: defineCommand({
description: 'Count the orders',
run: async () => `${await db.countOrders()} orders in ${session.session.threadId}`,
}),
},
}),
}
},
})The session gets the shared contributions and the session contributions together. The resources of session close with the session.
The MCP plugin works this way for stdio servers: one process serves every session of the host.
Reload plugins#
You changed a plugin, an agent file, or the list that plugins() returns, and an open session still runs the old setup. session.reloadPlugins() sets the plugins up again:
import { watch } from 'node:fs'
import { createHarnessHost } from '@tanstack/ai-harness'
const host = createHarnessHost()
const session = await host.open(assistant, { threadId: 'thread-1' })
// One session.
await session.reloadPlugins()
// Every open session of the harness, each time a file in the folder changes.
watch('./plugins', () => void host.reload(assistant))A reload works like this:
- It waits for the running turn to end. No new turn starts until the reload ends.
- It cleans up the resources of the session plugins, newest first.
- It calls plugins() again and runs setup of each session plugin.
- Clients get a harness.reloaded event. Read the commands, the settings, and the agents again.
The transcript, the log, the thread settings, and the inputs that wait stay. The next turn gets the new tools, prompts, and middleware. The ctx.on listeners of the old plugins stop.
The harness does not watch files. Your host decides when to reload, as the watch call above does.
If a setup throws, the session keeps working with no plugins. The promise rejects with the error, and the harness.reloaded event has its error message. Fix the plugin and call reloadPlugins() again.
See the plan#
session.inspect() lists the plugins in order, and who owns each tool, prompt, command, setting, and extension point item.
What you have now#
- A plugin that adds tools, prompts, commands, settings, and state to any harness.
- Middleware that sees every model call, in the lead turn and in every agent.
- Tools and commands that act as the user of their own run.
- Model calls of your plugin that count in the session usage and stop with their run.
- A picker that chooses the model of each turn from its message, context, and sender.
- Plugins that share services, lists, and events without knowing each other.
- Commands, agents, and whole plugins that change while the session runs.
Next: add the first-party plugins from @tanstack/ai-harness/plugins and @tanstack/ai-harness/plugins/node. Ask before risky tool calls and Switch between agent profiles are two of them.