I own the pipeline executor — the loop that turns your agent chain into actual LLM calls, in the right order, with the right state. For a long time, "right order" meant exactly one thing: one after another. And for a surprisingly long time, that was fine, because almost every pipeline we wrote internally genuinely was sequential — research feeds into a draft, a draft feeds into an edit.
Then we built a research pipeline that scraped both the web and academic papers before synthesizing them, and I watched it sit there for nine seconds waiting on two HTTP calls that had absolutely nothing to do with each other. That's when it became obvious: most multi-agent pipelines aren't sequential by necessity. They're sequential because sequential is what >> gives you for free, and nobody had bothered to give you anything else yet.
The shape of the problem
Look at almost any "pipeline" diagram for a multi-agent system and you'll usually find the same pattern hiding in it: a handful of steps that don't depend on each other, followed by one step that needs all of their outputs at once. Scrape news, scrape social, scrape forums — then merge. Run three reviewers over a draft — then pick the best one. Fan out, then fan in.
Running that sequentially doesn't make it more correct. It just makes it slower, because you're paying the full latency of every independent call back-to-back instead of overlapping them. For LLM calls specifically, where a single call can reasonably take several seconds, that overhead compounds fast.
The one line
So parallel() — and its operator form, |— does exactly one thing: it runs a set of agents concurrently instead of one after another, and merges their outputs into a dict keyed by each agent's function name:
from n00dles import agent, pipeline, parallel, run @agent(model="gpt-4o") def scrape_news(query: str) -> str: """Scrape latest news.""" @agent(model="gpt-4o") def scrape_twitter(query: str) -> str: """Pull recent posts.""" @agent(model="claude-sonnet-4-6") def merge_signals(scrape_news: str, scrape_twitter: str) -> str: """Merge and rank both signals.""" intel = pipeline(parallel(scrape_news, scrape_twitter) >> merge_signals, timeout=20) result = run(intel, query="AI regulation 2026")
Notice that merge_signals's parameters are named scrape_news and scrape_twitter— exactly matching the upstream agents' function names. That's deliberate, and it's the whole trick: the executor doesn't need a separate merge step, a separate key-mapping configuration, or a context object you have to dig values out of. It just matches parameter names to upstream agent names. If your downstream agent only wants some of the fan-out results, declare only those parameter names — n00dles passes you exactly what you asked for, nothing else.
Under the hood
There's no scheduler, no thread pool, no separate worker process. n00dles is async from top to bottom, so parallel() is, structurally, just asyncio.gather()over the member agents' calls, each wrapped in the same retry, timeout, and tracing logic every other agent gets. That last part matters more than it sounds: a flaky member of a parallel group retries and falls back exactly like it would standalone — fanning out doesn't mean fanning out your error handling too.
Each member is checkpointed under its own name as soon as it finishes, independently of the others. Concretely, that means if you're running parallel(a, b, c) and the process crashes after a and b have already succeeded, resuming that run re-checks each member individually — and only cactually gets re-run. You don't pay twice for work you already paid for once, the same principle that drove checkpoint-and-resume in the first place.
max_concurrency: parallel(*agents, max_concurrency=3) caps how many members run at once with a semaphore, instead of firing every call simultaneously.The other half: branch()
Fan-out solves "run all of these." The complementary problem is "run exactly one of these, chosen at runtime" — and that's what branch() is for. A classifier agent returns a category, and branch() routes execution to whichever downstream agent matches it, falling back to a default if nothing matches:
from n00dles import branch triage = pipeline(classify >> branch(billing=handle_billing, support=handle_support, default=handle_support))
Same philosophy as parallel(): one line, no separate router object to configure, reuses the exact same retry/timeout/checkpoint machinery underneath. We'll go deeper on routing strategies in a future post — for now, the branch() reference has the full signature and more examples.
Why this mattered enough to ship first
We could have shipped n00dles with sequential composition only and called fan-out a "future improvement." We didn't, because in practice almost every pipeline past a certain complexity hits this exact wall — independent work being forced through a single-file queue for no reason other than the framework not offering an alternative. One line of code shouldn't be a stretch goal for something this common. See the parallel() reference for the full signature, including how partial-resume interacts with retried members.