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.
A source needs two methods:
This source reads from any store that you wrap in a SkillStore:
// 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
},
}
}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.
The package ships a Vitest suite at @tanstack/ai-skills/testing. Seed your source with the fixture that the suite expects:
// 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.
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:
The suite skips the resource and script checks when your source does not have those methods.
| Method | Returns | Use 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.
This example reads from an S3-style bucket. Call assertSafeResourcePath before you read a path, so a path outside the skill root throws:
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}`)
},
}
}If your source has resource methods, the suite also expects this on alpha:
If your source has script methods, the suite also expects this on alpha: