Skip to content
Skills

Portable Agent Skills

withSkills lets the model pick one SKILL.md for the turn. The other skills stay out of the system prompt.

withSkills from @tanstack/ai-skills gives the model a short catalog of your skills and a load_skill tool. The model loads the full instructions only when it needs them.

Note

This is the portable path. It runs on the model you already use and needs no provider sandbox. For skills that run in a provider sandbox, see Provider Skills. You cannot mix the two in one call. See Portable vs hosted.

Quick setup

1. Install the package

shell
npm i @tanstack/ai-skills

2. Add a skill to the server route

Define a skill inline. Then pass it to withSkills in the middleware array:

ts
// api/chat.ts
import {
  chat,
  chatParamsFromRequest,
  toServerSentEventsResponse,
} from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { inlineSkill, withSkills } from '@tanstack/ai-skills'

const pptx = inlineSkill({
  name: 'pptx-builder',
  description: 'Build and edit PowerPoint decks with python-pptx.',
  instructions: `
# Building a deck
Use python-pptx. Open or create the presentation, edit slides, then save.
Keep one idea per slide.
`,
})

export async function POST(request: Request) {
  const params = await chatParamsFromRequest(request)
  const stream = chat({
    adapter: anthropicText('claude-sonnet-5-5'),
    messages: params.messages,
    threadId: params.threadId,
    middleware: [withSkills(pptx)],
  })
  return toServerSentEventsResponse(stream)
}

Your client does not change. Ask the chat for a slide deck. The model calls load_skill with pptx-builder and then follows the instructions.

How it works

withSkills adds two things to each request:

  • A catalog in the system prompt, with one line per skill (name and description).
  • A load_skill tool. Its name input accepts only your skill names, so the model cannot invent one. The tool returns the skill body without its frontmatter, and a list of the bundled resources.

If the model loads the same skill twice in one conversation, the second call returns a short "already loaded" marker, not the full body again.

Offer more than one skill

Pass an array. withSkills sorts the skills by name and removes duplicates:

ts
import {
  chat,
  chatParamsFromRequest,
  toServerSentEventsResponse,
} from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { inlineSkill, withSkills } from '@tanstack/ai-skills'

const pptx = inlineSkill({
  name: 'pptx-builder',
  description: 'Build and edit PowerPoint decks with python-pptx.',
  instructions: '# Building a deck\nUse python-pptx. Edit slides, then save.',
})

const brand = inlineSkill({
  name: 'brand-voice',
  description: 'Write in the company brand voice.',
  instructions: '# Brand voice\nWarm, direct, no jargon.',
})

export async function POST(request: Request) {
  const params = await chatParamsFromRequest(request)
  const stream = chat({
    adapter: anthropicText('claude-sonnet-5-5'),
    messages: params.messages,
    threadId: params.threadId,
    middleware: [withSkills([pptx, brand])],
  })
  return toServerSentEventsResponse(stream)
}

Most apps keep skills in a folder, a build-time bundle, or a database, not in code. See Skill sources.

Let the model read a skill's files

Some skills bundle reference files, such as a style guide or a field map. To let the model read them, add createResourceTool to tools. withSkills finds it and tells the model that it can call read_skill_resource:

ts
import {
  chat,
  chatParamsFromRequest,
  toServerSentEventsResponse,
} from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { createResourceTool, inlineSkill, withSkills } from '@tanstack/ai-skills'

const pdf = inlineSkill({
  name: 'pdf-filler',
  description: 'Fill a PDF form from a data object.',
  instructions: '# Fill a PDF\nSee references/fields.md for the field map.',
  resources: { 'references/fields.md': 'name -> field_1\nemail -> field_2' },
})

export async function POST(request: Request) {
  const params = await chatParamsFromRequest(request)
  const stream = chat({
    adapter: anthropicText('claude-sonnet-5-5'),
    messages: params.messages,
    threadId: params.threadId,
    tools: [createResourceTool(pdf)],
    middleware: [withSkills(pdf)],
  })
  return toServerSentEventsResponse(stream)
}

Without the resource tool, the load_skill result still lists the resources. The model is told that it cannot load them in this setup.

Run a skill's scripts

Some skills ship scripts, or tell the model to "run python3 extract.py". withSkills lists those scripts in the load_skill result, but it does not run them. Your app runs code through a tool that you supply.

Add an execution tool to chat(). Then write the skill so that it tells the model to call that tool:

ts
import {
  chat,
  chatParamsFromRequest,
  toServerSentEventsResponse,
  toolDefinition,
} from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { inlineSkill, withSkills } from '@tanstack/ai-skills'
import { z } from 'zod'

// Your own execution tool. Run the command in a provider sandbox, a local
// isolate, or a serverless worker. Guard it in production.
const executeShell = toolDefinition({
  name: 'execute_shell',
  description: 'Run a shell command and return its stdout.',
  inputSchema: z.object({ command: z.string() }),
  outputSchema: z.object({ stdout: z.string() }),
}).server(async ({ command }) => {
  const { stdout } = await runInYourSandbox(command)
  return { stdout }
})

const extractPdf = inlineSkill({
  name: 'pdf-extract',
  description: 'Extract text from a PDF with a small Python script.',
  instructions: `
# Extract PDF text
Run this with the execute_shell tool, then return the text it prints:
  python3 -c "import sys, pypdf; ..."
`,
})

export async function POST(request: Request) {
  const params = await chatParamsFromRequest(request)
  const stream = chat({
    adapter: anthropicText('claude-sonnet-5-5'),
    messages: params.messages,
    threadId: params.threadId,
    tools: [executeShell],
    middleware: [withSkills(extractPdf)],
  })
  return toServerSentEventsResponse(stream)
}

You can replace execute_shell with any tool: a container runner, a Code Mode sandbox, or a remote worker. The skill stays the same.

Advanced

Catalog options

withSkills(sources, options) accepts these options:

OptionDefaultWhat it does
maxCatalogTokens4000The token limit for the catalog.
onLimitExceeded'error'When the catalog is over the limit: 'error' throws. A function (skills, limit) => skills returns a smaller list.
requireApprovalfalseAsks a human to approve before load_skill or read_skill_resource runs.
catalogPlacement'system'Where the catalog goes: 'system' or 'tool-description'.
renderCatalogPer model family(skills, family) => string. Writes the catalog text yourself.
instructionTemplatePer model familyA template string that must contain {skills}. Escape literal braces as {{ and }}.
ts
withSkills(sources, {
  maxCatalogTokens: 4000,
  requireApproval: true,
})

Do not pass both renderCatalog and instructionTemplate. If you pass both, withSkills throws.

Catalog format per model family

By default, Anthropic models get the <available_skills> XML format that they are tuned for. All other models get a plain markdown list.

Next pages