LlamaIndex · Python
Build a research agent with LlamaIndex Workflows
Build a research agent as an event-driven LlamaIndex Workflow, with explicit steps for search, corroboration and synthesis.
What you are building
Workflow steps
plan
StartEvent to SearchEvent
search
SearchEvent to FindingsEvent
synthesise
FindingsEvent to StopEvent
Shared state
Context
ctx.set / ctx.get across steps
Services
Anthropic API
claude-opus-5
unlob
Your own index
Install
pip install llama-index-core llama-index-llms-anthropic httpxAlready have LlamaIndex Workflows wired up? TheLlamaIndex Workflows integration pagehas the connection config on its own — this page assumes you have it and gets on with building.
Step by step
Define the events
Events are the wiring. Each is a typed payload, and a step is selected by the event type it accepts — so the flow is legible from the type signatures alone.
events.pypython from llama_index.core.workflow import Event class SearchEvent(Event): query: str class FindingsEvent(Event): query: str hits: list[dict]Write the steps
A step takes an event and returns the next one. StartEvent begins the run and StopEvent ends it; everything between is yours.
workflow.pypython from llama_index.core.workflow import ( Workflow, StartEvent, StopEvent, step, Context ) class ResearchWorkflow(Workflow): @step async def plan(self, ctx: Context, ev: StartEvent) -> SearchEvent: await ctx.set("question", ev.question) return SearchEvent(query=ev.question) @step async def search(self, ctx: Context, ev: SearchEvent) -> FindingsEvent: r = await self.client.get( "https://api.unlob.com/search", headers={"x-api-key": self.api_key}, params={"q": ev.query, "min_independent_sources": 2, "limit": 8}, ) return FindingsEvent(query=ev.query, hits=r.json()["results"])Fan out across sub-questions
Here is where the event model earns its keep. Emit one SearchEvent per sub-question and the runtime runs the search step concurrently for each; collect_events waits for all of them. Returning a list does not fan out — emitting several events does.
fanout.pypython from llama_index.core.workflow import Context, step class ResearchWorkflow(Workflow): @step async def plan(self, ctx: Context, ev: StartEvent) -> SearchEvent | None: await ctx.set("question", ev.question) sub_questions = [ ev.question, f"{ev.question} — official sources", f"{ev.question} — criticism and dissent", ] await ctx.set("expected", len(sub_questions)) # Emit rather than return: each becomes its own concurrent run of # the search step. for q in sub_questions: ctx.send_event(SearchEvent(query=q)) return None @step async def synthesise(self, ctx: Context, ev: FindingsEvent) -> StopEvent | None: expected = await ctx.get("expected") results = ctx.collect_events(ev, [FindingsEvent] * expected) if results is None: return None # not all branches have landed yet hits = [h for r in results for h in r.hits] # ... synthesise over the merged, deduplicated hits return StopEvent(result=hits)
What happens when it runs
The whole thing
Complete and runnable. Set the two environment variables and it works.
"""A research agent as a LlamaIndex Workflow, backed by the unlob web index.
pip install llama-index-core llama-index-llms-anthropic httpx
export UNLOB_API_KEY=ulb_...
export ANTHROPIC_API_KEY=sk-ant-...
"""
import asyncio
import os
import httpx
from llama_index.core.workflow import (
Context,
Event,
StartEvent,
StopEvent,
Workflow,
step,
)
from llama_index.llms.anthropic import Anthropic
API = "https://api.unlob.com"
class SearchEvent(Event):
query: str
class FindingsEvent(Event):
query: str
hits: list[dict]
class ResearchWorkflow(Workflow):
def __init__(self, api_key: str, **kwargs):
super().__init__(**kwargs)
self.api_key = api_key
self.llm = Anthropic(model="claude-opus-5", max_tokens=4096)
@step
async def plan(self, ctx: Context, ev: StartEvent) -> SearchEvent:
await ctx.set("question", ev.question)
return SearchEvent(query=ev.question)
@step
async def search(self, ctx: Context, ev: SearchEvent) -> FindingsEvent:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(
f"{API}/search",
headers={"x-api-key": self.api_key},
params={
"q": ev.query,
"min_independent_sources": 2,
"limit": 8,
},
)
r.raise_for_status()
return FindingsEvent(query=ev.query, hits=r.json()["results"])
@step
async def synthesise(self, ctx: Context, ev: FindingsEvent) -> StopEvent:
question = await ctx.get("question")
# independent_sources travels with each hit, so the model can weight
# a well-corroborated claim without a second round trip.
sources = "\n".join(
f"- [{h['independent_sources']} sources] {h['title']} ({h['url']})\n"
f" {h['snippet']}"
for h in ev.hits
)
resp = await self.llm.acomplete(
f"Question: {question}\n\nSources:\n{sources}\n\n"
"Answer with citations. Flag any claim resting on a single source."
)
return StopEvent(result=str(resp))
async def main() -> None:
wf = ResearchWorkflow(api_key=os.environ["UNLOB_API_KEY"], timeout=120)
result = await wf.run(
question="What changed in the EU AI Act GPAI rules in 2026?"
)
print(result)
if __name__ == "__main__":
asyncio.run(main())What will go wrong
The failures that cost an afternoon rather than a minute, because they produce something that looks like it is working.
A step whose emitted event type nothing consumes makes the workflow hang until the timeout rather than failing fast. If a run stalls, check that every event type has a consumer.
Set `timeout` on the Workflow. The default is short enough that a real research run with several searches will be cut off mid-flight.
Context is shared mutable state across steps and is easy to overuse. Pass data on the event where you can — it keeps the flow readable and the steps independently testable.
To fan out, emit several events from one step and use `ctx.collect_events` to gather them. Returning a list does not fan out; it just returns a list.
Where this agent can go next
Every result the agent receives is a node in the coverage graph. These are the tools you can add to it without leaving LlamaIndex Workflows — each is one more entry in the same tools list.
Frequently asked questions
Do I need a LlamaIndex vector store for this?
No. Workflows are a standalone orchestration primitive — this agent has no local index at all, because unlob is the index. That is the usual shape when you are searching the open web rather than your own documents.
How do I search several sub-questions at once?
Emit one SearchEvent per sub-question from the plan step, then collect them with ctx.collect_events in the synthesise step. The runtime runs the search step concurrently for each, which is where the event model earns its keep.
You need a key to run this
10,000 requests a month on the free tier, no card. Enough to build the agent and put a real evaluation set through it.