Coordinators
Sometimes you want people to send work to one place and let it find the right worker. A coordinator is that place. It's a worker like any other on your roster, run by the coordinator flow, and the workers it hands work to are its delegates.
Each message you send a coordinator is a post. The coordinator hands it to one or more delegates, or, when it routes by judgment, may answer it itself. Each delegate works the post in a session of its own, and its answer comes back into your conversation under its own name. An organization's chief of staff can be one.
Setting one up
A coordinator is a WORKER.md that names flow: coordinator and lists its delegates:
---
description: Ask the support team anything.
flow: coordinator
delegates: [support.devices, support.accounts, support.general]
routing: best-fit
fallback: support.general
---
You are the support desk. When a question fits none of the team, answer it
yourself in a sentence or two.
The delegates are workers too. Under best-fit, their descriptions are what the coordinator picks by:
---
description: Phones, laptops, and anything else that won't turn on or charge.
---
You answer device questions for the support team.
| Key | What it sets |
|---|---|
delegates | The workers each new conversation starts with, by id. At most 25. |
routing | judgment (the default), best-fit, round-robin or everyone. See Choosing how it routes. |
fallback | The delegate that takes a post best-fit can't place. It must be one of delegates. |
rounds | How many times a delegate's answer goes back out to the others: 0 (the default) to 3. See Letting delegates answer each other. |
The coordinator's own turn, the one that runs when it routes by judgment, is the built-in worker's turn. Its model, tools, skills and capabilities read the way an agent worker's do. listDelegates, addDelegate, removeDelegate, setFallback and handOff come with the flow, whatever the coordinator's tools: line says.
Build the coordinator flow and register it beside the flows its delegates run on:
import {
createWorkerInstallation,
defineAgentWorkerFlow,
defineCoordinatorFlow,
hireWorkforce,
} from "@flow-state-dev/workforce";
import { readWorkforce } from "@flow-state-dev/workforce/loader";
const { workers } = await readWorkforce("./workforce");
const installation = createWorkerInstallation({
standardWorkers: workers,
workerFlows: () => ({ agent, coordinator }),
});
const agent = defineAgentWorkerFlow({ installation });
const coordinator = defineCoordinatorFlow({
installation,
delegateFlows: [agent],
routeModel: "openai/gpt-5.4-mini",
});
flowRegistry.registerMany(hireWorkforce(installation));
defineCoordinatorFlow takes:
| Option | What it does |
|---|---|
installation | The installation the coordinator and its delegates belong to. Required. |
delegateFlows | The flows a post can be delivered to. Each must declare the delegated-post entry: the built-in agent does, and a flow of your own does once you add it. A flow without it throws here. Required. |
routeModel | The model behind best-fit's evaluator call: a model id your resolver knows, or an evaluation model. Required, even when no coordinator routes by best fit. |
agent | What the coordinator's own turn is built with: the options you'd give defineAgentWorkerFlow, such as catalog and uses. A coordinator's tools: line is read against this catalog. Left out, the built-in's defaults. |
roundDeadlineMs | How long a round waits for its answers. Five minutes by default. A value that isn't a positive whole number of milliseconds throws. |
hireWorkforce refuses a coordinator file it can't run, naming the problem: a rounds: above 3 (rounds can be at most 3), a routing: it doesn't know, a fallback: that isn't one of its delegates:, or a delegate named twice. A coordinator declared in your files can name only workers declared in your files; createWorkerInstallation refuses one whose delegates: names anything else.
Talking to it
Open a session with the coordinator and send it a message, as you would any worker:
import { createClient } from "@flow-state-dev/client";
import { createWorkforceClient } from "@flow-state-dev/workforce/browser";
const workforce = createWorkforceClient({ userId, baseUrl });
const session = await workforce.ensureWorkerSession({ worker: "support.help" });
const help = createClient({ flowKind: session.flowKind, userId, baseUrl });
await help.sendAction("run", { message: "My laptop won't charge." }, { sessionId: session.id });
Say best fit picks support.devices. Its answer lands in the conversation as a message whose agentName is support.devices, the delegate's worker id. Replies the coordinator writes itself carry an agentName that starts with coordinator-judgment, exported as COORDINATOR_JUDGMENT.
A delegate's answer arrives in a request of its own once the delegate's turn ends. To see it, follow the session with createSessionSSEClient, not the run request's stream.
Choosing how it routes
routing: | Who gets a post |
|---|---|
judgment (the default) | The coordinator's own turn reads the post and decides. It hands the post on with its handOff tool, to as many delegates as it likes, or answers itself. |
best-fit | One evaluator call picks the delegate whose note or, failing that, description fits the post best. A delegate with neither isn't offered. While a delegate is still working your last post, your next one goes to it too, with no call. |
round-robin | The next delegate in the list after the one your last post went to. |
everyone | Every delegate. |
Each policy checks a delegate when the post arrives. One that can't take it, because it was fired or because its flow takes tasks but not posts, is skipped, and the record says why.
Under best-fit, a post the call can't place goes to the fallback: delegate. The call can't place a post when it fails, picks something that isn't a delegate, or has nobody to pick from. With no fallback, or one that can't be reached, the coordinator's own turn takes the post, as under judgment. If that turn fails too, nobody takes the post, and the conversation says so:
Nobody took this post: best fit couldn't place it, and the coordinator's own turn failed: <error>.
round-robin and everyone say the same when they find no delegate to reach: Nobody took this post: no delegate in this conversation can be reached.
Changing the delegates
The delegates: line is where each new conversation starts. A conversation takes its own copy of delegates: and fallback: on its first post, or on the first delegate action or tool call in it if that comes sooner. From then on, changes stay in that conversation. Other conversations with the same coordinator don't see them, and the file is never written. Editing delegates: later reaches only conversations that haven't had a post or a delegate action yet.
Your app and the coordinator's own turn can both change a conversation's delegates, and both pass the same checks.
From your app, with the coordinator's actions on the conversation's session:
await help.sendAction("addDelegate", { worker: "licenses", note: "Software license questions" }, { sessionId: session.id });
await help.sendAction("removeDelegate", { worker: "support.accounts" }, { sessionId: session.id });
await help.sendAction("setFallback", { worker: "licenses" }, { sessionId: session.id }); // { worker: null } clears it
await help.sendAction("listDelegates", {}, { sessionId: session.id });
note is optional. Best fit picks by it before the worker's description.
Each action's output is the conversation's list as it stands after the call. sendAction doesn't hand back the output: find it in the request's result with sessions.listSessionRequests, passing includeResultOutput: true.
{
"delegates": [
{ "worker": "support.devices" },
{ "worker": "support.general" },
{ "worker": "licenses", "note": "Software license questions" }
],
"fallback": { "worker": "licenses" },
"max": 25,
"filingSessionId": "…"
}
filingSessionId identifies this conversation to its delegates. Each delegate works this conversation's posts in a session of its own, and workforce.findWorkerSession({ worker, filingSessionId }) returns it. A lookup that names only the worker never does.
A refused change writes nothing. Its request fails, and the refusal is in result.error.message.
By the coordinator, with tools of the same names, when you ask it to bring someone in or take someone off. A tool hands a refusal back to the model as { refused: "<message>" }, so it can tell you why. The list isn't in the coordinator's prompt: to say who its delegates are, it calls listDelegates.
A conversation can't be created with delegates already set. A session create whose state carries delegates is refused with a 400:
Session state field "delegates" is written only by flow "coordinator"; a caller cannot set it.
Who can be a delegate
A delegate is a worker on your own roster, one of yours or a standard one, whose flow takes a delegated post or a task. A conversation holds at most 25.
| Refused | Message |
|---|---|
| Another user's worker, or one nobody holds | No worker "<id>" on your roster. |
| A worker whose flow takes neither a post nor a task | Worker "<id>" runs on flow "<flow>", which takes neither a delegated post nor a task. |
| A worker already on the list | "<id>" is already a delegate in this conversation. |
| A 26th delegate | This conversation already has 25 delegates, the most it can hold. Remove one first. |
| Removing a worker that isn't on the list | "<id>" isn't a delegate in this conversation. |
| A fallback that isn't on the list | "<id>" isn't a delegate in this conversation, so it can't be the fallback. |
A worker whose flow takes tasks but not posts can be added. A post handed to it is skipped, and the record's reason is Worker "<id>" runs on flow "<flow>", which can't take a delegated post. Only the flows in delegateFlows take posts: a worker on any other flow counts as one that can't, even when its flow declares the entry.
Removing checks only the list, so you can remove a delegate that has since been fired. A delegate you remove still answers a post it was already handed. Removing the fallback clears it; best fit then hands what it can't place to the coordinator's own turn until you set another with setFallback.
Letting delegates answer each other
With rounds: 0, the default, a delegate's answer lands in the conversation and goes no further. Set rounds: to 1, 2 or 3 and each answer goes back out to the other delegates, by the same policy, for that many rounds. The person's post is round 0.
best-fitandround-robinroute each answer again as it lands, never to its own author.everyonewaits for the round to close, then hands each delegate the other delegates' answers from it in one delivery. A delegate with no other answer to get is skipped.judgmentwaits for the round to close, then runs the coordinator's turn once with the round's answers. Its hand-offs in that turn go out in the next round.
A round closes when every delivery in it has been answered or has failed, or at its deadline: five minutes, unless you set roundDeadlineMs. A delegate whose turn fails says so at once, so the round doesn't wait on it. One whose run is cancelled does the same when its flow sets delegatedPostOnFinished, as the built-in agent flow does. One still working at the deadline keeps working, and its answer lands once when it comes, going no further. A delegate that never reports at all, because its process stopped, holds its round until the conversation's next activity after the deadline: a post, an answer, or another delegate's report.
A conversation keeps at most 50 rounds open. A post past that is still delivered, but its round isn't opened: its answers land and go no further, and its routing record carries a note saying so.
What it costs. Each delegate gets at most one delivery per post per round, so a post costs at most delegates × (rounds + 1) delegate turns: 100 at 25 delegates and 3 rounds. judgment adds up to rounds + 1 turns of the coordinator's own. best-fit adds at most one evaluator call per round, plus a coordinator turn when it can't place a post and no fallback takes it.
What it records
Every routing decision leaves one coordinator-route item in the conversation. It's a component item: it never enters the model's history, and you render it apart from the lines.
{
"postId": "req_…",
"round": 0,
"policy": "judgment",
"by": "judgment",
"delegates": [
{ "worker": "eng.em", "outcome": "delivered" },
{ "worker": "eng.coder", "outcome": "skipped", "reason": "Worker \"eng.coder\" runs on flow \"coder\", which can't take a delegated post." }
]
}
| Field | What it holds |
|---|---|
postId | The post: the id of the request that carried the person's message. Answers going back out keep it. |
round | 0 for the person's post, one more each time answers go back out. |
policy | The conversation's routing:. |
by | How the delegates were found: judgment (the coordinator's own turn), held (best fit, still on your last post), evaluated (best fit's call), fallback, round-robin, everyone, or unplaced (nobody took it). |
delegates | Each delegate the decision touched: worker, outcome (delivered, skipped or failed), and reason when it wasn't delivered. |
none | Why nobody was delivered to, when nobody was. |
note | Why this round's answers go no further: the conversation already had 50 rounds open. |
A delegate answers each post once per round. A post handed to the same delegate twice in a round is skipped the second time, with the reason it was already handed this post in this round.
In React, register a renderer under the record's component name:
import type { ReactNode } from "react";
import { FlowProvider } from "@flow-state-dev/react";
import type { CoordinatorRouteRecord } from "@flow-state-dev/workforce";
import { COORDINATOR_ROUTE } from "@flow-state-dev/workforce/browser";
function RouteNote({ item }: { item: { data: CoordinatorRouteRecord } }) {
const handedTo = item.data.delegates.filter((d) => d.outcome === "delivered").map((d) => d.worker);
return <p className="route-note">{handedTo.length > 0 ? `Handed to ${handedTo.join(", ")}` : item.data.none}</p>;
}
export function SupportDesk({ children }: { children: ReactNode }) {
return <FlowProvider renderers={{ component: { [COORDINATOR_ROUTE]: RouteNote } }}>{children}</FlowProvider>;
}
Import names from @flow-state-dev/workforce/browser in a client component; the package root is server code. A type-only import from the root, as above, is fine.
Making your own flow a delegate
A worker on the built-in agent flow can take posts as it is. A worker flow of your own takes them once it declares the delegated-post entry around its door, the block its run action runs. Here door and inputSchema are that flow's own:
import { defineFlow } from "@flow-state-dev/core";
import {
DELEGATED_POST_ENTRY,
delegatedPostEntry,
delegatedPostOnFinished,
workerConfigSchema,
} from "@flow-state-dev/workforce";
const researchFlow = defineFlow({
kind: "research",
configSchema: workerConfigSchema(),
session: installation.session(),
resources: { ...installation.resources },
request: { onFinished: delegatedPostOnFinished },
actions: { run: { inputSchema, block: door, userMessage: (input) => input.message } },
internal: { actions: { [DELEGATED_POST_ENTRY]: delegatedPostEntry(door) } },
});
Then add it to delegateFlows. The door is handed { message }, where the message reads <from>, through <coordinator>: <post>: who wrote it (the person's user id, or the delegates whose answers it passes on), and the coordinator's worker id. Whatever the door returns, a string or { text }, is the answer. An empty reply fails the turn, and nothing is answered.
delegatedPostOnFinished tells the coordinator when a delegated run is cancelled, so its round doesn't wait for the deadline. Without it, a failed turn is reported at once, but a cancelled one holds its round until the deadline.
What it won't do
- Hand a post to another user's worker. Their workers aren't on your roster, and naming one gets the same answer as a worker that doesn't exist.
- Write a change back to the file. A conversation's delegates are its own.
- Stop a delegate at the deadline. The round closes without it; the delegate's turn runs on.
- Recall a post. Removing a delegate doesn't take back what it was handed.
- Pick by a delegate's instructions. Best fit reads the delegate's note, or else its description, and nothing else.
Related pages
- The chief of staff: an org-level worker that hires workers of your own and, as a coordinator, hands them work.
- The built-in worker: the
agentflow, whose turn a coordinator runs when it routes by judgment. - Workers on disk:
WORKER.md,readWorkforce,hireWorkforce, and talking to a worker. - Hiring, forking and firing workers: how a person's own workers get onto their roster.