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.
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.
For the model:
For agents and users:
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.
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.
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],
}),
})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}.`
},
}),
},
}),
})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}.`
},
}),
},
}),
})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.
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 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.
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,
}),
},
}
},
})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)]
}),
],
}),
})Give your agent a computer stores each screenshot this way.
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),
}),
},
}
},
})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:
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. |
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:
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.
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:
source and label are the same names that the usage records use. See Usage of plugins.
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}`],
}
},
})The mcp(), codeMode(), and skills() plugins write their warnings this way.
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:
If the picker does not need the turn, it can take no parameter.
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 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.
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.Three ways, from simple to loose:
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.
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.
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(),
),
],
}
},
})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.
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:
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.
session.inspect() lists the plugins in order, and who owns each tool, prompt, command, setting, and extension point item.
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.