Skip to main content

Code on disk

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

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 worker flow 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 flow
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 workers carry
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 workerFlow(...) builder for flows/workers/, a BlockDefinition for a blocks/ folder, and a capability or a resource for a resources/ folder. A worker flow is built on the installation that runs its workers, which a file can't import, so it exports a builder and the installation builds it once, with itself:

// workforce/flows/workers/request-triage.ts
import { defineFlow } from "@flow-state-dev/core";
import { workerConfigSchema, workerFlow } from "@flow-state-dev/workforce";

export default workerFlow((installation) =>
defineFlow({
kind: "request-triage",
configSchema: workerConfigSchema(),
session: installation.session(),
resources: { ...installation.resources },
actions: { respond: { inputSchema, block: triage(installation), userMessage: (input) => input.message } },
}),
);

Every worker on the flow runs on that one 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

The command runs the generator @flow-state-dev/workforce ships, and finds it because your app depends on that package, so it has to be in your app's package.json dependencies. 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 { HireOptions, ResourceModules } from "@flow-state-dev/workforce";
import block_triage from "./blocks/triage";
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 workerblock_engineering__triage__page_oncall from "./teams/engineering/workers/triage/blocks/page-oncall";

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

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

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

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

export const packageBlocks = {};

A binding is where the file was found plus its name, with hyphens as underscores: worker_ and block_ for the two top-level folders, resource_ and the document ref for a module in a resources/ folder, teamblock_ or workerblock_ with the team or the worker for a block folder inside the team tree, and packageblock_ with the package's folder for a block inside a package. The imports arrive in one group per walk — the two top-level folders first, then the resource modules, then the blocks in the team tree, then the package blocks — 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
workerFlowscreateWorkerInstallation's workerFlows
blocksa task board's workers, or a worker flow's tool catalog
workerBlockscreateWorkerInstallation again
packageBlockscreateWorkerInstallation, for the packages a worker holds
resourceModulessplitResourceModules, whose halves go to the worker flow's uses and the flow's resource map
import { createWorkerInstallation, hireWorkforce } from "@flow-state-dev/workforce";
import { workerBlocks, workerFlows } from "./workforce/workforce.gen";

const installation = createWorkerInstallation({ standardWorkers: workers, workerFlows, workerBlocks });
const flows = hireWorkforce(installation);

The startup line names no worker flows. 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 worker flow, 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 flow cannot be passed over in silence.
  • 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 worker flow registers a worker flow 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 flow's name and a dot is not legal in one. Keep tests and fixtures beside the code they exercise, outside every folder on this page: flows/workers/, 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 by hireWorkforce, 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 package's blocks/the workers that hold that package

An org worker (workforce/org/workers/<worker>/) has no blocks/ folder of its own, and fsdev gen refuses one there. It gets tools from the catalog and from the packages it holds.

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. A package's block is never hidden that way: one that shares a name with a block in the worker's own or team folder, or with a catalog tool the worker lists, is refused at the hire.

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

import { createWorkerInstallation, defineAgentWorkerFlow, hireWorkforce } from "@flow-state-dev/workforce";
import { blocks, workerBlocks, workerFlows } from "./workforce/workforce.gen";

const installation = createWorkerInstallation({
standardWorkers: workers,
workerFlows: () => ({ ...workerFlows, agent: defineAgentWorkerFlow({ installation, catalog: blocks }) }),
workerBlocks,
});
const flows = hireWorkforce(installation);

The other two ride workerBlocks, which needs no option on the flow: a worker's registered blocks are that worker's, resolved into its configuration on each turn.

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 worker flow is built for the catalog, and by hireWorkforce 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 worker flow, and every worker on it shares them, so a store declared from inside one worker's folder would arrive for all of its siblings. Declare the store on the worker flow — through defineAgentWorkerFlow's uses — and the block reads it as usual. The refusal names the block and both fixes.

A hand-written map​

Passing workerFlows 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.

  • Workers on disk — the folder tree, WORKER.md, readWorkforce, hireWorkforce, and talking to a worker.
  • Capabilities on disk — what a .ts file in a resources/ folder gives a worker, and how a worker's file picks among its presets.
  • Documents on disk — the .md half of a resources/ folder.
  • The built-in worker — the agent worker flow, its catalog, and its tools.