LlamaIndex Workflow — @step decorator, Event types, Context state errors (2026) — Fix Guide (2026) | AI Error Hub
Home Providers LlamaIndex Workflow errors
LlamaIndex Workflow · @step + Event Severity: Medium HTTP n/a

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.

By Sana K. · Cloud AI Reliability Engineer Published 2026-07-26 Verified 2026-07-26

Quick fix (TL;DR)

Resolution: A 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.

Workflow hangs — no step consumes emitted event
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
Step never fires
# 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
Timeout — no StopEvent emitted
WorkflowTimeoutError: Workflow did not produce StopEvent within 60 seconds.

Reference

Workflow event lifecycle

Event classPurpose
StartEventKicks off the workflow (has input payload)
Custom EventsRoute between steps (user-defined subclasses of Event)
StopEventTerminates the workflow (has result payload)
InputRequiredEventPause for human input
HumanResponseEventResume from human input

<code>@step</code> annotations

AnnotationWhat it means
async def s(self, ev: MyEvent)This step consumes MyEvent
-> AnotherEventThis step emits AnotherEvent
-> AnotherEvent | StopEventMay emit either
ctx: ContextAccess 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

Fix #1

Type-annotate every step and its return

Annotations are the workflow's routing table.

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.

basic_workflow.py
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())
For workflow debugging, 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.
Fix #2

Use Context for shared state, not global variables

Context is workflow-scoped, thread-safe, and observable.

When multiple steps need shared state, use the Context parameter. It behaves like a namespaced dict scoped to one workflow run.

workflow_context.py
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.
Fix #3

Debug hanging workflows by tracing events

Workflow visualization + step-by-step logging.

Use draw_all_possible_flows to see the graph statically. Use verbose=True and per-step logging to see runtime behavior.

debug_workflow.py
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())
The 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 @step parameter and return; the workflow uses these for routing.
  • Ensure every branch of the workflow ultimately emits StopEvent.
  • Use Context for shared state; never module-level variables.
  • Use ctx.collect_events for fan-in patterns.
  • Set an explicit timeout on every workflow — never allow infinite runs.
  • Run draw_all_possible_flows at test time to catch orphaned events.
  • For production, enable event streaming and log every step transition.

Frequently asked questions

Both are event-driven state machines. LangGraph uses explicit node/edge definitions; LlamaIndex Workflow uses Event types as routing keys. Similar power; different aesthetics.
Yes — a step can emit an event that routes back to itself or to an earlier step. Ensure the loop has a termination condition that eventually emits StopEvent.
Yes — a step emitting multiple events via ctx.send_event() triggers parallel execution of the consuming steps. Use ctx.collect_events() to fan back in.
Emit InputRequiredEvent; the workflow pauses. When the user responds, the framework emits HumanResponseEvent, which a step consumes to resume.
Not replacement — complement. Query engines are still the primitive for RAG. Workflows are the primitive for orchestration of multiple query engines, agents, and custom logic.

Get the weekly AI-error digest

New fixes, provider status recaps, and one deep tutorial — every Tuesday. 8,400+ engineers.