Every tool in projects #1 through #3 lived on an MCP server at the end of an HTTP connection. That uniformity was quietly load-bearing: the host could treat "run a tool" as "make a request", and nothing else had to know the difference.
spawn_agent breaks it, and the way it breaks it is instructive.
Part 1 · Why it cannot be a server tool
Look at what spawn_agent needs in order to do its job:
async run(args, context) {
for await (const event of runAgentLoop({
messages: [{ role: "user", content: briefing(args.task) }],
toolbox: context.toolbox, // ← this run's servers
gate: true, // ← this run's approval rules
delegate: false,
budget: context.budget, // ← THIS RUN'S WALLET, by reference
})) {
context.emit(event); // ← this run's live event stream
if (event.type === "done") return event.finalText;
}
}Four things from context, and not one of them survives a trip over the
wire:
| What it needs | Why HTTP cannot carry it |
|---|---|
context.toolbox | live client connections to three servers |
context.budget | a shared mutable object three other agents are drawing from concurrently |
context.emit | a callback into an async generator that is currently running |
gate | the host's own rule list, which by definition is not the server's business |
The budget is the clearest one. You could serialise a TreeBudget and send it,
but the moment you do, it is a copy — and the whole point of
MAX_TREE_TOKENS is that every agent draws from the same object, so that
"the whole run stops at 600,000 tokens" is true rather than approximately
true.
Part 2 · And that is a security boundary
The distinction is not just about where code runs. It is about what the code can reach.
An MCP tool
Runs on a machine you don't control.
Has whatever privileges that server has — which is none of yours.
Can only ask. Everything it returns is text you chose to accept.
Project #4 has 11.
A local tool
Runs inside your host.
Has whatever privileges your host has — the API key, the database credential, the shared MCP token, the loop's own control flow.
Project #4 has 1.
There is a useful question to ask before adding one: could this be done by a
server, if I were willing to pass it what it needs? If yes, make it a server
tool and pass the data explicitly — you lose nothing and you keep the boundary.
spawn_agent genuinely fails that test, which is what earns it the exception.
Part 3 · The fiddliest twenty lines in the repo
There is a real problem hiding in context.emit.
A sub-agent produces a whole trace — tool calls, results, iterations — while
the parent is sitting inside await Promise.all(...). Those events need to
reach the UI as they happen, so the user sees three lanes moving.
But the parent is an async generator, and an async generator has two hard rules:
- it cannot
yieldfrom inside a callback - it cannot
yieldwhile it is parked on anawait
The fix is a buffer plus a doorbell
const queue = createEventQueue<LoopEvent>();
// workers push into the queue from wherever they are
const running = Promise.all(toolUses.map(runOne));
// close the queue when they finish — on BOTH paths
void running.then(() => queue.close(), () => queue.close());
// the generator drains the queue, which it CAN legally yield from
for await (const event of queue) yield event;
const outcomes = await running;Walk it:
createEventQueueis an async iterable with apushand aclose. Pushing is an ordinary function call, so a callback can do it.- The workers start. They push events as they go. Nobody is yielding yet.
- The generator does
for await (const event of queue)— it is now suspended on the queue, not on the workers, and every push wakes it up to yield one event. - When all workers settle, the queue closes, the
for awaitends, and the generator moves on.
Checkpoint
Run the crew demo and watch three lanes update while the workers are working, rather than appearing all at once at the end.
If your parallel work goes silent and then reports in a lump, this is the shape of the fix.
Part 4 · Those twenty lines get used twice
The best evidence that createEventQueue was the right abstraction is that a
completely unrelated feature needed it a year later.
In project #5, a server can ask the host to run a model call. That request
arrives inside a tools/call — which means it happens inside the
tool-running block of runAgentLoop, in exactly the same place the workers
run.
To show the user "the kitchen is asking to spend 6.0¢", that event has to get
out of a callback and into the generator's yield.
Part 5 · What a local tool looks like to the model
Nothing special. That is the point.
It appears in the tool list alongside the eleven MCP tools, with a name, a description and an input schema. The model picks it the same way it picks any other tool, for the same reason: the description convinced it.
{
name: "spawn_agent",
description:
"Delegate a self-contained sub-task to a fresh agent with its own context " +
"window. Use when a job has many independent pieces — the worker reports " +
"back a short summary, not a transcript. Prefer this over inspecting " +
"hundreds of items yourself.",
inputSchema: z.object({ task: z.string() }),
}The only thing that differs is dispatch: the toolbox recognises the name as local and calls a function instead of making an HTTP request.
Servers propose. The host disposes. A local tool is the host proposing to itself.
What you now know
- A tool must be local when it needs shared mutable state, live connections, or a callback into the running loop — things a copy cannot provide.
- Local tools run with your host's privileges, so the bar for adding one is much higher than for a server tool. Keep the count visible.
- An async generator cannot
yieldfrom a callback, so parallel work needs a queue plus a doorbell to stream events live. - Close that queue on the reject path too, or one throwing worker hangs the whole request silently.
- To the model, a local tool is indistinguishable from any other — the description is still the entire interface.