@flow-state-dev/workforce
v0.3.0
Published
The seat factory for flow-state-dev: worker records become configured flow instances.
Maintainers
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 idThat 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.mdreadSeatSkills 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 idThe 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 plusrunBoardon the worker; - the controls a capability preset declares, when the worker selects that preset in its
capabilities:key — a preset'scontrolToolsreach the worker, itstoolsdo 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(), notcreateMemoryCapability. The latter builds the read side only, producing a worker that recalls what something else stored and records nothing of its own.afterAnsweris the write side. Without it the durable stores are never written.semanticandepisodicon. 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 namefsdev 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 resourceimport { 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
configblock - a preset whose surface has to exist before a request runs (
resources, a state schema,model,providerOptionsorcaching)
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
