Skip to content
Night Owls.dev
Jump to a page
Concept

Every agent detail. No code change, no reseed.

The agent-config surface is the engine, storage, and runner layer behind a DB-driven agent studio, the admin screen where an operator configures every agent detail (persona, model, delegates, tool and skill grants, audiences, memory, rules, workflow) and publishes it, DB-authoritative, with no code change and no reseed. FR-032 ships the surface, not the screens: a discoverable grantable-capability catalog, persisted-and-enforced per-agent rules, workflow, and memory (migration 0022), a full-config read seam, publish-time validation, and the runner admin route family behind one default-deny guard.

The themeable Agent Studio component that mounts on top of this surface is FR-033. Everything here is additive and opt-in: a code-authored, seeded swarm behaves byte-identically until a host wires the admin guard or publishes head rules, workflow, or memory, and a run that resolves no head rules takes zero new hot-path reads beyond the existing row cache.

The four things the surface adds

AskAnswerAPI
“What can I grant this agent?”the grantable-capability catalogengine.listGrantableCapabilities(ctx)
“What is this agent's full config?”the full-config readengine.getAgent(ctx, slug, version?)
“Will this publish resolve, or is it valid?”publish-time validationengine.validateAgentContent(ctx, content)
“Save every detail from the UI”the admin route familyrunner.configRoute() / catalogRoute() / publishRoute() / more

The three engine methods (listGrantableCapabilities, getAgent, and validateAgentContent) are all optional, the same optionality class as threadEvents: the core SwarmEngine implements them, and a tier-0 or tier-1 protocol adapter simply omits them. configRoute and catalogRoute return a 404 when their backing read or catalog method is absent. publishRoute runs validation only when validateAgentContent exists, otherwise it publishes without the pre-check.

The grantable-capability catalog

A tool-grant picker needs the set of grantable tools and skills, with their metadata: name, description, input schema, approval flag. The public SwarmTool handle deliberately drops all of that (it is only { name, needsApproval, origin }), so before FR-032 a host had to reconstruct the catalog from its own source. listGrantableCapabilities gives it to you directly.

catalog.ts
const { capabilities, warnings } = await engine.listGrantableCapabilities(ctx);

It returns an envelope, never a bare array. A partial-catalog failure (a dead connector connection, an unreadable stored skill) surfaces as a warnings entry the UI shows as “catalog incomplete”, never as a silently absent grant. Each entry is a plain-data projection: it never carries execute or a live Zod object.

grantable-capability.ts
interface GrantableCapability {
  name: string;                        // what a head's skillNames entry resolves
  kind: "tool" | "skill";              // a single callable vs a named grant bundling instructions/tools
  description?: string;
  source: "first-party" | "mcp" | "connector" | "stored-skill";  // catalog-only provenance
  tools: GrantableToolProjection[];    // one projection per callable (empty for an instruction-only skill)
  hasRules: boolean;                   // the grant also carries governance (skill-borne rules/workflows)
  hasWorkflows: boolean;
}
interface GrantableToolProjection {
  name: string;
  description?: string;
  inputSchema?: unknown;               // serialized JSON Schema (zod v4 z.toJSONSchema)
  serializable?: boolean;              // false means the schema could not serialize; the entry still enumerates
  needsApproval: boolean;
  executionSide: "server" | "client";  // client tools run in the browser and need host UI wiring
  mutating?: boolean;                  // tri-state: true, false, or undefined (render undefined as dangerous)
}

The catalog covers four sources in one deduped, tenant-scoped response:

  • code tools and code skills, walked off the built swarm's agents
  • connector tools, tenant-materialized, preserving the provider's original JSON schema
  • stored skills, the FR-027 store (kind: 'skill', instruction-only, tools: [])

