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/workforce

v0.3.0

Published

The seat factory for flow-state-dev: worker records become configured flow instances.

Readme

@flow-state-dev/workforce

The seat factory for flow-state-dev.

A worker is a flow kind plus its instructions. Describe each one as a WORKER.md record, then hire the roster: hireWorkforce turns those records into one configured, addressable flow copy per worker, which you register.

Quick Start

Describe the roster on disk, read it, hire it, register what comes back.

workforce/teams/engineering/workers/lead/WORKER.md
---
description: Holds the board.
model: openai/gpt-5.4-mini
---
You are the engineering lead. You break work into tasks and report what came back.

readDeclaredRoster reads that tree — every worker, team, document and channel declared in it — and lists whatever failed to load on problems.

import { readDeclaredRoster } from "@flow-state-dev/workforce/loader";
import { hireWorkforce } from "@flow-state-dev/workforce";

const roster = await readDeclaredRoster("./workforce");
if (roster.problems.length) {
  throw new Error(`workforce: ${roster.problems.map((p) => `${p.layer} ${p.path}`).join(", ")}`);
}

const seats = hireWorkforce(roster.workers);
flowRegistry.registerMany(seats); // FlowInstance[], ordered by id

That record names no flow:, so it is hired into the built-in agent kind and needs no kinds argument. Its body becomes its instructions and steers its answers.

The built-in stores each worker's skills at org scope. Organization identity is unconditional, so every admitted request carries one and a caller cannot name its own org.

To run a worker on a flow you wrote, name that flow's kind in the record's flow: and pass the flow under the same key:

const seats = hireWorkforce(workers, { kinds: { "custom-agent": customAgentFlow } });

customAgentFlow is your own defineFlow(...). The record's frontmatter becomes that flow's config and its body arrives as config.instructions, so the flow's configSchema — not this package — decides what a worker may declare. One roster can mix both.

Personas

Use definePersona to declare resource-backed personas (parallel to Skills):

import { definePersona } from "@flow-state-dev/workforce";

const personas = definePersona({
  pattern: "personas/*",
  contentTemplate: "You are a {{ state.role }}. {{ state.instructions }}",
});

Reading a workforce from files

Describe each worker in a folder instead of in code. readWorkforceDirectory walks <root>/teams/<teamId>/workers/<workerName>/, reads each worker's WORKER.md, and returns one record per worker. readWorkforce wraps it, joining each seat's resolved skills and its team's instructions onto the records it hands back.

import { readWorkforceDirectory } from "@flow-state-dev/workforce/loader";

const { workers, errors } = await readWorkforceDirectory("./workforce");
if (errors.length) throw new Error(`workforce: ${errors.length} worker(s) failed to load`);

Each record is plain data:

| Field | Description | |-------|-------------| | id | The worker's whole identity, "<teamId>.<workerName>" — e.g. "engineering.lead". | | declared | The frontmatter exactly as written. Keys are not checked against a list, beyond a required description and four refused ones: persona:, seatSkills:, seatTools: and teamInstructions:. | | body | The Markdown below the frontmatter, verbatim. Empty when the worker has no instructions. |

description is the only required setting in a WORKER.md. Team and worker folder names must be lowercase letters, digits and single hyphens, at most 64 characters.

A WORKER.md may also carry resources:, a list of the file-declared documents that seat may touch. The loader carries it through onto declared untouched, the way it carries every key it does not name; the hire step is where a ref meets the documents it could match.

The optional team file

A team may also carry a TEAM.md at <root>/teams/<teamId>/TEAM.md: a required description, and a body holding the instructions every worker on that team is given. The file is optional, and a team without one loads exactly as it does without it — no layer, no placeholder, nothing reported.

readTeamsDirectory reads it on its own; readWorkforce reads it once per call and joins the result onto that team's worker records.

import { readWorkforce } from "@flow-state-dev/workforce/loader";

const { workers, teams, errors, skillErrors, teamErrors } = await readWorkforce("./workforce");

| Field | Description | |-------|-------------| | teams | One TeamManifest per team that has a TEAM.md — id, description, declared, and instructions (absent when the body is empty or whitespace). | | teamErrors | One entry per TEAM.md that failed, keyed by its path. The team's workers still load, without the layer. | | errors | One entry per worker slot that failed: a seat the app does not have. | | skillErrors | One entry per seat whose skills loaded short, carrying that seat's id and its own error list. |

All three are collected rather than thrown. Treating any of them as fatal is the caller's call. errors and teamErrors are flat { path, error, kind } lists, where kind is that reader's own closed union of conditions — narrow on it rather than matching error.message. skillErrors is one entry per affected seat, { worker, errors }, so it needs flattening before it reads like the other two.

A TEAM.md refuses id:, flow:, instructions: and teamInstructions: — the first two because the convention derives them, the last two because the body is already the instructions.

A hired seat then receives two instruction settings, never merged: instructions (its own body) and teamInstructions (its team's). Both are absent rather than empty when there are none. The built-in worker kind composes them into its prompt in a fixed order — the team's first, the seat's own last.

The reader builds nothing: no flow, no agent, no registry entry. It throws only when root itself cannot be read or is a symlink — a folder that produces no worker lands in errors, keyed by its path, and every other worker still loads. Treat a non-empty errors as fatal at startup unless you have a reason to run a short roster.

The subpath is separate because the reader imports node:fs; the package root stays isomorphic.

Reading one seat's skills

A skill is a folder with a SKILL.md in it. In a workforce tree, one worker's skills are spread across three folders: the org's, its team's, and any sitting beside the worker itself.

workforce/org/skills/triage/SKILL.md
workforce/teams/pentest/skills/port-scan/SKILL.md
workforce/teams/pentest/skills/review/SKILL.md
workforce/teams/audit/skills/review/SKILL.md            # a different `review`
workforce/teams/pentest/workers/recon/WORKER.md
workforce/teams/pentest/workers/recon/skills/sweep/SKILL.md

readSeatSkills reads all three for one worker and returns the records initialSkills takes.

import { readSeatSkills } from "@flow-state-dev/workforce/loader";

const { skills, errors } = await readSeatSkills("./workforce", {
  team: "pentest",
  worker: "recon",
});
if (errors.length) throw new Error(`skills: ${errors.length} entries failed to load`);

skills.map((s) => s.name).sort(); // ["port-scan", "review", "sweep", "triage"]

Every skill folder at those three levels is read. Nothing has to be listed anywhere for a skill to be included.

The set comes back level by level: the org's first, then the team's, then the worker's own. Each entry is { name, skillMd, files }, the same record readSkillsDirectory returns — name is the folder name, bare, with no team prefix.

Two calls naming different teams read different folders. Each result holds only what its own call read:

const recon = await readSeatSkills("./workforce", { team: "pentest", worker: "recon" });
const clerk = await readSeatSkills("./workforce", { team: "audit", worker: "clerk" });
// recon.skills has pentest's `review`; clerk.skills has audit's. Neither carries the other.

One name reaching a single worker from more than one of its levels is refused. All three levels count, so a collision can span two of them or all three:

errors;
// [{ kind: "duplicate-skill-name",
//    path: "org/skills/triage",
//    paths: [
//      "org/skills/triage",
//      "teams/pentest/skills/triage",
//      "teams/pentest/workers/recon/skills/triage",
//    ],
//    error: Error('Skill "triage" reaches seat "recon" from 3 levels — org/skills/triage
//                  and teams/pentest/skills/triage and
//                  teams/pentest/workers/recon/skills/triage. Remove one: there is no
//                  precedence rule.') }]

paths holds every file competing for the name, in the order the levels are read: the org's, then the team's, then the worker's own. None of them reaches skills — the contested name is left out of the set entirely. A worker-level folder does not override its team's, and a team's does not override the org's. The fix is a rename or a deletion.

path is paths[0], the level the name was first seen at, which is how every entry in errors is keyed.

A level that isn't in the tree is empty, not an error — an app may keep no org skills, and a worker may have none of its own. A level that exists and cannot be listed lands in errors under its own path, and so does a skill folder that fails to load, under <level>/<folder>.

Every entry carries a kind alongside its path and error, naming the condition it is; the conditions are listed under Error Semantics. Match on kind rather than on the message text when you want to tolerate one class (a malformed skill folder, say) and still refuse another.

A root that cannot be read throws instead: Failed to read workforce directory "./workforce": ENOENT .... So does a root that is a symlink: Symlinked workforce directory "./workforce" — refused for safety, with a trailing slash or without. If you run a linked root deliberately, pass the path it resolves to.

Symlinks are never followed, and that holds for the folders on the way to a level as much as for the level itself — org, teams, a team's folder, its workers, and the worker's own. A symlinked one is refused into errors under its own path, so a link out of the tree cannot pull skills in from outside the configured root.

team and worker follow the same naming rules as the folders they name: lowercase letters, digits and single hyphens, at most 64 characters, and not _meta. A name outside those rules throws.

A SKILL.md read this way may not declare scope:. A file that does lands in errors under kind: "refused-scope-key" and is left out of skills: where the folder sits is what decides which workers read it. readSkillsDirectory applies no such rule, so a folder you read both ways can load there and be missing from a seat's set. Drop the key and both readers agree.

The reader registers nothing and starts nothing. Wiring the records into a running seat is the caller's job: pass skills as the initialSkills of the skills capability or library you build for that worker — or let readWorkforce do it.

Reading the whole roster at once

readDeclaredRoster reads one workforce tree — the folder holding org/ and teams/ — and hands back everything declared in it, plus one list of what failed to load.

import { readDeclaredRoster } from "@flow-state-dev/workforce/loader";

const roster = await readDeclaredRoster("./workforce");
const seats = hireWorkforce(roster.workers);

| Field | What it holds | |-------|---------------| | workers | One WorkerManifest per worker, each carrying its own resolved skills. The records hireWorkforce takes. | | teams | One TeamManifest per team that wrote a TEAM.md. A team without one is absent, not present-and-empty. | | documents | One ResourceDoc per document, from every resources/ folder the convention reads. | | references | One ResourceDoc per reference, from every references/ folder, each carrying the filePath it was read from. | | channels | One ChannelManifest per channel under teams/<id>/channels/. There is no org/channels/ level, the way there is for documents. | | problems | Everything that did not load. Empty for a tree that loads cleanly. |

Each list arrives in tree order.

Each record's skills reaches the built-in agent kind as its seatSkills setting, imposed by the hire step the way a body is imposed as instructions. A WORKER.md declaring seatSkills: itself is refused by name at both the loader and the hire step — where a skill folder sits is what decides who can see it.

For seats alone, without documents or channels, read the worker half with readWorkforce.

What did not load

Every record that loaded is in the roster whether or not others failed, so a tree with problems resolves rather than throwing:

roster.problems;
// [{ layer: "worker",
//    path: "teams/qa/workers/broken",
//    error: Error('Worker folder "broken" has no WORKER.md. ...') },
//  { layer: "skill",
//    path: "org/skills/house-style",
//    worker: "qa.tester",
//    error: Error('Missing SKILL.md in "house-style/"') },
//  { layer: "document",
//    path: "org/resources/loose.md",
//    error: Error('"loose.md" has no frontmatter — a resource file needs at least a
//                  `description`') },
//  { layer: "channel",
//    path: "teams/qa/channels/standup",
//    error: Error('CHANNEL.md in "standup/" must declare a non-empty `description`') }]

layer is one of worker, skill, team, document, reference or channel, and the entries arrive in that order: worker slots, then each seat's skills, then team files, then documents, then references, then channels, and last a reference entry per basename claimed in both slots. path is the path that failed, relative to the root. error is the original Error, cause chain intact.

worker is set on the skill layer and nowhere else. A skills level that several seats read fails once per seat that read it, each entry naming its seat.

An entry carries no kind. To branch on one exact condition, call that layer's own reader and match on the codes under Error Semantics.

Nothing here decides what is fatal. Refuse the boot on any problem, or on the layers you care about:

const missingSeats = roster.problems.filter((p) => p.layer === "worker");
if (missingSeats.length) {
  throw new Error(`workforce: no seat at ${missingSeats.map((p) => p.path).join(", ")}`);
}

It throws on the root and nothing else: a path that cannot be read, a path that is a symlink, or one spelled with an interior .. that steps back through an earlier segment (pass the path that resolves to). Everything below the root is collected, including a team, worker, channel or document whose name breaks the naming rules.

Hiring a workforce

hireWorkforce turns worker records into seats: one configured, addressable flow copy per worker. It reads no files, builds no flow graph, and registers nothing. You pass the flow kinds your app defined, and you register what comes back.

import { hireWorkforce, type WorkerManifest } from "@flow-state-dev/workforce";

const workers: WorkerManifest[] = [
  {
    id: "engineering.lead",
    declared: { flow: "custom-agent", description: "Holds the board.", model: "openai/gpt-5.4-mini" },
    body: "You are the engineering lead. You break work into tasks and report what came back.",
  },
  { id: "engineering.intake", declared: { flow: "intake", description: "The front door." }, body: "" },
];

const seats = hireWorkforce(workers, { kinds: { "custom-agent": customAgentFlow, intake: intakeFlow } });
flowRegistry.registerMany(seats); // FlowInstance[], ordered by id

The factory reads three keys of its own: flow, which names the kind to instantiate, description, the roster label, and resources, the documents this seat may touch. Everything else is that worker's settings, handed to the flow verbatim and parsed against its configSchema. That schema is closed, so a setting the flow never declared is refused by name at the hire.

The documents a seat may touch

A WORKER.md may list the file-declared documents that seat is allowed to reach, chosen from the ones its kind was installed with:

---
description: Holds the engineering board and breaks work into tasks.
flow: custom-agent
resources:
  - teams/engineering/handbook
  - teams/engineering/board-notes: rw
---

A ref on its own is read-only: the seat reads the document, and a write is refused both at the resource handle and through the model's own write tool. <ref>: rw grants writes, and <ref>: ro spells the default out.

Absent and empty are different answers. A seat whose file has no resources: key reaches every document its kind installed, and writes the ones that allow writes. resources: [] is how a file says a seat gets none.

Only documents are narrowed. The stores, boards and anything else the kind declares at flow level stay reachable and writable whatever a seat's list says, and so do the resources the kind's own blocks declare.

For a ref to resolve, the hire step has to be told which entries in the kind's map are documents. Hand it the same catalog you spread into the flow:

import { defineFlow } from "@flow-state-dev/core";
import { hireWorkforce, resourcesFromDocs, workerConfigSchema } from "@flow-state-dev/workforce";
import { readResourcesDirectory } from "@flow-state-dev/workforce/loader";
import { inputSchema, runTurn } from "./blocks";
import { boardResource } from "./resources";

const { documents } = await readResourcesDirectory("./workforce");
const catalog = resourcesFromDocs(documents);

const customAgentFlow = defineFlow({
  kind: "custom-agent",
  cardinality: "collection",
  configSchema: workerConfigSchema(),
  resources: { board: boardResource, ...catalog },
  actions: { run: { inputSchema, block: runTurn } },
});

const seats = hireWorkforce(workers, {
  kinds: { "custom-agent": customAgentFlow },
  documents: catalog,
});

One catalog, spread into the flow and passed to the hire. Which entries in that map are documents is what the documents option answers: board above is not one, and no seat's list governs it.

documents is consulted only for a seat that declares resources:. A roster where none does hires the same whether it is passed or not, and a seat that does declare one while documents is absent is refused, naming what is missing.

Every problem with a list refuses the whole roster, naming the seat: a resources: that is not a list at all, an entry that is neither a ref nor a one-key ref: mode mapping, a ref no document matches, a ref naming a document the app declared but did not install on this seat's kind, a mode that is neither ro nor rw, the same ref twice, rw on a document whose own frontmatter says writable: false, a ref colliding with a name the kind's own blocks already declare, and a ref the kind does declare at flow level while what it holds there is not the document the app passed — the two cannot be told apart, so the hire refuses rather than guessing.

One more is checked on the seat after it is built rather than on the list: if a document the seat did not name is reachable anyway — because one of the kind's blocks declares that same document, and a block's declaration is merged back in after a seat's narrowed map replaces the flow-level one — the hire refuses, naming the ref and the kind. Keep such a document at flow level and let the block reach it from there, or grant it to the seat deliberately.

resources: never reaches the kind's settings. It is the factory's key, like flow and description, so a kind that declares a resources setting of its own does not receive one from a file.

references: is the sibling key, over the documents in references/ folders. It narrows within a wall the tree already imposes rather than selecting from everything the kind installed, and its entries take no mode — see What a seat reaches.

What a hireable kind must admit

A worker kind is an ordinary flow. What makes it hireable is that its configSchema composes workerConfigSchema(), which declares the four settings a seat's bag may carry:

| Setting | What it holds | | --- | --- | | instructions? | The worker's own instructions — its file body, or the frontmatter key. Imposed when the body is not empty, absent when it has none. | | teamInstructions? | The instructions its team wrote — read from that team's TEAM.md. Imposed when its team wrote any, absent when the team wrote none or has no file. Never merged with instructions. | | seatSkills | The skills its folders resolved for it, in level order. Imposed on every seat, present and empty when there are none. | | seatTools | The blocks this seat's tools: resolved to from its own folders, already resolved. Imposed on every seat, present and empty when there are none. Live blocks, not names — names that resolved to the kind's catalog stay on the kind's own tools setting. |

So hiring imposes all four: instructions when the body is not empty, seatSkills and seatTools always, and teamInstructions when the seat's team wrote a TEAM.md. A kind that reads teamInstructions for a seat whose team wrote none gets undefined — absent, never an empty string, which is what keeps "this team said nothing" and "this team said nothing yet" from being the same value in the bag.

One key is reserved across kinds: tools. It is not part of the contract — your kind declares it or leaves it out — but if you declare it, it means the names of tools that seat may call, because the hire step reads it. A name in a worker's tools: is resolved against what is registered for that seat (its own blocks/ folder, then its team's, then your kind's catalog), and the ones that resolved to the seat's own folders arrive on seatTools as live blocks instead. You decide what to check the remaining names against, and you may declare no tools at all. What the key is not available for is unrelated string configuration, which hiring would rewrite — give that its own name.

Add your kind's own settings on top, at the same level:

import { workerConfigSchema } from "@flow-state-dev/workforce";

const triage = defineFlow({
  kind: "request-triage",
  cardinality: "collection",
  configSchema: workerConfigSchema().extend({ desk: z.string().default("front") }),
  actions: { run: { inputSchema, block: triageWork } },
});

desk sits at the top level beside the four, where the schema closes it: a worker file that writes a key your kind never declared is still refused by name. If your kind genuinely holds open-ended data, give it one declared key whose own schema is a record, rather than a nested bag of keys you did name.

Reading any of it is optional. A kind that composes the contract and never looks at seatSkills is not an error. What is not optional is the door: the factory hands every seat a bag, so a kind whose schema cannot take it refuses at the hire, for the whole roster, with a message naming the worker and the fix. That is a one-line change per kind.

What is checked is what your schema accepts, not which function built it — so a kind that declares these keys by hand hires just the same. Composing is what keeps it current: when a key is added to the contract, a composed kind picks it up, and a hand-rolled one refuses at boot naming the new key until you add it.

Three of the four are never authored. A worker file that writes seatSkills:, seatTools: or teamInstructions: is refused by name, at the loader and at the hire: a seat's skills and its own blocks are the folders it can see, and a team's instructions come from its team's TEAM.md body. That last key is refused in a TEAM.md too — the file an author would most reasonably try it in — so all three doors refuse it, from one exported constant rather than a literal spelled into each.

A record that leaves flow: out is hired into the built-in agent kind — it talks, its body arrives as its instructions, and it reads the skills its own folders hold plus any the app seeded through defineAgentWorkerFlow({ skills }). It has no memory: nothing it is told survives the turn. kinds is therefore optional. A flow: that is present but empty or whitespace-only refuses, because it names no kind — only an absent key means the built-in.

Configure that kind by replacing it. Build the flow with defineAgentWorkerFlow and pass it under agent (kinds: { agent: defineAgentWorkerFlow({ catalog, skills }) }). It takes over for every seat that runs on the agent kind — the records that leave flow: out, and any that name agent — and leaves a worker on any other kind alone. A flow of your own registered under agent must declare kind: "agent" and cardinality: "collection"; without the second, each seat mints and is then refused when you register it, because a singleton's id is its kind.

A worker record declares data: a description, the kind it runs, and that kind's settings. Behavior lives in the flow the kind names, so a worker that has to do something none of your kinds do is a flow you define in your app and pass in kinds, named by that worker's flow:.

A record's body reaches its flow as one setting, instructions. Every hireable kind declares that setting by composing workerConfigSchema(), so a body always has somewhere to arrive and no worker flow has to check for one; the instructions are available at config.instructions, and what the flow does with them is the flow's business. A kind that wants instructions to be mandatory makes the key required when it extends the contract — workerConfigSchema().extend({ instructions: z.string() }) — and a worker of that kind with no body is then a failed hire.

A body that is empty or only whitespace contributes no instructions key at all; a body with content is handed over verbatim, leading and trailing whitespace included. A record that declares instructions: and carries a body is refused naming both sources. Whitespace is not a body, so a record that declares instructions: and carries an empty or blank one hires on the frontmatter value.

A WORKER.md has no persona setting: declaring it lands the worker in readWorkforceDirectory's errors, or is refused by hireWorkforce for a hand-built record. Spell it instructions.

Composing capabilities into the built-in kind

defineAgentWorkerFlow takes three more app-level options beyond its catalog, skills and model choices. All three are optional. defineAgentWorkerFlow() with no arguments builds the stock worker.

| Option | What it does | | --- | --- | | uses | Capabilities attached to every worker's answer generator. The skills binding stays first and is never displaced. A capability passed as a plain ref brings its own storage with it; one passed as a (ctx) => refs resolver brings none, so anything it needs has to be declared statically somewhere. | | afterAnswer | A block run after the answer as a side-chain. It receives the reply text as a string, it cannot change the answer, and a failure in it does not fail the turn. Absent, nothing runs after the answer. | | isolateUserState | Forwarded to defineFlow. Gives each worker its own user-scoped storage, keyed on the worker's id, instead of one cell shared across the roster. Default false. |

The tools fence. A worker's tools: is the complete set of tools it can call. The kind maps those names against the catalog and hands the model that list and nothing else. A skill does not widen it: a skill's allowed-tools are validated against the catalog but never registered, and a skill's delegated workers are seated from the holding worker's own list. Nor does a capability passed through uses: whatever tools it carries, the worker's own tools: is what the model gets. Everything else the capability brings — context, storage, helpers — arrives as usual.

What does reach a worker without appearing in tools: is a control, which is framework machinery rather than a tool from the app's catalog, switched on by the worker's own settings:

  • the skill loader, when a worker sets skills.activateTool: true — it pulls a skill the worker already holds into the turn;
  • the delegation surface, when a skill the worker holds declares agents:, which puts the task board's eight tools plus runBoard on the worker;
  • the controls a capability preset declares, when the worker selects that preset in its capabilities: key — a preset's controlTools reach the worker, its tools do not.

The memory recipe

Memory is one thing you can pass through these options. Install @flow-state-dev/memory separately:

import { AGENT_KIND, defineAgentWorkerFlow, hireWorkforce } from "@flow-state-dev/workforce";
import { system } from "@flow-state-dev/memory";

const mem = system({
  model: "openai/gpt-5.4-mini",
  working: { capacity: 7 },
  episodic: true,
  semantic: true,
});

const remembers = defineAgentWorkerFlow({
  catalog: appTools,
  uses: [
    mem.capability.presets({
      recall: false,    // a tool — reaches a worker through the catalog, not here
      connect: false,   // same
      semantic: true,   // context injection; OFF by default
      episodic: true,   // context injection; OFF by default
    }),
  ],
  isolateUserState: true,
  afterAnswer: mem.captureFromItems,
});

const seats = hireWorkforce(workers, { kinds: { [AGENT_KIND]: remembers } });

Each of these fails quietly if you skip it:

  • system(), not createMemoryCapability. The latter builds the read side only, producing a worker that recalls what something else stored and records nothing of its own.
  • afterAnswer is the write side. Without it the durable stores are never written.
  • semantic and episodic on. They are off by default, and without them the durable stores would be written and never read back.

recall and connect are memory's two tools, and the recipe leaves them off: a worker here reads what it remembers as injected context, and a capability's tools do not reach a worker in any case. A worker that wants on-demand search gets the tool through the catalog (catalog: { recall: mem.tool.recall() }) and names it in its own tools:.

Isolation is a decision for the whole kind: a roster is all-isolated or all-shared.

isolateUserState decides where a worker's user-scoped data is keyed, so anything that changes the key leaves the old data behind. Two ways that happens, both with no migration: renaming a worker (the key is its id), and flipping the flag on a roster already in use (shared and isolated are different cells). Decide it before the roster carries anything worth keeping.

Every problem is a startup misconfiguration: problems are collected and thrown as one error naming every bad worker, and nothing is returned, so a bad record cannot leave a half-hired roster.

Kinds and blocks from files

The kinds map above names each kind a second time, after the flow already declared it. There is a file convention for that half too: put a flow under workforce/flows/workers/ or workforce/flows/channels/, or a block under workforce/blocks/, and the basename is the name it registers under.

workforce/
  flows/
    workers/request-triage.ts                        ← default-exports a flow, cardinality: "collection"
    channels/standup.ts                              ← default-exports a flow, singleton (the default)
  blocks/triage.ts                                   ← a block any worker may name
  teams/engineering/blocks/build-status.ts           ← a block this team's workers may name
  teams/engineering/workers/triage/blocks/page.ts    ← a block this one worker may name

fsdev gen walks those folders and writes workforce/workforce.gen.ts beside them, exporting kinds, channelKinds, blocks and seatBlocks — parameters hireWorkforce, channelInstances, a task board and a worker kind's tool catalog already take. The same file carries resourceModules, covered in Resource modules from files.

import { defineAgentWorkerFlow, hireWorkforce } from "@flow-state-dev/workforce";
import { blocks, kinds, seatBlocks } from "./workforce/workforce.gen";

const agent = defineAgentWorkerFlow({ catalog: blocks });
const seats = hireWorkforce(workers, { kinds: { ...kinds, agent }, seatBlocks });

A blocks/ folder registers a name: workforce/blocks/ for every worker, a team's for that team's workers, a worker's own for that worker. It does not grant use — a worker still names the block in its tools:, and a name resolves nearest first (its own folder, its team's, the catalog). Two rules are checked before any worker runs: the file's basename, the map key and the block's own name must agree, and a block in a worker's own folder may read a store the kind installed but may not declare one of its own.

Discovery is a build step, not something this package does while your app runs. A walk at startup works on a Node host and finds nothing on a bundled one, because after a Next or Vercel build those files are no longer separate modules. Static imports are identical on both. Commit the generated file, run fsdev gen in front of your build, and give fsdev gen --check its own CI step — inside a build script it would regenerate the file and always pass.

The generator reads the tree and opens none of the modules in it, so it refuses only what a walker can see: an illegal basename, a directory inside a locked folder, one basename claimed by both flow folders, and a folder that is present and unreadable. Refusals are collected, so one run names all of them. A file that exports the wrong shape fails your own tsc against the generated module; a flow whose kind disagrees with its basename is refused at the hire.

Passing kinds by hand keeps working, unchanged, and composes with a generated map with no precedence rule.

Resource modules from files

The same command walks every resources/ folder the convention reads and exports a fourth map, resourceModules, keyed by the same ref a document of that name in that folder would get (the table under Reading documents from files). A resources/ folder takes Markdown documents and TypeScript modules side by side: a .md file is a document, and a .ts file default-exports a capability or a resource.

workforce/teams/engineering/resources/
  handbook.md      ← a document, unchanged
  research.ts      ← default-exports a capability or a resource
import { resourceModules } from "./workforce/workforce.gen";

One ref has one owner: a .md and a .ts of one name in one folder are refused by name at generation, rather than one of them quietly winning.

What a module exports is checked by your own tsc against the generated map's types, because the walk never opens a module. ResourceModuleExport is what a module in the organisation's or a team's folder may be; WorkerResourceModuleExport is the narrower type the generated map holds a module in one worker's own resources/ folder to — a resource, never a capability, because every seat of a kind shares that kind's capabilities and one installed from a single worker's folder would change every other seat. The generated file carries that sentence beside the entries it applies to.

Installing what was found

The two kinds of module have two destinations, so splitResourceModules separates them and your app writes the two lines:

import { resourcesFromDocs, splitResourceModules } from "@flow-state-dev/workforce";
import { resourceModules } from "./workforce/workforce.gen";

const { capabilities, resources } = splitResourceModules(resourceModules);

const agent = defineAgentWorkerFlow({ uses: capabilities, /* ... */ });
const flowResources = { ...resourcesFromDocs(documents), ...resources };

A capability goes to the worker kind's uses, which is the same option you pass one by hand, and the resources it declares for itself reach the flow from there. A plain resource module merges into the one resource map, under its own ref, beside the documents. Nothing is installed on your behalf — returning both and letting you spread them keeps the wiring in your own source, the same way resourcesFromDocs does.

What one seat picks up

A capability installed on a kind reaches every seat of that kind. A worker's own file names which of its presets that seat wants:

