Skip to content
Night Owls.dev
Jump to a page
Concept

One plan per conversation. The agents follow it. So can the user.

An agent that keeps its plan in the transcript loses it: the next turn re-reads a summary, a delegate never sees it at all, and the user has no way to say "not that one, do this first". A task list moves the plan out of the prose and into a row per item, scoped to the conversation rather than the lane, so the orchestrator and every delegate read the same list and a human can edit it while a run is mid-flight. Wire nothing and your swarm is byte-identical: the tables, the tools, the injection and the panel are all opt-in.

Turn it on

Two things have to be true: a storage adapter with a todos store (both shipped adapters have one), and the explicit flag. A wired tool family over an absent store is the failure shape this refuses to ship, so enabling one without the other throws at defineSwarm rather than at the first tool call inside a paid run.

lib/swarm.ts
import { defineSwarm } from "@nightowlsdev/core";
import { createSupabaseStorage } from "@nightowlsdev/storage-supabase";

const storage = createSupabaseStorage({ /* ... */ });

export const swarm = defineSwarm({
  agents: [coordinator, researcher, writer],
  storage,
  todos: true,   // opt-in. Enabling it WITHOUT storage.todos throws here, not at the first tool call.
});

The flag reaches all three native engines. Read whether the engine you are running supports it at all from EngineCapabilities.todos, which is optional and absent-means-unsupported: an adapter engine that predates the plane reports nothing rather than claiming support by omission.

What the agent sees, and when

The list is injected into both instruction chains, the orchestrator's and every sub-agent's, framed as the shared plan to follow rather than as advisory context. That injection is a turn-start snapshot: it is resolved when the generation launches. Mid-turn freshness is the tools' job instead, which is why every one of them returns the full current list rather than an acknowledgement.

ToolWhat it doesDanger
todo_listre-read the list mid-turn, to pick up a human edit or recover an id0
todo_addwrite the plan down before starting. The create-or-skip heuristic lives in the tool description, where the model actually reads it1
todo_updatemark an item in_progress on start and completed on finish; revise a subject, detail or owner1
todo_removearchive an item that turned out to be unnecessary1

Danger 1, on purpose. The three writes are annotated reversible, non-destructive, scope: "single", no egress, not live, which puts them at danger 1 and therefore auto-allowed at auto's default threshold. A checklist the operator has to approve forty times a day is a checklist nobody uses. See approval modes for what that annotation buys and what it costs.

At the end of a turn, both after a run and after a resume, the engine checks whether the list still holds pending or in_progress items and issues one deterministic nudge naming the count. It shares the existing maxContinueNudges budget with the completion verifier so the two cannot compound, it is never merged into another nudge's text, and an exhausted budget with work still open ends the run done. Pending work is a state, not a failure.

Ops, ids, and the compare-and-swap token

A write is a batch of per-item, id-addressed ops rather than a whole array the model rewrites. That distinction is the entire concurrency story: a model handing back its version of the list would clobber the human's simultaneous insert on every single turn. Ids are assigned by the server and returned to the caller, so nothing invents one.

the op union
// The five ops. Four of them are what the tools build; the fifth exists only here.
type TodoOp =
  | { op: "add"; subject: string; detail?: string; owner?: string; after?: string }
  | { op: "update"; id: string; status?: "pending" | "in_progress" | "completed"; subject?: string; detail?: string; owner?: string }
  | { op: "remove"; id: string }                 // -> archived. Never a hard delete.
  | { op: "reorder"; ids: string[] }             // the COMPLETE active set, or rejected
  | { op: "restore"; id: string };               // archived -> pending. Route origin ONLY.

// POST /api/swarm/todos/<container>
{ "ops": [{ "op": "update", "id": "0c1f...", "status": "completed" }], "expectedRev": 7 }

expectedRev is the list's revision as the caller last saw it; null means "only if no list exists yet", which is what makes two concurrent creates serialisable at all. The check and the write happen inside one critical section under an advisory lock in the store, never as a read-then-compare in the route: two writers holding the same token would both pass a lock-free select and one would be silently buried.

The model never sees the token. It rides the run state, is captured at turn start, advances after each successful op, and adopts the server's value on a conflict, so a model that lost a race is handed the human's list rather than allowed to reassert a stale one.

A conflict is a 200

Losing the compare-and-swap is not an HTTP error. The route returns the store's own discriminated union verbatim, with a 200, because the useful part of a conflict is the current list and pushing every client into reading a list out of an error path is how that part gets dropped.

POST /api/swarm/todos/<container>
// A lost compare-and-swap comes back as a 200, not a 409. Nothing was written, and the
// body carries the list the other writer left behind.
{
  "conflict": true,
  "currentRev": 9,
  "items": [ /* the current list, rendered */ ]
}

// The client adopts currentRev and re-renders. No second round-trip: the response it
// already had to read IS the re-read.

A malformed batch is a 400 (structural), a batch the store rejects is a 422 carrying a machine-readable code and, for a cap, the limit it hit. Branch on the code; never parse the prose.

Bounded, because every turn renders it

The list is injected into the prompt on every turn, so an unbounded list is a permanent context overflow rather than an occasional one. Each bound is enforced by the store and rejects with a typed error naming the limit, rather than truncating something the caller believed it had written.

