Skip to content
Night Owls.dev
Jump to a page
Getting started

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.

terminal
# 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-local

Scaffold 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.

terminal
# 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 types

Define 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.

agent.ts
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.

run.ts
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.

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.

route.ts
// 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.

room.tsx
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.