LlamaIndex Workflow — steps not firing, wrong Event types, or missing StopEvent
Workflows are LlamaIndex's primitive for event-driven orchestration. Each step takes an Event and emits an Event; the graph runs until a StopEvent. Steps not firing or workflow hanging is almost always about Event types.
Quick fix (TL;DR)
Workflow consists of @step-decorated methods. Each step's parameter type declares which Event it consumes; each step's return type declares which Event it emits. Failure modes: (a) no step consumes an emitted Event (workflow hangs); (b) no step emits StopEvent (workflow never terminates); (c) Context misused for shared state. Fix by (a) type-annotating every step parameter and return, (b) ensuring every branch ends in StopEvent, and (c) using ctx.set()/get() for shared state.Real error messages you'll see
These are the exact strings returned by the LlamaIndex framework and its integrations when this error occurs. Copy-paste-searching any of them should land on this page.
WorkflowRuntimeError: Workflow completed without producing StopEvent. Last emitted event: SearchEvent. No step consumes it. # The @step consuming SearchEvent has a typo in the parameter annotation
# Workflow starts, first step runs, but subsequent steps never fire # Silent — no error, just hangs until timeout # Cause: step returns an event with wrong type
WorkflowTimeoutError: Workflow did not produce StopEvent within 60 seconds.
Reference
Workflow event lifecycle
| Event class | Purpose |
|---|---|
StartEvent | Kicks off the workflow (has input payload) |
| Custom Events | Route between steps (user-defined subclasses of Event) |
StopEvent | Terminates the workflow (has result payload) |
InputRequiredEvent | Pause for human input |
HumanResponseEvent | Resume from human input |
<code>@step</code> annotations
| Annotation | What it means |
|---|---|
async def s(self, ev: MyEvent) | This step consumes MyEvent |
-> AnotherEvent | This step emits AnotherEvent |
-> AnotherEvent | StopEvent | May emit either |
ctx: Context | Access shared workflow state |
Root causes, ranked by frequency
Based on developer reports across LlamaIndex forums, GitHub issues, and Discord community during 2025–2026.
- 26%Wrong Event type in step annotation. Step's parameter type does not match any emitted event.
- 18%Return type not annotated. Workflow does not know which Event a step emits; routing breaks.
- 14%No branch reaches StopEvent. Workflow keeps running until timeout.
- 10%Async / sync mismatch. Non-async step in an async workflow deadlocks.
- 8%Context state race. Concurrent steps read/write ctx state; last write wins unpredictably.
- 7%Event with no subscriber. Emitted event goes to void; no step consumes it.
- 8%Multiple steps consuming same event. Sometimes intentional (fan-out); often a bug.
- 9%Custom Event without proper subclass. Plain dict instead of subclassing
Event.
Fixes — copy-paste solutions
Type-annotate every step and its return
The Workflow engine reads type annotations to decide which step handles which event. Every @step method needs (a) an event parameter annotation and (b) a return-type annotation.
from llama_index.core.workflow import ( Workflow, StartEvent, StopEvent, Event, step, Context ) from llama_index.llms.openai import OpenAI # 1) Define your custom events class RetrieveEvent(Event): query: str class GenerateEvent(Event): query: str context: str class RAGWorkflow(Workflow): def __init__(self, retriever, llm, **kwargs): super().__init__(**kwargs) self.retriever = retriever self.llm = llm # StartEvent -> RetrieveEvent @step async def start(self, ev: StartEvent) -> RetrieveEvent: return RetrieveEvent(query=ev.query) # RetrieveEvent -> GenerateEvent @step async def retrieve(self, ev: RetrieveEvent) -> GenerateEvent: nodes = await self.retriever.aretrieve(ev.query) context = "\n\n".join(n.text for n in nodes) return GenerateEvent(query=ev.query, context=context) # GenerateEvent -> StopEvent @step async def generate(self, ev: GenerateEvent) -> StopEvent: prompt = f"Context:\n{ev.context}\n\nQuestion: {ev.query}\nAnswer:" response = await self.llm.acomplete(prompt) return StopEvent(result=str(response)) # 2) Run it async def main(): workflow = RAGWorkflow( retriever=index.as_retriever(similarity_top_k=6), llm=OpenAI(model="gpt-4o-mini"), timeout=60, # give it 60s max verbose=True, # print steps and events ) result = await workflow.run(query="What is the refund policy?") print(result) import asyncio asyncio.run(main())
verbose=True prints each step as it fires and each event as it is emitted. This is the fastest way to see where the graph gets stuck.Use Context for shared state, not global variables
When multiple steps need shared state, use the Context parameter. It behaves like a namespaced dict scoped to one workflow run.
from llama_index.core.workflow import Workflow, StartEvent, StopEvent, Event, step, Context class ProcessingEvent(Event): item_id: str class ItemProcessedEvent(Event): item_id: str success: bool class MultiItemWorkflow(Workflow): @step async def start(self, ctx: Context, ev: StartEvent) -> ProcessingEvent: # Store initial state in ctx await ctx.set("total_items", len(ev.items)) await ctx.set("processed", 0) await ctx.set("errors", []) # Fan out — emit one event per item for item_id in ev.items: ctx.send_event(ProcessingEvent(item_id=item_id)) return None # no direct return; events sent above @step async def process_item(self, ctx: Context, ev: ProcessingEvent) -> ItemProcessedEvent: # Do the work try: await do_work(ev.item_id) return ItemProcessedEvent(item_id=ev.item_id, success=True) except Exception as e: errors = await ctx.get("errors", []) errors.append({"item": ev.item_id, "error": str(e)}) await ctx.set("errors", errors) return ItemProcessedEvent(item_id=ev.item_id, success=False) @step async def gather(self, ctx: Context, ev: ItemProcessedEvent) -> StopEvent | None: # Collect results with ctx.collect_events until all items are processed total = await ctx.get("total_items") events = ctx.collect_events(ev, [ItemProcessedEvent] * total) if events is None: return None # wait for more # All items processed errors = await ctx.get("errors", []) return StopEvent(result={ "processed": len(events), "succeeded": sum(1 for e in events if e.success), "errors": errors, }) # Run async def main(): wf = MultiItemWorkflow(timeout=120) result = await wf.run(items=["item_1", "item_2", "item_3"]) print(result)
ctx.collect_events is the pattern for fan-in — waiting for N events before proceeding. Without it, gather steps fire per-event which is usually not what you want.Debug hanging workflows by tracing events
Use draw_all_possible_flows to see the graph statically. Use verbose=True and per-step logging to see runtime behavior.
from llama_index.core.workflow import Workflow, draw_all_possible_flows # Static: visualize what CAN happen draw_all_possible_flows(RAGWorkflow, filename="rag_workflow.html") # Opens an HTML file with all possible event routes — inspect for orphaned events # --- Runtime: verbose + custom logging --- class DebuggableWorkflow(RAGWorkflow): @step async def start(self, ctx, ev): print(f"[start] ev={ev}") return await super().start(ctx, ev) @step async def retrieve(self, ctx, ev): print(f"[retrieve] query={ev.query}") result = await super().retrieve(ctx, ev) print(f"[retrieve] emitting {type(result).__name__}") return result # --- Timeout diagnosis --- async def diagnose(): wf = RAGWorkflow(retriever=r, llm=llm, timeout=10, verbose=True) try: result = await wf.run(query="...") except Exception as e: print(f"Workflow failed: {e}") # Print any events collected mid-run for ev in wf._events: print(f" event: {ev}") # --- Streaming events --- async def stream_events(): wf = RAGWorkflow(retriever=r, llm=llm) handler = wf.run(query="...") async for event in handler.stream_events(): print(f"streamed: {type(event).__name__}") result = await handler print(f"final: {result}") asyncio.run(stream_events())
draw_all_possible_flows visualisation catches "no step consumes this event" bugs at write time, before you ever run the workflow. Wire it into your test suite as a lint step.Prevention checklist
Ship these seven safeguards once and this error stops appearing in your logs.
- Type-annotate every
@stepparameter and return; the workflow uses these for routing. - Ensure every branch of the workflow ultimately emits
StopEvent. - Use
Contextfor shared state; never module-level variables. - Use
ctx.collect_eventsfor fan-in patterns. - Set an explicit
timeouton every workflow — never allow infinite runs. - Run
draw_all_possible_flowsat test time to catch orphaned events. - For production, enable event streaming and log every step transition.
Frequently asked questions
ctx.send_event() triggers parallel execution of the consuming steps. Use ctx.collect_events() to fan back in.InputRequiredEvent; the workflow pauses. When the user responds, the framework emits HumanResponseEvent, which a step consumes to resume.Related errors & hubs
Get the weekly AI-error digest
New fixes, provider status recaps, and one deep tutorial — every Tuesday. 8,400+ engineers.