BoundValueWhat it counts
active items100pending + in_progress only. Completed and archived rows are never capped
subject200 charsone line, because it renders as one
detail2000 charsthe optional body
ops per batch20one round-trip, one transaction, whole-batch rollback
rendered projectionall active + 10every active item in order, then the newest completed ones. Older completed rows still exist; they just stop rendering

Exactly one item can be in_progress, enforced by a partial unique index. Promoting a second demotes the first in the same batch, so a model that says "starting both" gets a deterministic answer rather than an intermittent constraint violation.

Why remove is safe to auto-allow

todo_remove is annotated reversible: true, and that claim has to be worth something. It is: remove archives the row (there are no hard deletes on this plane), and the way back, restore, is accepted only from the route origin.

That is not a policy check a prompt can talk its way past. Every batch carries an origin set by the code that constructed it: the tool wrappers hard-code "tool" and do not expose a restore op at all, the route hard-codes "route", and the store rejects a restore from anything else. origin is not part of any tool schema, exactly like the compare-and-swap token, so no phrasing of a tool call reaches it. An agent can archive; only a human can bring it back.

The restore surface reads through the same participation-gated route with ?includeArchived=1, which widens the projection to every row instead of loosening the gate: a UI cannot offer to restore what it cannot see, and it still only sees conversations its caller participates in.

The route, and its one refusal

app/api/swarm/todos/[id]/route.ts
// app/api/swarm/todos/[id]/route.ts    (the [id] segment is the conversation container)
import { runner } from "@/lib/swarm";

export const { GET, POST } = runner.todosRoute();

Both verbs sit behind the participation gate: a caller reaches a container only when they already take part in it, or when they own its thread row and nothing has run in it yet. An empty container nobody owns is not readable. Identity comes from the AuthProvider, never from the request, so a forged tenant in the query changes nothing.

todosRoute() throws when the gate is unwired. The shared gate denies what it cannot authorize, so an adapter without runs.resolveContainerAccess already 403s every container route. This plane goes further and refuses to mount: a task list that rejects every request while looking wired is a worse failure than one that names the missing method at construction. It is also the only accessor for these handlers — a second door would skip the check.

The route validates the batch structurally and hands the fields through untouched. @nightowlsdev/runner-nextjs has no runtime dependencies and that is deliberate, so there is no schema validator here; the store has to validate every field anyway, and one authoritative validator beats two that agree until they do not.

The panel

app/chat/page.tsx
import { SwarmProvider, SwarmChat, TodoPanel } from "@nightowlsdev/react";
import { createSupabaseTodosRealtime } from "@nightowlsdev/react/supabase";

<SwarmProvider
  endpoints={{ todos: "/api/swarm/todos" }}
  realtimeTodos={createSupabaseTodosRealtime(supabase, { getToken })}
>
  <TodoPanel container={containerId} />
  <SwarmChat agentSlug="coordinator" threadId={containerId} />
</SwarmProvider>

<TodoPanel> renders the server's projection, adds, edits, completes, archives, reorders, and flips to an archive view with a restore action. Without endpoints.todos it renders nothing and fires no request. It is a registry slot like every other built-in, so a host can replace it wholesale and keep the hook.

Updates are optimistic and keyed on server ids, which is what lets a checkbox tick before the round-trip finishes without the two views drifting. An add is the one gesture with no id yet, so it renders a placeholder the response replaces wholesale. Every outcome (applied, conflict, rejected, failed) reseats the rows on server truth, so an optimistic projection never survives into a state anyone reads as the list.

a bespoke UI over the same hook
import { useSwarmTodos, projectUpdate, reorderIds, projectReorder } from "@nightowlsdev/react";

const todos = useSwarmTodos(containerId);

// Every write goes through one call: the ops for the server, and how to show it meanwhile.
todos.send([{ op: "update", id, status: "completed" }], projectUpdate(id, { status: "completed" }));

// A move always sends the whole active set. A subset is rejected, never silently placed.
const ids = reorderIds(todos.rows, id, -1);
if (ids) todos.send([{ op: "reorder", ids }], projectReorder(ids));

Cross-client edits arrive over Realtime on the private topic todos:<org_id>:<container>. The topic is namespaced by org and the receive policy binds authorization to the originating row, so two organizations whose conversations share a container name address different topics and can never hear each other. The ping carries { container, rev, updated_at } and no item text: subscribers refetch through the gated endpoint rather than trusting a broadcast.

API reference

  • SwarmConfig.todos?: boolean, the opt-in. Throws at defineSwarm without storage.todos.
  • StorageAdapter.todos?: TodoStore: get(tenantId, container, { includeArchived }) and apply(tenantId, container, ops, expectedRev, actor, origin). Implemented by both shipped adapters.
  • applyTodoOps / renderTodoList / TodoOpError: the op semantics, the bounded projection, and the typed rejection both stores share verbatim.
  • runner.todosRoute() returns { GET, POST }. Throws at construction without a participation gate.
  • EngineCapabilities.todos?: boolean: optional, and absent means unsupported. The three native engines set it.
  • <TodoPanel>, useSwarmTodos, useTodosRealtime, createTodosClient, and the pure reducer/projection helpers, all from @nightowlsdev/react.
  • createSupabaseTodosRealtime(client) from @nightowlsdev/react/supabase, the org-namespaced subscription adapter.

Not in v1. The blocked_by column ships and round-trips, but no op writes it and no engine reasons over it yet. The column is here so that adopting dependency edges later is not a migration of live rows. Cross-conversation views and promotion into a durable task manager are separate features, not this one.