A cron job or a webhook needs to run one tool, not hold a conversation. You want fresh stats every hour, or a tool that runs when a pull request opens. A full model turn is waste, and the model could pick the wrong tool. session.tool runs one tool with no model. The call still goes through your middleware, the input check, and permissions(), and the call and its result join the transcript.
Call session.runTool({ name, args }). A schedule or a webhook handler calls it.
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
const fetchStats = toolDefinition({
name: 'fetchStats',
description: 'Read the latest usage stats',
inputSchema: z.object({ window: z.string() }),
}).server(async ({ window }) => ({ window, signups: 42 }))
const dashboard = defineHarness({
name: 'acme/dashboard',
adapter: openaiText('gpt-6.1-sol'),
tools: [fetchStats],
expose: { tools: ['fetchStats'] },
})
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(dashboard, { threadId: 'ops-thread' })
// From a cron job, every hour:
const run = session.runTool({ name: 'fetchStats', args: { window: '1h' } })
const { text } = await runTypeScript types args from the input schema of a harness tool. session.runTool({ name: 'fetchStats', args: { window: 1 } }) does not compile. A plugin tool takes any args, and its input schema checks them when the tool runs.
Pass meta to record why the tool ran, for example the schedule or the webhook event. The harness sends it as a tanstack.injection custom event with the tool call id.
const fromWebhook = session.runTool({
name: 'fetchStats',
args: { window: '24h' },
meta: { trigger: 'webhook', deliveryId: 'whk-99' },
})
await fromWebhookA client reads the tanstack.injection event to show which runs a trigger started, not a person.
A tool that writes to your own database needs to know which thread called it. Each tool call of a harness turn gets threadId and runId in its context:
const saveReport = toolDefinition({
name: 'saveReport',
description: 'Save the stats report for this thread',
inputSchema: z.object({ signups: z.number() }),
}).server(async ({ signups }, ctx) => ({
savedFor: ctx?.threadId,
run: ctx?.runId,
signups,
}))This works for a model call and for session.tool. Your own context data stays as you sent it.
import { createHarnessClient } from '@tanstack/ai-harness/client'
import type { dashboard } from './harness'
const client = createHarnessClient<typeof dashboard>({
url: '/api/harness',
threadId: 'ops-thread',
headers: { authorization: 'Bearer my-token' },
})
const receipt = await client.runTool({
name: 'fetchStats',
args: { window: '1h' },
inputId: 'hourly-stats-09',
})The capabilities document marks each tool with exposed, so a client shows only the tools it can run. See Connect clients to a harness.
An MCP client runs an exposed tool through a tool_<name> tool. See Use a harness from any MCP client.
To give the model a fact from a webhook instead of running a tool, add a record to the thread. See Let a client add records.