TanStack
Harness

Add skills to a harness

Your users keep skills as SKILL.md folders, in the repo and in their home folder, and they want to call them from your agent like any other command. The skills plugin reads those folders, gives the model the list of skills, and makes each skill a /<name> command. A skill that a user adds while the session runs works at once.

The plugin reads the folders with node:fs, so it runs on Node.js. It does not work on Cloudflare Workers.

1. Add the plugin

shell
npm i @tanstack/ai-skills
ts
import { homedir } from 'node:os'
import { join } from 'node:path'
import { defineHarness } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'
import { skills } from '@tanstack/ai-skills/harness'

// One time, outside `plugins`, so the sessions share the folder watchers.
const agentSkills = skills({
  dirs: ['./.agents/skills', join(homedir(), '.acme', 'skills')],
})

export const assistant = defineHarness({
  name: 'acme/assistant',
  adapter: openaiText('gpt-6.1-sol'),
  plugins: () => [agentSkills],
})
  • dirs lists the skill folders. Each one holds skill folders with a SKILL.md.
  • When two folders have a skill with the same name, the first folder wins. Put the repo folder first, so a project can replace a user's skill.
  • A folder that does not exist yet is fine. Its skills show once the user makes it.

2. Call a skill

/release-notes write them for v2 starts a turn with "Use the release-notes skill: write them for v2". The model loads the skill with load_skill, then does the task. /release-notes alone asks for the skill with no task.

The model also sees the list of skills, so it can load one on its own when a task matches.

Names that are taken

A skill with the name of another command gets a prefix: a model skill is /skill:model, and /model stays the model picker. Pass reserved with the names that your UI handles itself:

ts
export const cliSkills = skills({
  dirs: ['./.agents/skills'],
  reserved: ['help', 'exit'],
})

/skills lists every skill, its folder, and its command.

Skills that change while the session runs

The plugin watches the folders, and the commands follow them:

  • A new SKILL.md adds its command.
  • A removed skill takes its command away.
  • An edited description updates the command.
  • A broken SKILL.md is left out until it is fixed.

Each change sends a harness.commands.changed event, so a UI can show the new command list at once.

The plugin watches each folder once for each host. All sessions of the harness on that host share the watchers, so many open sessions still use one watcher for each folder. A change only tells the sessions to update. The plugin reads the folders again at the start of each turn.

If the plugin cannot watch a folder, it logs one warning with the folder and the reason. An example is a Linux system with no free file watchers. Changes in that folder still show at the next turn.

Many skills

The model's list has one line for each skill: the name and the first sentence of the description. When the list is longer than maxCatalogTokens (default 4000), the skills of the earlier folders stay, and the rest leave the model's list. A skill that the user calls with its command always stays in it.

The other options of withSkills pass through, for example maxCatalogTokens and requireApproval:

ts
export const manySkills = skills({
  dirs: ['./.agents/skills'],
  maxCatalogTokens: 8000,
})

What you have now

  • The skills of your folders, in the model's list and as /<name> commands.
  • New and changed skills that work in a running session, with no restart.

Next: Agent Skills has the SKILL.md format, and Write a harness plugin shows the ctx.commands API that the plugin uses.