---
description: Holds the board.
capabilities:
  research: [briefing]
---

A seat that names nothing carries each installed capability's own defaults, which is what every seat gets without the key. Naming presets adds to that — there is no spelling that takes one away. Which capabilities a workforce may reach is the app's call, made where it builds the kind; a worker file picks among them.

The whole selection is checked when the roster is hired, so a typo is a refusal at boot rather than a failed answer in front of a user. A seat is refused, by name, when it names:

  • a capability its kind does not carry
  • a preset the capability does not declare
  • a preset the app turned off where it installed the capability
  • a preset on a capability declared with a config block
  • a preset whose surface has to exist before a request runs (resources, a state schema, model, providerOptions or caching)

The last three are the app's to set where it installs the capability.

Only the built-in agent kind reads this key. A kind of your own reads whatever its own settings schema declares.

Reading documents from files

A team's shared documents — a handbook, a glossary, an escalation procedure — can be Markdown files instead of defineResource stanzas. Frontmatter is settings and the body is the document, the same bargain WORKER.md makes.

Two folders, one namespace. The convention reads references/ and resources/ at the same four levels and mints refs for both by the same rule, so one basename claimed in both at one level is refused rather than resolved. What differs is what the document is once installed:

| Folder | Content an agent reads | Writable | Reach | | --- | --- | --- | --- | | references/ | the file, re-read whenever an execution context is built | no. writable, llmWritable, render and flowIsolation come from the folder, and a file declaring any of them is refused | the org's, the seat's own team's, and the seat's own folder's — derived from the tree | | resources/ | the file's body seeds a row; the row is the source from then on | yes, unless the file says otherwise | every document the flow was installed with |

A reference changes when someone edits the file and deploys. A resources/ document can change from inside the product. Put standing material an author owns under references/, and anything an agent writes under resources/.

A document is a file, not a folder. It is <name>.md directly in the slot, unlike a worker or a skill, which is a folder with a fixed file inside it. A directory in either slot lands in errors rather than being passed over.

Four places are read, in both slots — the org level, a team, and a worker's own folder under either of those:

| Path | Ref | |------|-----| | <root>/org/<slot>/<name>.md | <name> | | <root>/teams/<teamId>/<slot>/<name>.md | teams/<teamId>/<name> | | <root>/teams/<teamId>/workers/<worker>/<slot>/<name>.md | teams/<teamId>/workers/<worker>/<name> | | <root>/org/workers/<worker>/<slot>/<name>.md | workers/<worker>/<name> |

