Skip to main content

Code on disk

A WORKER.md says which flow kind a worker runs. The kind itself is TypeScript, and something has to hand it to hireWorkforce.

You can write that map yourself. You can also put each file where the convention looks for it and let fsdev gen write the map, which means adding a kind is adding a file.

When it runs

fsdev gen runs when you run it, and what it writes is static imports. Every bundler already resolves those, so a deployed app registers exactly what a local one does — on a Node host, and after a Next or Vercel build.

Nothing regenerates while next dev is running, and nothing scans the tree while your app runs.

Where the files go

workforce/
flows/
workers/
request-triage.ts ← a worker kind
channels/
standup.ts ← a channel kind
blocks/
triage.ts ← a block any worker may name
teams/
engineering/
blocks/
build-status.ts ← a block this team's workers may name
resources/
research.ts ← a capability this team's kind carries
workers/
triage/
WORKER.md ← flow: request-triage
blocks/
page-oncall.ts ← a block this one worker may name

Each file default-exports one thing: a flow for the two flows/ folders, a BlockDefinition for a blocks/ folder, and a capability or a resource for a resources/ folder. A worker kind needs cardinality: "collection", because a seat — one hired worker — mints its own copy under its own id. A channel kind needs the default, singleton, because a channel is a session on one shared copy.

resources/ is the one folder that takes both code and Markdown. A .ts file there is what this page covers; a .md file is a document, and what a capability in one does for a worker is Capabilities on disk.

Running it

fsdev gen

It writes workforce/workforce.gen.ts beside the tree:

// Generated by `fsdev gen`. Do not edit.

import type { BlockDefinition } from "@flow-state-dev/core";
import type { ChannelInstancesOptions, HireOptions, ResourceModules } from "@flow-state-dev/workforce";
import block_triage from "./blocks/triage";
import channel_standup from "./flows/channels/standup";
import worker_request_triage from "./flows/workers/request-triage";
import resource_teams__engineering__research from "./teams/engineering/resources/research";
import teamblock_engineering__build_status from "./teams/engineering/blocks/build-status";
import seatblock_engineering__triage__page_oncall from "./teams/engineering/workers/triage/blocks/page-oncall";

export const kinds = {
"request-triage": worker_request_triage,
} satisfies NonNullable<HireOptions["kinds"]>;

export const channelKinds = {
"standup": channel_standup,
} satisfies NonNullable<ChannelInstancesOptions["kinds"]>;

export const blocks = {
"triage": block_triage,
} satisfies Record<string, BlockDefinition>;

export const resourceModules = {
"teams/engineering/research": resource_teams__engineering__research,
} satisfies ResourceModules;

export const seatBlocks = {
"engineering.triage": {
"build-status": teamblock_engineering__build_status,
"page-oncall": seatblock_engineering__triage__page_oncall,
},
} satisfies Record<string, Record<string, BlockDefinition>>;

A binding is where the file was found plus its name, with hyphens as underscores: worker_, channel_ and block_ for the three top-level folders, resource_ and the document ref for a module in a resources/ folder, and teamblock_ or seatblock_ with the team or the seat for a block folder inside the team tree. The imports arrive in one group per walk — the three top-level folders first, then the resource modules, then the blocks in the team tree — each group ordered by path, under the type imports the maps need. Both keep the committed file's diff stable: a name you can predict, and an order a directory listing cannot move.

Each export feeds a parameter that already exists:

ExportGoes to
kindshireWorkforce
channelKindschannelInstances
blocksa task board's workers, or a worker kind's tool catalog
seatBlockshireWorkforce again
resourceModulessplitResourceModules, whose halves go to the kind's uses and the flow's resource map
import { hireWorkforce } from "@flow-state-dev/workforce";
import { kinds, seatBlocks } from "./workforce/workforce.gen";

const seats = hireWorkforce(workers, { kinds, seatBlocks });

The startup line names no kinds. Adding one means adding a file.

Keeping it current

Run fsdev gen when you add, rename or delete a file in one of those folders. Put it in front of your build, and commit the file it writes:

{
"scripts": {
"build": "fsdev gen && next build"
}
}

fsdev gen --check regenerates nothing and exits non-zero when the committed file and the tree disagree. Give it its own CI step. Inside a build script it would regenerate the file first and always pass.

What it checks, and when

The generator reads the tree. It never opens the files it finds, so what it can refuse is what a walker can see:

  • A basename that breaks the tree's name rule, including one Windows reserves for a device. The basename is the name the file registers under — a kind, a block, or a document ref.
  • A directory inside one of the code folders. Refused by name rather than skipped, so a folder you meant as a kind cannot be passed over in silence.
  • One basename in both flows/workers/ and flows/channels/.
  • A .md and a .ts of one name in one resources/ folder, which would claim one ref twice.
  • A blocks/ folder at a level no worker reads — beside org/, or beside an org-level worker. The refusal names the three places one may sit.
  • A tools/ folder, anywhere in the tree. There is one folder name for code that can be called, and it is blocks/.
  • A folder that is there and cannot be read. A folder that is simply absent is fine, and means you have no custom code of that kind.

Files that are not TypeScript are skipped: a README, a JSON sample, a note beside the code.

TypeScript files are not. Every .ts and .tsx file in one of the code folders is a declaration, so these folders are for declarations only. A fixture.ts sitting beside a kind registers a kind called fixture. A file whose name carries a second dot — request-triage.test.ts, helpers.fixture.ts — is refused outright, because the basename becomes the kind name and a dot is not legal in one. Keep tests and fixtures beside the code they exercise, outside every folder on this page: the two flows/ folders, every blocks/ folder, and every resources/ folder.

Not everything is caught by the walk, and what it misses is caught later rather than not at all. A file that exports the wrong shape fails your own tsc, naming the generated module and the assignment, because the maps are typed. A flow whose declared kind disagrees with its basename is refused at the hire, naming both.

Blocks a worker can call

A blocks/ folder registers a name. Whether a worker can call that block is a separate question, answered by its own tools: list — so a file appearing in a folder never quietly widens what an agent can do.

Where the folder sits decides who can resolve the name:

The folderRegisters the name for
workforce/blocks/every worker, once the app hands the map over as a tool catalog
workforce/teams/<team>/blocks/every worker on that team
workforce/teams/<team>/workers/<worker>/blocks/that one worker

A name is resolved nearest first: the worker's own folder, then its team's, then the catalog. The first match wins, so a worker can keep a private summarize without renaming the team's.

The org-level half is one line in your app, on the option the built-in kind already takes:

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 });

The other two ride seatBlocks, which needs no option on the kind: a worker's registered blocks are that worker's, so they travel with the rest of what the hire step already gives one seat at a time.

Then a worker names what it wants:

---
description: Triages inbound reports.
tools: [triage, page-oncall]
---

Two rules are checked before any worker runs:

  • One tool has one name. The file's basename, the map key and the block's own name must agree. A worker authorises by the key and the model is handed the block's name, so a disagreement is a worker authorising one tool and a model calling another. Refused when the kind is built for the catalog, at the hire for a worker's own folder.
  • A block in a worker's own folder may read a store, not declare one. Resources belong to the kind, and every worker of a kind shares them, so a store declared from inside one worker's folder would arrive for all of its siblings. Declare the store on the kind — through defineAgentWorkerFlow's uses — and the block reads it as usual. The refusal names the block and both fixes.

A hand-written map

Passing kinds yourself works, and an app that never runs fsdev gen needs nothing from this page. You can also do both: compose a generated map with a hand-written one and pass the result. Nothing is told which came from where, and there is no precedence rule to learn.