npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@flow-state-dev/orchestration

v0.3.0

Published

Orchestration substrate for flow-state-dev: task collections, dispatchers, the task-board primitive, and the skills runtime.

Readme

@flow-state-dev/orchestration

The orchestration substrate for flow-state-dev. One package, three layers that build on each other:

  • Task substrate — the Task schema and state machine, the storage-agnostic TaskCollection, and the dispatcher catalog.
  • Task board — a concurrent drain over a TaskCollection with dependency gating and per-task worker routing. The orchestration primitive that supervisor, parallelTasks, and planAndExecute (in @flow-state-dev/patterns) are built on.
  • Skills — user-editable SKILL.md folders injected as inline instructions, optionally with an agents: field that installs a private delegation board, the taskTools surface, and runBoard — the skill assigns work as tasks and drains the board.

Layering: core → orchestration → patterns. This package depends only on @flow-state-dev/core and never imports from patterns or workforce.

pnpm add @flow-state-dev/orchestration

Task substrate

import { taskSchema, type Task } from "@flow-state-dev/orchestration";

A Task is the unified work-unit record: id, goal, status, deps, lease, attempts, retryLedger, optional typed input/output. Status enum: pending | in_progress | blocked | parked | completed | errored | cancelled.

pending ─┬─→ in_progress ─┬─→ completed
         │                ├─→ errored
         │                ├─→ in_progress       (claim, after a lease runs out)
         │                ├─→ pending           (reclaim)
         │                ├─→ cancelled
         │                └─→ parked ─┬─→ completed
         │                            ├─→ errored
         │                            ├─→ pending    (unpark)
         │                            └─→ cancelled
         ├─→ blocked ─┬─→ pending               (unblock)
         │            └─→ cancelled
         └─→ cancelled

completed, errored, and cancelled are terminal. A move to the status a task already holds is on the table, so a repeat write doesn't throw. Anything else throws an IllegalTaskTransitionError carrying taskId, from, and to, and writes nothing.

retryLedger records the task's standing against the collection's maxTotalRetries budget:

task.retryLedger;   // { granted: 2, deniedByBudget: false } | undefined

granted counts the failure retries this task was authorized, so it excludes the re-entries that do not spend the budget (unblock, unpark, reclaim) and includes a retry that was granted but never picked up. deniedByBudget turns true once a retry was refused because the collection's budget was spent; the board's terminationReason reads that flag, so branch on it rather than parsing the error string.

The field is absent on a task that has never failed, so read it with a single guard — task.retryLedger?.granted ?? 0 — and treat absent as zero granted, not denied.

TaskCollection