A worker's own folder is how two seats each get their own runbook without their authors coordinating a name. The ref drops org/ for an org worker, exactly as an org document's does.

---
description: How the engineering team works — on-call, review, escalation.
llmReadable: true
---

# Engineering handbook

Escalate anything customer-visible within 15 minutes.

readResourcesDirectory walks all four and returns one record per document; resourcesFromDocs turns those records into the resource map you already pass to a flow. readReferencesDirectory and referencesFromDocs are the same pair over references/. Both maps spread into one flow, and both are passed to hireWorkforce:

import { defineFlow } from "@flow-state-dev/core";
import { hireWorkforce, referencesFromDocs, resourcesFromDocs } from "@flow-state-dev/workforce";
import { readReferencesDirectory, readResourcesDirectory } from "@flow-state-dev/workforce/loader";
import { answerQuestion } from "./blocks";
import { ticketResource } from "./resources";

const references = await readReferencesDirectory("./workforce");
const resources = await readResourcesDirectory("./workforce");
for (const { errors } of [references, resources]) {
  if (errors.length) throw new Error(`workforce: ${errors.length} document(s) failed to load`);
}

const documents = resourcesFromDocs(resources.documents);
const referenceMap = referencesFromDocs(references.documents);

export const supportFlow = defineFlow({
  kind: "support",
  actions: { answer: { block: answerQuestion } },
  resources: { ticket: ticketResource, ...documents, ...referenceMap },
});

const seats = hireWorkforce(workers, {
  kinds: { support: supportFlow },
  documents,
  references: referenceMap,
});

references is what tells the hire which entries on a kind's map are references, and the tree wall applies only to the entries it names. So once a kind holds references, the option is required: omit it, or pass a map that is missing one of them, and hireWorkforce throws, naming every reference it was not given. A kind that holds none needs no map. A ref passed in both maps is refused as well, naming it.

Every file-declared document is org-scoped, and nothing needs declaring for that. Organization identity is unconditional, so every admitted request carries one and the org resource registry is always built. A request with no organization is refused at the door rather than arriving empty, so a block reads a document with nothing extra declared:

// ./blocks.ts
import { generator, readResourceContentTool } from "@flow-state-dev/core";

export const answerQuestion = generator({
  name: "answer-question",
  model: "openai/gpt-5.4-mini",
  prompt: "Answer support questions. Check the team handbook before you answer.",
  tools: [readResourceContentTool()],
});

Each record is plain data:

| Field | Description | |-------|-------------| | ref | The document's identity and storage key, taken from where the file sits — see the table above. It is also the accessor key, so a team's handbook is ctx.resources["teams/engineering/handbook"] and one seat's runbook is ctx.resources["teams/pentest/workers/recon/runbook"]. | | declared | The frontmatter exactly as written. A file that declares one of the refused settings below produces no record at all, so nothing is stripped here. | | body | The Markdown below the frontmatter, verbatim. For a resources/ document it becomes the resource's content. | | filePath | The absolute path the file was read from. A reference is served from it, so a hand-built reference record must set it and referencesFromDocs throws without it. Carried on a resources/ record too, where nothing reads it. |

Merge the map yourself. A flow copy created with supportFlow({ resources }) replaces the definition's map rather than merging with it, so passing resourcesFromDocs(documents) there on its own drops whatever resources the flow kind declared. Spread it into your own map, as above.

What a seat reaches

A reference is walled by where its file sits. A seat reaches the org's references, its own team's, and its own folder's. Another team's and a teammate's are not on its map at all, so ctx.resources.get on one throws is not registered. No install-side filter is involved and there is no setting that widens the wall: a reference reaches more people by moving up the tree. Seats are read from teams/<teamId>/workers/<name>/, so a reference under org/workers/<name>/references/ sits beside the org level rather than above any seat, and no seat reaches it.

A references: list in a WORKER.md narrows within that wall. Each entry is a ref on its own — there is no mode, since nothing writes a reference. An entry naming a reference the seat could not already reach refuses the whole roster, and so does a malformed list or a duplicated ref. Leaving the key out means every reference at or above the seat; references: [] means none.

A resources/ folder is a namespace, not a visibility boundary — at every level. Every file-declared document is org-scoped, and a flow's resource tools reach every installed document marked llmReadable with no per-team filter. Installing a whole tree on one flow makes every team's documents reachable from it. To give a team's seats only its own, filter the records before installing:

const engineering = resourcesFromDocs(
  resources.documents.filter((d) => d.ref.startsWith("teams/engineering/")),
);

This holds for a worker's folder too: putting a resources/ document under workers/recon/ addresses it to that seat, it does not keep it from the others. Every seat hired into one kind shares that kind's flow definition, so by default all of them read the same row.

Filtering decides what a kind installs; a seat's own file decides what that seat reaches. A resources: list in a WORKER.md narrows one seat within a kind and can take a document read-only — see The documents a seat may touch. A ref for a document this kind was not installed with is refused at the hire, so the filter above holds.

To make a resources/ document that seat's alone, say so in the document. A file whose frontmatter carries flowIsolation: true gets one row per seat: the seat that writes it reads it back, and a sibling seat asking for the same document gets its own empty copy rather than an error.

---
description: This seat's own working notes.
flowIsolation: true
---

A worker folder without that line is an address, not a boundary. It is a resources/ setting: a references/ file declaring it is refused, and a reference's boundary is where its file sits.

Moving a document from resources/ to references/

Moving the file is the whole job unless something wrote that document while it lived in resources/. The written body is still stored, and a stored body wins over the file, so agents keep reading the old write.

import { clearShadowedReferences, describeShadowedReferences } from "@flow-state-dev/workforce";

const result = await clearShadowedReferences({
  references: referenceMap,
  orgId,
  content: stores.content,
  installedOn: { id: flow.id, isolatesOrgState: false },
  // dryRun: true,
});
console.log(describeShadowedReferences(result));
// references: 1 of 4 were shadowed by a stored write and have been cleared — teams/engineering/handbook.
// Each now serves its file again.

The result is { cleared, checked, dryRun, scopeId }. Each entry in cleared is { ref, shadowedContent }, carrying the body that had been served in the file's place — the last place that text exists, so log or keep it before deciding the clear was right. dryRun: true reports the same finding, deletes nothing, and makes the sentence read "WOULD be cleared". Running it again over a migrated tree clears nothing, and it never touches a resources/ document.

