Ship the screen with the capability.
A package can already contribute agents, tools and skills. What it could not contribute was its admin surface, so every adopter rebuilt the same panel, route family, client and nav entry by hand. A studio plugin is that missing unit: one object carrying a section's client, discovery, copy and UI, which a host registers in a line.
Nothing about the shell widened. A plugin is normalised into an ordinary AIStudioSection whose mount is a shell-authored wrapper closing over your client and your strings, so the frozen AIStudioSection / AIStudioClients / AIStudioStrings contracts are untouched and two plugins' strings can never collide.
Register one, or all three of ours
Three reference plugins ship with @nightowlsdev/react/studio, built on exactly the contract below and reaching no shell internal — the seam, their own transport, their own chrome: internal-tools (a read-only catalog projection), external-tools (connections, the MCP registry, and the grant-holders lookup), and knowledge (index enumeration, a search preview, and index-only deletion).
import {
AIStudio, createKnowledgePlugin, externalToolsPlugin, internalToolsPlugin,
} from "@nightowlsdev/react/studio";
// MODULE SCOPE, not inside the component: a plugin's object identity is its section's
// component identity (step 05). The two tools plugins call the runner's OWN routes, so they
// ride pluginClientOptions.base. The knowledge plane is mounted by you, wherever you put
// createKnowledgeHandlers — so that plugin carries its own base, which wins for its routes.
const knowledge = createKnowledgePlugin({ base: "/api/knowledge" });
export function Admin({ getToken }: { getToken: () => Promise<string | null> }) {
return (
<AIStudio
clients={{ agents, skills }}
plugins={[knowledge, externalToolsPlugin, internalToolsPlugin]}
pluginClientOptions={{ base: "/api/swarm", getToken }}
/>
);
}
// There is also a bare `knowledgePlugin` export. It is the zero-config form and assumes the
// five handlers sit DIRECTLY under pluginClientOptions.base — /api/swarm/stats, /sources,
// /search, /source, /source/delete. If yours are anywhere else, use the factory above.pluginClientOptions.base is a default for plugins that carry none, not a ceiling — one base could not serve every plugin, since nothing obliges two planes to live under one prefix. Each reference plugin also exports a create…Plugin(config) factory for a deployment whose paths, labels or copy differ, and a create…Client() for a host composing its own chrome.
The plugin object
import type { AIStudioPlugin } from "@nightowlsdev/react/studio";
export const reportsPlugin: AIStudioPlugin<ReportsClient> = {
id: "acme.reports", // namespaced by convention; a bare id may collide with a default
label: "Reports",
order: 40, // defaults are 10 agents / 20 skills / 30 graph
tier: "admin", // what the VIEWER needs — see step 03
strings: { title: "Reports", empty: "No reports yet" },
// Called at most once per (studio instance, plugin), lazily, at the first probe.
createClient: ({ base, getToken, fetch }) => createReportsClient({ base, getToken, fetch }),
// Availability is an AFFIRMATIVE typed payload, never a truthy response.
async discover(client) {
try {
const { ok, canWrite } = await client.health();
return ok ? { available: true, canWrite } : { available: false, reason: "Reports are disabled here." };
} catch {
return { available: false, reason: "The reports service did not answer." };
}
},
// Your own mount contract: your client, your strings. Never the shell's bags.
mount: ({ client, readOnly, strings }) => <ReportsPanel client={client} readOnly={readOnly} strings={strings} />,
};A truthy response is not availability. Only a literal available: true counts. An unmounted route under a Next catch-all answers 200 with an HTML shell — truthy, and completely useless — and a section that offers itself and then fails on every action is worse than one that hides. A probe that throws, or a client that cannot be constructed, renders the section unavailable with your reason.
Clients are built lazily, at the first probe, one per (studio instance, plugin) — never module-global, so two studios on one page pointed at different tenants never share a transport.
Lazy mount is not code splitting. The shell mounts a section only on first activation, so its component never runs until someone opens the tab — but a plugin object referencing mount directly is a static import, so the panel is downloaded with your plugin either way. (Our three reference plugins are in that shape.) If your panel is heavy, split it yourself — the shell renders the mount inside a Suspense boundary, so a lazy component works as-is:
import { lazy } from "react";
// MODULE SCOPE, like the plugin itself: lazy() called per render is a new component type
// every render — the same remount bug, one level down.
const ReportsPanel = lazy(() => import("./ReportsPanel"));
export const reportsPlugin: AIStudioPlugin<ReportsClient> = {
id: "acme.reports", label: "Reports", createClient, mount: ReportsPanel,
};
// Now the panel's chunk is fetched on first activation, and the shell's own
// <Suspense> shows its `loading` string while it arrives. This is exactly how the
// three built-in sections are wired.Declare the tier your routes actually enforce
tier is advisory for the tab and mandatory for your routes. The shell decides whether to offer the section; it can never be the authorization. Unknown resolves to admin — unknown means most restrictive.
type SectionTier = "admin" | "authenticated" | "participant" | "public";
// admin (default) — your routes sit behind runner.adminRouteGate(<your scope>)
// authenticated — bare auth.authenticate; any signed-in tenant member
// participant — the participation gate: a member of THIS container
// public — THROWS at construction. There is no anonymous-context
// seam: AuthContext requires tenantId AND userId, auth is
// required, and every route 401s a null session. A public
// tab would invite visitors its own routes reject.
// Host side, optional and NARROW-ONLY: absent ⇒ today's behaviour (an admin console);
// present ⇒ sections above the viewer's tier are hidden. It can only remove tabs.
<AIStudio clients={clients} plugins={plugins} viewerTier={session.tier} />;Declare the tier honestly, including down. The shipped knowledge plugin declares authenticated, not the admin default, because its plane authorizes reads for any tenant member and gates only deletion separately — claiming admin would hide a section the server happily serves. And not every section's guard is the runner's: @nightowlsdev/knowledge ships its own auth + authorizeWrite pair. A tier is a floor a section must satisfy, not a promise about which function enforces it.
Gate your route family on the console's own admin plane
An admin-tier plugin does not reimplement authorization. runner.adminRouteGate(scope) hands you the same default-deny gate every built-in route uses, bound to a scope you mint. The scope set is open, and an unknown scope denies: a plugin can name its own plane without a runner release, and a credential that never heard of it is refused.
// lib/reports-routes.ts — a FACTORY over the gate, so the same handlers you mount are
// the handlers you can test (see the deny test below). The gate is the only thing injected.
import type { AdminGateResult } from "@nightowlsdev/runner-nextjs";
export function acmeReportsRoutes(gate: (req: Request) => Promise<AdminGateResult>) {
return {
list: {
GET: async (req: Request): Promise<Response> => {
const g = await gate(req);
if (g instanceof Response) return g; // 401/403 already decided — return it unchanged
return Response.json({ reports: await reportsFor(g.tenantId), canWrite: !g.readOnly });
},
},
create: {
POST: async (req: Request): Promise<Response> => {
const g = await gate(req);
if (g instanceof Response) return g;
if (g.readOnly) return Response.json({ error: "read_only" }, { status: 403 });
return Response.json(await createReport(g.tenantId, g.actor, await req.json()), { status: 201 });
},
},
};
}
// app/api/acme/reports/route.ts — your family, your scope, the console's gate.
const routes = acmeReportsRoutes(runner.adminRouteGate("acme.reports"));
export const { GET } = routes.list;
export const { POST } = routes.create;
// The host then names your scope in its grant — and only for the people who should have it:
guardDefinitionAdmin: async (req, authCtx) => {
const role = await roleOf(authCtx.userId);
if (role === "owner") return { actor: operator(authCtx),
adminScopes: ["definitions", "integrations", "acme.reports"] };
if (role === "support") return { actor: operator(authCtx), adminScopes: ["acme.reports"] };
return null; // 403
};Write the deny test first, and watch it fail. The shipped { actor } grant resolves to ["definitions"], so a pre-existing full-admin credential must be refused by your family: adding a section to a console is not a reason for everyone who can edit a persona to inherit your plane. That is the exact property that was specified, approved, and then silently missing from a shipped release once already — a deny test that has never been red proves nothing.
import { describe, it, expect } from "vitest";
import { createNextjsRunner, type DefinitionAdminGrant } from "@nightowlsdev/runner-nextjs";
import type { AuthContext, AuthProvider, SwarmActor } from "@nightowlsdev/core";
import { acmeReportsRoutes } from "@/lib/reports-routes";
const SCOPE = "acme.reports";
const AUTH: AuthContext = { tenantId: "t", userId: "op" };
const HUMAN: SwarmActor = { type: "human", userId: "op", tenantId: "t" };
const auth = (session: AuthContext | null): AuthProvider => ({ authenticate: async () => session });
const get = () => new Request("http://x/api/acme/reports");
const post = () => new Request("http://x/api/acme/reports",
{ method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
// Did THIS request die on the scope axis? The SHAPE is the assertion — "not 200" is
// satisfied by a 400 or a 404 and would keep passing the day the gate vanished.
async function scopeDenial(res: Response): Promise<string | null> {
if (res.status !== 403) return null;
const body = await res.clone().json() as { reason?: string; requiredScope?: string };
return body.reason === "missing_admin_scope" ? (body.requiredScope ?? "") : null;
}
// engine and storage are your own test doubles — the gate touches neither.
const family = (grant: DefinitionAdminGrant | null, session: AuthContext | null = AUTH) =>
acmeReportsRoutes(
createNextjsRunner({ engine, auth: auth(session), storage, guardDefinitionAdmin: () => grant })
.adminRouteGate(SCOPE),
);
it("DENIES the shipped unscoped { actor } grant — reads AND writes", async () => {
// The FR-043 failure mode as a test: an existing full-admin credential must not inherit a
// plane it was never audited for, merely because a new section appeared in the console.
const flat: DefinitionAdminGrant = { actor: HUMAN }; // resolves to ["definitions"]
expect(await scopeDenial(await family(flat).list.GET(get()))).toBe(SCOPE);
expect(await scopeDenial(await family(flat).create.POST(post()))).toBe(SCOPE);
});
it("denies a credential scoped to a DIFFERENT plane, read and write alike", async () => {
const other: DefinitionAdminGrant = {
actor: HUMAN, adminScopes: ["definitions", "integrations", "models"],
};
expect(await scopeDenial(await family(other).list.GET(get()))).toBe(SCOPE);
expect(await scopeDenial(await family(other).create.POST(post()))).toBe(SCOPE);
});
it("401s an unauthenticated caller BEFORE any scope arithmetic", async () => {
const res = await family({ actor: HUMAN, adminScopes: [SCOPE] }, null).list.GET(get());
expect(res.status).toBe(401);
expect(await scopeDenial(res)).toBeNull(); // not a scope denial — another problem
});
it("is default-deny when the host wired NO guard at all", async () => {
// guardDefinitionAdmin is ABSENT here, not a hook returning null: a plugin cannot open a
// plane simply by existing in a deployment that never configured the admin seam.
const gate = createNextjsRunner({ engine, auth: auth(AUTH), storage }).adminRouteGate(SCOPE);
expect((await acmeReportsRoutes(gate).list.GET(get())).status).toBe(403);
});
it("serves the credential that names your scope, and carries readOnly into YOUR refusal", async () => {
const ro = family({ actor: HUMAN, readOnly: true, adminScopes: [SCOPE] });
const read = await ro.list.GET(get());
expect(read.status).toBe(200);
expect((await read.json()).canWrite).toBe(false);
const write = await ro.create.POST(post());
expect(write.status).toBe(403);
expect(await scopeDenial(write)).toBeNull(); // refused as readOnly, NOT as scope
});
it("mounting on an un-grantable scope throws at WIRING, not as a permanent silent 403", () => {
expect(() => createNextjsRunner({ engine, auth: auth(AUTH), storage }).adminRouteGate(""))
.toThrow(/non-empty string/);
});
// RED-PROVE IT: bind the family to "definitions" instead of SCOPE and the first test must
// fail. A deny test that has never been red proves nothing.Assert the shape, not merely “not 200”. Several routes legitimately answer 400 or 404 on a bare fixture, so a test that accepts any non-200 passes for reasons that have nothing to do with authorization — and would keep passing the day the gate disappeared. The two 403s are also different facts and stay distinguishable: a scope denial carries reason: "missing_admin_scope", while your own read-only refusal carries whatever you return.
Mounting on an empty scope name throws at the wiring call, not per request: a name no grant can carry would otherwise deny forever while looking correctly wired.
Keep the plugin object stable
A plugin's object identity is its section's component identity. A new object is a new component type, and React responds by unmounting the panel and mounting a fresh one — discarding a half-written edit or an open preview. Export a module-scope object, or memoize the factory call in the host.
// ✅ the contract: one stable object, module scope
import { reportsPlugin } from "@acme/reports/studio";
<AIStudio clients={clients} plugins={[reportsPlugin]} />;
// ✅ also fine: an inline ARRAY around a stable object
<AIStudio clients={clients} plugins={[reportsPlugin, billingPlugin]} />;
// ❌ a fresh object every render — React sees a new component TYPE, unmounts the
// section, and the operator loses whatever was in flight. The shell warns in dev.
<AIStudio clients={clients} plugins={[createReportsPlugin({ base })]} />;
// ✅ the fix: hoist it, or useMemo it in the host
const reports = useMemo(() => createReportsPlugin({ base }), [base]);The shell warns in development when the same id arrives as a different object, and a replaced plugin is re-probed rather than inheriting its predecessor's verdict — a warning is not a gate.
Ordering, replacement, and strings
order sorts the merged list against the defaults (agents 10, skills 20, graph 30); a section with no order lands after them, and ties keep registration order. Claiming a default's id replaces it in place — the shipped knowledge plugin uses order: 25 to sit between Skills and Graph, a position the append-only merge could not produce before. Two plugins claiming one id is a construction error, not a silent winner.
Your strings live in a side-table keyed by section id, handed to your mount and never merged into the shell's. Two plugins can both use title, and neither can repoint unavailable for the whole studio.