Most agent tutorials hand you a loop you can't see inside. LlamaIndex Workflows 1.0 — stable since June 22, 2026 — does the opposite: the entire orchestrator is a typed event bus you wire yourself, small enough to understand in one sitting. You write steps, each step consumes one event and emits the next, and the events are the control flow. This walkthrough goes from an empty file to a research agent that fans a topic out into several concurrent searches and joins them back.
1. Install and grasp the three rules#
pip install llama-index-workflows
The whole model is three rules: a @step method consumes one typed event and returns the next; the step that accepts StartEvent runs first; the step that returns StopEvent ends the run. Nothing else decides order — the event types do.
from workflows import Workflow, step, Context
from workflows.events import StartEvent, StopEvent, Event
(Inside the full framework these are re-exported at llama_index.core.workflow; the standalone package puts them under workflows.)
2. Define your own events#
Events are just typed messages. Define one per hand-off so the runtime — and the next reader of your code — knows exactly what flows where.
class QueryEvent(Event):
query: str
class ResultEvent(Event):
query: str
text: str
3. Write the fan-out step#
The first step accepts the StartEvent, plans a few sub-queries, and emits several events at once with ctx.send_event(). It stashes the count in ctx.store so the join step later knows how many results to wait for, then returns None because it fanned out instead of handing back a single event.
class ResearchFlow(Workflow):
@step
async def plan(self, ctx: Context, ev: StartEvent) -> QueryEvent | None:
topic = ev.topic
queries = [f"{topic} pricing", f"{topic} alternatives", f"{topic} reviews"]
await ctx.store.set("expected", len(queries))
for q in queries:
ctx.send_event(QueryEvent(query=q))
return None
4. Run the branches concurrently#
The search step consumes each QueryEvent. Marking it @step(num_workers=3) lets three copies run in parallel — this is where an event bus earns its keep. Swap the stub for a real web-search or retrieval call.
@step(num_workers=3)
async def search(self, ctx: Context, ev: QueryEvent) -> ResultEvent:
text = await do_search(ev.query) # your tool call goes here
return ResultEvent(query=ev.query, text=text)
5. Join the results and stop#
The join is the one trick worth learning. ctx.collect_events(ev, [ResultEvent] n) buffers incoming events and returns None until all n have arrived — so this step is invoked once per result but only proceeds* on the last one. Then it synthesizes and returns a StopEvent, whose payload becomes the run's return value.
@step
async def synthesize(self, ctx: Context, ev: ResultEvent) -> StopEvent | None:
n = await ctx.store.get("expected")
results = ctx.collect_events(ev, [ResultEvent] * n)
if results is None:
return None
answer = summarize([r.text for r in results]) # your LLM call goes here
return StopEvent(result=answer)
6. Run it#
import asyncio
async def main():
flow = ResearchFlow(timeout=60)
answer = await flow.run(topic="vector databases")
print(answer)
asyncio.run(main())
That's a complete, concurrent, branch-and-join agent in about forty lines — and you can see every transition. Want a live view of intermediate steps? Start the run without awaiting it and iterate the stream:
handler = flow.run(topic="vector databases")
async for ev in handler.stream_events():
print("progress:", type(ev).__name__)
answer = await handler
Where to take it next#
Everything real plugs into a step: your model call, your retriever, an MCP tool, even a whole CrewAI crew. Because the orchestration is just events, you can add a @router-style branch (a step that emits different event types on different conditions) or a human-in-the-loop pause without touching the steps around it — the new event type is the only edit.
If you're still deciding whether this event-first model is the right foundation at all, read CrewAI Flows vs LlamaIndex Workflows for the agent-first alternative, and LlamaIndex Workflows vs LangGraph for the graph-first one. The engine you just built is deliberately the smallest of the three — which is exactly why it's the easiest to reason about when a run goes wrong.



