@nightowlsdev/skills
Adapter/ToolingBrowse, import, and refresh external skills (skills.sh, GitHub, https) into storage-backed, versioned, grantable instruction-only skills, third-party guidance, safely fenced.
What it does
@nightowlsdev/skills is the skill-provider seam. A CODE skill (`defineSkill({ name, instructions, tools })`) lives in your source; an IMPORTED skill is third-party SKILL.md text pulled from a registry, it can teach an agent methodology (copywriting, SEO, AEO) but must never carry executable grants. This package enforces that boundary end to end. `skillsShProvider()` searches and downloads from Vercel's skills.sh directory (the same unauthenticated endpoints the `npx skills` CLI uses, plus its partner security audits); `githubSkillProvider()` reads a SKILL.md straight off raw.githubusercontent (no git subprocess); `httpSkillProvider()` fetches any https URL and is SSRF-hardened (https-only, redirects rejected, private/loopback/link-local IP literals blocked, optional host allowlist). `importSkill`/`refreshSkill` gate on a host authorize hook BEFORE any network fetch, enforce provider/author allowlists, reject reserved names and silent source swaps, and are idempotent by the upstream version, persisting each import as an append-only, provenance-tracked version in the adapter (`storage.skills`). `materializeSkillStore(storage.skills)` builds the `SwarmConfig.dynamicSkills` resolver: it wraps every imported instruction block in an `<imported-skill untrusted="true">` fence (labeled third-party reference guidance that cannot grant tools or override policy) with a TTL-bounded cache and per-skill error isolation. Structurally tool-less, an imported skill can only ever contribute prompt guidance, never a callable tool, and a code-defined skill always wins a name clash. Engine-wall clean: depends only on `@nightowlsdev/core` types + `yaml`, all network I/O injected. FR-039 adds `createSkillProviderRegistry`, the set of sources a deployment offers, with a governance FLOOR (a per-call policy can only ever TIGHTEN it: allowlists intersect, byte caps take the minimum, both authorize hooks run) and a `{ listings, warnings }` search envelope, so one provider being down is a partial result rather than a silent empty one. `checkSkillUpdates` reports drift WITHOUT publishing, because a badge saying "3 skills have updates" must not install them; it returns a discriminated union per skill and fans out through a bounded pool, since skills.sh publishes 600 req/min and an unbounded burst turns every badge into a 429.
Install
pnpm add @nightowlsdev/skillsKey exports
- skillsShProvider / githubSkillProvider / httpSkillProvider
- SkillProvider (seam) / SkillProviderError / isSkillProviderError / isBlockedHost
- parseSkillMd / fenceImportedSkill
- importSkill / refreshSkill / refreshAllSkills
- createSkillProviderRegistry (FR-039: describe / search / policy FLOOR)
- checkSkillUpdates (FR-039: reports drift WITHOUT publishing)
- materializeSkillStore (builds SwarmConfig.dynamicSkills)
Usage
import { skillsShProvider, importSkill, refreshAllSkills, materializeSkillStore } from "@nightowlsdev/skills";
import { defineSwarm } from "@nightowlsdev/core";
const skillsSh = skillsShProvider();
// 1. Browse the directory, then import a skill (append-only, versioned, provenance-tracked).
// The host authorize hook + allowlists run BEFORE any network fetch.
const hits = await skillsSh.list({ q: "seo", limit: 10 });
await importSkill({
provider: skillsSh,
ref: "coreyhaines31/marketingskills@seo-writing",
storage, // createSupabaseStorage(...), has storage.skills
tenantId,
actor: { type: "human", userId, tenantId },
policy: { allowAuthors: ["coreyhaines31"], authorize: assertIsAdmin },
});
// 2. Grant it to an agent by adding its name to the agent's skillNames (publishAgentVersion / bundle apply).
// 3. Resolve at runtime, instruction-only, fenced untrusted, cached. Code skills win a name clash.
const swarm = defineSwarm({ agents, dynamicSkills: materializeSkillStore(storage.skills) });
// 4. Keep imports current (re-pull → diff by upstream version → re-version on change).
await refreshAllSkills({ providers: { "skills.sh": skillsSh }, storage, tenantId, actor });
// Also: githubSkillProvider() (raw fetch, no clone) and httpSkillProvider() (SSRF-hardened) for other sources.What it provides
skills is the skill-provider seam: it browses, imports, and refreshes external SKILL.md text (skills.sh, GitHub, plain https) into storage-backed, versioned, grantable INSTRUCTION-ONLY skills for a swarm. A code skill (defineSkill) lives in your source; an imported skill is third-party methodology (copywriting, SEO, AEO) that can teach an agent but must never carry executable grants — this package enforces that boundary end to end. importSkill/refreshSkill gate on a host authorize hook before any network fetch and persist each import as an append-only, provenance-tracked version; materializeSkillStore(storage.skills) builds the SwarmConfig.dynamicSkills resolver that wraps every imported block in an <imported-skill untrusted> fence, cached and per-skill error-isolated.
When to use it
- You want to give an agent third-party methodology guidance — a copywriting, SEO, or AEO skill from skills.sh or a GitHub SKILL.md — without hand-copying prompt text.
- You need imports to be governed: a host authorize hook and provider/author allowlists that run BEFORE any fetch, append-only versioned storage with provenance, and idempotency by upstream version.
- You want a UI over multiple skill sources — one search across providers behind a policy floor, capability discovery by presence, and a drift badge that reports updates WITHOUT installing them.
- You want imported instructions treated as untrusted: fenced third-party reference text that can never grant a tool or override policy, with a code-defined skill always winning a name clash.
When not to
- The skill needs to carry executable tools — that is a CODE skill (defineSkill({ name, instructions, tools }) in @nightowlsdev/core); an imported skill is structurally tool-less by design.
- You want retrieved factual documents an agent searches at runtime — that is the @nightowlsdev/knowledge plane, not instruction-only methodology text.
- You have no storage adapter with a skills repo — importSkill/materializeSkillStore persist and resolve through storage.skills / storage.skillsWritable, so this needs a backing store (e.g. storage-supabase).
Alternatives
- A code skill (defineSkill in @nightowlsdev/core)The skill is yours and may carry tools. Code skills live in source, get code review, and can grant executable tools — imported skills can only ever contribute prompt guidance.
- @nightowlsdev/knowledgeYou want retrievable facts/documents (chunked, embedded, searched at runtime) rather than a block of methodology instructions injected into the system prompt.
Strengths
- Enforces the instruction-only boundary end to end: parse-time sanitization (YAML-only frontmatter, slugged names, control chars stripped, size cap), an import policy gate, append-only provenance, and a spoof-proof untrusted fence at resolution.
- The provider registry is honest: capabilities are reported by presence (canList == typeof provider.list === 'function'), search returns a { listings, warnings } envelope so one provider being down is a partial result not a silent empty one, and duplicate provider ids are rejected at construction.
- policy is a floor, not a default: a per-call policy can only TIGHTEN the registry's — allowlists intersect, byte caps take the minimum, both authorize hooks run — the only merge rule that makes a deployment-wide policy worth setting.
- checkSkillUpdates reports drift without publishing (a badge must not install), through a bounded worker pool so a few hundred skills don't burst skills.sh's 600 req/min limit into 429s.
- Engine-wall clean: depends only on @nightowlsdev/core types + yaml, with all network I/O injected — providers are hermetically testable.
Limits & trade-offs
- Imported skills can never grant a tool — correct and deliberate, but a limit if you expected an installable skill to bring executable capabilities; use a code skill for that.
- It needs a storage adapter with the skills repo (storage.skills / skillsWritable), plus migrations for versioning and org-scope (storage-supabase 0026 append-only, 0030/0032 for the FR-055 scope axis).
- httpSkillProvider is SSRF-hardened (https-only, redirects rejected, private/loopback/link-local IP literals blocked) but a public hostname that DNS-resolves to a private IP (rebinding) is the residual gap — Node fetch has no pre-connect hook — so pin allowHosts for untrusted-adjacent deployments.
- You still own the authorize hook and allowlists — the repo actor-bar only excludes agent principals; it is NOT authorization, so a missing authorize hook is an open import surface.
How it works
A provider (skillsShProvider, githubSkillProvider, httpSkillProvider, or a custom SkillProvider implementing { id, list?, fetch }) locates and fetches raw SKILL.md. importSkill runs the host authorize hook and provider/author allowlists BEFORE any network call, parses and sanitizes the text, rejects reserved names and silent source swaps, and is idempotent by the upstream sourceVersion — persisting each import as an append-only version in storage.skills with provenance. A stored skill can belong to a sub-org scope (FR-055): reads resolve most-specific-wins through the exact head a caller can see (their scope and its ancestors, never siblings or descendants), and writes land at exactly ctx.orgScope. At runtime materializeSkillStore(storage.skills) is the dynamicSkills resolver: it wraps every imported instruction block in an <imported-skill untrusted="true"> fence with embedded delimiters neutralized, TTL-cached and keyed on (tenantId, orgScope), with per-skill error isolation. createSkillProviderRegistry bundles the sources behind a policy floor for a UI, and checkSkillUpdates reports drift (a discriminated union per skill) without ever writing a version.
Examples
Import a skill, resolve it at runtime, keep it current
The authorize hook + allowlists run before any fetch; imports are append-only and idempotent by upstream version; resolution is instruction-only, fenced, cached.
import { skillsShProvider, importSkill, refreshAllSkills, materializeSkillStore } from "@nightowlsdev/skills";
import { defineSwarm } from "@nightowlsdev/core";
const skillsSh = skillsShProvider();
// Browse, then import into the tenant's store (append-only, versioned, provenance-tracked).
const hits = await skillsSh.list({ q: "seo", limit: 10 });
await importSkill({
provider: skillsSh,
ref: "coreyhaines31/marketingskills@seo-writing",
storage, // createSupabaseStorage(...), has storage.skills
tenantId,
actor: { type: "human", userId, tenantId },
policy: { allowAuthors: ["coreyhaines31"], authorize: assertIsAdmin }, // runs BEFORE the fetch
});
// Resolve at runtime: instruction-only, fenced untrusted, cached. Code skills win a name clash.
const swarm = defineSwarm({ agents, dynamicSkills: materializeSkillStore(storage.skills) });
// Keep imports current: re-pull → diff by upstream version → re-version only on change.
await refreshAllSkills({ providers: { "skills.sh": skillsSh }, storage, tenantId, actor });A registry + a drift badge that never installs
policy is a floor a per-call gate can only tighten; checkSkillUpdates reports updates without publishing.
import { createSkillProviderRegistry, githubSkillProvider, httpSkillProvider, skillsShProvider, checkSkillUpdates } from "@nightowlsdev/skills";
const registry = createSkillProviderRegistry({
providers: [skillsShProvider(), githubSkillProvider(), httpSkillProvider()],
policy: { allowAuthors: ["acme"], maxInstructionBytes: 40_000, authorize: assertIsAdmin }, // the FLOOR
});
registry.describe(); // the source picker: capability by presence
const { listings, warnings } = await registry.search({ q: "seo" }, gate); // partial-result envelope
// A badge: report drift WITHOUT publishing (a discriminated union per skill).
const statuses = await checkSkillUpdates({ registry, storage, tenantId, actor, persist: true });Doing the parts it doesn't support
- An imported skill that grants a toolNot possible by design — ImportedSkill/InstructionSkill have no tools field and core's tool/gate paths never consult dynamic skills. If a skill must carry executable capability, author it as a code skill (defineSkill with tools) in your source, where it gets code review and always wins a name clash.
- Cloning a git repo for its skillNo git subprocess ships — githubSkillProvider reads a SKILL.md straight off raw.githubusercontent. For a full clone, implement the SkillProvider interface ({ id, list?, fetch }) as a host-side adapter; a git-clone provider is a natural one, and all network I/O is injected.
- Deleting an imported skill's historyStorage is append-only — storage-supabase's 0026 triggers reject a cascading skill_versions delete. Offboarding archives instead: SkillWritableRepo.archiveScope flips a scope's heads to archived (every resolution path excludes archived heads), and unarchiveSkill is the way back. Content redaction is deferred on that same precedent.
Related
- core — defineSkill (code skills) and the SwarmConfig.dynamicSkills resolver materializeSkillStore produces.
- skill-store — The guide: browse providers behind a policy floor and import through a server-issued preview token.
- storage-supabase — The backing store: append-only versioned skills (0026) plus the org-scope columns (0030/0032).
- react — SkillStudio — the library-and-store UI that drives import/refresh over these providers.
- org-scope — The sub-org scope axis that partitions a stored skill's overlays by department inside one org.