Skip to content
unlob

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

Your side of the API. unlob is one HTTPS service among the others — nothing here is specific to how our index works, so this agent survives a change of framework.

Install

Dependenciesbash
pip install llama-index-core llama-index-llms-anthropic httpx

Already 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

  1. 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]
  2. 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"])
  3. 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

YouWorkflowunlobClauderun(question)plan -> SearchEventGET /searchhits -> FindingsEventsynthesise promptanswerStopEvent.result
One turn of the loop. Search returns metadata only, so the agent spends a few hundred tokens deciding rather than tens of thousands reading.

The whole thing

Complete and runnable. Set the two environment variables and it works.

research_workflow.py — completepython
"""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.

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.

relatedNear-duplicatescorroborateIndependent sourcesauthoritiesAuthoritative hostsdossierThe entity briefpathA second documentassemble_contextA packed context setA search resultwhat your agent has
The two marked in blue are the tools this tutorial wires up. The other four are the same shape.

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.