Somewhere for work to live. And a safe way to point an agent at a piece of it and walk away.
A swarm can do work, but it has nowhere to represent the work itself — the ticket a human filed, the item mirrored from a tracker, the thing an agent should pick up next. The task-manager plane is that surface: an org- and scope-scoped board of tasks with a status, an assignee, a priority, and one level of subtask. Its more interesting half is what happens when the assignee is an agent: startAgentTask runs an agent against a task unattended, behind a resume-safe cost ceiling, a single-winner start, and a prompt-injection fence. It ships as its own package, discovered only by hosts that install it, so a swarm that never represents work is byte-identical.
What a task is
A task carries a title, a status (open · in_progress · needs_input · blocked · done · cancelled), a priority, labels, a due date, a manual board rank (lexorank, so a reorder touches one row), and up to one level of subtask. The load-bearing field is the assignee, a four-arm union — and the agent arm is the one this plane exists for.
| Assignee | What it means |
|---|---|
| { kind: "user" } | a person owns it — closed by hand via update({ status: "done" }) |
| { kind: "agent" } | an agent runs it. REQUIRES an execution policy with a budget, and closes only through closeAgentTask (never a direct status: "done") |
| { kind: "scope" } | a department/team queue — unassigned WITHIN that scope, waiting for someone to pick it up |
| { kind: "unassigned" } | the default — nobody holds it yet |
An agent task without a budget is refused at assignment. Assigning a task to an agent with no execution.budget (a finite maxCostUsd and integer maxSteps) throws TaskBudgetRequired at create/update time — before any row is written. A zero, negative, or non-finite cap is TaskBudgetInvalid, not "no cap": an agent pointed at work with no ceiling is exactly the unattended runaway the plane exists to prevent.
A separate, opt-in package — not StorageAdapter.tasks
Like @nightowlsdev/metrics, this is deliberately not a field on StorageAdapter and not a storage-supabase numbered migration. That package's plugin is ejected into every engine adopter, so a tasks table there would be mandatory for everyone, contradicting this plane's own additive/opt-in criterion. Instead it is a library-shaped package that owns its own tasks_000N series and is discovered by the CLI only from a host's installed deps. Install nothing, receive no DDL.
pnpm add @nightowlsdev/tasks pg
owl install tasks # ejects tasks_0001 into supabase/migrations/
supabase db push # you apply it, with your own toolingThe migration does not stand alone: nightowls.orgs (the org_id FK target) and the nightowls schema itself come from @nightowlsdev/storage-supabase — apply its series through 0013_rename_schema first. The version is prefixed tasks_0001_… rather than a bare 0001_… precisely so the CLI, which merges every plugin's migrations and sorts by string, does not apply it before the schema it lives in exists.
Server-only, and that is enforced. Both tables ship RLS on with no policy and no client grant, and the migration explicitly REVOKE ALL … FROM authenticated, anon, public — because storage-supabase installs a default-privilege grant that would otherwise make the tables born readable. Two consequences: the Pool you pass must be the tables' owner or hold BYPASSRLS (an unprivileged role reads zero rows and writes nothing, which looks exactly like an empty board), and the UI reaches tasks through your server — the runner routes — never a direct browser read.
The store and the provider registry
createInternalTaskStore({ pool }) is the internal Postgres TaskProvider (id "internal") — the default provider over nightowls.tasks and nightowls.task_activity. It queries exactly those two tables and never joins an engine one, so — library-shaped like the metrics store — you may point it at a database separate from the engine's. There is no close(); the consumer that owns the Pool is the only thing that may end it. createTaskProviderRegistry({ providers }) fans a list() across N providers and degrades a dead one into a named warning rather than failing the whole call.
import { createInternalTaskStore, createTaskProviderRegistry } from "@nightowlsdev/tasks";
import type { TaskCtx } from "@nightowlsdev/tasks";
import { Pool } from "pg";
declare const orgId: string; // your org's UUID
declare const userId: string;
// You own the Pool (pg is a peer dep). It must OWN the tables or hold BYPASSRLS — they are RLS-on with no
// policy, so an unprivileged role reads zero rows and writes nothing, which looks exactly like an empty board.
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// The internal pg-backed provider (id "internal") is the default. The registry fans list() across N providers,
// degrading a dead one into a NAMED WARNING rather than failing the whole call.
const internal = createInternalTaskStore({ pool });
const registry = createTaskProviderRegistry({ providers: [internal] });
// Every provider call carries the AUTHENTICATED context — tenant, the FR-055 orgScope, and the acting actor —
// never a model-supplied identity.
const ctx: TaskCtx = { tenantId: orgId, orgScope: "finance/emea", actor: { type: "human", userId, tenantId: orgId } };
// A human task. Reads see org-level rows + the caller's own scope + its ancestors; writes land at one exact scope.
const task = await internal.create(ctx, { title: "Draft the Q3 board deck", priority: "high" });
const board = await registry.list(ctx, { status: ["open", "in_progress"] });Scoping is query-level, not RLS. Because RLS is server-only, the FR-055 scope axis is enforced in the query, with the two predicates copied verbatim from @nightowlsdev/knowledge. A read (Rule A) returns org-level rows, the caller's own scope, and its ancestors — a finance/emea caller also sees finance and org-level rows, but not siblings and not descendants. A write (exactScope) requires the row's scope to be not distinct from the caller's. Both carry the load-bearing unrestricted branch: an org-level (undefined-scope) admin sees — and may write — every row, so the board is never silently empty for the one operator who should see all of it.
Point an agent at work: startAgentTask
createAgentTaskExecutor({ pool }) returns the four agent-execution entry points bound to one table config. startAgentTask is the only entry that runs an agent task, and it is a strict ordered protocol so nothing double-starts or runs un-capped. Its resolveTarget seam maps the assignee's slug to the engine that will run it — host-supplied because a deployment may run different agents on different engines — and the engine must advertise capabilities.governance.costCaps === true or the start is refused with TaskEngineCannotEnforceBudget, before anything is enqueued. A mandatory budget the engine cannot enforce is not a budget.
import { createAgentTaskExecutor } from "@nightowlsdev/tasks";
import type { TaskCtx } from "@nightowlsdev/tasks";
import type { Engine, Runner } from "@nightowlsdev/core";
declare const pool: import("pg").Pool;
declare const runner: Runner; // createBackgroundRunner(...) — enqueue + FR-028 cancel
declare const ctx: TaskCtx;
declare const engineFor: (slug: string) => Engine; // your slug -> engine map
// The executor shares the SAME table config as the store (one Pool, one table).
const executor = createAgentTaskExecutor({ pool });
// startAgentTask is the ONLY entry that runs an agent task. resolveTarget maps the assignee's slug to the engine
// that will run it; the engine MUST advertise cost caps, or the start is REFUSED before anything is enqueued
// (TaskEngineCannotEnforceBudget). Under the hood: an atomic single-winner claim so no task double-starts, and —
// for a MIRRORED task — the attacker-controllable title/detail ride a FENCED envelope in RunInput.context.
const { runId, runEpoch, runCtx, task } = await executor.startAgentTask(ctx, "TK-1A2B3C4D5E", {
runner,
resolveTarget: async (_ctx, slug) => {
const engine = engineFor(slug);
if (engine.capabilities?.governance?.costCaps !== true) return undefined;
return { engine, agentSlug: slug };
},
});Two guarantees are baked into the protocol. The claim is an atomic single-winner CAS (open/blocked → in_progress, bumping a run_epoch and binding the assignee), so a concurrent second start matches zero rows and throws TaskAlreadyStarted; exactly one caller enqueues, and a failed enqueue is compensated with an epoch-fenced rollback so a task never wedges in_progress. And the run's SwarmContext is built server-side: for a mirrored task, whose title and detail came from an external tracker and are attacker-controllable, that text rides a fenced envelope in RunInput.context, never the trusted message — the model is told, structurally, that it is data to act on, never instructions.
The budget ceiling is the observer, not RunInput.budget
This is the load-bearing decision of the whole plane. A per-run cost cap is not resume-safe: the engine builds a fresh zero-dollar CostGovernor on every human-in-the-loop resume, so a task that parks and resumes N times over RunInput.budget alone would get N× its ceiling. The real ceiling is a task-plane cumulative observer: for each started run, createRunObserver({ runner }).observe(…) consumes the run's event stream, sums swarm.turn_usage across all resume segments, and cancels via Runner.cancel on breach. RunInput.budget is demoted to per-segment defence-in-depth (it does still enforce maxSteps per segment).
import type { AgentTaskExecutor, StartAgentTaskResult } from "@nightowlsdev/tasks";
import type { Runner, SwarmEvent } from "@nightowlsdev/core";
declare const executor: AgentTaskExecutor;
declare const runner: Runner;
declare const started: StartAgentTaskResult; // what startAgentTask returned
declare const storage: { events: { subscribe(runId: string): AsyncIterable<SwarmEvent> } };
declare const persisted: { usdSum: number; genSum: number }; // the cumulative you saved, for a restart
const { task, runId, runEpoch, runCtx } = started;
// THE LOAD-BEARING CEILING. One observer per run sums swarm.turn_usage (cost.usd + externalUsd) across ALL
// resume segments and cancels via Runner.cancel on breach. RunInput.budget (per-segment) is defence-in-depth
// only — it cannot bound cumulative spend, because the engine builds a fresh zero-dollar governor on every resume.
const observer = executor.createRunObserver({ runner });
await observer.observe({ task, runId, runEpoch, runCtx, events: storage.events.subscribe(runId) });
// storage.events.subscribe is LIVE-ONLY (it does not replay). On a reconnect / replica failover / process
// restart, pass the persisted cumulative as seed so the ceiling is not silently reset to zero:
await observer.observe({ task, runId, runEpoch, runCtx, events: storage.events.subscribe(runId), seed: persisted });Two things the sum must not miss. The ceiling sums cost.usd + externalUsd — metered, non-token external spend is a sibling of cost, and a task that summed tokens alone could burn unbounded tool spend under a USD cap it never tripped. And storage.events.subscribe is a live-only Realtime stream — it does not replay — so a host that restarts observation must either feed a replay-from-start stream or pass the persisted cumulative as seed; a bare re-subscribe restarts the sum at zero and reopens the cross-resume money hole.
Two more writers keep an agent task from stranding. The deadline sweep settles a task whose persisted deadline_at has passed — enforceable whether the run is executing or parked on a human, which a step/USD governor could never do. It is stateless and host-scheduled: there is no in-package timer, so you point a cron at sweepDeadlines. And an agent task reaches done through exactly one path — closeAgentTask — which records the result and, when the task carries a definitionOfDone, runs verifyCompletion fail-closed. A run ending done never auto-closes the task; it stays in_progress awaiting a recorded outcome.
import type { AgentTaskExecutor, TaskCtx } from "@nightowlsdev/tasks";
import type { CompletionVerifier, Runner } from "@nightowlsdev/core";
declare const executor: AgentTaskExecutor;
declare const ctx: TaskCtx;
declare const runner: Runner;
declare const verifyCompletion: CompletionVerifier;
// The deadline is PERSISTED (deadline_at = started_at + budget.deadlineMs), enforceable whether the run is
// executing or PARKED on a human. The sweep is STATELESS and HOST-SCHEDULED — there is no in-package timer;
// point a cron at it. It cancels the run, then flips the task, across every org's past-deadline agent tasks.
const sweep = await executor.sweepDeadlines({ runner });
// An agent task reaches "done" through ONE path: closeAgentTask records the result, and when the task carries a
// definitionOfDone it runs verifyCompletion FAIL-CLOSED — an absent or throwing judge REFUSES the close (the
// task stays in_progress for a retry), never flips unverified work to done. A run ending never auto-closes it.
const closed = await executor.closeAgentTask(
ctx,
"TK-1A2B3C4D5E",
{ summary: "Deck drafted; 12 slides." },
{ verifyCompletion }, // required only when execution.definitionOfDone is set
);The unified queue
An operator asking "what is there to do?" should answer it from one surface, not three. mergeQueue folds the three operator sources — tasks, pending approvals, and triage issues — into one ordered QueueItem[]. It is pure (no I/O): the route fetches the three sources with the authenticated context and calls it. The sources do not share a timestamp — an approval carries an epoch createdAt, a task an ISO updatedAt, an issue an ISO lastSeenAt — so each member normalizes into one epoch sortAt, and the merge is descending by sortAt then ascending by a deterministic tie-break. One malformed timestamp sorts last rather than crashing the merge.
import { mergeQueue } from "@nightowlsdev/tasks";
import type { Task } from "@nightowlsdev/tasks";
import type { PendingApproval } from "@nightowlsdev/core";
declare const tasks: Task[]; // from registry.list — typically open / in_progress / needs_input / blocked
declare const approvals: PendingApproval[]; // from engine.listOrgPendingApprovals(ctx) — already epoch-stamped
declare const issues: { reference: string; status: string; title: string; occurrences: number; lastSeenAt?: string }[];
// mergeQueue is PURE — no I/O. The route fetches the three operator sources with the authenticated ctx and calls
// it. Each member normalizes its own timestamp into one epoch sortAt; the merge is DESC by sortAt, then ASC by a
// deterministic tieBreak, so the same inputs order identically across runtimes. Omit issues and the queue carries
// no kind "issue"; a single malformed timestamp sorts LAST rather than throwing the whole merge.
const queue = mergeQueue({ tasks, approvals, issues });The board, the routes, and the admin floor
The plane's REST surface is runner.taskRoutes() from @nightowlsdev/runner-nextjs, mounted under /api/swarm. The React UI is two @nightowlsdev/react studio plugins: tasksPlugin is the admin board — the create/assign/reorder surface, and the editor for an agent's AgentExecutionPolicy — and myTasksPlugin is the participant "my tasks" list. The registry is the only member taskPlane requires; wire execution and the board gains the start/close ops.
// illustrative — the host wires the plane on the runner, then mounts one route per file. The tasks API above
// is compiled against @nightowlsdev/tasks' src; this wiring spans runner-nextjs + host auth/storage, so it is
// shown as-is rather than machine-compiled.
import { createNextjsRunner } from "@nightowlsdev/runner-nextjs";
import { createTaskProviderRegistry, createInternalTaskStore, createAgentTaskExecutor } from "@nightowlsdev/tasks";
const registry = createTaskProviderRegistry({ providers: [createInternalTaskStore({ pool })] });
const executor = createAgentTaskExecutor({ pool });
const runner = createNextjsRunner({
engine, auth, storage,
taskPlane: {
registry, // the only REQUIRED member — a human-only board needs nothing else
execution: { executor, startDeps, closeDeps }, // unlocks start/close (host supplies the resolver + verifier)
},
});
// One mount per App Router route file, under /api/swarm:
export const { GET, POST } = runner.taskRoutes().board; // /api/swarm/tasks — ADMIN board (adminRouteGate("tasks"))
export const { GET } = runner.taskRoutes().mine; // /api/swarm/tasks/mine — PARTICIPANT, the caller's own rows only
export const { GET } = runner.taskRoutes().detail; // /api/swarm/tasks/[ref] — ADMIN, one task + its activity
export const { GET } = runner.taskRoutes().queue; // /api/swarm/tasks/queue — ADMIN, the unified queueEditing an execution policy is admin-only, enforced server-side. The board sits behind adminRouteGate("tasks") — a scope deliberately absent from DEFAULT_ADMIN_SCOPES, so a shipped { actor } grant that already reaches the agent-config and skill-store families gains nothing here. The board POST refuses a read-only credential before the body is even parsed, so attaching an execution policy — pointing an agent at work with a budget — can only happen through the admin-gated, non-read-only path. A tab rendering is never an authorization. The participant mine surface uses a separate authenticated gate, is GET-only, and hard-codes the assignee filter to the caller server-side, so it can never return another user's rows.
API reference
- createInternalTaskStore({ pool, table?, activityTable?, mintRef? }) — the Postgres TaskProvider (id "internal") with create / list / get / update / comment. Table names are trusted config, interpolated directly.
- createTaskProviderRegistry({ providers, displayNames? }) — the fan-out registry (get / describe / list), structural capability probing, named-warning failure isolation, and a composite cursor that resumes a failed provider where it stopped.
- createAgentTaskExecutor({ pool, table?, activityTable? }) → startAgentTask / closeAgentTask / createRunObserver / sweepDeadlines / findApprovalByRunId. Plus buildAgentRunInput (the pure untrusted-title fence) and createTaskApprovalResolver.
- mergeQueue({ tasks?, approvals?, issues? }) — the pure merge into one ordered QueueItem[] across the three operator sources.
- TASKS_MIGRATIONS / T_TASKS_0001_TASKS / TASKS_MIGRATION_SQL — the migration series and its raw DDL, plus nightOwlsPlugin, the data-only CLI manifest that never runs DDL.
- Typed guardrail errors — TaskBudgetRequired / TaskEngineCannotEnforceBudget / TaskAlreadyStarted / TaskCloseRequiresResult / TaskVerificationUnavailable / TaskVersionConflict / … — each a TASK_* code the route layer maps to a 4xx.
Deferred to v1.1. The typed MCP task-provider seam (letting an agent read and file tasks through an ask-style tool) is approved in design but not shipped: v1 registers no agent-callable task tool. A linear provider and an MCP-mirroring provider are the planned follow-ups on the same registry seam.
Where to go next
The recurrence plane
Schedule a task to be filed or an agent to be started again next week — the periodic intake this board pairs with, at-most-once per occurrence.
Approval modes
What an execution policy's auto danger ceiling folds against — the swarm's non-removable approval floor a per-task mode can only ever narrow.
Sub-org scopes
The orgScope axis that partitions the board by department — Rule A reads, exact-scope writes, and why an unrestricted admin sees every row.
Building on Night Owls? See the source on GitHub.