AWS Builder Center
Blog-Writer Agent using Swarm Pattern

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_agent  again — 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 of  max_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 than  execution_timeout  — a slow individual agent can blow this budget long before the swarm-level timeout is even close.
  •  repetitive_handoff_detection_window  and  repetitive_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 Agent
from strands.multiagent import Swarm
from strands.models import BedrockModel

model = 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 list
entry_point=researcher, # explicit for clarity, even though it matches list order
max_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 handoffs
repetitive_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 / FAILED
print(result.execution_count) # number of node executions
print(result.execution_time) # wall-clock time for the run
print(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.
  1. Entry-point ordering silently matters, even though the pattern is decentralized. If  entry_point  isn't passed explicitly, the swarm starts with whichever  Agent  is first in your  nodes  list. Reorder  nodes=[writer, researcher, editor]  by accident and your pipeline kicks off with the writer having nothing to write about. Pass  entry_point=  explicitly in real code rather than relying on list order to match your intent.
  1. 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_window  and  repetitive_handoff_min_unique_agents  default to  0 , 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 until  max_handoffs  finally cuts it off.
  1. There is no  result.final_response  — decide up front how you'll extract the finished post.  SwarmResult  only adds  node_history  on top of the base result fields; no convenience "give me the answer" attribute exists. Treat the finished blog post as whatever the last node in  node_history  produced (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.
  1.  max_handoffs  and  max_iterations  look 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 lower  max_handoffs  to control cost while leaving  max_iterations  at 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.
  1.  node_timeout  and  execution_timeout  bound 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. Tune  node_timeout  for your slowest individual agent, not just your overall time budget.
  1. The  handoff_to_agent  tool is injected automatically, and it will conflict with a tool of the same name. Because Strands registers a real  handoff_to_agent  tool into every agent's tool registry when the  Swarm  is 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 a  Swarm .
  1. 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_agent  to 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 the  message  argument of  handoff_to_agent  — don't assume  SharedContext  reconstructs it for you.
  1.  result.results  is 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.results  is 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 from  node_history  plus whatever you log yourself, not from  result.results  alone.
  1. 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_factchecker  with 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.
  1. 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 as  Status.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.
  1. 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  ExpiredTokenException  from Bedrock partway through the researcher's turn, the swarm did not retry or continue with stale output —  blog_swarm(...)  returned normally with  result.status == Status.FAILED  and an empty  result.node_history  (the researcher's turn never completed, so it never entered history). That's a reasonable failure mode to build around: check  result.status  after every run before trusting  node_history[-1]  for the final output, since a  FAILED  status 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  message  argument when it calls  handoff_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;  SharedContext  does 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_window  and  repetitive_handoff_min_unique_agents  — both default to  0  (disabled), so without setting them a writer/editor loop has no early warning, only the eventual  max_handoffs / execution_timeout  ceiling.
  • Size  node_timeout  to 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_handoffs  and  max_iterations  intentionally 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_history  after a run to see which agents ran, in what order, and which node produced the final output via  node_history[-1].node_id  — there is no  .final_response  shortcut.
  • Check  result.status  before trusting anything else in the result. A  FAILED  status (for example, from an expired credential or a model error mid-turn) can mean  node_history  is empty or stops short of your expected final agent — don't assume the chain ran to completion just because you got a  SwarmResult  back.
  • Don't rely on  result.results  to 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. Use  node_history  (a full ordered list) if you need to see every visit, including repeats.
  • Use  swarm.stream_async  during development to watch  multiagent_handoff  events 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 from  node_history . Version note: this requires  strands-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 from  node_history  after 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.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article