Building a multi-step research loop claude system means chaining planning, retrieval, and synthesis into one autonomous cycle. This tutorial implements that pattern with Claude Opus 4.5 using the Anthropic SDK and a stubbed search backend. You get runnable code and expected outputs at each stage.
Prerequisites
- Python 3.10 or newer
anthropicpackage:pip install anthropic- An Anthropic API key exported as
ANTHROPIC_API_KEY - (Optional) An OpenAI-compatible gateway such as n4n.ai if you want automatic fallback across providers; the native SDK code below swaps cleanly to a base URL change.
Architecture of the loop
A research agent that tries to do everything in one prompt loses attribution and hits context limits. Splitting the work into three roles keeps each step debuggable:
- Planner decomposes the user query into answerable subquestions.
- Researcher executes those subquestions, calling a search tool as needed.
- Synthesizer merges the collected findings into a cited brief.
Why not a single prompt
Claude Opus 4.5 handles long context, but a 50-page research dump in one message forces the model to both retrieve and write simultaneously. Separation lets you cache the planner’s output, retry the researcher on tool failure, and swap the retriever without rewriting the writing logic.
State shape
from dataclasses import dataclass, field
@dataclass
class ResearchState:
query: str
subquestions: list[str] = field(default_factory=list)
findings: dict[str, str] = field(default_factory=dict)
report: str = ""
Step 1: The planner
We force structured output by offering a single tool and locking tool_choice. This avoids fragile JSON parsing from free text.
import os
from anthropic import Anthropic
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
MODEL = "claude-opus-4-5"
def plan_subquestions(query: str) -> list[str]:
resp = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=[{
"name": "emit_plan",
"description": "Return research subquestions",
"input_schema": {
"type": "object",
"properties": {
"subquestions": {
"type": "array",
"items": {"type": "string"}
}
},
"required": ["subquestions"]
}
}],
tool_choice={"type": "tool", "name": "emit_plan"},
messages=[{
"role": "user",
"content": f"Decompose this research task into 3-5 specific subquestions: {query}"
}]
)
return resp.content[0].input["subquestions"]
Expected output for query = "What are the tradeoffs of MVCC in distributed databases?":
[
"How does MVCC work in single-node vs distributed systems?",
"What consistency anomalies can arise under MVCC in distributed settings?",
"Which production databases use MVCC and how do they differ?"
]
Step 2: The researcher with tool use
The researcher calls a web_search tool. We run a stub, return the result, and loop until Claude emits text instead of a tool call.
def web_search(query: str) -> str:
# Stub: replace with Bing, SerpAPI, or an internal vector index.
return f"Stub result for '{query}': MVCC avoids reader-writer locks but requires version cleanup."
def research_subquestion(sub: str) -> str:
messages = [{"role": "user", "content": sub}]
while True:
resp = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=[{
"name": "web_search",
"description": "Search the web for factual context",
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}
}],
messages=messages
)
if resp.stop_reason == "tool_use":
tool = resp.content[-1]
result = web_search(tool.input["query"])
messages.append({"role": "assistant", "content": resp.content})
messages.append({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool.id,
"content": result
}]
})
else:
return "".join(block.text for block in resp.content if block.type == "text")
Checkpoint output inside the loop (abridged):
> assistant requests tool_use: web_search("MVCC distributed consistency anomalies")
< tool_result: Stub result for 'MVCC distributed consistency anomalies'...
> final text: MVCC in distributed systems can exhibit write skew unless guarded by serializable snapshot isolation...
Step 3: Synthesis
The synthesizer receives only the gathered findings, not the raw tool chatter. That keeps the final brief focused.
def synthesize(state: ResearchState) -> str:
context = "\n\n".join(
f"Q: {q}\nA: {state.findings[q]}" for q in state.subquestions
)
resp = client.messages.create(
model=MODEL,
max_tokens=2048,
messages=[{
"role": "user",
"content": f"Write a concise research brief using only these findings:\n{context}"
}]
)
return "".join(b.text for b in resp.content if b.type == "text")
Step 4: Wiring it together
def run_research(query: str) -> ResearchState:
state = ResearchState(query=query)
state.subquestions = plan_subquestions(query)
for sub in state.subquestions:
state.findings[sub] = research_subquestion(sub)
state.report = synthesize(state)
return state
if __name__ == "__main__":
result = run_research("What are the tradeoffs of MVCC in distributed databases?")
print(result.report)
Running the loop
Execute python research_loop.py. A typical final brief opens like:
# Research Brief: MVCC in Distributed Databases
Multi-version concurrency control (MVCC) decouples readers from writers by
snapshotting row versions. In distributed deployments, this avoids coarse
locks but introduces version garbage and cross-node visibility gaps...
Sources:
- How does MVCC work in single-node vs distributed systems?
- What consistency anomalies can arise under MVCC in distributed settings?
- Which production databases use MVCC and how do they differ?
The multi-step research loop claude design keeps each phase isolated, so you can replace the stub search with a real retriever or add a critique pass without disturbing the planner.
Operational notes
Tool loops burn tokens fast. Set max_tokens per call and cap researcher iterations at something like five to avoid runaway cost. Log the raw tool inputs and outputs; when the researcher calls web_search with a malformed query, you want that visible in logs.
Idempotency matters. Key findings by subquestion so a crashed run can resume from the synthesizer instead of re-paying for search. If you route through an OpenAI-compatible gateway that honors client routing directives, forward Anthropic’s cache_control hints on the system prompt to reuse prompt prefixes across the planner and researcher. n4n.ai forwards those hints and meters per-token usage, which simplifies debugging a long-running multi-step research loop claude job.
Extending the loop
The pattern recurses naturally. If a subquestion returns thin findings, feed it back to plan_subquestions and run a deeper layer. Store depth in ResearchState and bail at a max depth. That turns a linear brief into a tree-backed research agent without changing the core calls.