Dedup mirrors runtime resolution, so the catalog never lies about what a grant does. On a name clash the precedence is code skill or tool → connector tool → stored skill (exactly the runtime resolver's order); a shadowed entry is dropped. Engine builtins (ask, scratchpad_write, recall_lane) and extraTools (get_page_context) are excluded. They are ambient, not grantable via skillNames.

source is catalog-only. It does not widen SwarmTool.origin ('first-party' | 'mcp' stays untouched at runtime); a connector tool is origin: 'first-party' on the run path but source: 'connector' in the catalog. mutating is deliberately tri-state: an unannotated tool reads as undefined and the studio renders that as dangerous (conservative UX), never as “safe”.

Reading a full config with getAgent

AgentSummary (the chat roster) stays minimal on purpose; the admin studio reads everything through this config plane instead.

get-agent.ts
const detail = await engine.getAgent(ctx, "support");   // AgentConfigDetail | null (null = unknown slug)
agent-config-detail.ts
interface AgentConfigDetail {
  content: AgentVersionContent;        // the full editable content: persona, model, delegates, capabilities,
                                       //   skillNames, memory, rules, workflow
  audiences: string[];                 // CURRENT exposure, read LIVE off the FR-031 head row ([] = trusted-only)
  headVersion: number;                 // the CAS token a publish passes as expectedHeadVersion
  enforcement: { rules: boolean; workflows: boolean };  // whether THIS engine can enforce persisted rules/workflows
  divergedFromCode?: boolean;          // head vs code-seed divergence, see the Authority model section below
  writable: boolean;                   // the backing store admits a version publish (ANDed with the guard's !readOnly)
}

Pass a version to read a specific historical version's content. audiences, headVersion, and divergedFromCode always reflect the live head: exposure and divergence are head properties, not versioned content.

Audiences live on the head row, not the version. The audiences field above is read live off the FR-031 head row ([] means trusted-only); a version publish or rollback never touches it, and it is edited only through its own audiencesRoute() write. See the Audience-scoped agents guide for the full caller-side reachability model.

Persisted rules, workflow, and memory (migration 0022)

Three per-agent facets were code-only or silently dropped before FR-032. Migration 0022 persists them on nightowls.agent_versions so a full agent definition round-trips through publish → head or getVersion → rollback.

FieldTypeMeaning
rulesRuleDef[]per-agent rules, normalized via defineRule at publish, default []
workflowWorkflowDef | nullan inline per-agent procedure (advisory or strict), normalized via defineWorkflow
memoryAgentMemoryOverride | nullfixes the silent drop: AgentVersion.memory existed but was never persisted
providerstring | nullFR-045, migration 0027: which provider serves modelId, so the allow-list gate and the model factory both resolve it to one vendor and usage prices at that vendor's rate. Authored, never parsed out of the id (gateway ids contain /, Ollama ids contain :). Absent means unqualified, and every pre-existing version reads back undefined and republishes byte-identically. See model providers.

The migration is purely additive: rules defaults to '[]', workflow and memory default to NULL, and the 0014 append-only immutability guard extends to the three new columns (a published version's rules can never be rewritten in place). Migration 0027 adds provider the same way, and enumerates it in the frozen-column trigger in the same migration. An un-enumerated column on an append-only table is silently mutable, which is the defect class 0023 and 0026 were written to close. Audiences do not ride this shape. They live on the nightowls.agents head row per FR-031, so a version publish or rollback can never alter exposure.

Additive-only enforcement. Published head rules are folded into the runtime decision additively: a DB rule can add a deny or an ask, it can never relax or override a code rule. The merge order at the tool seam is, most restrictive wins:

  1. the SP5 policy floor and host hook (fail-closed)
  2. code enforce rules (swarm, per-agent, and code-skill-borne, compiled at defineSwarm)
  3. runtime-skill-borne enforce rules (rules carried by a DB-granted skill)
  4. DB head enforce rules

Layers 3 and 4 can only tighten layers 1 and 2. advise (soft-policy) head rules render into the system prompt at each turn's instruction assembly. A strict head workflow drives the agent's turn only when no code workflow exists (code strict workflows always win, the workflow analog of additive-only). The dynamic layer reads through the 30 second row cache (R12 NOTIFY collapses cross-instance staleness to near immediate), so a newly published rule takes effect on the next tool call or generation, including mid-run. Tightening mid-run is the safe direction. The strict-workflow choice is fixed at run start and re-resolved at resume.

Persisted-but-unenforced honesty. An engine that cannot enforce rules or workflows never half-applies persisted config: the dynamic layer is gated on EngineCapabilities.rules and EngineCapabilities.workflows. Verified matrix: the core SwarmEngine is rules: true, workflows: true; engine-ai-sdk and engine-openai-agents are rules: true, workflows: false; the three protocol adapters are both false. So the surface is honest about what it cannot do:

  • the config GET carries enforcement: { rules, workflows } (from engine.capabilities) so the studio can banner persisted-but-inert config persistently
  • a publish response carries unenforced: { rules?: true; workflows?: true } whenever the content carries rules or a workflow this engine cannot enforce

Publish-time validation with validateAgentContent

A grant that resolves to nothing at runtime injects no tool, a silent dead grant. The publish path validates against the same deduped catalog.

agent-content-validation.ts
interface AgentContentValidation {
  unresolvedSkillNames: string[];   // skillNames outside the deduped catalog known-set
  ruleErrors: string[];             // per-rule defineRule normalization failures (collected, not thrown)
  workflowErrors: string[];         // defineWorkflow failure for the inline head workflow
  catalogWarnings?: string[];       // present means the catalog was INCOMPLETE, so absent names couldn't be confirmed
}

The engine owns all three catalog sources, so the dependency arrow stays engine → storage (never storage → code-registry). publishRoute rejects with 422 on any rule or workflow error, and on any unresolvedSkillNames unless the caller passes allowUnresolved (an admin may deliberately grant a stored skill they are about to import). When a catalog source is degraded, unconfirmable names move to catalogWarnings instead of a hard 422, so a transient connector outage never blocks an unrelated persona-only edit.

The admin route family, default-deny behind guardDefinitionAdmin

runner-nextjs ships six route factories for this family, all behind one host-supplied seam — and they are the "definitions" slice of it. The same hook also fronts the skill store (same scope), connections + the MCP registry + grant holders ("integrations") and the provider listing ("models"). One hook, three planes: a grant that names none of them means ["definitions"] and reaches exactly the routes in the table below.

route.ts
export const runner = createNextjsRunner({
  engine, auth, storage,
  // Map the authenticated HTTP caller to a definition-admin principal. Return null for a 403.
  // readOnly means GETs pass, writes 403. DEFAULT-DENY: omit this and every admin route below returns 403.
  guardDefinitionAdmin: async (req, authCtx) => {
    const user = await resolveOperator(req, authCtx);         // YOUR per-human admin decision
    if (!user?.isAgentAdmin) return null;                     // null means 403
    return {
      actor: { type: "human", userId: user.id, tenantId: authCtx.tenantId },
      // Which PLANES this credential may act on. Omit for ["definitions"] — fail-closed, and
      // exactly this family. Naming "integrations"/"models" also opens connections + the MCP
      // registry + grant holders / the provider listing, which are different authorities.
      adminScopes: user.isPlatformOwner ? ["definitions", "integrations", "models"] : ["definitions"],
    };
  },
});

The guard's returned actor is what every write is attributed to. There is no SEED_ACTOR fallback on the route paths, so audit_log records studio edits against the real operator, not system:seed. Authentication runs via the existing AuthProvider first (tenant and user), then the guard.

Route factoryMethod, default pathBehavior
configRoute()GET /api/swarm/agent-config?slug= reads one agent's AgentConfigDetail (content, audiences, headVersion, enforcement, divergedFromCode, writable). No slug reads the unfiltered admin roster { agents, canWrite, capabilities }, never the audience-filtered chat roster.
catalogRoute()GET /api/swarm/capability-catalog{ capabilities, warnings, models, audiences }, the grant catalog, the models.allow list, and the tenant's in-use audience vocabulary.
publishRoute()POST /api/swarm/agent-config{ slug, content, expectedHeadVersion?, allowUnresolved?, resetToCode? }, validates, then a CAS and no-op-aware publish, returning { published, version, unenforced?, unresolvedSkillNames? }.
rollbackRoute()POST /api/swarm/agent-rollback{ slug, toVersion, expectedHeadVersion? }, an append-only republish (carries rules, workflow, and memory forward), returning { published, version, restoredFrom }.
versionsRoute()GET /api/swarm/agent-versions?slug=&limit=&cursor= returns paginated history { versions, nextCursor } (newest first).
audiencesRoute()POST /api/swarm/agent-audiences{ slug, audiences }, the FR-031 head-row exposure write, same guard and actor. Audiences are edited here, never via publish.

Wire each like any other route file, for example export const { GET } = runner.configRoute();.

Typed errors. The Agent Studio (FR-033) binds these directly.

StatusMeaning
401unauthenticated (the AuthProvider returned null)
403guard denied, not configured (default-deny), or a readOnly grant attempting a write
404unknown slug or version, a method unsupported by this engine, or a read-only store
409CAS conflict: a stale expectedHeadVersion, the body carries the current { currentVersion }
422validation failed: { unresolvedSkillNames | ruleErrors | workflowErrors }

CAS and the no-op guard are atomic in the store. Two admins editing the same agent cannot silently clobber each other. expectedHeadVersion and the content-equality no-op check are decided atomically under the store's head lock: the read, the CAS check, the equality check, and the append happen in one critical section, so two concurrent same-version publishes can never both append (one wins, the other gets a 409). A save with zero changes (the common UI case) mints no junk version. It returns { published: false, version: headVersion } with no INSERT. The same expectedHeadVersion CAS guards rollbackRoute.

Authority model: the DB wins after divergence, code governance always tracks code

Seeding is seed-once: defineSwarm seeds a code agent's head only on first publish, and thereafter a code deploy never updates that head. So after the studio's first publish there are two potential sources of truth for an agent's head fields. FR-032 makes that split stated and visible instead of silent.

  • Doctrine. The DB head wins for head fields after first divergence. But engine-local governance (code rules and workflows, host hooks, and the SP5 policy floor) always tracks code and is structurally impossible to remove from the DB: it is never copied onto the seeded head, so a DB publish cannot drop it, and head rules fold additively, so a DB publish cannot relax them. The DB owns what the agent is; code owns the floor it can never sink below.
  • divergedFromCode. getAgent and the config GET compute it against the code seed: false means the DB head still equals the code seed, true means it has diverged, undefined means the slug has no code seed (a DB-born agent). The studio surfaces it so an operator sees a split-brain instead of guessing.
  • resetToCode. POST /api/swarm/agent-config with { resetToCode: true } republishes the code baseline (engine.getCodeAgent(slug)) as a new version: append-only, CAS-able, attributed to the operator. It is the deliberate, audited convergence action, not a magic background sync. (An engine with no code baseline for the slug, a DB-born agent, returns a 404 for the request.)

API reference

  • engine.listGrantableCapabilities?(ctx): Promise<{ capabilities: GrantableCapability[]; warnings: string[] }>, the tenant-scoped, deduped grant catalog (code tools and skills, connector actions, and stored skills).
  • engine.getAgent?(ctx, slug, version?): Promise<AgentConfigDetail | null>, the full editable config plus the live head-row audiences, headVersion, enforcement, divergedFromCode, and writable.
  • engine.validateAgentContent?(ctx, content): Promise<AgentContentValidation>, publish-time grant and rule or workflow validation.
  • engine.getCodeAgent?(slug): AgentVersionContent | null, the code-defined baseline for resetToCode and divergedFromCode (sync, ctx-less, a static swarm config read).
  • engine.allowedModels?(): string[], the swarm's models.allow, so a model picker validates against the real allow-list.
  • GrantableCapability / GrantableToolProjection / AgentConfigDetail / AgentContentValidation, the plain-data projection, read, and validation types (all JSON-safe, no execute, no live Zod).
  • AgentVersionContent.rules?: RuleDef[] / .workflow?: WorkflowDef / .memory?: AgentMemoryOverride, the persisted per-agent facets (migration 0022). Additive-only at runtime, capability-gated.
  • NextjsRunnerOpts.guardDefinitionAdmin?, the single default-deny hook in front of the admin plane. Returns { actor: SwarmActor; readOnly?: boolean; adminScopes?: readonly AdminScope[] } | null. adminScopes selects the planes — "definitions" | "integrations" | "models" or a name of your own — absent meaning ["definitions"], fail-closed.
  • runner.adminRouteGate(scope), that same bound default-deny gate for a route family this package does not ship, so a plugin never reimplements authorization.
  • runner.configRoute() / catalogRoute() / publishRoute() / rollbackRoute() / versionsRoute() / audiencesRoute(), the six admin route factories.

Engine authors: the FR-032 read and validation methods are optional and shape-checked when present. See E21 (grantable-catalog and config read) and E22 (additive head-rule enforcement, capability-gated, tighten-only, and code-wins workflows) in ENGINE-CONTRACT.md and the verifyEngineContract harness. Full design rationale lives in the FR-032 agent-config-surface design doc (docs/superpowers/specs/2026-07-05-fr032-agent-config-surface-design.md).