@nightowlsdev/runner-nextjs
RunnerThe interactive runner for Night Owls, Next.js App Router routes that authenticate server-side and stream a swarm run to the browser as an AI SDK v7 UI Message Stream.
What it does
`createNextjsRunner({ swarm | engine, auth, storage, background? })` returns a core `Runner` plus nine App Router route factories: `chatRoute()` (POST /api/swarm/chat → authenticate, build SwarmContext from AuthContext only, `engine.run()`, SSE), `resumeRoute()` (POST /api/swarm/resume → authenticate, authorize the typed follow-up cross-tenant, recover the run, `engine.resume()`, SSE), `eventsRoute()` (GET /api/swarm/events/[id] → hydrate `events.list` for dedup-by-seq reconnect), `historyRoute()` and `threadEventsRoute()` (GET .../history|thread-events/[id] → the message history / full SwarmEvent log for a thread, to rebuild the rich timeline on reload), `scratchpadRoute()` (GET .../scratchpad/[id] → the public scratchpad section), `activeRoute()` (GET .../active/[id] → in-flight runs for a container), `threadsRoute()` (GET .../threads), and `agentsRoute()` (GET .../agents); `handlers()` exposes all eight raw handler functions for hosts that wire their own routes. It's a pure transport adapter that projects each typed `SwarmEvent` onto a custom AI SDK v7 `data-<name>` UI Message Stream part (status as transient, question/answer/tool parts durable). Identity is server-only (forged tenant/userId in the body is ignored; runId is server-minted). `ai` (^7) is a required peer; `next` (>=15) is an optional types-only peer, handlers are plain Request→Response functions and `next` is never imported at runtime (the engine wall, no `@mastra/*` either). Durable execution is now opt-in IN this runner: pass `background: createBackgroundRunner(...)` and `chatRoute` ENQUEUES the run on it (returns `{ runId }` JSON the react client subscribes/hydrates) instead of streaming SSE, so the run outlives the request; `enqueue()` delegates to that background runner and only throws when one isn't wired.
Install
pnpm add @nightowlsdev/runner-nextjsKey exports
- createNextjsRunner
- skillStoreRoutes() (FR-040: ten skill-store route groups)
- connectionsRoutes() (FR-043: connections plus the HTTP-only MCP registry)
- streamEvents
- projectEvent
- nightOwlsPlugin
- NextjsRunner
- NextjsRunnerOpts
Usage
// app/api/swarm/chat/route.ts
import { createNextjsRunner } from "@nightowlsdev/runner-nextjs";
const runner = createNextjsRunner({ swarm, auth, storage });
export const POST = runner.chatRoute(); // streams an AI SDK v7 UI Message Stream
// resumeRoute() and eventsRoute() wire the other two App Router endpoints.What it provides
runner-nextjs is the interactive transport for a core swarm on the Next.js App Router. createNextjsRunner({ engine, auth, storage }) returns a core Runner plus a family of route factories: the chat/resume/events/history/thread-events/scratchpad/active/threads/agents routes for a full chat client, and — behind one default-deny guard — the definition-admin, skill-store, connections/MCP, model-provider, and grant-holder studio routes. It authenticates every request server-side, `for await`s over engine.run()/engine.resume(), and projects each typed SwarmEvent onto an AI SDK v7 UI Message Stream part that @nightowlsdev/react understands. It is a pure transport adapter — it never imports @mastra/* and never imports `next` at runtime.
When to use it
- You are on the Next.js App Router and want production chat routes (auth + SSE streaming + typed HITL resume + reconnect/hydrate) without hand-writing SSE and the AI SDK codec.
- Identity must be resolved server-side — the runner builds SwarmContext only from your AuthProvider and ignores any tenantId/userId in the request body.
- You want an agent-admin studio: the definition-admin, skill-store, connections/MCP, model-provider, and grant-holder routes, each gated by scope.
- You want serverless cron endpoints for the stuck-run reaper (createReaperHandler) or the recurrence plane (createScheduleHandler), or a short-lived Realtime-socket JWT (createRealtimeTokenHandler).
- You want semantic search over conversation history — inject a message-search plane and mount searchRoute(), which carries the participation gate the store deliberately does not.
When not to
- You are not on Next.js or don't want HTTP route handlers — call core's engine.run() directly, or expose the swarm over MCP with @nightowlsdev/mcp-server.
- The turn must survive process death (a long HITL park) with interactive SSE alone — SSE dies with the request; you still use this runner, but pass a durable `background` runner.
- You want a raw model call with no governance or transport — that is core / a provider SDK, not a route layer.
Alternatives
- @nightowlsdev/runner-backgroundA turn must park across process death or run long in the background. It is complementary, not a replacement: pass it as this runner's `background` option to keep the exact route surface while enqueuing durably.
- @nightowlsdev/mcp-serverYou want to expose the swarm to MCP clients (editors, agents) over the Model Context Protocol instead of browser HTTP routes.
- Hand-rolled route handlers over engine.run() + streamEventsYou need a bespoke routing layer. streamEvents / projectEvent are exported for exactly this — but you re-implement the auth gate, the participation gate, cross-tenant-safe resume, and the codec the factories give you for free.
Strengths
- Turnkey App Router routes: auth, SSE streaming, typed resume, hydrate-by-seq reconnect, history, threads and roster — all wired from one factory.
- Server-only identity with a participation gate on every thread-scoped endpoint and cross-tenant-safe resume; the admin plane is default-deny (unset guard ⇒ every admin route 403s).
- Scoped admin credentials: the admin surface is three planes (definitions / integrations / models) gated independently, and a host route family can mint its own scope via runner.adminRouteGate().
- One flag flips the whole thing durable — pass `background` and chatRoute enqueues instead of streaming, with no route-surface change.
- Engine-wall clean: handlers are plain Web Request/Response, `next` is an optional types-only peer never imported at runtime, and there is no @mastra/* dependency.
- Studio surfaces (skill store, connections + HTTP-only MCP registry, model providers, grant-holders) are injected structurally, so a chat-only adopter inherits none of @nightowlsdev/skills / connectors / mcp.
- Post-turn hygiene (delta compaction, message-search indexing) rides a detached chore lane that can never fail or delay the turn — and, when it is misconfigured for serverless, says so once at construction instead of failing silently forever.
Limits & trade-offs
- It is coupled to the AI SDK v7 UI Message Stream wire format (ai@^7 is a required peer); the intended client is @nightowlsdev/react.
- Interactive SSE lives and dies with the request — durability requires wiring runner-background as `background`.
- The studio route families need their planes assembled and injected (skillPlane / connectionsPlane / modelProviders), and construction-time asserts throw if a plane is half-wired (e.g. an http provider with no host allowlist).
- The admin scope default is fail-closed: a grant that omits adminScopes gets only ["definitions"], so an owner who forgets to spell out the set silently cannot reach connections or model providers.
- Serverless can't run an in-process interval, so the reaper and schedule ticks must be mounted as cron routes and secured with a cronSecret.
- Background chores are PER RUNNER INSTANCE: compactDeltas and messageSearch live on one createNextjsRunner closure, so a deployment that constructs a second runner has to set the flag on that one too — nothing is global and nothing is persisted.
- On a serverless platform a detached chore is orphaned when the function freezes at response completion, so waitUntil (e.g. Next's after) is mandatory for the chores to finish; the runner warns once when it detects the combination, but it will not import next/server for you.
How it works
createNextjsRunner returns a NextjsRunner (which also implements the core Runner interface). Each route factory runs the same spine: auth.authenticate(req) first (null ⇒ 401), then the participation gate (storage.runs.resolveContainerAccess) on thread-scoped endpoints, then a SwarmContext built only from the AuthContext (body tenantId/userId ignored, runId server-minted). chatRoute() then iterates engine.run() and streamEvents serializes each SwarmEvent onto the AI SDK v7 UI Message Stream — one custom data-<suffix> part per event, with stable part ids so re-writes reconcile in place across SSE, hydrate, and Realtime. resumeRoute() validates the follow-up cross-tenant before recovering the run and calling engine.resume(). Pass `background` and chatRoute enqueues via background.enqueue() (returning { runId } JSON) instead of streaming, without changing any route path.
Background chores, and why serverless needs waitUntil
Two things happen after a turn ends that are not part of the turn: delta compaction (FR-071) folds a thread's settled single-token swarm.message deltas into one event per bubble, and message-search indexing (FR-072) walks the conversation's new messages into the embedding sidecar. Both are hygiene, so both ride the same lane: the runner calls an internal detach(), which swallows every error with a log and hands the promise to your waitUntil if you supplied one. Neither can fail a turn, and neither can make the user wait.
That is exactly why a serverless host MUST pass waitUntil. detach() starts the chore and does not await it, and on Vercel, Lambda or Netlify the function FREEZES the moment the response flushes — so an un-kept promise is cut short mid-flight, silently, on every single turn. No data is lost (compaction is idempotent and retried on the thread's next terminal; the indexer's repair pass rediscovers whatever a truncated call missed), but neither chore ever finishes, and nothing in the logs says so. The fix is one import: `import { after } from "next/server"` and `createNextjsRunner({ …, waitUntil: after })`.
Since FR-072 the runner diagnoses this itself. At construction, if a chore is configured (compactDeltas, or messageSearch with turn-end indexing that can actually be detached) AND waitUntil is absent AND the environment looks serverless (VERCEL, AWS_LAMBDA_FUNCTION_NAME or NETLIFY), it emits exactly ONE console.warn naming the orphaned chores and the fix. It stays silent when nothing is configured — a warning an operator cannot act on is how a channel gets ignored. The INDEXING half of that predicate is also dropped for a FULLY durable deployment (background wired WITH resumeEnqueue), because there every turn takes the enqueue branch and the indexing chore is never detached at all; a background runner WITHOUT resumeEnqueue falls through to the interactive resume and still detaches it, so it still counts. Compaction is detached on BOTH paths — enqueue of turn N+1 is a perfectly good moment to fold turn N — so a host with compactDeltas warns regardless of how durable it is. Auto-importing next/server and defaulting waitUntil to after() was declined deliberately: this package declares zero dependencies and next is an optional peer, and after() throws outside a request scope — so a runner constructed at module load, the near-universal shape, would throw at import time on precisely the deployments the warning exists for.
Both settings are PER RUNNER INSTANCE — not per process, not per deployment, and not shared with the durable path. The flag, its tuning and the sweep's entire bookkeeping live on one createNextjsRunner(...) closure, so a host that constructs two runners (a stock chat route plus a bespoke one, or one per tenant, or a separate instance used for resumes) has two independent sweepers: enabling a chore on one does NOT enable it on the other. Each sweep also starts its cursor empty, so compactDeltas' maxPasses (default 8) is a per-CALL budget, never a running total. And @nightowlsdev/runner-background's compactDeltas / messageSearch are a THIRD, separate pair of settings, covering runs with no request behind them; a host running both enables both, and the overlap is safe because the operations are idempotent.
compactDeltas is DEFAULT OFF even though it is the cheaper win, because the fold DELETES rows from events — a table some deployments treat as an audit trail, where per-token arrival timing is data. It also needs a store that carries compactContainerDeltas (storage-supabase ≥ 2.18); on any other adapter it is a silent no-op, so setting it is always safe. Message-search indexing is default ON once a plane is wired, for the mirror-image reason: it only ever inserts into its own tables and deletes nothing a host could be relying on.
Message search: the injected plane, the route, and the gate
messageSearch is one option carrying a host-constructed plane: { plane, indexOnTurnEnd?, allowTenantSearch? }. The plane is @nightowlsdev/storage-supabase's createMessageSearch({ pool, embedder, embedderTag, dimensions }) — a Postgres + pgvector object a chat-only adopter must never inherit — so this package takes no dependency on it and mirrors only the two methods the route calls, the same structural-injection posture as skillPlane / connectionsPlane / taskPlane.
searchRoute() mounts POST /api/swarm/search. Its contract is the executable version of the FR-072 authorization split: authenticate through the same auth context as the thread-events routes → canonicalize the container with core's containerOf → run the SAME fail-closed resolveContainerAccess every container route runs, with the caller's orgScope forwarded → only mayRead admits. A denial returns BEFORE the plane is invoked, so a same-org nonparticipant cannot even cause an embedding to be computed. The tenant and the FR-055 scope come from the auth context and are never read from the body; tenantId / orgId / orgScope keys in the request are ignored outright.
scope:"tenant" widens the search across every conversation in the org, including ones the caller does not participate in, so no participation gate can authorize it — only the host can. It is refused unless allowTenantSearch(authCtx) exists and returns exactly true; missing, false, OR THROWING all deny (a throwing authorizer is logged and treated as a denial, because an exception falling through as "allowed" would open the whole org).
The accessor NEVER throws at the mount, unlike todosRoute() / taskRoutes(). Search is a capability a client PROBES for rather than a wiring mistake, so with no plane injected POST answers a stable 404 { ok:false, reason:"absent" } — the same status and body for every caller, checked before the participation gate so participation cannot make it vary. @nightowlsdev/react's dock retires itself on that 404 and no warning fires. The other statuses are deliberately distinguishable: 200 matches · 400 a malformed body or the store's own bad-input · 401 unauthenticated · 403 denied by the gate or by tenant-search authorization · 503 the plane reported itself unavailable (wrong dimension, embedder outage, schema drift).
Indexing after a turn happens on INTERACTIVE turns only. The durable path's runEnd fires at ENQUEUE — before the run has written a single message — so indexing there would walk a container whose newest turn does not exist yet, spending a lock and a cursor advance to index nothing, every turn, forever. The durable half lives in @nightowlsdev/runner-background (messageSearch.indexOnDriveEnd), after the drive. Set indexOnTurnEnd: false to drive indexContainer from your own cron or backfill route instead. Wiring messageSearch also activates the run-end hook the chore hangs off, so a host that wires ONLY search still gets the interactive terminal it needs.
Examples
The three interactive route files
Pin the Node.js runtime and a generous maxDuration on each streaming/resume route.
// lib/swarm.ts
import { createNextjsRunner } from "@nightowlsdev/runner-nextjs";
import { supabaseAuth } from "@nightowlsdev/auth-supabase";
import { createSupabaseStorage } from "@nightowlsdev/storage-supabase";
import { engine } from "./engine";
export const runner = createNextjsRunner({
engine,
auth: supabaseAuth({ url, anonKey }), // identity resolved server-side only
storage: createSupabaseStorage({ dbUrl: process.env.DATABASE_URL! }),
});
// app/api/swarm/chat/route.ts
export const runtime = "nodejs";
export const maxDuration = 300;
export const { POST } = runner.chatRoute(); // SSE: an AI SDK v7 UI Message Stream
// app/api/swarm/resume/route.ts -> export const { POST } = runner.resumeRoute();
// app/api/swarm/events/[id]/route.ts -> hydrate/reconnect via runner.eventsRoute().GETFlip to durable background mode
One field switches interactive SSE to durable enqueue; the route surface is unchanged.
import { createNextjsRunner } from "@nightowlsdev/runner-nextjs";
import { createBackgroundRunner, createTriggerBackend } from "@nightowlsdev/runner-background";
export const runner = createNextjsRunner({
engine,
auth,
storage,
background: createBackgroundRunner({ engine, storage, backend: createTriggerBackend(swarmRun) }),
});
// chatRoute() now returns { runId } JSON; the react client subscribes over Realtime and hydrates via eventsRoute().Scoped, default-deny admin guard
The guard maps a caller to a principal and the admin planes it may reach. Spell the set out — a bare { actor } means ["definitions"] only.
export const runner = createNextjsRunner({
engine,
auth,
storage,
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
},
});
// app/api/swarm/agent-config/route.ts -> export const { GET } = runner.configRoute();Message search + the serverless chore lane
The plane is host-constructed and injected. On Vercel/Lambda/Netlify pass waitUntil, or every detached chore is cut short when the function freezes — the runner warns once if you don't.
import { after } from "next/server";
import { createNextjsRunner } from "@nightowlsdev/runner-nextjs";
import { createMessageSearch } from "@nightowlsdev/storage-supabase";
const plane = createMessageSearch({
pool: storage.ctx.pool,
embedder, // (texts) => Promise<number[][]>
embedderTag: "openai/text-embedding-3-small",
dimensions: 1536,
});
export const runner = createNextjsRunner({
engine,
auth,
storage,
// Keeps a detached chore alive past the response. Without it on serverless, the chore is orphaned.
waitUntil: after,
compactDeltas: true, // opt-in: DELETES folded delta rows from events
messageSearch: {
plane,
// indexOnTurnEnd defaults to true (interactive turns only — durable turns index in runner-background).
allowTenantSearch: async (authCtx) => (await roleOf(authCtx.userId)) === "owner",
},
});
// app/api/swarm/search/route.ts
export const { POST } = runner.searchRoute();
// 200 matches · 400 bad input · 401 unauthenticated · 403 denied · 404 no plane wired · 503 plane unavailableDoing the parts it doesn't support
- A turn that parks across process deathInteractive SSE ends with the request. Pass background: createBackgroundRunner({ engine, storage, backend }) so chatRoute enqueues on a durable backend and returns { runId }; the client subscribes over Realtime instead of reading SSE.
- Running the reaper or the schedule tick on serverlessThe in-process interval can't run there. Mount createReaperHandler({ reap }) and createScheduleHandler({ tick }) (both exported by this package) on cron routes and secure them with a cronSecret bearer check; the host pre-binds the reap / tick functions from runner-background.
- A skill store or connections studioInject a skillPlane (assembled from @nightowlsdev/skills) and/or a connectionsPlane (from @nightowlsdev/connectors + @nightowlsdev/mcp), then mount runner.skillStoreRoutes() / runner.connectionsRoutes(). The package keeps zero dependency on them — the planes are structural.
- Indexing a durable turn's messagesThis runner cannot: its runEnd fires at ENQUEUE on the durable path, before the run has written anything. Pass the same plane to @nightowlsdev/runner-background as messageSearch: { plane } — it indexes after the drive, which is a durable run's only terminal. Both halves are safe to wire at once.
- Backfilling conversations that predate the planeTurn-end indexing only ever walks forward from the container it just touched. For history, call the plane's backfillTenant(tenantId, { afterContainer }) from your own cron route (or a one-off script), feeding afterContainer back verbatim until done — and set indexOnTurnEnd: false if you would rather own the whole schedule.
- A route family this package doesn't shipMint your own admin scope with runner.adminRouteGate("acme.reports"): it returns the same bound, default-deny gate every built-in admin route uses, so a plugin reuses the gate instead of re-implementing (and drifting from) it.
Related
- core — The engine, the Runner interface, and the SwarmEvent union this transport serializes.
- runner-background — The durable backend you pass as `background` for park-across-process-death; also the reaper + schedule its cron handlers drive, and the half that indexes a durable turn's messages after the drive.
- react — The client hooks and components that consume the AI SDK v7 UI Message Stream these routes emit.
- auth-supabase — An AuthProvider for the required `auth` seam, plus mintRealtimeToken for the realtime-token handler.
- storage-supabase — The StorageAdapter behind run/event/thread persistence and the participation gate every route enforces — and the createMessageSearch plane searchRoute() fronts.
- agent-config-surface — The definition-admin studio the config / publish / rollback / versions routes back.