Skip to content
Night Owls.dev
Jump to a page
Advanced · optional plane

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 kindWhat runs
agenta NAMED swarm agent via runAgent — governed (cost cap, tool gate, rules, approval) + metered. The differentiator.
modelan ephemeral inline agent from { system, model } — a governed raw-prompt step
toola host-provided tool executor (a granted SwarmTool / connector action)
codea registered pure function — the decide / merge / filter glue that feeds a skipIf guard
fan_outa 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.

install
pnpm add @nightowlsdev/pipeline @nightowlsdev/core
pnpm add pg                # optionalonly the durable Postgres store needs it
owl install pipeline       # ejects pipeline_0001_* into supabase/migrations/
supabase db push           # you apply it, with your own tooling

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

lib/pipeline.ts
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.

lib/pipeline-store.ts
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
// 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:

app/studio/page.tsx
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.