01. Why Event-Driven Workflows
Think of a workflow like a factory assembly line: each worker station receives a specific type of package, does a task on it, and then sends it to the next station. The purpose is to turn a messy, long AI task into a clear series of small steps that automatically trigger each other. In practice, you mark each station using a special Python label called a @step decorator. The first station receives a StartEvent package (the initial request), and the last station hands back a StopEvent package (the final answer). Each station’s input and output package types are like blueprints that define which station connects to which. Before the line ever runs, the system automatically checks that every package produced is actually received by some later station and that no station waits for a package that nobody sends—this validation catches wiring mistakes early. The trickiest part is that sometimes a station might send packages dynamically on the fly rather than only through its blueprint. For example, a station can call ctx.send_event to shoot out packages during a loop. A static check would flag those as missing because it can’t see the dynamic sends. To handle this, you can add skip_graph_checks=["reachability"] to the station’s label, telling the system to trust the dynamic behavior. Without this subsystem, a beginner trying to build loops or branches would face validation errors complaining about “unreachable” steps—a confusing failure that stops the whole design cold.
A LlamaIndex Workflow is an application divided into steps. Each step receives an event and returns another event. The step decorator marks a method as a step. The event types a step accepts and returns describe the connections of the flow. The logic inside each step is written in regular Python.
Workflows can be validated before they run. The validation checks that every produced event has a consumer. It also checks that every consumed event has a producer. That catches design problems early.
There is a trade-off between two API styles. The type-first style uses typed inputs and typed returns. It is easier to visualize and validate. The context-based style uses methods like context dot send event for dynamic events. That gives more flexibility but requires more bookkeeping. You can also use context dot collect events to wait for a specific set of events.
Start events and stop events are special. A start event holds arbitrary fields that you pass to the run method. A stop event ends the workflow and returns a result. You can subclass both for stronger type safety.
Overall, the pattern keeps the flow clear through event types while letting you write plain Python inside each step.
A LlamaIndex Workflow is built from steps that receive and return events, with the @step decorator.
class JokeFlow(Workflow):
llm = OpenAI(model="gpt-4.1")
@step
async def generate_joke(self, ev: StartEvent) -> JokeEvent:
topic = ev.topic
prompt = f"Write your best joke about {topic}."
response = await self.llm.acomplete(prompt)
return JokeEvent(joke=str(response))
@step
async def critique_joke(self, ev: JokeEvent) -> StopEvent:
joke = ev.joke
prompt = f"Give a thorough analysis and critique of the following joke: {joke}"
response = await self.llm.acomplete(prompt)
return StopEvent(result=str(response))
In a LlamaIndex workflow, execution proceeds through an ordered sequence of steps connected by events. The mechanism starts when .run() is called: keyword arguments become fields of a StartEvent, which is automatically emitted to the first step marked with the @step decorator. Each step receives an event, performs logic in plain Python, and returns a new event; that returned event triggers the next step whose type annotation accepts it. This continues until a step returns a StopEvent, which immediately terminates the workflow and returns the value in its result parameter. On failure—for example, if a step raises an unhandled exception—the workflow halts and the exception propagates to the caller of .run(). The framework also allows early validation: calling workflow.validate() before the first run checks that every produced event has a consumer and every consumed event has a producer, catching design problems such as orphaned events or missing terminal steps.
The central invariant the design preserves is the consumer–producer guarantee enforced by static validation. Before execution, the @step decorator infers the input and output event types from the method’s type annotations; the workflow then verifies that for every event type emitted by a step, there exists exactly one step (or multiple, via branching) that accepts it, and that every step’s input event is produced by some other step. This guarantees that no event is lost and no step is starved, ensuring the workflow’s edges are sound without relying on runtime checks. If a step intentionally dispatches events dynamically via ctx.send_event, the static check can be skipped with skip_graph_checks=["reachability"] on that step, but the default invariant remains strict graph connectivity.
The key trade-off is between type‑first static validation and fully dynamic flexibility. The design explicitly rejects the previous DAG pattern used by other frameworks, where loops and branches had to be encoded into graph edges—making them hard to read and understand—and where passing data between nodes created complexity around optional values and parameter passing. By adopting an event‑driven, typed‑step model, the workflow system forces the developer to declare event types explicitly, which enables automatic validation and eliminates the “complexity hidden in edges” cost. The alternative rejected is an entirely untyped, dynamic graph that offers flexibility at the cost of runtime surprises and difficult debugging. This trade‑off pays off because the static checks surface errors before a single LLM call is made, and the plain Python logic inside steps remains natural to write and read.
A concrete failure mode occurs when a step produces an event that no other step consumes. For example, a step generate_joke returns JokeEvent, but no step in the workflow has a type annotation accepting JokeEvent. The signal an operator would see is a validation error when calling workflow.validate()—the framework raises an exception stating that the event has no consumer. If validation is skipped, the workflow would simply hang at runtime because the event is never handled, and the operator would observe a timeout (the timeout parameter passed to Workflow constructor, e.g., timeout=60, would eventually raise an asyncio.TimeoutError). This explicit failure signal, combined with the static check, allows operators to catch missing edges long before production deployment.
A step returns an event type that has no consumer
- Trigger: A developer writes a step whose return annotation is an event type (e.g.,
MyCustomEvent) that no other step in the workflow accepts as input. - Guard:
Workflow.validate()— the validation routine that “checks that every produced event has a consumer”. If validation is run before execution, the workflow aborts immediately. - Posture: fail-hard — validation prevents the workflow from starting.
- Operator signal: A validation error message, such as “A step produces an event that nobody consumes”. The error identifies the unconnected event type.
- Recovery: The developer must either add a step that consumes that event, change the return type to a type that is consumed, or, if the event is intentionally unreachable, apply
skip_graph_checks=["reachability"]to the step and re‑run validation.
The workflow has no terminal event
- Trigger: All steps in the workflow return custom event types (e.g.,
JokeEvent,AnotherEvent) and none returnsStopEventor a subclass ofStopEvent. - Guard:
Workflow.validate()— the same validation routine that checks “the workflow has [a] terminal event”. This failure is caught statically. - Posture: fail-hard — validation fails, and the workflow does not execute.
- Operator signal: A validation error, specifically “The workflow has no terminal event”.
- Recovery: Add a step that returns
StopEvent(or a customStopEventsubclass) to end the workflow. The developer can then re‑run validation.
A step consumes an event that is never produced (static mismatch)
- Trigger: A step’s input annotation declares an event type that is never returned by any other step. This often happens when the step is intended to receive an event sent dynamically via
ctx.send_event, but static analysis does not see a producer. - Guard:
Workflow.validate()— it checks that “every consumed event has a producer”. To bypass the static check, the developer can addskip_graph_checks=["reachability"]to the step decorator. - Posture: fail-hard if validation is run without
skip_graph_checks; the workflow is aborted before start. If validation is skipped orskip_graph_checksis used, the workflow may run but the step never receives the event, leading to a deadlock. - Operator signal: A validation error: “A step consumes an event that is never produced”. At runtime, if no guard is in place, the operator sees the workflow hang until the configurable
timeoutis reached. - Recovery: Either add a step that produces the missing event statically, or add
skip_graph_checks=["reachability"]and verify that the event is indeed sent viactx.send_eventat the correct time. If a timeout occurs, increasetimeoutor fix the missing send.
A dynamic event sent via ctx.send_event has no consumer at runtime
- Trigger: A step uses
ctx.send_event(...)to emit an event type that no step in the workflow is annotated to accept. Because the event is sent dynamically, static validation does not necessarily catch it (or it may be bypassed). - Guard: None shown in the source. The source only notes that “a step produces an event that nobody consumes” is a symptom but does not describe any runtime handler for unhandled dynamic events.
- Posture: fail-soft — the event is lost (no handler processes it), and the workflow continues with the rest of its steps. Downstream operations that depend on that event will never execute.
- Operator signal: Silent absence of expected work. If a step uses
ctx.collect_eventsto wait for that event, the collection may never complete, resulting in a timeout. Otherwise, the workflow completes but with missing results. - Recovery: The developer must add a step that accepts the event type, or correct the
ctx.send_eventcall to emit a handled event. Manual inspection of the event graph is required.
A step returns a list of events but the downstream consumer expects a single event
- Trigger: A step is annotated to return
list[SomeEvent](e.g.,-> list[Retrieved]), but the next step declares its input as a single event typeSomeEventinstead oflist[SomeEvent]. - Guard:
Workflow.validate()— the validation routine checks that “produced events have consumers”. A type mismatch betweenlist[SomeEvent]andSomeEventwill cause validation to flag that the produced event (the list type) is not consumed by any step, or that the consumer’s input type does not match. - Posture: fail-hard — validation fails, preventing execution.
- Operator signal: A validation error similar to “A step produces an event that nobody consumes”, or an explicit type‑mismatch error (the source does not give the exact message, but the symptom matches).
- Recovery: Change the downstream step’s input to accept
list[SomeEvent](e.g.,ev: list[SomeEvent]), or have the producing step emit individual events one at a time usingctx.send_eventso that the consumer can accept a single event.
In Why Event-Driven Workflows, what triggers A step returns an event type that has no consumer — and how is it caught?
Show answer
A developer writes a step whose return annotation is an event type (e.g., `MyCustomEvent`) that no other step in the workflow accepts as input.
From the research: Retrieval practice / testing effect — Testing (quizzing) boosts classroom learning: A systematic and meta-analytic review (2021)
In Why Event-Driven Workflows, what triggers The workflow has no terminal event — and how is it caught?
Show answer
All steps in the workflow return custom event types (e.g., `JokeEvent`, `AnotherEvent`) and none returns `StopEvent` or a subclass of `StopEvent`.
From the research: Retrieval practice / testing effect — Testing (quizzing) boosts classroom learning: A systematic and meta-analytic review (2021)