Skip to content
Skills

Write a Skill Source

A skill source adapter loads skills from S3, a database, or a registry. withSkills then reads them through that adapter.

Write a SkillSource for your store. It is a small interface that returns bytes only, so it works anywhere, including the edge. Then prove it with the conformance suite.

Quick setup

1. Write the source

A source needs two methods:

  • list returns the catalog: a name and a description for each skill.
  • load returns the raw SKILL.md of one skill, frontmatter included. It throws when the name does not exist.

This source reads from any store that you wrap in a SkillStore:

ts
// db-skills.ts
import type { SkillSource } from '@tanstack/ai-skills'

export interface SkillRow {
  name: string
  description: string
  /** The raw SKILL.md, frontmatter included. */
  body: string
}

export interface SkillStore {
  listSkills: () => Promise<Array<SkillRow>>
  getSkill: (name: string) => Promise<SkillRow | undefined>
}

export function dbSkills(store: SkillStore): SkillSource {
  return {
    async list() {
      const rows = await store.listSkills()
      return rows.map((row) => ({
        name: row.name,
        description: row.description,
      }))
    },
    async load(name) {
      const row = await store.getSkill(name)
      if (!row) {
        throw new Error(`no skill named "${name}"`)
      }
      return row.body
    },
  }
}

2. Pass it to withSkills

ts
import { withSkills } from '@tanstack/ai-skills'
import { dbSkills } from './db-skills'

const skills = withSkills(dbSkills(yourStore))

Add skills to the middleware array of chat(), as in Portable Agent Skills. The middleware removes the frontmatter from what load returns.

3. Run the conformance suite

The package ships a Vitest suite at @tanstack/ai-skills/testing. Seed your source with the fixture that the suite expects:

  • A skill alpha with a description.
  • A skill beta with a description.
ts
// db-skills.test.ts
import { runSkillSourceConformance } from '@tanstack/ai-skills/testing'
import { dbSkills } from './db-skills'
import type { SkillRow } from './db-skills'

function makeTestStore() {
  const rows: Array<SkillRow> = [
    {
      name: 'alpha',
      description: 'First test skill.',
      body: '---\nname: alpha\ndescription: First test skill.\n---\n# Alpha',
    },
    {
      name: 'beta',
      description: 'Second test skill.',
      body: '---\nname: beta\ndescription: Second test skill.\n---\n# Beta',
    },
  ]
  return {
    listSkills: async () => rows,
    getSkill: async (name: string) => rows.find((row) => row.name === name),
  }
}

runSkillSourceConformance(() => dbSkills(makeTestStore()), 'db')

Run your tests. When the suite passes, your source is safe to give to withSkills.

How it works

withSkills calls list() once for each chat() call to build the catalog. When the model calls load_skill, the middleware calls load(name).

A source has no path field. It returns bytes, so a database or a registry works the same as a folder.

The suite tests these rules:

  • A missing skill name throws. It does not return an empty string.
  • Two list() calls at the same time return the same skills.
  • revision(), if present, returns the same value for the same content.
  • Resources load, and a path like ../../etc/passwd is rejected.
  • Script bytes come back unchanged.

The suite skips the resource and script checks when your source does not have those methods.

Advanced

Optional methods

MethodReturnsUse it for
revision()Promise<string>A stable value that changes only when the content changes, such as a bucket ETag or a content hash.
listResources(name)Promise<Array<string>>Paths under references/ and assets/, relative to the skill root.
readResource(name, path)Promise<string | Uint8Array>The content of one resource. read_skill_resource calls it.
listScripts(name)Promise<Array<SkillScriptRef>>The scripts of a skill, listed but not run.
readScript(name, path)Promise<Uint8Array>The bytes of one script.

withSkills does not cache on revision(). Add revision() so that combinators, your own cache, or a catalog memo can use it as a key.

Add resources and a revision

This example reads from an S3-style bucket. Call assertSafeResourcePath before you read a path, so a path outside the skill root throws:

ts
import { assertSafeResourcePath } from '@tanstack/ai-skills'
import type { SkillSource } from '@tanstack/ai-skills'

export function s3Skills(bucket: S3Bucket): SkillSource {
  return {
    revision: () => bucket.getText('skills/index.etag'),
    async list() {
      const index = await bucket.getJSON('skills/index.json')
      return index.map((s) => ({ name: s.name, description: s.description }))
    },
    async load(name) {
      return bucket.getText(`skills/${name}/SKILL.md`)
    },
    async listResources(name) {
      return bucket.listKeys(`skills/${name}/references/`)
    },
    async readResource(name, path) {
      assertSafeResourcePath(path)
      return bucket.getBytes(`skills/${name}/${path}`)
    },
  }
}

Full fixture for resources and scripts

If your source has resource methods, the suite also expects this on alpha:

  • A resource references/note.md with the content hello.

If your source has script methods, the suite also expects this on alpha:

  • A script scripts/run.py whose bytes decode to print(1), listed with executable: false.