installedOn is { id, isolatesOrgState } for the flow the references are installed on. A flow that isolates its org scope stores content under a different address, so a wrong value here looks in the wrong place, finds nothing, and reports success. Read isolatesOrgState off the flow. For an org or flow id containing : or a backslash it throws. That address needs the engine's own escaping, so clear those rows with the engine's store helpers instead.

What a file may and may not declare

description is required. A file without one lands in errors. It reaches the resource with the rest of the frontmatter, and nothing puts it in front of a model. Write it for whoever opens the tree. Frontmatter never reaches an agent either way: a reference is served with its --- block stripped, and a resources/ document is installed with its parsed body.

The convention owns identity, storage and content. Where a document lives decides all three, so a file may not declare any of scope, ref, stateSchema, default, content, contentFile, contentTemplate or contentTemplateRef. Each is refused by name rather than quietly ignored, and so is prefetchMode: "lazy": a file-declared document is always loaded eagerly. Everything else is carried through as written, so llmReadable, llmWritable, writable, allowedExtensions and metadata all reach the resource.

A references/ file may not declare writable, llmWritable, render or flowIsolation either, writable: false included, even though it agrees with the folder. A reference is read-only on both doors: writeContent() throws a FlowError with code resource_read_only, and the model is never offered the write tool for it. A document that needs to be written belongs in resources/.

A document that needs a state schema, a render function, reactive bindings or an edge graph stays in code. Those are functions, and a Markdown file cannot hold one. Session- and user-scoped resources are not file-declared.

Document, team and worker folder names all follow one rule: lowercase letters, digits and single hyphens, at most 64 characters. A worker folder's documents load whether or not the folder holds a WORKER.md, and a slot with no seat file is reported separately by readWorkforceDirectory. A non-.md file in the slot is passed over in silence. An absent org/ root or document folder is not an error. A team may have no documents.

What did not load

Either reader throws only when root itself cannot be read or is a symlink. Everything else lands in errors, one entry per thing that should have produced a document and did not. Each entry is { kind, path, error }, keyed by a path relative to the root, with kind naming the condition (see Error Semantics):

errors;
// [{ kind: "folder-where-file-belongs",
//    path: "teams/marketing/resources/handbook",
//    error: Error('"handbook" is a directory. A resource is a file, not a folder — write
//                  the document as "handbook.md" in this resources/ folder instead.') }]

Neither reader sees the other's slot, so neither reports a basename claimed by both. readDeclaredRoster reads the whole tree and puts that collision in its problems, naming both files; hireWorkforce throws on the same collision for a catalog that never passed a loader.

resourcesFromDocs and referencesFromDocs throw instead of collecting, because a record that cannot become a resource is a startup misconfiguration.

Channels

A channel is a place several agents talk about one topic, with one durable transcript, where nobody is assigned the work and nobody closes it out. This package ships the flow kind that runs one, plus the two calls that bind a roster of channels to it.

The identity rule is the thing to get straight first, because it is not the one WORKER.md teaches: one kind is one instance, and one channel is one named session on that instance. A hundred channel records are a hundred sessions on a single registered flow. What differs per channel (who its members are, what its charter says, what has been said in it) lives in that session's state.

You register nothing to use channels. The built-in kind is seeded for you.

import { channelInstances, openChannels, type ChannelManifest } from "@flow-state-dev/workforce";

const channels: ChannelManifest[] = [
  {
    id: "engineering.standup",
    declared: {
      members: ["engineering.lead", "engineering.analyst"],
      description: "Where the engineering team posts daily status.",
    },
    body: "Post what you finished, what you're on, and what's blocking you.",
  },
];

// Build time. One instance per distinct kind, not per record.
flowRegistry.registerMany(channelInstances(channels)); // one instance, id "channel"

// Runtime, once the host is up. One named session per record.
await openChannels(channels, { client: sessionClient, userId: "u_42" });

The two calls are separate because they happen at two different times: an instance is registered when the server is built, and a session can only be opened once it is running. openChannels needs a userId because a session belongs to one user, as What a transcript proves below explains.

Every channel session runs in an organization, and your app does not name it. The server binds it from the caller's verified identity, which is whatever your resolvePrincipal returned; an app that configures no authentication gets the reserved DEFAULT_ORG_ID. Storage at organization scope resolves against it inside the channel, including file-declared documents and a channel board's rows. A session's organization is fixed when the session is created, so open your channels as a caller whose verified identity already carries the organization you want them in.

A record declares five keys and no others: flow (which kind, optional), description, members, boards, and instructions (or a body, which is the same setting). The list is closed and checked at channelInstances: an undeclared key, an id:, a system:, or a body alongside instructions: each refuse by name.

Declaring channels in files

A channel can be a folder with a CHANNEL.md in it, the way a worker is a folder with a WORKER.md. readChannelsDirectory walks <root>/teams/<teamId>/channels/<channelName>/ and hands back the same ChannelManifest[] the two calls above take.

workforce/teams/engineering/channels/standup/CHANNEL.md
workforce/teams/engineering/channels/incidents/CHANNEL.md
---
description: Where the engineering team posts daily status.
flow: channel
members: [engineering.lead, engineering.analyst]
---

Post what you finished, what you're on, and what's blocking you.
import { readChannelsDirectory } from "@flow-state-dev/workforce/loader";

const { channels, errors } = await readChannelsDirectory("./workforce");
if (errors.length) throw new Error(`workforce: ${errors.length} channel(s) failed to load`);

flowRegistry.registerMany(channelInstances(channels));

Each record is plain data:

| Field | Description | |-------|-------------| | id | "<teamId>.<channelName>", minted from the two folder names — e.g. "engineering.standup". This is the channel's session id. An id: in the frontmatter does not set it, and refuses. | | declared | The frontmatter exactly as written. A CHANNEL.md must set description, and cannot set system:; either one fails at load. The rest of what a channel may declare (flow, members, boards, instructions) is checked when you call channelInstances, so a misspelled key loads without complaint and refuses at registration. | | body | The Markdown below the frontmatter — the channel's charter. |

A channel is a folder, not a file, unlike a resource. A loose file in a channels/ folder is passed over, so a README.md sitting beside the channel folders is fine. Team and channel folder names must be lowercase letters, digits and single hyphens, at most 64 characters. A team with no channels/ folder is not an error: an app can declare no channels in files, or build some records by hand and read the rest.

The reader builds nothing: no instance, no session, no registry entry. A flow: naming a kind you never passed is not caught here; channelInstances refuses it. It throws only when root itself cannot be read or is a symlink. A folder that produces no channel lands in errors, keyed by its path, and every other channel still loads. Treat a non-empty errors as fatal at startup unless you have a reason to run a short roster.

