Skip to content
Night Owls.dev
Jump to a page
Guide · Stable

The skill store. Import, but behind a real gate.

An imported skill is third-party SKILL.md text that lands in your agents' system prompts. The store makes that safe to operate: a registry of the sources a deployment offers, update checks that report drift without installing it, ten admin route groups, and a mountable <SkillStudio>. Imported skills stay instruction-only and can never carry executable grants.

registrydrift checkspreview + import<SkillStudio>

Reach for this when

  • you want operators to pull methodology (SEO, copywriting, AEO) from a directory;
  • third-party instruction text must clear human review before it reaches a prompt;
  • you want drift badges without an update quietly installing itself;
  • a tenant should curate its own skill library, versioned and auditable.

Reach for something else when

  • the skill must call a tool — that is a code skill (defineSkill) in your source, not an import;
  • you author the skill yourself and deploy it — keep it code-defined, where it gets code review and deploy-time control;
  • you just want to grant an existing stored skill — that is a skillNames entry on an agent version, no store UI involved.

A registry of sources, with a policy floor

Importing takes one provider; a UI needs the set: which sources exist, what each can do, and one search across all of them. describe() reports capability by presence rather than a hand-maintained flag, so a picker cannot offer a search box for a provider that has no search.

lib/skills.ts
import {
  createSkillProviderRegistry, skillsShProvider, githubSkillProvider,
} from "@nightowlsdev/skills";

const registry = createSkillProviderRegistry({
  providers: [skillsShProvider(), githubSkillProvider()],
  // A FLOOR, not a default: a per-call policy can tighten this, never loosen it.
  // Allowlists intersect, byte caps take the minimum, and BOTH authorize hooks run.
  policy: { allowAuthors: ["acme"], maxInstructionBytes: 40_000, authorize: assertIsAdmin },
});

registry.describe();   // [{ id, displayName, refHint?, capabilities: { canList, canAudit } }, …]
registry.isLocal("manual");                  // a local provider has no upstream to track
await registry.search({ q: "seo" }, gate);   // { listings, warnings }. One provider down is a
                                             // PARTIAL result, not a failed search

Duplicate provider ids are rejected at construction. Two providers answering to one id makes every later describe, search, and import ambiguous, and the ambiguity would surface as a mis-attributed import long after the wiring mistake.

Check for updates without installing them

refreshSkill fetches upstream and publishes when the text changed. That is the wrong operation behind a badge, so drift detection is its own non-mutating call.

updates.ts
import { checkSkillUpdates } from "@nightowlsdev/skills";

// Reports drift. Publishes NOTHING, because showing "3 skills have updates" must not
// itself install those updates.
const statuses = await checkSkillUpdates({
  registry, storage, tenantId, actor,
  names: ["seo-writing"],  // omit ⇒ every stored skill
  persist: true,           // record each verdict on the head row (CAS-guarded)
});
// → a discriminated union per skill, keyed by `name` + `status`:
//   { name, status: "up-to-date" | "update-available", headSourceVersion, upstreamSourceVersion, checkedAt }
//   { name, status: "local-only" | "missing-provider", provider }
//   { name, status: "name-drift", upstreamName } · { name, status: "missing-skill" }
//   { name, status: "error", error }

// Providers are checked through a bounded worker pool, not Promise.all:
// skills.sh publishes a 600 req/min limit, and a few hundred skills fanned out
// at once would burst it and turn every badge into a 429.

With persist, the verdict is recorded on the skill's head row and a publish clears it, so a badge can never outlive the drift it describes. Requires @nightowlsdev/storage-supabase 2.7.0 (migration 0024).

Mount the routes

All ten route groups sit behind the same default-deny guardDefinitionAdmin as the agent-config family, and on the same "definitions" scope — a stored skill is a definition, not an integration, so a credential that reaches agent config reaches these too and one that does not, does not. The provider machinery is injected structurally, so runner-nextjs keeps zero dependency on @nightowlsdev/skills. A chat-only adopter must not inherit it merely because the runner can serve these routes.

lib/swarm.ts
const runner = createNextjsRunner({
  swarm, auth, storage, guardDefinitionAdmin,
  skillPlane: {
    registry,
    previewSecret: process.env.SKILL_PREVIEW_SECRET!,   // REQUIRED (throws without it)
    allowlistedHosts: ["raw.githubusercontent.com"],    // REQUIRED for http-family providers
    previewSkill, importSkill, refreshSkill, refreshAllSkills, checkSkillUpdates,
  },
});

