Hiring a workforce
Say your app runs three AI workers who collaborate: an intake desk, an engineering lead, an analyst. Each needs an address you can open a session against, and each needs its own settings — its model, its tools, the instructions that make it that worker and not another one.
You could write that by hand, three times. hireWorkforce does it in one call: you hand it a list of worker records and the flow kinds your app defined, and it hands back one running copy of a flow per worker. A seat is what comes back — one flow copy with its own address and its own settings.
import { hireWorkforce } from "@flow-state-dev/workforce";
import { workerAgentFlow, intakeFlow } from "./flows";
const seats = hireWorkforce(workers, {
kinds: { "worker-agent": workerAgentFlow, intake: intakeFlow },
});
flowRegistry.registerMany(seats);
Two things it deliberately leaves to you. It does not build a flow — the record says which flow a worker runs and how it is configured, never what the flow does step by step. And it does not register anything: you register what comes back, so a workforce read from files and one written by hand arrive at the registry through the same door.
A worker record
One record per worker. Nothing here is interpreted except the flow kind:
interface WorkerManifest {
id: string; // "engineering.lead" — the whole identity, and the address
declared: Record<string, unknown>; // what the worker declared about itself
body: string; // the worker's instructions, or "" for a seat with none
codePath?: string; // set when the worker's folder holds a TypeScript file
}
The id is used exactly as written. It is the seat's flow instance id, so it is also the address you talk to it on: POST /api/flows/engineering.lead/sessions. Dots, not slashes — a / inside an id survives registration and then fails to route, which is a failure you would not find until somebody tried to use the worker.
Two keys inside declared mean something to the hire. flow names the kind, and description is a label for the roster. Everything else is that worker's settings, handed to its flow exactly as written.
The flow decides what a worker may say about itself
A flow kind declares its settings with configSchema, and that schema is closed: a key it never declared is refused by name, at the hire, before anything runs.
export const workerAgentFlow = defineFlow({
kind: "worker-agent",
cardinality: "collection",
configSchema: z.object({
persona: z.string(),
model: z.string().default("openai/gpt-5.4-mini"),
tools: z.array(z.string()).default([]),
}),
// ...its graph builds a generator from `ctx.flow.config`.
});
So the flow's author, not the framework, decides what a worker of that kind may declare. A worker that asks for a temperature its flow never offered does not quietly run without one:
hireWorkforce refused 1 of 3 workers; nothing was hired:
- worker "engineering.lead" — Flow "worker-agent" instance "engineering.lead"
has an invalid config bag: "temperature" is not a declared setting.
Settings are written the way the flow declares them. There is no translation between how a record spells a setting's name and how the code spells it, on purpose — one such rule is how a convention picks up a dialect.
Copies, ids, and settings bags are covered in Flows; addressing is in How an instance is addressed.
A worker's instructions are one setting
A record's body is the worker's instructions. It reaches the flow as a setting named persona, alongside everything the record declared:
seats[1].config;
// { model: "openai/gpt-5.4-mini", tools: ["board", "search"],
// persona: "You are the engineering lead. …" }
persona is the one name the hire imposes — a body has no name of its own until something gives it one. Routing it through the settings bag is what makes the next part free.
A worker with no body is a thin seat: a fully addressable worker that carries no persona at all, because none was written. It is not a lesser kind of worker; it is a copy of a flow that was not written to take instructions.
seats[0].config; // {} — this record declared no settings and has no body
And a flow that never declared persona refuses a body by name, the same way it refuses any other undeclared setting:
hireWorkforce refused 1 of 2 workers; nothing was hired:
- worker "engineering.intake" — Flow "intake" instance "engineering.intake"
has an invalid config bag: "persona" is not a declared setting.
That is the whole reason the body travels as a setting: no worker flow has to check for one. Prose written under a seat whose flow does not want it is an error at startup rather than something the flow silently ignores.
Two other rules follow from the same place. A body that is only whitespace contributes no persona at all — whitespace is not instructions, and an empty string handed to a flow that requires a persona would be a worse lie than sending nothing. And a record that declares persona: and carries a body is refused, naming both sources: there is no precedence rule, because picking a winner would mean a worker's instructions live in two places.
What refusal looks like
Every problem here is a startup misconfiguration, so every problem throws. They are collected first, so one run names all of them and you fix them in one pass — and nothing is returned, so a bad record cannot leave you with a half-hired roster:
- a record with no
flow, so there is no kind to hire it into; - a record naming a kind that was not passed to
kinds, with the kinds that were; - a flow passed under a key that is not its own kind, since the seat would otherwise run a different worker's graph;
- a setting the flow never declared, or a required one the record omits, in the flow's own words;
- a body handed to a flow kind with no
persona; - two records claiming one id, which is two workers claiming one address;
- a record that carries a
codePathand noflow. Pointing a seat at a TypeScript file is not wired up, so such a record is refused by name rather than dropped — the message says the code path is recorded and not yet in use, which is different from saying you forgot something.
What a hired worker is
A seat is a flow copy and nothing else. There is no second species and no separate registry of workers: an intake desk is a seat, a coordinator is a seat, and a worker with a personality is a seat whose flow was written to take one. What separates them is what their flow accepts, not what kind of thing they are.
That also means a seat is a dispatch target — an address you open a session against. It is not the same list as the in-process workers a task board drains. Same idea, different mechanism, and keeping the two apart is deliberate.