The subpath is separate because the reader imports node:fs; the package root stays isomorphic.

Posting and reading

post and read are declared both as public actions and as internal entries, so a client and another flow reach the same blocks. A post addresses the channel's session id:

const postToStandup = dispatcher({
  name: "post-to-standup",
  flowKind: "channel",                          // the shared instance
  action: "post",
  inputSchema: z.object({ body: z.string() }),
  session: { id: () => "engineering.standup" }, // the channel
  payload: (input) => ({ body: input.body, author: "engineering.lead" }),
});

Address { id }, never { key }: a key-derived session resolves to a different session for every poster, so the channel never sees the post. Nothing detects that mistake.

A flow-to-flow post needs in-process dispatch. On a deployment whose dispatcher hands work to an external queue, a delivery into an existing session refuses external-dispatcher by name. The public action route still works; the dispatch door does not.

A post into a session nobody opened refuses channel-not-bound and writes nothing. The shared instance answers for every session id and the action path creates what it does not find, so boundness, not existence, is what makes a session a channel.

Holding a board

boards: declares durable task ledgers the channel keeps, as a list of plain local names:

members: [engineering.lead, engineering.analyst]
boards: [followups]

The ledger id is minted from the channel that holds it — engineering.incidents holding followups is engineering.incidents.followups — and no record writes it. A board name is a plain local name: not empty, no whitespace, none of . / * [ ], and not __proto__, prototype or constructor. The dot is the one that matters, since it is the join and a name carrying one would address another channel's board.

A channel holding one or more boards declares two more actions, fileTask and readBoard, public and internal like post and read. Both take the board's local name; fileTask hands back { board, boardId, taskId, status }, and the row's id is minted rather than chosen. Naming a board the channel does not hold refuses board-not-declared and lists what it does hold; naming another channel's board refuses the same way. read gains a boards key listing the local names; a channel holding none omits it and declares neither action.

readBoard returns a declared projection of each row, not the whole record: the board's own facts, without the execution coordinates (claimedBy, the lease) or the substrate's write provenance. channelBoardRowSchema is that shape.

A board's ledger is readable directly by a browser, which is what lets a UI draw the board as columns without going through an action. The ledger is org-scoped, so that read resolves against the organization the reading session belongs to. What crosses is id, title, goal, status, assignee, priority, attempts, maxAttempts, deps, labels, error, createdAt, updatedAt, startedAt and completedAt.

The channel owns the ledger and runs nothing. A seat that claims rows resolves the same declaration with channelBoard, declares it as a resource, and drains it:

import { channelBoard } from "@flow-state-dev/workforce";

const followups = channelBoard("engineering.incidents", "followups");
const board = taskBoard({ name: "followups", collection: followups, workers });

defineFlow({
  kind: "analyst",
  // A seat that only drains declares the ledger itself. A seat that composes
  // `channelBoardTaskTools(followups)` does not — the capability declares it.
  resources: { [followups.id]: followups },
  actions: { drain: { block: board.drain } },
});

channelBoard returns the same ledger the channel writes to, carrying its id, so the two sides agree on both the rows and the board's settings.

The channel's id and the board's name are retyped at that call and nothing checks them against the tree. A typo does not fail: it resolves a second, empty ledger, and the only signal is the unattended-board warning below.

channelBoardTaskTools(board) is the model's door onto one. Compose it in the seat kind's uses and the seat holds all eight task tools over that board, each name carrying the board's id — addTask_engineering_incidents_followups and so on for assignTask, updateTask, listTasks, completeTask, failTask, blockTask and cancelTask. Composing the capability also declares the ledger, so the seat's flow does not declare it again. A seat's tools: list can neither grant these nor fence them out. A narrower set is a different capability.

Compose it once per board; a seat holding two boards holds sixteen tools and the names say which board each writes to. A channel board is org-scoped, so it cannot be declared by a block colocated in a seat's own folder; that refuses at hire.

Pass hireWorkforce the roster's minted ids as channelBoards and it warns on stderr for any board no hired seat declares, naming the channel and the board. It never refuses: a channel may keep a board that only people read.

Renaming or moving a channel's folder re-keys its boards, because a board id is derived from where the channel sits. Rows filed under the old id stay at the old key, nothing migrates them and nothing refuses. The unattended-board warning is what makes it visible, since the seat still names the id that moved.

What a transcript proves

A session is bound to one user, so every line of a given channel carries the same principal, the server-derived identity the post ran under. The optional author is a label the poster supplied, stored beside authorVerified: false, and it is the only thing distinguishing participants. The members check on author is a validity check against the declared roster, not authentication. Build an audit or approval flow on this and you get a far weaker guarantee than the field names suggest.

Waking members

defineChannelFlow({ notify }) takes a block run once per declared member per post. The roster it walks is the whole declared list, the poster included, so a block that should not wake the writer compares the delivery's author against the member it was handed and returns without delivering. It runs in its own request, outside the post's turn, so a slow delivery never delays the next post. A delivery that fails is recorded; the post stays written and membership is unchanged. Without a slot, posts land and nobody is woken.

The framework carries the policy and your app supplies the addresses: the framework will not pick a dispatch target out of stored data, so a notify block declares its own recipients.

Registering your own kind

The escape hatch, not a setup step. Reach for it when the workflow graph genuinely diverges. A standup, a DM and an announce channel are all channels on the one built-in kind, differentiated by members and charter.

// A kind of your own, alongside the built-in.
channelInstances(channels, { kinds: { "my-channel": defineMyChannelFlow() } });

// Or replace the built-in wholesale, keeping the standard behaviour with your own notify block.
channelInstances(channels, { kinds: { channel: defineChannelFlow({ notify }) } });

Your factory carries the same contract the built-in does: cardinality: "singleton", so flow.id === flow.kind. A flow: naming a kind you did not pass refuses by name and never falls back to the built-in. The kinds map is the whole registration surface; there is no second API.

What channels do not do yet

No join or leave verb, no delete or retirement, and no summary pass over a long transcript. Membership is the declared list and nothing else writes it, so changing who is in a channel means editing the record and opening a fresh channel. Re-running openChannels over an open channel finds it bound and leaves its session alone, so the three settings written at create — members:, the charter (a body or instructions:) and description: — keep whatever they were opened with. flow: is settled at create too, since it picks the session's kind. Re-opening is not a migration.

boards: is the one that does reach: the board list is built onto the kind from the roster on every bind and is never stored on the session, so adding a board to an open chann