// skills/route.ts          → skillStoreRoutes().collection   (GET list · POST create · PUT update)
// skills/preview/route.ts  → skillStoreRoutes().preview      (mints the token)
// skills/import/route.ts   → skillStoreRoutes().import       (redeems it)
// …providers · search · detail · versions · rollback · fork · refresh

Why import needs a server-issued token

The first design gated import on expectedName / expectedSourceVersion. Those close the window between preview and import, but they are values the caller supplies, so a direct POST /import with both hand-filled bypassed the gate entirely. And what it bypassed is human review of third-party instruction text headed for your agents' prompts.

preview now mints a short-lived HMAC token bound to { tenant, actor, name, sourceVersion }, and import accepts only that. An expired token returns 410 with a reason so the UI can offer a re-preview; every other token failure returns an opaque 403, because distinguishing a bad signature from a wrong tenant would hand an attacker an oracle.

A plane wired without previewSecret throws at construction rather than falling back to trusting the request body. A gate that quietly stopped gating is worse than no gate, because operators believe imports are reviewed.

Mount the studio

Browse the library with drift badges, edit and publish instruction text, walk version history with a field-level diff, browse providers, preview a candidate's fenced text, and import it. The Import button is reachable only from a preview, because preview is what mints the token and what a human actually reviews.

admin-skills.tsx
import { SkillStudio, createSkillEndpointsClient } from "@nightowlsdev/react/studio";

<SkillStudio client={createSkillEndpointsClient({ base: "/api/swarm", getToken })} />;

// Props: client · readOnly? · selectedSkill? / onSelectSkill? · components? · onAuthError?
// No theme prop. Mount inside a <SwarmProvider> to inherit theme, as <AgentStudio> does.

// Provider capability is signalled by OMISSION. A client without listProviders/search/
// preview/import hides the browse chrome entirely. A present-but-throwing method would
// render browse UI that fails at click time, which is worse than not offering it.

Seven slots are overridable via components, each defaulting to a working implementation. The diff uses an LCS rather than a line-count heuristic: a review gate is only as good as what a reviewer can actually read.

What an imported skill can never do

  • Carry tools. The imported-skill type has no tools field at all, and core's tool and gate paths never consult dynamic skills. A code skill always wins a name clash, and built-in tool names are unclaimable.
  • Escape its fence. Every imported instruction block is wrapped in an untrusted="true" fence with embedded delimiters neutralized, labelled as third-party reference material that cannot grant tools or override policy.
  • Silently change source. Instructions decide whether to publish for a given source, but a change of source identity always publishes. Otherwise an authorized source swap with byte-identical text would leave head pointing at the abandoned upstream, and every later refresh would pull the wrong provider.

Code skills and imported skills are different tools

The store only ever handles the second column. A skill that needs to do something — call a tool, hit an API — is authored in your source as a defineSkill({ name, instructions, tools }) and lives where it gets code review and deploy-time control. An imported skill teaches methodology and nothing more.

 Code skillImported skill
Defined inyour sourcea provider's SKILL.md
Can carry toolsYesNever — instruction-only, no tools field
In a system prompttrusted, first-partywrapped in an untrusted="true" fence
Name clashwinsloses to the code skill
Versioningyour VCS + deployappend-only store, drift-tracked, forkable

Common pitfalls

  • A plane wired without previewSecret. It throws at construction rather than falling back to trusting the request body — a gate that quietly stopped gating is worse than no gate.
  • Forgetting allowlistedHosts. It is required for the http-family providers; the import fetch will not reach an un-allowlisted host.
  • Hand-filling an import. A direct POST /import with name / sourceVersion is rejected — import accepts only the server-minted HMAC token that preview issues, because the token is what a human actually reviewed.
  • Fanning out with Promise.all. Update checks run through a bounded worker pool: skills.sh publishes a 600 req/min limit, and a few hundred skills fired at once would 429 every badge.
  • Expecting persist without the migration. Recording drift on the head row needs @nightowlsdev/storage-supabase 2.7.0 (migration 0024); a publish then clears the badge it wrote.
  • Reading the policy as a default. It is a floor: a per-call policy intersects allowlists and takes the minimum byte cap — it can tighten the registry-wide one, never loosen it, and both authorize hooks run.