Sources
This is a featured battery
The battery ships the seam, not a filesystem policy, registry client, or cloud marketplace.
Skills have to come from somewhere, and this battery does not know where. There is no bundled filesystem loader, no registry client, and no marketplace. SkillSource is the interface you implement to answer where your skills live — a directory, an S3 bucket, a database, a package you import(), or an in-memory array in a test.
What the manager asks of it splits into a cheap half and an expensive one. discover() yields routing metadata (id, name, description, version) so the catalog can list a hundred skills without loading any of them. descriptor() is called only when a skill is actually activated, and that is where live tools come from; read() streams the body, and optional list() enumerates bundled files when a skill ships scripts.
That split is what keeps list_skills cheap. The expensive work — module loading, tool construction — is deferred until something is actually used, so a hundred discoverable skills cost a hundred metadata records, not a hundred module loads. InMemorySkillSource ships as a reference implementation and is what the sample below uses.
import { InMemorySkillSource } from '@nhtio/adk/batteries/skills/in_memory'
import type { SkillDescriptor } from '@nhtio/adk/batteries/skills'
const descriptor = {
id: 'reports', name: 'Reports', description: 'Generate reports', version: '1.0.0',
} satisfies SkillDescriptor
const source = new InMemorySkillSource('local', [{
id: 'reports', version: '1.0.0',
manifest: '---\nname: reports\ndescription: Generate reports\n---\n\nUse reports carefully.',
descriptor,
}])
for await (const skill of source.discover()) console.log(skill.id, skill.description)SkillSource has no sourceId in discovery. The manager stamps sourceId from the source's id, rejects duplicate source ids, and resolves id collisions with earlier sources winning. A descriptor's id and version must match the discovered reference.
Conformance
You implement SkillSource; this is how you find out whether you implemented it correctly. implementsSkillSource is only a duck-guard — it checks that the method names exist. It cannot check that discover() really yields an async iterable, that descriptor() returns an id and version matching the ref it was handed, that read(ref) with no path returns the body rather than some other file, or that stat().version tracks the source. Those are protocol properties, not shapes. A source can satisfy every type in the interface and still fail at load time, in production, against a third-party skill.
The conformance suite is the executable half of that contract: a framework-free async function we wrote that runs against your implementation and throws when your source breaks an assumption the manager makes. It exercises three cases: discovery is genuinely an async iterable and preserves the discovered reference; descriptor() agrees with the ref on id and version; and read() streams the body as bytes with stat() reporting a size and version consistent with it. Wrap it in whichever test runner you use:
import { test } from 'node:test'
import { runSkillSourceConformance } from '@nhtio/adk/batteries/skills/conformance'
test('MySkillSource conforms', async () => {
await runSkillSourceConformance('MySkillSource', () => new MySkillSource(fixture))
})import { it } from 'vitest'
import { runSkillSourceConformance } from '@nhtio/adk/batteries/skills/conformance'
it('MySkillSource conforms', async () => {
await runSkillSourceConformance('MySkillSource', () => new MySkillSource(fixture))
})Deep import only. The suite has no test-framework dependency and is never pulled in by the battery barrel.
Three cases is not a lot. It proves your source is not obviously broken; it does not prove it is correct, and you should keep your own tests alongside it. A conformance suite that only ever passes is theatre: an earlier draft of this suite contained a "mutation" test that reached into the source's own internals, changed a value, then asserted the source reported the value it had just been handed — it was asserting that assignment works. It was cut. If you add cases, make sure each one can actually fail.
Why there is no node_fs
There is deliberately no filesystem source subpath. A source needs decisions about runtime, path translation, symlinks, caching, authentication, and module loading. Making those decisions in the neutral battery would either import Node APIs or pretend browser and worker deployments are the same thing. Keep filesystem discovery consumer-owned and implement SkillSource at the boundary.
parseSkillMd is a helper for source authors, not a manager load step. InMemorySkillSource uses it; a source with metadata elsewhere need not.