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.
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.
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 searchDuplicate 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.
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.
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 · refreshWhy 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.
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.