
Blog-Writer Agent using Swarm Pattern
Learn how to build a 5-agent blog-writer (Researcher → Outliner → Writer ⇄ Editor → Fact-checker) using the Swarm pattern in AWS's Strands Agents SDK, where peer agents dynamically hand off control, loop back for revisions, and self-terminate — no central orchestrator required. Grounded in two real, live runs against Amazon Bedrock, with genuine lessons learned on shared context limitations, handoff mechanics, and safety configuration (timeouts, repetitive-handoff detection).
A swarm is a set of peer agents operate on the principle of emergent intelligence (the idea that a group of specialized agents working together can solve problems more effectively than a single agent) with shared working memory that hand off control to each other - there's no central coordinator dictating every step. That's the key difference from a fixed pipeline: in a pipeline, step 2 always follows step 1; in a swarm, each agent decides who should act next, and can even send work backward if something isn't ready.
For a blog, that maps naturally onto five specialized roles:
- Researcher - gathers facts, sources, and statistics on the topic; writes findings into the shared context.
- Outliner - turns the research into a structured outline (hook, sections, takeaways).
- Writer - drafts prose section by section from the outline.
- Editor - tightens language, fixes flow, enforces tone/style — and can hand a thin section back to the Writer instead of always moving forward.
- SEO/Fact-checker - verifies claims against the research, adds keywords and a meta description, and either signals "publish-ready" or hands a failed claim back to the Writer.
This is a builder's guide to constructing that pipeline correctly: the real
Swarm constructor signature, how handoffs and shared context actually work under the hood, what makes this a swarm rather than a fixed pipeline, a full 5-agent code example, and the lessons that fall out of actually running it.What actually makes this a swarm, not a fixed pipeline
It's easy to build this same five-role idea as a hardcoded sequence — call the researcher, then the outliner, then the writer, then the editor, then the fact-checker, always in that order. That's a workflow, and Strands supports it too. What makes the
Swarm pattern different is four specific mechanisms:- Shared memory. All agents read and write against the same evolving
SharedContext— task history, accumulated notes, and knowledge each agent contributed — so the Editor can see what the Researcher found without you wiring that plumbing yourself. (With one real caveat: shared context tracks that an agent contributed something and what agents it can hand off to — it does not automatically forward an agent's full raw output. More on this in Lessons Learned.)
- Dynamic handoffs. Each agent decides who should act next by calling
handoff_to_agent(agent_name, message, context)itself — this is Strands' Python-SDK handoff mechanism, documented here alongside the equivalent structured-output routing used by the TypeScript SDK. The Editor isn't hardcoded to always move forward to the fact-checker — its system prompt can tell it to hand back to the Writer if a section is thin, and forward to the fact-checker once it's satisfied.
- Emergent loops, bounded by a cap. Writer ↔ Editor can iterate — editor sends specific revision notes back, writer rewrites, hands it forward again — until quality criteria are met. Nothing in the API caps this for you; you cap it with
max_handoffs,max_iterations, and (for detecting an actual ping-pong loop specifically)repetitive_handoff_detection_window+repetitive_handoff_min_unique_agents.
- Termination by signal, not by running out of steps. The swarm is "done" when the last agent in the chain (the fact-checker, in this design) produces output without calling
handoff_to_agentagain — that absence of a further handoff is what ends the run, rather than an externally imposed step count. The handoff limit is a safety net for when that signal never comes, not the intended way to stop. This cuts both ways, though: if the fact-checker's prompt doesn't give it an explicit rejection path, "being the last agent" just means it always terminates the swarm, whether or not what it found was actually good — see the fact-checker's prompt below and Lesson 10.
So what actually happens if the fact-checker finds a problem? In the design shown in this post, it does not silently publish and it does not just note the issue in its own output and stop — its prompt gives it a second, explicit branch for exactly this case: if any claim is unsupported by the research notes, contradicts them, or looks fabricated, it calls
handoff_to_agent to send the draft back to writer , quoting the specific failing claim and explaining why it fails. That handback re-enters the swarm at the Writer, who revises and hands forward again (through Editor, back to the fact-checker) until a pass finally checks out clean. The swarm only terminates once the fact-checker produces output without calling handoff_to_agent — so a failed check, correctly wired, extends the run rather than ending it. The catch, covered in Lesson 10, is that this only works because the prompt spells out the rejection branch by name; a fact-checker prompt that only describes the "everything's fine" path has no mechanism to do anything else with a bad result except finalize it.TL;DR
Strands'
Swarm gives you working multi-agent handoffs for free — via an auto-injected handoff_to_agent tool and a shared SharedContext — but it gives you almost no free protection against infinite loops. repetitive_handoff_detection_window and repetitive_handoff_min_unique_agents are both 0 (disabled) by default, so a writer/editor pair will happily ping-pong revisions back and forth until max_handoffs (default 20) or execution_timeout (default 900.0s, 15 minutes) finally kills the run. If you're building a drafting pipeline like this, configure repetitive-handoff detection and sane limits explicitly before pointing it at a real topic — don't rely on the defaults to save you.How the Swarm pattern works
Swarm is a decentralized multi-agent pattern: you don't write an orchestrator that calls each agent in sequence. Instead, you hand Swarm a list of Agent instances, and the agents themselves decide when to pass control to one another.Two mechanisms make that possible:
1. The
handoff_to_agent tool is injected automatically, not prompt-engineered.When you construct a
Swarm , it walks every agent in nodes and registers a real tool — handoff_to_agent(agent_name, message, context) — into each agent's tool registry. This isn't a system-prompt suggestion that the model might or might not follow; it's an actual callable tool the model invokes the same way it would invoke any other tool. You only need to tell each agent when to call it (via the system prompt); Strands wires up the tool itself.One consequence of this being a real injected tool: if one of your agents already has a custom tool literally named
handoff_to_agent , constructing the Swarm will fail with an error rather than silently doing the wrong thing.2.
SharedContext carries accumulated knowledge between agents.Every agent in the swarm sees a shared context that accumulates as the run progresses: the original task, the full history of which agents have worked on it so far, key-value knowledge that agents have explicitly contributed, and descriptions of the other agents available for collaboration. This is what lets your writer agent see the researcher's notes without you manually passing them along — the researcher hands off with a message, and the shared history + context is available to whichever agent picks up next.
3. Safety is opt-in, not automatic.
Swarm bounds a run along four independent axes:-
max_handoffs(default 20) — total number of agent-to-agent transfers allowed.
-
max_iterations(default 20) — total number of node executions allowed. This is checked independently ofmax_handoffs, against the same underlying counter, so tuning one does not automatically tune the other.
-
execution_timeout(default 900.0 seconds / 15 minutes) — wall-clock time for the entire swarm run, measured from when the swarm starts.
-
node_timeout(default 300.0 seconds / 5 minutes) — wall-clock time allowed for a single agent's turn. This bounds a different thing thanexecution_timeout— a slow individual agent can blow this budget long before the swarm-level timeout is even close.
-
repetitive_handoff_detection_windowandrepetitive_handoff_min_unique_agents(both default to 0, i.e. disabled) — an opt-in check that looks at the last N handoffs and fails the run if fewer than the configured number of distinct agents appear in that window. This is the mechanism that catches a writer/editor ping-pong loop — but only if you turn it on.
4. The entry point defaults to whichever agent is first in your list.
Unless you pass
entry_point= explicitly, Swarm starts the run with the first Agent in the nodes list. The pattern is decentralized once it's running, but the starting point is still an ordinary list-ordering detail you can get wrong.A 5-agent blog-writing swarm
Here's the full pipeline:
researcher → outliner → writer ⇄ editor (can loop) → seo_factchecker , which either terminates the swarm (by not handing off further, once every claim checks out) or hands a rejected claim back to writer — giving the pipeline two real backward edges, not just one.Python
from strands import Agentfrom strands.multiagent import Swarmfrom strands.models import BedrockModelmodel = BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0",region_name="us-west-2",)researcher = Agent(name="researcher",model=model,system_prompt=("You are the RESEARCHER in a 5-agent blog-writing swarm ""(researcher, outliner, writer, editor, seo_factchecker). ""Gather accurate facts, sources, and statistics on the given topic. ""Do NOT outline or write prose yourself. When you have enough facts, ""call handoff_to_agent to hand off to 'outliner', and put your FULL ""research notes verbatim in the message."),)outliner = Agent(name="outliner",model=model,system_prompt=("You are the OUTLINER in a 5-agent blog-writing swarm. Using the ""researcher's notes (must be in the handoff message you received), ""produce a structured outline: a hook line, section headers with ""one-line descriptions, and a takeaway line. When done, call ""handoff_to_agent to hand off to 'writer', and put your FULL outline ""verbatim in the message."),)writer = Agent(name="writer",model=model,system_prompt=("You are the WRITER in a 5-agent blog-writing swarm. Using the ""outline handed to you (must be in the handoff message you ""received), draft the post section by section, following the ""outline's structure. When your draft is complete, call ""handoff_to_agent to hand off to 'editor', and put the FULL draft ""text verbatim in the message — a summary is not enough. You may ""receive a draft back from EITHER 'editor' (for thin or unclear ""sections) or 'seo_factchecker' (for a specific factual claim ""that failed its check) — either way, fix exactly what was ""flagged, correcting or removing any claim you cannot support ""from the research notes, and hand off to 'editor' again with ""the FULL updated draft so it goes through review again."),)editor = Agent(name="editor",model=model,system_prompt=("You are the EDITOR in a 5-agent blog-writing swarm. You will ""receive the writer's full draft text in the handoff message. ""Tighten language, fix flow, and enforce tone/style. You have TWO ""options: (1) if a section is thin or needs real rework, call ""handoff_to_agent to send it BACK to 'writer' with specific notes ""on exactly what to fix — do this at most once, to avoid an ""endless back-and-forth; (2) once the draft is solid, call ""handoff_to_agent to send it FORWARD to 'seo_factchecker' with ""the FULL edited draft text verbatim in the message. Do not ""finalize yourself — the swarm isn't done until the ""seo_factchecker signals publish-ready."),)seo_factchecker = Agent(name="seo_factchecker",model=model,system_prompt=("You are the SEO/FACT-CHECKER in a 5-agent blog-writing swarm, and ""the FINAL stage. You will receive the editor's full draft text in ""the handoff message. Check every factual claim against the ""research notes visible in shared history. You have TWO options: ""(1) if ANY claim is unsupported by the research notes, contradicts ""them, or looks fabricated, call handoff_to_agent to send the draft ""BACK to 'writer' with the specific claim quoted and exactly why it ""fails the check — do NOT silently note the problem and publish ""anyway; (2) if and only if every claim checks out, suggest ""relevant keywords, write a meta description, and present the ""FINAL publish-ready blog post (full text) plus keywords and meta ""description as your output, and do NOT call handoff_to_agent — ""ending your turn without a handoff is what signals the swarm is ""publish-ready and done."),)# Real constructor signature:# Swarm(# nodes: list[Agent],# *,# entry_point: Agent | None = None,# max_handoffs: int = 20,# max_iterations: int = 20,# execution_timeout: float = 900.0,# node_timeout: float = 300.0,# repetitive_handoff_detection_window: int = 0,# repetitive_handoff_min_unique_agents: int = 0,# )blog_swarm = Swarm([researcher, outliner, writer, editor, seo_factchecker], # positional listentry_point=researcher, # explicit for clarity, even though it matches list ordermax_handoffs=10, # custom override, NOT the default (library default is 20)max_iterations=10, # custom override, NOT the default (library default is 20)execution_timeout=240.0, # custom override: 4 min total, NOT the default (900.0)node_timeout=60.0, # custom override: 1 min per agent turn, NOT the default (300.0)repetitive_handoff_detection_window=4, # look at the last 4 handoffsrepetitive_handoff_min_unique_agents=2, # require at least 2 distinct agents in that window)result = blog_swarm("Write a blog post about why idempotency keys matter for reliable API design.")print(result.status) # e.g. COMPLETED / FAILEDprint(result.execution_count) # number of node executionsprint(result.execution_time) # wall-clock time for the runprint(result.accumulated_usage) # accumulated token usage across all agents# There is no result.final_response — that attribute does not exist on# SwarmResult. The last agent to run holds the final output:last_node = result.node_history[-1]print(last_node.node_id) # which agent produced the final output# Inspect result.node_history to see the full handoff sequence, e.g.:for node in result.node_history:print(node.node_id)
An async variant is useful if you're already inside an async context (e.g. a web handler) —
Swarm exposes invoke_async alongside the synchronous __call__ used above:Python
async def run_blog_swarm(topic: str):result = await blog_swarm.invoke_async(f"Write a blog post about {topic}.")print(result.status)for node in result.node_history:print(node.node_id)return result
Lessons Learned
These aren't abstract API notes — they're the specific gotchas that fall out of how
Swarm is actually implemented, and they'll bite a blog-writing pipeline in particular because it has a natural writer/editor feedback loop.- Entry-point ordering silently matters, even though the pattern is decentralized. If
entry_pointisn't passed explicitly, the swarm starts with whicheverAgentis first in yournodeslist. Reordernodes=[writer, researcher, editor]by accident and your pipeline kicks off with the writer having nothing to write about. Passentry_point=explicitly in real code rather than relying on list order to match your intent.
- Repetitive handoff detection is off by default — a Writer↔Editor loop can ping-pong until it burns your entire handoff budget. Both
repetitive_handoff_detection_windowandrepetitive_handoff_min_unique_agentsdefault to0, which is a no-op — the check only runs when the window is greater than zero. In a blog pipeline with a real backward edge (Editor → Writer), it's easy for the editor to bounce a draft back to the writer, who bounces it right back with a small tweak, over and over. Set both parameters explicitly (for example, a window of 4 requiring at least 2 unique agents, as in the 5-agent example above) so a loop gets caught early instead of running untilmax_handoffsfinally cuts it off.
- There is no
result.final_response— decide up front how you'll extract the finished post.SwarmResultonly addsnode_historyon top of the base result fields; no convenience "give me the answer" attribute exists. Treat the finished blog post as whatever the last node innode_historyproduced (typically your editor, since it's the one that finalizes rather than hands off further), and design that agent's output format with that in mind.
-
max_handoffsandmax_iterationslook redundant but are two separate dials, not one. Both are checked against the same underlying execution counter, so whichever is smaller triggers first — but the library does not tie them together for you. If you lowermax_handoffsto control cost while leavingmax_iterationsat its default of 20, you haven't added a second safety net; you've just made one of the two checks irrelevant. Set both deliberately based on what you're actually trying to bound.
-
node_timeoutandexecution_timeoutbound different things, and the smaller one can be the real bottleneck.node_timeout(default 300.0s, 5 minutes) caps a single agent's turn;execution_timeout(default 900.0s, 15 minutes) caps the whole run's wall-clock time. A research-heavy first step that makes several tool calls or lookups can hit the default 5-minute node timeout well before the 15-minute swarm-level timeout is anywhere close to mattering. Tunenode_timeoutfor your slowest individual agent, not just your overall time budget.
- The
handoff_to_agenttool is injected automatically, and it will conflict with a tool of the same name. Because Strands registers a realhandoff_to_agenttool into every agent's tool registry when theSwarmis constructed, an agent that already carries a custom tool with that exact name will cause construction to fail outright with a clear error. If you're porting agents in from an earlier, manually-orchestrated prototype, check for a naming collision before wiring them into aSwarm.
- A handoff does not automatically forward the previous agent's full output — only what it explicitly writes into the handoff message. This is a lesson from an actual live run, not just from reading source: in the 3-agent researcher→writer→editor run against Bedrock, the writer produced a complete draft, called
handoff_to_agentto send it to the editor, but the editor's turn began by saying "I don't see the actual content of the draft included in your message" and asked for the draft to be re-sent — it could only see the shared-context summary of what the writer had done, not the verbatim draft text. The run still completed (Status.COMPLETED), but the editor never actually reviewed real prose. If a downstream agent needs the previous agent's full output verbatim, tell the upstream agent's system prompt to include the complete text in themessageargument ofhandoff_to_agent— don't assumeSharedContextreconstructs it for you.
-
result.resultsis keyed by node ID, so if an agent runs twice (a loop), you only keep its latest output — not the history of each pass. In the 5-agent run,result.resultsis a dict from node ID to that node's result; if the Writer↔Editor loop actually triggers and the writer runs a second time,result.results["writer"]holds only the second draft, not the first.result.node_history, by contrast, is a list and does record every visit in order — so if you need to see what changed across a revision loop, reconstruct it fromnode_historyplus whatever you log yourself, not fromresult.resultsalone.
- The actual handoff path is genuinely dynamic, not just theoretically so — the same code can take different routes on different runs. The 5-agent run used for this post went straight
researcher → outliner → writer → editor → seo_factcheckerwith no loop-back, because the editor judged the first draft acceptable. The Writer↔Editor loop is real and reachable (the editor's system prompt explicitly permits it), but nothing in the code forces it to happen — the model decides, based on its own judgment of draft quality, every time. Don't design monitoring or cost estimates around "the happy path we saw once"; design them around the bounds you configured (max_handoffs,max_iterations, the timeouts), since those are the only guarantees.
- The last agent in the chain will always terminate the swarm — even on a failed check — unless its prompt gives it an explicit rejection path. This is the sharpest edge in the whole design. The fact-checker's only documented way to end its turn was "don't call
handoff_to_agent," with no separate instruction for "and if a claim is actually wrong, do something else instead." That means an earlier version of this exact prompt would find a fabricated fact, potentially even say so in its own output, and still finalize the post asStatus.COMPLETED— because nothing told it that finding a problem should change what it does next. Being the terminal node in a swarm is not the same as being a quality gate; you have to write the rejection branch yourself; the pattern doesn't infer it from "fact-checker" being in the agent's name or role description.
- A node-level failure (like an expired credential) fails the whole swarm cleanly — it doesn't hang, retry silently, or corrupt partial state. When a live test run hit an
ExpiredTokenExceptionfrom Bedrock partway through the researcher's turn, the swarm did not retry or continue with stale output —blog_swarm(...)returned normally withresult.status == Status.FAILEDand an emptyresult.node_history(the researcher's turn never completed, so it never entered history). That's a reasonable failure mode to build around: checkresult.statusafter every run before trustingnode_history[-1]for the final output, since aFAILEDstatus can mean the list is empty or stops partway through your expected chain.
Best Practices Checklist
Agent design
- Give each agent a narrow, single-purpose system prompt with an explicit handoff trigger condition (e.g., "hand off to writer once you have enough sourced facts" — not just "do research").
- Explicitly instruct every agent to put its complete output — not a summary — into the
messageargument when it callshandoff_to_agent. A live run of this exact pipeline showed the editor receiving only a shared-context summary of the writer's draft and having to ask for the real text;SharedContextdoes not reconstruct full prior output for you.
- Require the editor's revision requests to be specific and actionable, not vague "make it better" notes — vague feedback is exactly what drives ping-pong loops.
- Tell the editor explicitly when to stop looping (e.g., "only send back for revision once or twice, then finalize")
- Give every "checker" or "gate" agent an explicit rejection branch, not just a pass condition. If an agent's prompt only describes what to do when everything looks fine (e.g., "finalize and don't hand off"), it structurally cannot reject anything — write the "if this fails, hand off to X with specifics" branch as its own explicit instruction, the same way you'd write the happy path.
Safety and timeouts
- Explicitly set
repetitive_handoff_detection_windowandrepetitive_handoff_min_unique_agents— both default to0(disabled), so without setting them a writer/editor loop has no early warning, only the eventualmax_handoffs/execution_timeoutceiling.
- Size
node_timeoutto your slowest realistic single turn (a full draft or a full review pass), not to a quick exchange. The default is 300.0s — that may be too tight for a long-form draft or too loose for a quick research call, so set it deliberately.
- Set
max_handoffsandmax_iterationsintentionally for a small pipeline rather than leaving both at their default of 20 — a converged researcher → writer → editor flow for one post rarely needs anywhere near 20 handoffs, and a lower ceiling fails fast instead of quietly burning a long timeout window.
Observability
- Always inspect
result.node_historyafter a run to see which agents ran, in what order, and which node produced the final output vianode_history[-1].node_id— there is no.final_responseshortcut.
- Check
result.statusbefore trusting anything else in the result. AFAILEDstatus (for example, from an expired credential or a model error mid-turn) can meannode_historyis empty or stops short of your expected final agent — don't assume the chain ran to completion just because you got aSwarmResultback.
- Don't rely on
result.resultsto reconstruct a revision loop — it's keyed by node ID and only holds each agent's latest output, so a second Writer pass overwrites the first. Usenode_history(a full ordered list) if you need to see every visit, including repeats.
- Use
swarm.stream_asyncduring development to watchmultiagent_handoffevents as they happen — it's the fastest way to spot a ping-pong loop forming before it eats your handoff budget, rather than reconstructing what happened afterward fromnode_history. Version note: this requiresstrands-agents≥1.15.0 (confirmed by inspecting package source across versions — it's absent in 1.13.0/1.14.0 and present from 1.15.0 onward); see the Streaming Events section of the official Swarm docs for the full event reference. If you're pinned to an older release, reconstruct the handoff sequence fromnode_historyafter the run completes instead.
Wrap-up
Swarm genuinely removes the tedious part of building a multi-agent pipeline: you don't write the routing logic, you don't manually pass context between steps, and you get a real, callable handoff_to_agent tool and a shared context object for free. What it doesn't do is protect you from the obvious failure mode of a research → outline → draft → review loop — an editor and writer that keep handing a draft back and forth. That protection exists in the API ( repetitive_handoff_detection_window and repetitive_handoff_min_unique_agents ), but it's off unless you turn it on. Nor does it protect you from a quieter failure mode: a "checker" agent that can only ever pass, because nobody wrote its rejection branch. Swarm will happily terminate on a bad result if that's the only behavior you gave the terminal agent.The five-role split — Researcher, Outliner, Writer, Editor, SEO/Fact-checker — is a good fit for
Swarm specifically because it has a genuine backward edge (Editor → Writer, and now Fact-checker → Writer) and a genuine termination condition (the fact-checker stops handing off only once claims actually check out), not because five roles is inherently better than three. If your use case is a strict, always-forward pipeline with no real feedback loop, a Workflow or Graph will give you more predictability for less complexity — save Swarm for the cases where "who acts next" actually needs to be a runtime decision, not a foregone conclusion. And however many agents you use, read the constructor signature closely, set your limits and detection windows on purpose rather than accepting the defaults, give every gate agent a real rejection path, and plan your "what counts as the final output" logic around node_history from the start.Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article