Authoring a delegating skill
A skill is a folder with a SKILL.md in it. Its body gets spliced into a
generator's system prompt when the skill matches, so most skills are just
instructions. Some jobs are too big for one set of instructions and one model
turn: research a company from three angles, analyze every competitor in a
category, review a document section by section. Those want a small team.
Declaring a team is what this guide is about. You add one field to the skill's frontmatter, and the generator that binds the skill gets a private task board — a ledger of work with dependency ordering and a concurrent runner over it. That generator is the coordinator: it plans the work as tasks and runs the whole graph in one call. The teammates it hands tasks to are agents.
Nothing about the plan is fixed in your code. The coordinator decides how many tasks there are and who does what, per request.
Which page you want. This one takes a single skill from empty frontmatter to a running team, the model-plans-the-work way. Building a research team is the tutorial: it builds the same team several ways starting from code you write yourself, which is the better entry if you'd rather see the board before the skill. Delegation is the reference for the fields and knobs.
Before you start. You should have skills wired into your app already
(Adding skills to your app) and know what a
SKILL.md looks like (Authoring skills). Knowing how a
board seeds, runs, and settles helps too, but you can pick it up as you go
(The board lifecycle).
Every SKILL.md snippet in this guide is a trimmed copy from
examples/guides/research-team;
open the example for the complete source. Two short illustrations are generic
rather than from it: the rosterless binding under
Without a roster, and the rejected-assignee error under
When it goes wrong. To watch a team actually run:
cd examples/guides/research-team
OPENAI_API_KEY=... TAVILY_API_KEY=... pnpm fsdev run research-team chat \
-i '{"message":"research ACME Corp"}'
That path needs two keys configured, not one. A model key, because the
coordinator and every agent are models. And a search key, because the
analysts carry search and call it on their first step — with no search
provider configured it throws rather than degrading, so the run dies there. Any
one supported provider will do; TAVILY_API_KEY above is an example, and
Search lists them all. The agents also carry fetch,
which needs no key: it falls back to a built-in reader.
(The example's other two actions use deterministic handler workers and need no keys at all, but they don't go through delegation.)
One thing to know before you run it: that flow preloads both of the example's
skills at once, so a single chat turn has two teams available and may start
the wrong one, or ask a clarifying question that belongs to the other skill.
This guide teaches research-company and names it explicitly.
1. Declare a team
Add an agents: map to the skill's frontmatter. Each key is an agent name, each
value says where that agent's persona comes from.
---
description: Multi-angle company research delivered by a small team of analysts.
agents:
market-analyst:
prompt-ref: ./reference/market.md
tools: [search, fetch]
financial-analyst:
prompt-ref: ./reference/financials.md
tools: [search, fetch]
synthesizer:
prompt-ref: ./reference/synthesis.md
---
Declaring the field is what turns delegation on. When a generator binds this
skill, the skills library installs three things on it: a private task board, a
set of tools for planning work on that board, and a runBoard tool that runs it.
The board is scoped to that one generator. Nothing else in your app can see or
write to it.
That default holds unless the binding overrides it. delegation: false on
skills.with({...}) suppresses the whole surface even for a skill that declares
agents. delegation: true installs it for a skill that declares none, which is
Without a roster below.
The set of declared agents is the board's roster. It shows up in two places:
in a short prompt fragment the library injects so the coordinator knows who it
has, and in the board's dispatch table, so a task assigned to market-analyst
reaches the persona in ./reference/market.md.
2. Staff each seat
An agent needs a persona, and there are two ways to give it one.
Inline. Write the persona in the skill folder and point at it with
prompt-ref, or put it directly in the frontmatter with prompt. That is what
all three agents above do. The payoff is portability: the skill folder carries
its own team, and copying the folder into another app carries the team with it.
No app code registers anything.
From the registry. Define the agent once in app code with defineAgent, then
borrow it by name with agent-ref:
agents:
analyzer:
agent-ref: competitor-analyst
The tradeoff is the mirror image. A registry agent can't travel alone, because
it resolves against the agentRegistry your app hands to createSkillsLibrary.
What you buy is reuse: one definition, borrowed by as many skills as you like.
research-company is entirely inline, so the snippet above comes from the
example's other skill, competitor-analysis. For the registry form end to end,
including the defineAgent call and the library wiring,
the research-team tutorial walks it.
Inline agents take a few more keys: tools (catalog keys the agent may call
itself), model, visibility, and context-supply. See
Delegation for the full field list
and Agents for the registry side.
One thing that surprises people: the one-line blurb the coordinator sees for each
agent is not something you write. For an inline agent it's the first non-blank
line of the persona, truncated. So make that line say what the agent is for. A
registry agent doesn't get a blurb at all — it's listed as agent `competitor-analyst` ,
its reference name and nothing else. The description you gave defineAgent
isn't used here.
3. Plan the work as a graph
The skill body is where you tell the coordinator what to put on the board. You're writing instructions for a model, so write them as steps.
The planning tool is addTask. It takes a goal and, optionally, an assignee
naming one of your agents or tools, deps listing task ids that must finish first,
and a structured input payload the worker receives. It returns the new task's id.
You run the board. Extract the target from the user's message, then:
1. `addTask` — goal: `"Analyze market positioning of <target> — category,
target customer, key differentiators, recent narrative shifts. Cite
sources."`, `assignee: "market-analyst"`.
2. `addTask` — goal: `"Analyze financial health of <target> — revenue scale
and trajectory, funding or public financials, profitability/burn, runway
signals. Cite sources."`, `assignee: "financial-analyst"`.
3. `addTask` — goal: `"Synthesize the prior reports into a single research
brief for <target>. Lead with the takeaway, then evidence, then risks."`,
`assignee: "synthesizer"`, `deps` set to the two task ids returned above.
4. Call `runBoard` once. The analysts run in parallel; the synthesizer starts
when both complete.
That's a fan-out and a fan-in. Two tasks with no dependencies, and a third that
waits on both. deps is the only thing expressing the ordering. You never write
"wait for the analysts" anywhere, and the two analysts run at the same time
because nothing says they can't.
The board is what makes that work. Its runner hands out tasks whose dependencies have all completed and holds the rest back. A delegation board runs four at a time, and that isn't tunable from the skill. For the primitive underneath, and the knobs you do get when you build a board yourself, see Task board.
4. Draining is the running
addTask writes a task, it does not run one. This is the thing people get
wrong first. Nothing executes until the coordinator calls runBoard.
runBoard drains the board. Draining means dispatching every task whose
dependencies are satisfied, waiting, dispatching whatever that unblocked, and
repeating until nothing runnable is left. It returns once, with every task's
output.
Each task goes to its assigned agent, or to a tool if you assigned one — a tool runs as a direct call, with no model turn.
So the whole shape of a delegating skill is: plan with addTask, run with
runBoard, read the result. There is no other way to execute delegated work.
There is no per-agent tool the coordinator calls directly, and nothing drains the
board behind the coordinator's back. The skill body decides when to run it.
Reading results works the same way inside the team. The synthesizer's persona
gets its dependencies' outputs on input.deps, keyed by task id, so it can read
both analyst reports without either of them being in its conversation.
addTask and runBoard are the two you'll write instructions for. Seven more
task tools come with them, for steering a board mid-flight rather than planning
one: assignTask, completeTask, failTask, blockTask, cancelTask,
updateTask, listTasks. They're documented in
Delegation, including what
each one takes and how a refused call comes back.
5. An agent that plans more work
An agent can put work on the board too. Give one taskTools in its tools: list
and it can call addTask mid-drain, onto the same board:
agents:
discoverer:
prompt-ref: ./reference/discover.md
tools: [search, taskTools]
analyzer:
agent-ref: competitor-analyst
comparison-writer:
prompt-ref: ./reference/compare.md
Now the coordinator's plan can be one task:
1. `addTask` — goal: `"Identify 3 to 5 competitors for <target> across direct /
adjacent / DIY-status-quo tiers, then enqueue one analyzer task per
competitor plus a single comparison-writer task whose deps cover every
analyzer task you queued."`, `assignee: "discoverer"`.
2. Call `runBoard` once.
One addTask, one runBoard. The drain runs the discoverer, which decides there
are (say) four competitors and enqueues four analyzer tasks plus a
comparison-writer gated on all four. The same drain picks those up, runs the
analyzers in parallel, and runs the writer once they're all done. The number four
is nowhere in your code.
Reach for taskTools on an agent when the shape of the work depends on what an
earlier step found. Grant it through the agent's tools: list, which is the path
that reaches the coordinator's board.
6. Without a roster
You can delegate without declaring a team at all. Every delegation board comes
with a default worker underneath the roster: a generic, capable agent with no
persona and no tools, which runs any task the roster doesn't claim. Force
delegation on with no agents: field, and it's the only worker there is.
const planner = generator({
uses: [skills.with({ active: ["triage"], delegation: true })],
});
// addTask({ goal }) with no assignee runs on the default worker.
This is the cheapest way into delegation. You get task planning, dependency ordering, and parallel execution without writing a single persona. Reach for it when the work decomposes into steps but the steps don't need specialists.
Be aware that it inverts one behavior. With a declared roster, a typo'd assignee is rejected. With no roster there is nothing to check a name against, so any assignee is accepted and everything lands on the default worker either way. If you're relying on the rejection to catch mistakes, declare the team.
7. When it goes wrong
Three assignment outcomes behave differently, and one of them isn't an error at all. Treating them as one thing is what sends people down the wrong path. Caps are a separate topic and come last.
A wrong assignee, on a board with a declared roster. addTask refuses. No
task is created, and the error names the agents that do exist so the coordinator
can correct itself and retry. assignTask and updateTask check the same way.
It comes back as a tool result, the same { ok: false, error } shape every other
call on this surface uses, carrying the roster the coordinator needs to fix it:
addTask({ goal: "Find sources", assignee: "reseacher" })
→ { ok: false, error: 'unknown_assignee: "reseacher" is not an agent on this
board. Available: …' }
The full message lists every declared agent with its blurb and tells the
coordinator it can leave assignee unset instead —
Delegation has it verbatim.
Catching it at creation rather than at dispatch is deliberate. A typo can't sit on the board and resurface twenty seconds later as a failed task.
No assignee at all. Not a failure. The task runs on the default worker
described above. Leaving assignee unset is how you say "anyone can do this," and it's the
only way to reach the default worker on a board that has a roster. Mistyping a
specialist's name is not the same thing and doesn't get the same treatment.
A board that didn't fully drain. runBoard reports how it ended:
status: "drained" when every task settled, status: "blocked" when any task is
left unresolved. Read blocked as "work is still outstanding," not as "something
failed." It has more than one cause, and they want different responses.
The one people hit first is a task stranded behind a failed dependency. Its dependents don't fail and don't get skipped. They stay pending, because a dependency counts as satisfied only when it completes. The drain runs out of runnable work and stops. Read the settled board it hands back to find which task errored. (Delegation has no opt-in cascade that cancels stranded dependents. Some patterns do, like Supervisor, but it isn't wired into this drain.)
The other cause is deliberate. A coordinator can mark a task with blockTask
when it's waiting on something outside the board, and such a task counts as
unresolved, so the drain settles as blocked with nothing having failed. Same
status, opposite meaning. Inspect the tasks before you treat a blocked board as
a failure.
Know what blockTask commits you to, though: it's one-way. No task tool moves a
task back out of blocked — cancelTask is the only exit. So it retires a task
with a reason attached rather than pausing one you can resume. If work needs to
wait and then continue, keep it off the board until its precondition holds.
You'll meet the word "blocked" on both board surfaces. They are two different contracts with different triggers, so don't read one as the other:
- Delegation.
runBoardreturnsstatus: "blocked"when a task is leftpending,in_progress,awaiting_review, orblocked. Terminal tasks don't count toward it. - A code-defined board. Its final item carries
terminationReason: "blocked-by-failures"when any task is notcompleted—erroredandcancelledincluded — or"retry-budget-exhausted"whenmaxTotalRetriesrefused a retry.
They can disagree about the identical board. One errored task with everything
else complete reports blocked-by-failures on the code-first side and drained
on the delegation side. The code-first name is loose on its own terms too: the
classification is purely structural, so a board that stops with tasks still
pending reports blocked-by-failures even when nothing failed. On that surface,
read the counts rather than the reason string. The tutorial covers it in
When the board stops, and when it waits.
Too much work. A board won't accept new tasks forever, and it refuses in two
ways that recover differently. addTask returns enqueued_task_cap_exceeded
when too many tasks are waiting to start, and draining with runBoard frees
those slots, because that bound counts only pending work. It returns
total_task_cap_exceeded at the board's lifetime ceiling, which draining does
not relieve — there the coordinator has to finish with a smaller plan rather
than retry. Both are soft results; the numbers and how to change them are in
Delegation.
There's a trap where that meets the previous section. Draining only frees
enqueue slots for tasks that can actually run. A task stranded behind a failed
dependency stays pending and keeps its slot, and that's precisely the board
that comes back blocked. So if runBoard returns blocked and addTask is
still refusing, draining again changes nothing — cancel or replan the stranded
tasks first.
When the graph belongs in code instead
Delegation is for when the coordinator should decide the tasks. When your code
already knows the graph — a fixed set of seeded tasks, a custom collection
backing, a tuned dispatcher — you don't need agents: or runBoard at all.
Build a code-defined task board and call it as a
single tool. A taskBoard(...).drain is a block, and
any block can be a tool.
Register the drain block in the skills catalog, list it under the skill's
allowed-tools, and the generator calls the whole board in one shot, getting back
only the finished result.
The two shapes, side by side:
- The coordinator plans the work per request — a delegation skill with
agents:. It assigns tasks and drains the board withrunBoard. This guide. - The graph is fixed in code — a code-defined
taskBoardcalled as a tool, or mounted directly in a flow. Your code seeds the tasks and the board's own dispatcher runs them.
Related
- Delegation — the reference: every
agents:field, the board overrides, the caps, and the default worker. - Building a research team — the tutorial. Builds the same team several ways from an empty flow, code-first first, and covers the two ways to staff an agent in depth.
- The board lifecycle — seed, drain, read, from a code-first angle.
- Agents — the registry side of
agent-ref. - Task board — the concurrent-drain primitive underneath, and its config.
- Context supply — how to let an agent inherit the conversation so far instead of seeing only its task input.