From zero to a running swarm
Install the Night Owls framework, define one agent, and run it locally on just a model key, then wire storage, auth, runner, and UI adapters when you're ready.
The mental model, in one line. Night Owls is a governed multi-agent engine wrapped in an engine wall: @nightowlsdev/core owns the run loop, the cost governor, the approval floor, and the typed event stream, and everything else is a swappable adapter — the model provider, the storage backend, the runner, auth, telemetry. A swarm is the composed config you hand the engine: a set of agents, each with a role, a persona, skills (typed tools plus instructions), optional delegates, a model, and a per-run budget. You can go from an in-memory dev swarm to a Supabase-backed, Next.js-served, React-rendered one by swapping adapters, never the agent code.
What you need: Node 20+ and a TypeScript project, a single model provider key (this guide uses ANTHROPIC_API_KEY), and nothing else — no database, no account, no hosted service. Storage, auth, and a durable runner come later, only when you need persistence and human-in-the-loop resume.
Install
Add the engine core and a single model provider. The local LibSQL store lets you run a swarm with nothing but a model key, no database required yet.
# The engine core + a model provider (add more providers later; they compose)
pnpm add @nightowlsdev/core @nightowlsdev/provider-anthropic
# Local LibSQL snapshot store, run on just a model key, before Supabase
pnpm add @nightowlsdev/storage-localScaffold with the CLI
The owl CLI bootstraps a host: it writes nightowls.config.ts, merges adapter env vars, scaffolds files, and ejects each adapter's migrations into supabase/migrations/. Night Owls never runs DDL, you apply migrations with your own tooling.
# Scaffold nightowls.config.ts + .env.example and pick your adapters
npx owl init
# Add an adapter later (merges env, scaffolds files, ejects its migrations)
npx owl install storage-supabase
# List installed plugins; generate DB types
npx owl plugins
npx owl db typesDefine your first agent
Declare a typed tool with defineTool, bundle it into a skill, give an agent a role and personality, then compose a runnable swarm. The swarm reads ANTHROPIC_API_KEY from the environment by default.
import { z } from "zod";
import {
defineTool,
defineSkill,
defineAgent,
defineSwarm,
InMemoryStorage,
} from "@nightowlsdev/core";
import { anthropicModels } from "@nightowlsdev/provider-anthropic";
import { createMastraLibsqlStore } from "@nightowlsdev/storage-local";
// 1. A typed tool, the smallest unit of capability.
const greet = defineTool({
name: "greet",
description: "Greet a person by name.",
inputSchema: z.object({ name: z.string() }),
outputSchema: z.object({ greeting: z.string() }),
execute: async ({ name }) => ({ greeting: `Hello, ${name}!` }),
});
// 2. An agent: a role, a personality, and a set of skills.
const helper = defineAgent({
slug: "helper",
role: "orchestrator",
personality: "A friendly assistant who greets people warmly.",
skills: [defineSkill(greet)],
modelId: "claude-sonnet-4-5",
});
// 3. Compose a runnable swarm. This IS the whole minimal config — every field
// below is required by defineSwarm, and each has a zero-infra dev value:
const swarm = defineSwarm({
agents: [helper],
// In-memory store: run locally with no database (swap for storage-supabase in prod).
storage: new InMemoryStorage(),
// The per-tenant model allow-list every resolved model must pass.
models: { allow: ["claude-sonnet-4-5"] },
// Reads ANTHROPIC_API_KEY from the environment by default.
modelFactory: anthropicModels(),
// A simple per-run budget: at most 20 steps and $1 of model spend.
cost: { maxSteps: 20, maxCostUsd: 1 },
// Durable snapshot store for human-in-the-loop resume. :memory: for a single
// session; an absolute file: url to resume across process restarts.
mastraStore: createMastraLibsqlStore({ url: ":memory:" }),
});Run it
Construct a SwarmEngine and drain the typed SwarmEvent stream. Each run emits assistant messages, delegation and handoffs, tool activity, status, and human-in-the-loop asks.
import { SwarmEngine } from "@nightowlsdev/core";
const engine = new SwarmEngine(swarm);
// Drain the typed SwarmEvent stream from a single run.
for await (const event of engine.run({ message: "Say hi to Ada." }, ctx)) {
console.log(event.type, event);
}The second argument, ctx, is the run's trusted SwarmContext — the identity every event, tool call, and stored row is attributed to. In this bare script you build it by hand ({ tenantId, userId, agentSlug, runId, threadId }); once you mount a runner, your auth adapter resolves it from the request instead, and a forged tenant in the request body is ignored. The stream is a plain async iterable of a discriminated SwarmEvent union, so a switch (event.type) gives you exhaustively-typed handling of assistant text, tool activity, delegation, cost/status, and the human-in-the-loop swarm.question / swarm.client_action pauses.
Wire adapters
Everything around the engine is a swappable adapter. Pick a model provider, a storage backend, an interactive or durable runner, an auth provider, and a telemetry exporter, install each with owl install.
Models
anthropic, openai, openrouter, vercel-gateway, ollama, groq. Install one package per provider you use; route per agent with createModelFactory.
Adapter docs →Storage
storage-local (LibSQL bootstrap) for dev; storage-supabase (Postgres + Realtime + credit ledger) for production.
Adapter docs →Runners
runner-nextjs streams interactively; runner-background parks HITL turns on a durable waitpoint and resumes across process death.
Adapter docs →Auth & telemetry
auth-supabase and auth-auth0 resolve identity server-side; telemetry-otel and telemetry-langfuse export gen_ai.* spans.
Adapter docs →Serve a run from Next.js
The Next.js runner returns a family of App Router route factories. Identity is resolved server-side, a forged tenant in the request body is ignored.
// app/api/swarm/chat/route.ts
import { createNextjsRunner } from "@nightowlsdev/runner-nextjs";
import { supabaseAuth } from "@nightowlsdev/auth-supabase";
import { createSupabaseStorage } from "@nightowlsdev/storage-supabase";
const auth = supabaseAuth({
url: process.env.SUPABASE_URL!,
anonKey: process.env.SUPABASE_ANON_KEY!,
});
const storage = createSupabaseStorage({
url: process.env.SUPABASE_URL!,
secretKey: process.env.SUPABASE_SECRET_KEY!,
// Direct/Session Postgres URL (port 5432, not the 6543 transaction pooler).
dbUrl: process.env.SUPABASE_DB_URL!,
});
const runner = createNextjsRunner({ swarm, auth, storage });
// chatRoute() returns { POST }; destructure it as the App Router handler.
// Streams an AI SDK v7 UI Message Stream to the browser.
export const { POST } = runner.chatRoute();Render it in React
Wrap your tree in SwarmProvider, import the precompiled styles, and drop in SwarmChat, no Tailwind build needed in the host.
import { SwarmProvider, SwarmChat } from "@nightowlsdev/react";
import "@nightowlsdev/react/styles.css";
export function Room() {
return (
<SwarmProvider mode="dark" theme={{ "--owl-accent": "#E8A33D" }}>
<SwarmChat agentSlug="helper" threadId="thread-1" />
</SwarmProvider>
);
}When to reach for it — and when not
Night Owls earns its keep when you need governed, multi-agent, human-in-the-loop behavior behind a stable seam — a budget ceiling the model cannot exceed, an approval gate on side-effecting tools, delegation between specialist agents, durable resume across a process restart, and per-tenant isolation. If that is the shape of your problem, the pieces are already wired together and hardened.
It is more than you need for a single stateless “one prompt in, one answer out” call — reach for a provider SDK (or the Vercel AI SDK) directly. It also does not try to be a workflow engine or a job queue: for durable, retrying background steps that are not agentic, a tool like Trigger.dev or a queue is the better primitive, and Night Owls composes with it (the background runner parks a HITL turn on a durable waitpoint rather than reimplementing durability). And a scope is not a hard security boundary against your own server code — if two parties must never reach each other even through a host bug, give them separate tenants, not two agents in one swarm.
Adapters, not a rewrite. The engine is deliberately engine-agnostic: SwarmEngine is the default run loop, but the same swarm config drives alternative engines (ai-sdk, mastra, openai-agents, a2a, and others) through the same Engine SPI. Start on the default; a later engine swap is a config change, not a port of your agents.
Four things that trip up a first run
- Every resolved model must pass models.allow. The allow-list is the per-tenant gate, checked at run time. A model an agent pins (or a tier resolves to) that is not in allow fails the run loudly rather than silently downgrading — add it to the list, do not remove the check.
- "tier:" needs a tier config. Many factory-built agents (and the pre-built personas) default their modelId to the sentinel "tier:" rather than a vendor pin. That only resolves if you supply models.tier; without one it fails the allow-list at run time. A hand-written agent that pins claude-sonnet-4-5 (as above) needs no tier config.
- cost is required, and a bad cap is not “no cap”. defineSwarm requires a finite maxSteps and maxCostUsd. The governor sums model spend and metered external tool spend against the same ceiling — an agent that only counts tokens could burn unbounded tool cost under a USD cap it never trips.
- In-memory storage does not survive a restart. InMemoryStorage and a :memory: snapshot store are perfect for a dev loop, but a run that suspends on a human ask can only resume within the same process. For resume across a restart, point the snapshot store at a file: URL, and move to storage-supabase for production persistence and the credit ledger.
Where to go next
Dive into the module reference for the full API surface, or read the architecture story behind the framework.