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.
Reach for this when
- an operator needs to see what is in the index and remove a source by hand;
- you are about to revoke a capability and want its blast radius first;
- a tenant manages its own Slack / Linear / Notion connections;
- admins register outbound HTTP MCP servers from a UI, not from source.
Reach for something else when
- you want thread recall — that is memory.semanticRecall, hard-pinned to the current conversation, not this arbitrary-document index;
- you want a number over time — @nightowlsdev/metrics, never a document you embed or a graph edge that keeps its first value;
- two parties must be unreachable to each other even through a host bug — give them separate orgs, not a scope;
- the server is stdio — those stay code-defined; the registry admits http alone.
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.
The management surface enumerates what retrieval hides
A retrieval tool fails closed to a visibility allow-list (default ["org"]), and quarantine may never appear in a configured one. The operator surface is the deliberate exception: listSources, getSource and stats enumerate every source — quarantined rows included — and flag what retrieval excludes (restricted, restrictedChunks). Hiding quarantined content from the only screen that can review or delete it would turn a boundary into an audit hole.
deleteSource returns { deleted }, and onDelete fires even when deleted === 0 — a delete that matched nothing is audited exactly like one that did, so the trail never has a silent gap and a scoped denial is not an existence oracle for what a sibling partition holds.
Common pitfalls
- Skipping authorizeWrite. Authorization is separate from authentication and default-deny: a valid session still 403s on deleteSource until authorizeWrite returns { actor }. Returning it both allows the write and attributes it.
- A loose mcpAllowedHosts. It is the load-bearing control, and DNS is deliberately not resolved. Pin exact hosts (or a leading dot for a domain and its subdomains), never a bare wildcard — private, loopback, link-local and the cloud metadata endpoint are rejected even if an entry would admit them.
- Forgetting the provider roster. integrationProviders is required — the seam has no enumerator, so an undeclared provider is uniformly absent rather than invisible-yet-reachable.
- Reading a throwing probe as healthy. A connection probe that throws reports unknown, never active. Do not surface a green badge on the strength of a broken check.
- Assuming grant-holders is always mounted. The route 404s when the engine has no listGrantHolders, and unresolvable delegates come back as warnings — a short holder list is only “safe” if warnings is empty.
- Running the index on 0001. Migration 0002 is required — its unique key includes source_type, which is what stops a page from silently overwriting a guide that shares its id.
Next
The AI Studio
Mount both planes as sections of one operator console.
Agent config surface
The admin routes and the default-deny guard these planes sit behind.
Document ingestion
How a whole document gets into the index these routes then enumerate and delete.
Building on Night Owls? See the source on GitHub.