A pipeline your agents can run. Structured extraction as a governed DAG, not a prompt chain.
defineWorkflow is a single-cursor agent/tool/human orchestration — the wrong shape for the ingest → extract-per-item → score → route pattern a content-ingestion swarm keeps hand-rolling, and its validator checks only acyclicity, not that the stages a graph names actually exist. The pipeline plane is a separate primitive for that shape: a graph stored as data, walked by a cursor, with fan_out for the per-item map. Its more interesting half is that a stage of kind: "agent" runs a real, named swarm agent — so the extraction step inherits the engine's cost cap, tool gate, rules, approval, and metering. It ships as its own package, discovered only by hosts that install it, so a swarm that never runs a pipeline is byte-identical.
A graph, stored as data
A pipeline is { start, stages }. Each stage is one of five kinds; its edges (onPass / onFail) and guards (haltIf / skipIf) are fields on the stage, not a separate list. The cursor follows onPass on success (null ends the run succeeded) and onFail on failure (halt, or reroute to a recovery stage). fan_out is the only parallelism.
| Stage kind | What runs |
|---|---|
| agent | a NAMED swarm agent via runAgent — governed (cost cap, tool gate, rules, approval) + metered. The differentiator. |
| model | an ephemeral inline agent from { system, model } — a governed raw-prompt step |
| tool | a host-provided tool executor (a granted SwarmTool / connector action) |
| code | a registered pure function — the decide / merge / filter glue that feeds a skipIf guard |
| fan_out | a bounded-concurrency map of a sub-stage over an array (default 4, cap 16); each iteration is metered + persisted |
A separate, opt-in package
The four tables (nightowls.pipelines / pipeline_versions / pipeline_runs / pipeline_stage_runs) ship in this package's own migration set (pipeline_0001_*), not in storage-supabase's mandatory set — so a host that never installs it never receives the DDL. The plane is additive to every existing engine adopter, with no change to runs / run_steps. The version is plugin-prefixed so it sorts after 0013_rename_schema (its nightowls.orgs FK target) — apply @nightowlsdev/storage-supabase through 0013 first.
pnpm add @nightowlsdev/pipeline @nightowlsdev/core
pnpm add pg # optional — only the durable Postgres store needs it
owl install pipeline # ejects pipeline_0001_* into supabase/migrations/
supabase db push # you apply it, with your own toolingRun one headless: runPipeline / definePipeline
runPipeline is background/Trigger-friendly — no chat surface. definePipeline authors and validates a code-defined graph (the sibling to defineWorkflow). Input references resolve against the run state: context.*, stages.<key>.output(.…), and inside a fan_out, item / index.
import { runPipeline, definePipeline } from "@nightowlsdev/pipeline";
import type { PipelineDeps, PipelineGraph } from "@nightowlsdev/pipeline";
import type { ModelFactory, AgentDef, SwarmContext } from "@nightowlsdev/core";
declare const modelFactory: ModelFactory; // openaiModels() / createModelFactory({ ... })
declare const agentRegistry: Record<string, AgentDef>;
declare const ctx: SwarmContext; // the AUTHENTICATED tenant / user / agentSlug / run / thread
declare const invoiceText: string;
// A pipeline is a GRAPH stored as data. definePipeline VALIDATES referent existence AND acyclicity — a broken
// graph throws HERE (author time), never on the 10,000th run (the gap defineWorkflow's validator lacks).
const graph: PipelineGraph = definePipeline({
start: "extract",
stages: {
extract: {
kind: "agent", // ← a NAMED, GOVERNED swarm agent runs this stage
agent: "invoice-extractor",
outputSchema: { type: "object", required: ["items"] },
schemaMode: "enforce", // a reply that violates the schema retries, then FAILS the stage
input: { text: { kind: "ref", path: "context.text" } },
onPass: "score",
onFail: { halt: true, reason: "extraction failed" },
},
score: {
kind: "code",
code: "score",
input: { items: { kind: "ref", path: "stages.extract.output.items" } },
onPass: null, // null ⇒ the run ends succeeded here
onFail: { halt: true, reason: "scoring failed" },
},
},
});
const deps: PipelineDeps = {
modelFactory,
resolveAgent: (slug) => agentRegistry[slug] ?? null, // an "agent" stage runs THIS agent via runAgent
codeStages: { score: (input) => ({ score: (input.items as unknown[]).length }) },
ctx,
};
const result = await runPipeline({
pipeline: graph,
context: { text: invoiceText },
budgetUsd: 2, // a REAL ceiling across ALL stages, fan-out spend included
deps,
});
// result.status ∈ "succeeded" | "failed" | "halted" | "cancelled"Validation, the budget ceiling, and schema enforcement
Three guarantees a hand-rolled prompt-DAG usually lacks. Validation: validatePipelineGraph checks that every stage / edge / agent a graph names exists (referent existence) as well as acyclicity — enforced server-side on the author route (a 422 at author time) and mirrored live in the Studio editor. Budget: budgetUsd is a hard ceiling across all stages, fan-out spend included — a runaway fan-out trips it rather than spending unbounded and being billed at $0. Schema: a stage's outputSchema with schemaMode: "enforce" is validated — a reply that parses but violates the schema is retried, then fails the stage, so a malformed object never flows downstream.
Durable runs: the store
For durable pipelines (a stored definition, versioned + append-only, and a run/stage-run history a transcript can stream), wire createPipelineStore({ pool }). InMemoryPipelineStore is the Postgres-free twin with the same admin contract — proven behaviorally identical by a shared conformance suite.
import { createPipelineStore, InMemoryPipelineStore } from "@nightowlsdev/pipeline";
import type { PipelineAdminStore } from "@nightowlsdev/pipeline";
declare const pool: import("pg").Pool;
// Durable: you own the Pool (pg is an OPTIONAL peer — only this needs it). The tables are RLS-on with no policy
// (server-only), so the Pool must OWN them or hold BYPASSRLS — the runner uses the service connection.
const store: PipelineAdminStore = createPipelineStore({ pool });
// The Postgres-free twin — the SAME admin contract, for dev and tests.
const memory: PipelineAdminStore = new InMemoryPipelineStore();
// Reads are org-scoped: list an org's pipelines, or one run + its per-stage transcript.
const pipelines = await store.listPipelines("00000000-0000-4000-8000-00000000abcd");
const run = await store.getRun("00000000-0000-4000-8000-00000000abcd", "run-uuid");The admin routes + the Studio section
The REST surface is runner.pipelineRoutes(), behind a new adminRouteGate("pipelines") scope — absent from DEFAULT_ADMIN_SCOPES, so a shipped { actor } grant does not open it. Authoring a version or firing a run — which spends real model budget — is refused server-side for a read-only credential, and the graph is validated server-side before any write.
// app/api/swarm/pipelines/route.ts
import { createNextjsRunner } from "@nightowlsdev/runner-nextjs";
import { createPipelineStore, validatePipelineGraph, runPipeline } from "@nightowlsdev/pipeline";
const runner = createNextjsRunner({
engine, auth, storage,
pipelinePlane: {
store: createPipelineStore({ pool }),
validateGraph: validatePipelineGraph, // the B10 gate, enforced SERVER-SIDE (a broken graph → 422)
// OPTIONAL — firing a run. Wire your own runPipeline call (or a durable enqueue). Absent ⇒ fire answers 404.
fire: async (ctx, { slug, context, budgetUsd, dryRun }) => {
const r = await runPipeline({ pipeline: slug, context, budgetUsd, dryRun, deps: pipelineDeps(ctx) });
return { runId: r.runId, status: r.status };
},
},
});
// Each route file mounts one family — all behind adminRouteGate("pipelines") (a default-deny scope):
export const { GET, POST } = runner.pipelineRoutes().library; // /api/swarm/pipelines
export const { GET } = runner.pipelineRoutes().runs; // /api/swarm/pipelines/runs
export const { GET } = runner.pipelineRoutes().runDetail; // /api/swarm/pipelines/runs/[id]The operator UI is pipelinesPlugin from @nightowlsdev/react — a library, a graph editor with a live validator + a stage-kind picker whose kinds include agent, and a realtime run transcript (per-stage status/cost/tokens with fan-out grouping). Pull it in on demand:
import { AIStudio, pipelinesPlugin } from "@nightowlsdev/react";
// "Pulled in on demand" — the section only appears when you register it (tree-shaken otherwise), and only when
// its routes are mounted (discover only-404-degrades). Authoring/firing is refused SERVER-SIDE for a non-admin.
<AIStudio plugins={[pipelinesPlugin]} pluginClientOptions={{ base: "/api/swarm", getToken }} />The package reference is at /docs/pipeline. Related: the task-manager plane (run an agent against a single task) and the Studio plugins guide. Source on GitHub.