Knowledge and tools. The two planes an operator actually asks about.
Ingesting a document and granting a tool are easy. The hard questions come later: what is in this index and how do I remove something, and who currently holds this capability, and what breaks if I revoke it. These two planes answer them. The tools plane sits behind the runner's default-deny admin hook on the "integrations" scope — a persona-editing credential does not reach it. The knowledge plane carries its own guard entirely (its auth + authorizeWrite pair, in @nightowlsdev/knowledge), which is worth knowing before you assume one function gates the whole console.
Enumerate, inspect, and delete knowledge sources
The knowledge store gained listSources, getSource, deleteSource, and stats, plus HTTP handlers.
import { createKnowledgeHandlers } from "@nightowlsdev/knowledge";
const handlers = createKnowledgeHandlers({
store,
// MANDATORY. There is no unauthenticated path: null (or a throw) ⇒ 401.
auth: async (req) => resolveSession(req), // → { orgId, … } | null
// Authorization is SEPARATE from authentication, and default-deny: omit this and
// deleteSource always 403s. Return null to deny; return { actor } to allow AND attribute.
authorizeWrite: async (req, session) => (isAdmin(session) ? { actor: session.actor } : null),
onDelete: (info) => audit.record(info), // fires even when deleted === 0
});
// → { search, sources, source, stats, deleteSource }
// `source` is a direct two-query lookup rather than a filter over the paginated
// `sources` list. A lookup that rides pagination can only find sources on the
// current page, which fails exactly when a library is large enough to matter.Search terms escape LIKE metacharacters, so a % typed by a user is a literal percent rather than a full-table scan.
Upgrading: migration 0002 is required
This fixes live data loss. On 0001, two sources of different types sharing an id collided, and one silently overwrote the other's text. A pre-existing test appeared to cover the case and passed both before and after the fix, because it counted rows, and the collision produces a row. The regression test asserts on chunk text, which is what actually distinguishes them.
// ⚠ @nightowlsdev/knowledge 0.2.0 requires migration 0002.
import { knowledgeMigrations } from "@nightowlsdev/knowledge";
for (const m of knowledgeMigrations({ dimensions: 1536 })) await apply(m.sql);
// 0001's unique key did not include source_type, so ingesting a "page" whose id
// collided with an existing "guide" OVERWROTE the guide's text. The store fix is
// hard-coupled to 0002: a conflict target requires a matching unique index, so
// there is no version of this change that runs against the old schema. It fails
// loudly rather than silently writing to the wrong key.updated_at backfills to created_at, so existing rows report their first ingest rather than a fabricated “now”, a lie that would have been indistinguishable from real data later.
Who holds this capability?
The capability catalog answers “what can be granted”. Before a revoke, an operator needs the reverse: who holds it now. Without that, auditing a dangerous tool means opening every agent one by one, which is why adopters hand-maintain capability lists that then drift from DB-edited grants.
// app/api/swarm/grant-holders/route.ts
export const { GET } = runner.grantHoldersRoute(); // scope: "integrations"
// GET /api/swarm/grant-holders?name=send_email
// { capability: "send_email",
// holders: [{ slug: "editor", via: "toolNameAlias", viaGrant: "mailer" },
// { slug: "root", via: "delegate", path: ["editor"] }],
// warnings: [] }
// …or in-process, which is what the route calls:
const { holders, warnings } = await engine.listGrantHolders(ctx, "send_email");
// Published HEADS only, because a draft is not a grant. A grant matches by its own name OR
// by a code skill whose inner tools include it, because both are real grants at
// runtime; counting only exact names understates blast radius exactly when a
// dangerous tool was handed out as part of a skill.Delegation reach is traversed breadth-first, bounded by the runtime's own depth limit and a visited set, reporting the shortest path, so the answer can never claim reachability a real run could not achieve. Unresolvable delegates surface as warnings rather than being dropped: a short list here reads as “safe”, and must never be wrong quietly. The route applies the same rule to itself — a row it cannot rebuild through its field allowlist is dropped and named in warnings.
The route is read-only and gated on the "integrations" scope — the same plane that performs revokes, because blast radius is what makes a revoke safe. It needs no connectionsPlane: it reads the tenant's own published heads, so it answers in a deployment with no connectors at all. It 404s when the engine has no listGrantHolders.
Connections and the MCP registry
const runner = createNextjsRunner({
swarm, auth, storage, guardDefinitionAdmin,
connectionsPlane: {
integrations: myIntegrationProvider,
// REQUIRED: the seam has no enumerator, so "list all connections" has nothing
// to iterate. The host is the only party that knows what a deployment offers.
integrationProviders: ["slack", "linear", "notion"],
mcpRegistry,
// REQUIRED alongside mcpRegistry, because a row carries a URL AND a credential ref,
// and the connector injects that credential into that URL.
mcpAllowedHosts: ["mcp.example.com", ".corp.example"],
},
});
// list · connect · revoke · tools · mcpServers · mcpProbeA row's status can read unknown (the adapter cannot probe) or static (configured in the environment). Both live at the transport boundary only, so no adapter is asked to return a value its own type forbids. A probe that throws reports unknown, never active, because reporting a connection healthy on the strength of a broken check is the one answer this route must not give.
The declared roster is the canonical boundary, checked by every endpoint, so an undeclared provider is uniformly absent rather than invisible in the UI yet still reachable by direct API call.
Why the MCP registry is HTTP-only
A stdio MCP server is defined by a command and args, and the MCP client executes that definition. Persisting it in a UI-editable table would turn an admin CRUD screen into a remote-code-execution surface. So the table admits http alone by CHECK constraint and has no command columns to fill. A future transport must be added deliberately, in a migration, with that question answered again. stdio servers stay code-defined, where they get code review and deploy-time control.
URLs are validated before they are ever persisted: https only, an allowlisted host (exact, or a leading dot for a domain and its subdomains, with no bare wildcards), and private, loopback, and link-local literals rejected even when an allowlist entry would admit them, including the cloud metadata endpoint in both plain and IPv6-mapped notation.
DNS is deliberately not resolved: this runs where a resolver may be unavailable, and a check that silently no-ops on some runtimes is worse than one that is honestly narrow. Pin mcpAllowedHosts tightly, because it is the load-bearing control. Credentials are stored as vault refs, never raw, so the table is safe for an admin UI to read.