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

One console. Not four admin surfaces.

Agents, skills, knowledge, tools, and the graph workbench are five separate components. <AIStudio> composes them into one operator console with a section registry. It is a new composition layer, not a refactor: every one of those components keeps working standalone, byte-identically.

Ships stable. The section contract and shell props are frozen on arrival, so later enrichment rides optional context feeds rather than widened props. It lives on the @nightowlsdev/react/studio subpath alongside the still-experimental <AgentStudio>.

Mount it, and wire only the sections you have

Availability defaults to the simplest honest question: is my client here? A deployment that registers no knowledge plugin never sees a Knowledge tab. There is no placeholder state to design and no dead tab to explain.

admin.tsx
import {
  AIStudio, createEndpointsClient, createSkillEndpointsClient,
  createKnowledgePlugin, externalToolsPlugin, internalToolsPlugin,
} from "@nightowlsdev/react/studio";

// Knowledge handlers are mounted by YOU, so that plugin carries its own base — which wins
// over pluginClientOptions for its routes. Module scope: a plugin's object identity is its
// section's component identity.
const knowledge = createKnowledgePlugin({ base: "/api/knowledge" });

// One mount. Three built-in sections come from the clients bag; the rest arrive as
// PLUGINS, each carrying its own client. A section you wire neither way simply does
// not appear — there is no "coming soon" state and no dead tab.
export function Admin({ getToken }: { getToken: () => Promise<string | null> }) {
  return (
    <AIStudio
      clients={{
        agents: createEndpointsClient({ base: "/api/swarm", getToken }),
        skills: createSkillEndpointsClient({ base: "/api/swarm", getToken }),
        graph: myGraphClient,              // optional (@nightowlsdev/graph-react)
      }}
      // Knowledge, External tools and Internal tools ship as reference plugins.
      // Registering none of them costs nothing; each probes its own backend.
      plugins={[knowledge, externalToolsPlugin, internalToolsPlugin]}
      pluginClientOptions={{ base: "/api/swarm", getToken }}
      defaultSection="agents"
    />
  );
}

The clients bag has a fixed member list, so a section it does not name gets its client from a plugin instead — including the three we ship. That is also why the bag was never widened for them: a plugin owns its own transport, its own discovery and its own copy.

Add, replace, reorder, or hide a section

The registry merges by id, so you replace a default without reimplementing the shell, and you can add a section that has nothing to do with the framework. Sections mount lazily on first activation. One nobody opens costs nothing, including its bundle.

sections.tsx
// `sections` MERGES over the defaults by id. Replace one, add your own, or reorder
// (order is render order). `hiddenSections` removes one outright.
<AIStudio
  clients={clients}
  sections={[
    { id: "billing", label: "Billing", mount: BillingPanel },   // added
    { id: "agents", label: "Team", mount: DefaultAgentsPanel }, // relabelled in place
  ]}
  hiddenSections={["graph"]}
/>;

// Controlled: bind to your router for deep-linkable sections.
<AIStudio
  clients={clients}
  activeSection={segment}
  onSectionChange={(id) => router.push(`/admin/${id}`)}
/>;

The graph section resolves its package at runtime. @nightowlsdev/graph-react is an optional peer, and a static import would break the build of every host that does not install it, which is the opposite of optional. So react never statically references it, and a failed resolution marks the section unavailable instead of throwing into your tree.

Server discovery, and what happens when it fails

A section can supply a discover() probe when presence of a client is not the real answer, for instance when the section exists but this operator may not use it.

discover.ts
// A section may probe the server for availability instead of relying on client presence.
const section = {
  id: "knowledge",
  label: "Knowledge",
  mount: KnowledgePanel,
  async discover(clients) {
    const res = await fetch("/api/swarm/knowledge/stats");
    return { available: res.ok, canWrite: res.status !== 403 };
  },
};

// A probe that THROWS renders the section unavailable, never available-by-default.
// An indeterminate answer is treated as "no": a studio that offers a section it cannot
// serve is worse than one that hides it.

readOnly can only ever narrow. The prop tightens what server discovery already permits and can never widen it, because the browser is untrusted UI everywhere in this family: enforcement lives behind guardDefinitionAdmin, and the visible state is an affordance, never the boundary.

One guard hook, scoped grants

The runner's admin routes sit behind one guardDefinitionAdmin seam — one hook per deployment rather than a second guard per feature. It is default-deny: leave it unconfigured and every admin route returns 403.

One hook is not one privilege. The grant carries adminScopes, and the planes behind the hook are not interchangeable: "definitions" (agent config + the skill store), "integrations" (connections, the MCP registry, grant holders) and "models" (the provider listing). Editing a persona is not the authority to revoke a tenant's credentials. Omit the field and a grant means ["definitions"] — fail-closed, so a credential written before the axis existed keeps its agent-config access and is refused by the other two.

A section this package does not ship gates its own routes on runner.adminRouteGate("acme.reports") — the same bound gate, a name of its own. Not every section's backend is the runner's, either: the knowledge plane ships its own auth + authorizeWrite pair in @nightowlsdev/knowledge. A tier is a floor a section must satisfy, not a promise that one function enforces it.

lib/swarm.ts
// ONE guard hook. Leave guardDefinitionAdmin unset and every admin route below
// returns 403 — the plane is default-deny.
const runner = createNextjsRunner({
  swarm, auth, storage,
  guardDefinitionAdmin,   // → { actor, readOnly?, adminScopes? } | null
  skillPlane,        // FR-040 (see the Skill store guide)
  connectionsPlane,  // FR-043 (integrations + the MCP registry)
});

// The grant names the PLANES it may act on. Absent ⇒ ["definitions"], fail-closed —
// so "everything" must be spelled out, even for an owner.
guardDefinitionAdmin: async (req, authCtx) => {
  const role = await roleOf(authCtx.userId);
  if (role === "owner")  return { actor: operator(authCtx),
                                  adminScopes: ["definitions", "integrations", "models"] };
  if (role === "editor") return { actor: operator(authCtx), adminScopes: ["definitions"] };
  return null;                                                                    // 403
}

// definitions  → agent-config/route.ts       → configRoute() + publishRoute()
// definitions  → capability-catalog/route.ts → catalogRoute()
// definitions  → skills/route.ts             → skillStoreRoutes().collection
// integrations → connections/route.ts        → connectionsRoutes().list
// integrations → grant-holders/route.ts      → grantHoldersRoute()
// models       → model-providers/route.ts    → modelProvidersRoute()
// …one route file per entry; one hook, three scopes.

Two of those planes throw at wiring time rather than serving broken routes: a skill plane with no preview secret, and a connections plane whose MCP registry has no host allowlist. Both misconfigurations look fine at a glance and silently are not, so they are made unable to reach production at all.

One theming namespace

Every studio surface (chat, agent studio, skill studio, and the graph workbench) now themes through the same --owl-* custom properties. graph-react previously used its own --graph-* prefix; it migrated fully, because two prefixes for one concept is exactly what stops a design system from being one.