---
name: nightowls-ai-studio
description: Use when building an operator or admin screen for Night Owls, mounting AIStudio, SkillStudio or AgentStudio, or editing agent configuration from a database instead of code.
license: MIT
metadata:
  source: https://nightowls.dev/skills/nightowls-ai-studio.md
  verified: "2026-07-28"
---

# Mounting the operator console

`<AIStudio>` composes agents, skills, knowledge, tools and the graph workbench into one console.
Ships in `@nightowlsdev/react@^2.11.0` on the `./studio` subpath.

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

// Module scope. The knowledge handlers are mounted by you, so that plugin carries its own
// base; pluginClientOptions.base is only the default for plugins that name none.
const knowledge = createKnowledgePlugin({ base: "/api/knowledge" });

<AIStudio
  clients={{
    agents: createEndpointsClient({ base: "/api/swarm", getToken }),
    skills: createSkillEndpointsClient({ base: "/api/swarm", getToken }),
  }}
  // Knowledge and the two tools sections are PLUGINS, each owning its own client.
  plugins={[knowledge, externalToolsPlugin, internalToolsPlugin]}
  pluginClientOptions={{ base: "/api/swarm", getToken }}
  defaultSection="agents"
/>;
```

The clients bag has a fixed member list, so anything outside it (`knowledge`, the tools sections,
your own `acme.reports`) arrives as a plugin: one object carrying a section's `createClient`,
`discover`, `mount`, `strings`, `order` and `tier`. See `/docs/studio-plugins`.

Server side, the runner's admin routes ride one seam — one *hook*, not one privilege:

```ts
createNextjsRunner({ engine, auth, storage, guardDefinitionAdmin });

// The grant names the planes it may act on. Absent ⇒ ["definitions"], fail-closed.
guardDefinitionAdmin: async (req, authCtx) => ({
  actor: operator(authCtx),
  adminScopes: ["definitions"],          // | "integrations" | "models" | your own
});
```

`guardDefinitionAdmin` is **default-deny**. Leave it unset and every admin route returns 403. That
is not a bug to work around.

`adminScopes` splits the plane: `"definitions"` (agent config + skill store), `"integrations"`
(connections, the MCP registry, grant holders), `"models"` (the provider listing). Editing a persona is
not the authority to revoke a tenant's credentials, so a grant that names neither of the latter two is
refused there with `403 { requiredScope }`. A section this package does not ship gates on
`runner.adminRouteGate("acme.reports")`.

Not every section's backend is the runner's, either: the knowledge plane ships its own `auth` +
`authorizeWrite` in `@nightowlsdev/knowledge`. A section's tier is a floor it must satisfy, not a promise
that one function enforces it.

## Props that exist, and one that does not

`<AIStudio>`: `clients`, `plugins`, `pluginClientOptions`, `viewerTier`, `sections`, `hiddenSections`,
`activeSection`, `defaultSection`, `onSectionChange`, `readOnly`, `strings`, `components`, `onAuthError`.

`<SkillStudio>`: `client`, `readOnly`, `selectedSkill`, `onSelectSkill`, `components`, `onAuthError`.

Neither takes a `theme` prop. Mount inside a `<SwarmProvider>` to inherit theme, the way
`<AgentStudio>` does.

## What will surprise you

A section whose client you do not pass does not render at all. There is no placeholder and no
disabled tab. If a section is missing, you did not pass its client — or, for anything outside the
fixed clients bag, you did not register its plugin.

A plugin's object identity is its section's component identity. `plugins={[makePlugin()]}` written
inline mints a new object every render, which React treats as a new component type: the section
remounts and the operator loses whatever was in flight. Export it from module scope, or `useMemo` it.

`tier` is advisory for the tab and mandatory for your routes. Unknown resolves to `admin`. `"public"`
throws at construction — there is no anonymous-context seam to serve it. `viewerTier` is narrow-only:
absent means no filtering at all, and present can only remove tabs.

`readOnly` can only narrow what server discovery already permits. It cannot widen it.

A `discover()` probe that throws marks the section unavailable, never available. An indeterminate
answer is treated as no, because a console that offers a section it cannot serve is worse than one
that hides it.

Sections mount lazily on first activation, so do not expect a section's effects to have run before
an operator clicks it.

`@nightowlsdev/graph-react` is an optional peer resolved at runtime. A host without it installed
builds fine and simply sees that section unavailable.

## Stability

`<AIStudio>` and `<SkillStudio>` are stable, with props frozen on arrival. `<AgentStudio>` on the
same subpath is still experimental and may change shape in a minor release.