getOrCreateTaskCollection resolves a TaskCollectionRef over one of three backings — request-state (the taskBoard default; survives block boundaries within a request), block-scoped state (per board invocation — backing: "sequencer" is the common case), or resource-collection (outlives the request: a user's queue, an org work pool — declare one with defineTaskCollection). Every mutation that changes a field emits a task-change component item.

Server-only task fields. A task-change item carries the whole post-mutation row, and that stream is client-visible. A few fields on Task are substrate bookkeeping that must not reach a browser — claimedBy, the execution coordinate a claim records — so the factory omits them from the emitted payload via toEmittedTask, which honours the exported SERVER_ONLY_TASK_FIELDS set. If you wire a backing's onChange to a client transport yourself, apply toEmittedTask to event.task before you publish it — a field that is server-only on one boundary is server-only on every one.

How far claim safety reaches. Both backings are compare-and-swap with retry. The state backings mutate through atomicState; the resource backing mutates through ResourceRef.updateState, which chains writes per key within one execution context and then persists at the version that context read. A claim written against a stale read is refused and re-applied against the state that won, rather than overwriting it. Two workers contending for one task cannot both land a write.

The filesystem store is safe inside one process. A durable collection shared across replicas needs SQLite or Postgres. A stale write is refused, not overwritten.

Freshness is scoped to one request. Every ref resolved over the same collection inside a request sees the same tasks, so a task added through any of them is immediately visible through all of them — including a ref a task-board worker resolved before it went idle. On the resource backing, don't rely on a running request seeing a write made by another request. A later request reads it.

Removals reconcile when you resolve. A task the running request removed (an explicit delete on the resource collection, or a capacity eviction) stops being reported from the next resolution onward. A ref you are already holding keeps reporting such a task until it resolves again.

Every lifecycle transition — complete, fail, block, unblock, awaitReview, unpark, cancel — takes an optional trailing TaskTransitionOptions argument that makes the write advisory. ifAllowed skips the write when the task is already settled, or when the transition is one the state machine or the calling verb refuses. A legal status transition is necessary but not sufficient: a verb that owns one edge runs only from that edge's source status. unblock runs on a blocked task and no other, and unpark on a parked task and no other. Like cancel, unpark declines without options: disallowed for a live task, terminal once settled, and a second answer to an already-queued task is refused. in_progress → pending sits in the status table too, but it belongs to reclaim(). claim takes a TaskClaimTicket (mint one with ticketForClaim(collectionId, claimedTask)) and skips the write unless the task in front of it is the one that ticket was issued for, still on that attempt, holding a lease that has not run out. A guard cannot be raced: the task cannot change between the check and the write. A declined write is skipped and never throws; the call reports it on the returned TaskWriteOutcome. Omit the argument and the methods throw on an illegal transition.

A TaskCollectionRef you write yourself is a supported extension point. The substrate's own write-backs contain a throw they can attribute to a decline a conforming store would have made before committing: the late result is dropped and the drain continues. So a board keeps draining even against a ref that drops the options argument — a two-parameter complete(id, output) satisfies the interface structurally. The guarantee is board survival, not equivalence. It fires on a throw, so a stale write the state machine happens to permit still commits and still clobbers, and no error a store never raises can be contained. Everything else — a store outage on a task the worker still holds, a write that committed and then failed on the way out — propagates unchanged. When the task was already settled or displaced before the call, the seam cannot tell an outage apart from the decline a conforming store would have made, and contains it. Don't build an alerting path on an error the substrate may drop.

Every mutation method except claim and reclaim resolves to a TaskWriteOutcome: recorded (a field changed and a task-change item was emitted), unchanged (the task already held the state asked for, nothing written), or declined with a reason (immutable-assignee / terminal / not-my-task / disallowed / parked / lost-claim, resolved in that precedence order) and the status the task was in when the write was refused. immutable-assignee means the board hands rows off to a child session, where the assignee is fixed at admission; not-my-task means the ticket names a different task, a different collection, or an id since reused; parked means the caller passed refuseWhenParked and the task is in parked — nobody took it, so unlike lost-claim the answer is not to re-claim and redo; lost-claim means it names the right task but a claim that has moved on. A decline never throws, and discarding the return value is supported. cancel is advisory whether or not options are passed: cancelling a settled task declines with reason terminal.

setAssignee is the one field mutator that refuses anything — it declines on a terminal task, and on a durable collection any hand-off board draws from it declines every reassignment whatever the task's status (see "Handing tasks off through a dispatcher seat" below). setPriority, addLabel, removeLabel, and patchMetadata write to a terminal task, so a post-drain failure audit can label what went wrong; those four answer only recorded or unchanged (patchMetadata merges rather than compares, so it answers recorded even for a no-op patch). unchanged is a statement about the task record — on a resource backing the write reaches the resource either way, so a resource_change can fire for an unchanged write. A missing task throws on all eight.

A verdict only reaches a call that returned. For the path where a write commits and the call then throws, pass write: beginTaskWrite(collection.get(id)) on the same options argument and ask didWriteLand(collection.get(id), write) afterwards. It answers true (committed), false (changed nothing), or undefined (cannot tell — no provenance on the record, the token names a different incarnation of the task (delete/recreate under the same id), or the receipt aged out of the task's four-entry log). Mint the token before the write; report undefined rather than guessing. Correlation is available on the seven methods that take TaskTransitionOptions. setAssignee — the eighth method able to decline, above — takes no options object at all, so it and the other four field mutators advance task.revision but carry no token. A hand-written TaskCollectionRef that maintains no provenance leaves its callers with undefined, never a false false.

import { getOrCreateTaskCollection } from "@flow-state-dev/orchestration";

const collection = await getOrCreateTaskCollection({ ctx, backing: "request", collectionId: "plan" });
await collection.addTask({ id: "research", goal: "research the topic" });
await collection.addTask({ goal: "draft the post", deps: ["research"] });

Leases and recovery

A claim carries a lease: how long the claimant may be gone before the task is handed to somebody else. A worker the substrate drives pushes that deadline out while it runs (renewLease), so a lease that runs out means no worker is renewing it, and the next claim on any host takes it back as a fresh attempt. Nothing to schedule.

ClaimOptions.leaseDurationMs is the knob, defaulting to two minutes. Shorter means a stranded job comes back sooner and a merely-slow worker is likelier to lose its task; longer means the opposite. Values under a second, over about 74 days, or non-finite throw rather than being rounded into range.

Recovery is bounded at three re-dispatches, after which the task settles errored. Three is fixed, and exported as DEFAULT_MAX_ABANDONMENTS. That allowance is separate from maxAttempts.

A task you claim by hand gets no renewal. You hold it, so you call renewLease(id, deadline, { claim }) yourself, or size the lease to cover the work. Two helpers do it for you: withLeaseRenewal wraps work that is a single call, and startLeaseRenewal returns a { signal, stop } driver for work that spans several steps. Both renew in the background while the work runs, keep one renewal in flight at a time, and stop when the signal you hand them aborts. Both also give you a second signal that aborts when a renewal comes back declined. withLeaseRenewal composes it with the signal you passed and hands run the result; startLeaseRenewal exposes it bare as driver.signal, so compose that one yourself. Loss is detected at a renewal rather than the instant it happens, and one goes out every third of the lease, so the signal can lag a lost claim by about 40 seconds on the two-minute default. It is there to stop you paying for work you can no longer record; the fence on the settling write is what keeps the result correct.

Settle the task inside withLeaseRenewal's run: it stops renewing the moment run returns, so a complete() or fail() issued after it is a fenced write on a lease nobody is keeping alive.

A claimant that has not started yet can take a lapsed row back. renewLease(id, deadline, { claim, adoptLapsedLease: true }) renews a row whose lease has already run out, as long as the ticket still names it and the attempt is still the row's. That is the queued-worker case — a task handed to a child that waited longer than the lease with nobody renewing it — and the takeover is decided by the write: it declines lost-claim the moment a reclaim has moved the row on, and stays on the same attempt when it lands. It is opt-in because the substrate cannot tell that caller apart from a stalled worker whose renewal fired late, and only the first should take the row back. The flag applies to renewals only; a settlement on a lapsed lease is still declined.

Renew it for committedLeaseSpan(task) — the duration that claim committed to, which the claim writes onto the row (leaseDurationMs) rather than leaving to be inferred from leaseUntil - updatedAt. Those two stamps stop agreeing as soon as anything else writes to the row: setPriority, the label verbs and patchMetadata all move updatedAt and leave the deadline alone. The renewal driver reads the same function, so a task's cadence and its takeover cannot disagree about one lease. A row claimed before that field existed falls back to the subtraction.

A worker composed as several steps reaches its driver through currentLeaseRenewal(). Wrap the block that claims the task in withLeaseRenewalScope(async () => { … })as its first statement, before any await — and call stampLeaseRenewal(driver) inside it once the driver exists. The scope rides AsyncLocalStorage, which only propagates to later steps when it is entered before the claiming block awaits anything; stamping with no scope open throws rather than publishing to nobody. (openLeaseRenewalScope() is the same thing without the guarantee below, if you want to manage the failure path yourself.)

The wrapper stops the driver if that block throws after the claim commits. That window has no other cover: the step that runs the work never starts, so no recorder and no onSettled fires, and a failed request does not abort its own signal — the lease would be renewed for a task nobody is working until the host died, and a live lease is exactly what stops claim() recovering it.

Stop the driver after your fenced write, not before it. complete() and fail() are fenced on the claim, and the fence refuses a write on a lapsed lease, so stopping first can get a healthy worker's finished result refused and its work redone. A renewal in flight across the settlement is harmless: it writes only leaseUntil, and on a settled row it is declined and the driver stops itself.

isClaimable(task, lookup, now) is the substrate's admission rule, exported so a custom TaskCollectionRef can read it rather than restating it. It answers admission only — whether an admitted row is handed out or settled because its allowance is spent is claimDisposition(task, now, max), exported alongside it. Anything that reports which task runs next needs both; anything asking only whether there is work to do needs the first. Compare leases against collection.now(), not Date.now(): it is the clock that stamped leaseUntil.

Dispatchers

fifoDispatcher, topologicalDispatcher (the default), priorityDispatcher, classifierDispatcher({ classify }), and eventDispatcher({ topicFor, topic }). None of them claims a task whose deps aren't all completed. That eligibility rule lives on collection.claim, and a dispatcher's own eligibility narrows it, so they differ only in ordering. fifoDispatcher and topologicalDispatcher are ordered identically; priorityDispatcher takes the highest priority first, ties on createdAt.

Task board

import { taskBoard, taskWorkerInputSchema } from "@flow-state-dev/orchestration/task-board";

taskBoard({ name, collection, workers, ... }) returns { drain, collectionId, capability, backing, boardId, handedOff, hasIdlessInitialTasks, caps }. Mount board.drain in a sequencer. hasIdlessInitialTasks is true when any initialTasks entry omits an id; an idless seed re-adds on every drain, which is why goalSeekLoop rejects such a board when maxIterations > 1. workers is a single uniform worker or a { [assignee]: block } registry; each task's assignee routes it. Config: defaultWorker (optional fallback for a task whose assignee is unmatched or omitted — reached only on a miss, declared workers untouched), concurrency (default 4), dispatcher (default "topological"), onIdle ("complete-or-blocked" default | "complete" | "wait"), onReview ("hold" default | "exit" — whether a task parked with awaitReview keeps the drain open, or is excused from the in-flight counts so the drain returns and leaves it parked for a later one to claim once it is resumed; "exit" needs a defineTaskCollection collection, the default onIdle, and ids on initialTasks, and is refused at construction otherwise; board.unparkAndDrain takes { taskId, feedback }, re-queues the parked task, and drains in the same request, returning the write outcome and draining only when it was recorded), initialTasks, onError, maxIterations (per-worker claim-loop cap, default 10000), the two creation caps maxEnqueuedTasks (default 100 — tasks addable while others are pending, refreshes on drain) and maxTotalTasks (default 500 — lifetime count incl. terminal, never refunded), and the retry budget maxTotalRetries (default 50 — failure retries the board may authorize across every task). The creation caps take a positive integer or null (explicitly unbounded); maxTotalRetries takes a nonnegative integer or null, so 0 means "run every task once, never retry". Omission reapplies the default on all three. They apply only when the board constructs its own collection — a supplied collection is left alone and passing any of them is a construction error, so configure caps on getOrCreateTaskCollection's sequencer/request backing instead. Per-task retries are set via maxAttempts on each task (TaskInit), not on the board. At the retry budget the failing task settles terminal errored and the board's completion item reports terminationReason: "retry-budget-exhausted" alongside counts.retries and the limit in force. See the Task board guide.

When the board cannot record a result. Saving a result is a commit followed by a change announcement, and the announcement can fail on its own. The board's two recorders correlate every result write (beginTaskWrite / didWriteLand, above) and report one that landed — or that they cannot account for — on a persisted task-board-recorder-failure component item carrying { collectionId, taskId, recorder: "complete" | "fail", verdict: "committed" | "undetermined", error, runId? }. The drain then fails the run, after every other task has finished, with a TaskBoardRecorderFailureError (code: "task-board-recorder-failure") naming every affected task; on a handed-off row, which has no batch to drain, the failure fails that child run instead. onError is not consulted on this path — it is a policy about a task going wrong, not about the board's bookkeeping. A write that demonstrably committed nothing is unchanged and still takes the ordinary error path. verdict: "undetermined" is the permanent answer on a caller-supplied TaskCollectionRef, and on rows that predate write provenance; such a row is also released rather than left claimed. If the report itself cannot be emitted, the run fails immediately with a TaskBoardReportFailureError instead. task-board-recorder-failure is excluded from per-task item attribution — it is the substrate's, not the worker's — and is suppressed in chatAssistantRenderers; an app with its own renderer registry should add component: { "task-board-recorder-failure": false }, as it already does for task-change. The recorders themselves (createRecordSuccess / createRecordError) raise a recorder failure by default; only a composition that supplies a tail able to read the report — which taskBoard()'s drain does — passes recorderFailure: { onRecorderFailure: "defer" }.

Handing tasks off through a dispatcher seat

A seat under workers is a block. Put a dispatcher({ action, session }) in that position and the seat hands its tasks off: the drain claims a row, sends a task dispatch to the flow's task.actions[action] entry, and moves on, while the entry's block runs in a child session of the session that drained — on a request of its own — and settles the row itself. Any other block in that position runs inline in the drain. The worker is declared once, on the flow, exactly like an action. A seat holding anything other than a block (an object such as { worker, dispatch } or { block, session }) is refused by name at construction.

import { defineFlow, dispatcher } from "@flow-state-dev/core";
import { defineTaskCollection } from "@flow-state-dev/orchestration/tasks";
import { taskBoard } from "@flow-state-dev/orchestration/task-board";
import { z } from "zod";

const issueLedger = defineTaskCollection({
  id: "issues",
  scope: "session",
  sharedToLineage: true,               // the child session addresses the same rows
  stateSchema: z.object({ issueKey: z.string() }),
});

const board = taskBoard({
  name: "issue-work",
  boardId: "issue-work",               // required once any seat hands off
  collection: issueLedger,             // must be a defineTaskCollection()
  workers: {
    triage: triageBlock,               // runs inline
    implement: dispatcher({            // hands off to flow.task.actions.implement
      name: "hand-off-implement",
      action: "implement",
      session: "per-task",
    }),
  },
});

export default defineFlow({
  kind: "issues",
  actions: { drain: { block: board.drain } },
  task: { actions: { implement: { block: implementBlock } } },   // what runs in the child
})();

session decides which child session a seat's rows run in:

| session | Child session | Use it when | |---|---|---| | "per-task" | one per row, keyed on the task id | rows are independent | | "per-worker" | one per seat, shared by every row the seat runs | the worker should remember what it already did | | { key: (task: TaskWorkerInput) => string } | keyed on what the function returns, verbatim | one issue across several phases, or a key shared across seats |

The presets include the board id in the key, so two boards' per-task children stay apart even when their task ids coincide. A custom key is used as returned, so two seats (or two boards) that return the same key share one child. A child that runs several rows runs them under its entry's concurrency policy. An entry a per-worker or key seat hands off to defaults to queue, so the rows run one at a time; a per-task seat's entry keeps the ordinary default (the flow's request.concurrency, else allow). An explicit concurrency on the entry wins:

task: { actions: { implement: { block: implementBlock, concurrency: "allow" } } },

Only a named seat hands off: defaultWorker and a uniform workers block have no seat name and so no assignee to route by.

defineFlow refuses a hand-off whose entry the flow forgot to declare, a task entry no board hands off to, a task dispatcher no board holds, and two boards handing off to one entry. board.handedOff lists the seats that hand off, in declaration order.

The task dispatch carries the claim's identity (boardId, taskId, attempt, createdAt, incarnationId) and the worker input the drain packed at claim time — taskDispatchInputSchema / TaskDispatchInput. That input has to be JSON-serializable; a payload that is not fails the row in the drain. When the dispatch arrives, the entry re-reads the row and runs the worker only if the claim is still current: same attempt, same row (not deleted and recreated under the same id), still in_progress, still routed to this seat. Otherwise it throws StaleTaskClaimError (code: "stale-task-claim") and writes nothing; the row stays in_progress until its lease runs out and the next drain reclaims it. On the drain side the hand-off block returns { handedOff: true, taskId, sessionId, requestId, adopted }, and a refused dispatch fails the row through the board's ordinary error path, throwing the same DispatchRefusedError (with its refused code) that a dispatcher() block throws. The child settles its own row, and the board's onError reaches it: "skip" settles the row and lets the child's request complete, "fail" also fails that request. The one thing onError does not govern is the board failing to record what it saved — see the recorder-failure note under taskBoard() — which fails the child run under either setting.

taskBoard() refuses a board that hands off unless all of these hold, naming the board and the fix:

  • boardId is declared. Every task dispatch names it, so renaming it orphans children already in flight.
  • The collection is durable — a defineTaskCollection() resource backing. The request, sequencer, and factory backings are refused: a handed-off row outlives the request that claimed it.
  • A session-scoped collection declares sharedToLineage: true, or the child would resolve an empty ledger and never find its row. user and org scope need nothing extra; they already span every session the principal touches.

defineFlow adds one more: no handed-off entry block declares sessionStateSchema, at its root or in a composed child. Keep the block's state on the task.

A board that hands anything off fixes each task's assignee at admission: the seat name is what a row is routed by, so setAssignee declines with reason immutable-assignee. The rule belongs to the collection rather than the board, so a second board over the same defineTaskCollection value declines too. File a new row rather than reassigning one.

A claim carries a lease (two minutes by default), and nothing renews it between the hand-off and the child's first step. A child that starts after the lease has lapsed takes the row back before it runs: it renews the lease against the claim it was dispatched with, on the same attempt, and proceeds when that write lands. It refuses only when the renewal is declined, which is the case another drain reclaimed the row first. So a deep queue in front of the child costs nothing, and a genuine reclaim is still the successor's to run. The board's lease is not configurable.

On serverless without a queue adapter, the child runs inside the invocation that started it and is bounded by that function's maximum duration. With a queue adapter it moves to a worker process and is not.

goalSeekLoop

import { goalSeekLoop } from "@flow-state-dev/orchestration/task-board";

goalSeekLoop({ name, board, seed?, judge, maxIterations, finalize?, ... }) wraps a board's drain in an outer, judge-gated loop: seed → drain → judge → (replan) → repeat → finalize. The judge returns a three-way Verdict (done/continue/replan); maxIterations is a mandatory finite backstop, and the loop lands with a typed goal-seek-loop-termination item rather than hanging. The board must be request- or resource-backed. parallelTasks and planAndExecute are expressed on it. See the GoalSeekLoop guide.

Skills and delegation

A skill is inline instructions injected into a generator's prompt. Bind skills to one generator with createSkillsLibrary — no shared bag, no cross-agent bleed — and configure the binding where the generator is defined:

import { createSkillsLibrary } from "@flow-state-dev/orchestration";

const skills = createSkillsLibrary({ catalog, initialSkills });

// Preload a skill, fails loud on a typo:
generator({ uses: [skills.with({ active: ["detailed-analysis"] })] });

// Let the agent load a skill mid-turn, stored in this generator's block state
// (the binding installs the block-state field for you):
generator({
  uses: [skills.with({ allowed: ["deep-research"], dynamicActivation: true })],
});

initialSkills also takes a function of the execution, for a catalog that belongs to the flow copy rather than to the definition — two registered copies then seed two different sets. Pair it with collectionConfig: { flowIsolation: true } so each copy reads its own storage. The resolver runs before every step of every turn, so keep it to an O(1) read of something already resolved; and because there is no build-time catalog under one, binding a skill by name (active / allowed) is refused rather than left unvalidated.

Seeding writes a copy, so a later edit to the source does not reach a catalog that already holds the skill. refreshSeededSkills(collection, sources) is the deliberate act that pulls one through. It replaces a touched skill's folder whole, so a supporting file the source has dropped is deleted rather than left reachable; it skips a name whose manifest is gone, so a deletion stands; and ordinary seeding stays additive.

importSkillsDirectory({ overwrite: true }) is not the same thing, and picking the wrong one is how stale instructions survive. Both write a source skill over one that is already there, but overwrite enumerates nothing — it writes the files the source has and leaves everything else, so a supporting file the source has since dropped stays in the folder and stays resolvable through prompt-ref. refreshSeededSkills prunes it. Reach for overwrite when you mean "write these on top" (a migration, a test fixture); reach for refresh when you mean "make this match the source".

A skill that declares an agents: field turns on delegation (or force it on with delegation: true even with no agents:). An agent is a prompt-driven teammate — defined inline (prompt / prompt-ref) inside the skill, or referenced from the registry (agent-ref).

The board's other seats are the skill's tools, and nothing declares them: every key in its allowed-tools (or the whole catalog, when it declares none) is assignable by that key. The board calls the tool directly with the task's input as its arguments — no model turn — and records what it returns. A tool task gets dependency ordering from deps but not an upstream task's output; a step that must read one is an agent.

Those seats come from the skill, which is the wrong owner when the host did not choose the skills it holds. toolSeatFence is the host's ceiling: return the keys a board worker may be seated with for this execution, and the seats are narrowed to them — an empty array means none. It only narrows, and it leaves the declared agent roster alone.

A binding that contributes the catalog contributes all of it, never the subset a skill's allowed-tools names. It contributes the catalog when it preloads skills (active), when it installs the load tool (dynamicActivation), and when it pairs activeState with allowed; an activeState binding with neither contributes no catalog tools at all. registerCatalogTools: false turns that grant off while leaving the validation on, for a host that owns tool registration itself; pair it with toolSeatFence so a held skill's delegated workers cannot reach past the same fence.

Every delegation board also gets an on-demand default worker: it materializes on demand and runs any task whose assignee is unset, so a task with no named agent still runs, and an empty roster still delegates.

Every tool that writes an assignee checks it: addTask, assignTask, and updateTask reject a name that is neither a declared agent nor an assignable tool, returning the available ones so the caller can correct it, instead of letting a mistyped name fall through to the default worker at drain time. A board with no agents and an empty catalog has no roster to check and accepts any assignee.

Binding the skill installs a private task board (own-state, scoped to that generator), the eight taskTools (addTask, assignTask, completeTask, failTask, blockTask, cancelTask, updateTask, listTasks), runBoard, and a guidance context. The generator orchestrates by planning a graph with addTask (assignee, deps, structured input) and calling runBoard once: the board drains under concurrency with dependency gating and returns every task's output. There is no per-agent tool the generator calls directly; draining the board is the sole execution path. Agents materialize at runtime, so agent-ref agents resolve through the library's agentRegistry/materializeAgent options and runtime-activated skills contribute their tools too. With no delegation board resolvable, a stray taskTools call returns { ok: false, error: "no_delegation_board" } rather than throwing.

The board is bounded by default: addTask is refused past 100 tasks enqueued at once ({ ok: false, error: "enqueued_task_cap_exceeded" } — drain with runBoard to free slots, though tasks stranded behind a failed dep stay pending and hold theirs) or 500 over the board's lifetime (total_task_cap_exceeded, never refunded by draining), tunable via createSkillsLibrary's maxEnqueuedTasks / maxTotalTasks (null = unbounded). It carries no retry budget: a task created through the delegation addTask tool takes no maxAttempts, so it runs once and never retries.

Which surfaces are capped. The caps come from the code that CONSTRUCTS the collection, so they cover boards the skills library installs and boards taskBoard builds itself — not the capability surface on its own. Wiring the exported taskTools singleton by hand (uses: [taskTools]) resolves the host generator's own-state board through a bare, uncapped collection: addTask there is unbounded. For a bounded board on that path, build the collection yourself with getOrCreateTaskCollection({ …, maxTotalTasks, maxEnqueuedTasks }) and hand a resolver for it to createTaskToolsCapability(resolver). That resolver must target the host generator's own state via ctx.parent (each tool runs as a child block, so ctx.sequencer is the wrong container) and name the board's stateKey — see Delegation for the full recipe.

Every taskTools call reports a problem the same way. A status change the task's current status does not permit is a recoverable tool result too, not a throw: completeTask on a task that was never started answers { ok: false, error: "illegal_status_transition: …" }, naming the task's current status and the calls actually available from it. So the recoverable set across the eight tools is no_delegation_board, task_not_found, unknown_assignee, enqueued_task_cap_exceeded, total_task_cap_exceeded, illegal_status_transition, and terminal_task_write_declined — a coordinator rule like "when a tool returns ok: false, re-plan" covers all of them.

Match those by prefix, not equality. no_delegation_board, task_not_found, enqueued_task_cap_exceeded, and total_task_cap_exceeded are the whole error string, but unknown_assignee, illegal_status_transition, and terminal_task_write_declined are followed by : and a sentence of guidance for the model, so error === "illegal_status_transition" never matches. Use error.startsWith("illegal_status_transition"). There is no separate structured code field today.

Only the tool boundary translates a refusal into a result. Driving a collection directly throws, and the error is an exported class you can catch:

import { IllegalTaskTransitionError } from "@flow-state-dev/orchestration";

try {
  await collection.complete(taskId, output);
} catch (err) {
  if (err instanceof IllegalTaskTransitionError) {
    // err.taskId, err.from, err.to — the refused move.
  }
  throw err;
}

Catch it by type, not with a blanket catch: a CAS conflict, a scope-mutation timeout, or a storage failure is not a task-state problem and should keep propagating.

The one exception is a call that asked for it. complete and fail accept the advisory options described under TaskCollection above, and a refused transition on such a call is a returned declined verdict rather than a throw, so it never reaches this catch.

At the delegation taskTools boundary a declined verdict does become a tool result: assignTask, cancelTask, and an updateTask carrying an assignee answer { ok: false, error: "terminal_task_write_declined: …" } on a finished task rather than reporting a success that did not happen. ok: true from those tools means "the backing reported no decline", not "the write happened" — for the two built-in backings those coincide, but a custom ref that reports nothing is carried past rather than having a verdict synthesized for it.

// "research-lead" declares agents: → delegation installs automatically.
generator({ uses: [skills.with({ active: ["research-lead"] })] });

An inline agent may set context-supply: conversation to inherit the parent conversation up to the point it is dispatched (fork-like), bounded to the last 8 whole turns (a turn count, not a token budget), while its own steps stay out of the host's history (output keeps history: false). Omitting the field is the default: the agent is isolated and sees only its task input — there is no isolated value to set. Write it on the skill entry for an inline prompt:, or in the prompt file's YAML frontmatter for prompt-ref. Setting it on an agent-ref agent, or on a prompt-ref skill entry, fails loud. See Context supply.

For a graph fixed in code (seeded initialTasks, custom collection, tuned dispatcher), put a taskBoard(...).drain or a goalSeekLoop in the generator's tools: — any block can be a tool, and only the finalized result re-enters the caller's history.

See Per-generator binding for the active / allowed / activeState surface and Delegation for the agents: shape.

skillsManifestSource

import { skillsManifestSource } from "@flow-state-dev/orchestration";

skillsManifestSource({ collectionKey?, allowed?, initialSkills? }) is the skills domain's projection into the agent discovery door (discoveryTools / createManifestRegistry in @flow-state-dev/core). It lists what loadSkill will actually accept: enabled, inline-mode, and inside the binding's allowed set when it declares one — pass the same array the binding was given. Advertising a skill the loader then refuses sends the agent somewhere it cannot go, so the two filters are the point rather than a nicety. A collection key this scope does not hold throws, which the door reports as a problem on the skills domain while every other domain still answers.

The ambient catalog listing in the prompt is now the catalogContext preset, on by default — an app that upgrades sees turn 1 unchanged. Pass catalogContext: false in the same .with({ ... }) call to take the listing out and let the agent find skills through the door instead. Preset overrides replace rather than merge, so put both flags in one call.

resolveCatalogTools

import { resolveCatalogTools } from "@flow-state-dev/orchestration";

resolveCatalogTools(agentKey, toolKeys, catalog, logPrefix) resolves an agent's tools: list against a tool catalog and returns GeneratorTool[]. agentKey is the agent name quoted in the warning text; toolKeys is the agent's declared tools: list (readonly string[] | undefined); catalog is the available tools keyed by name (Record<string, GeneratorTool>); and logPrefix is the bracket tag the warning carries ("skills", "workforce"). An empty or absent toolKeys returns []. An unknown key warns ([skills] agent "x": unknown tool "y" — skipped) and is dropped rather than throwing, so one bad key in a user-authored SKILL.md does not take down the agent; only own properties count, so a key like constructor misses. It lives on the package root because the skills worker-materializer and @flow-state-dev/workforce both need this lookup and the miss path has to stay identical between them.

Documentation

Running tests

pnpm --filter @flow-state-dev/orchestration test