Skip to content
datarekha

LlamaIndex Workflows (event-driven)

Build LlamaIndex applications as typed, event-driven workflows: steps, branching, loops, fan-out, fan-in, testing, and the production traps that make implicit control flow hard to debug.

12 min read Intermediate Agentic AI Lesson 38 of 78

What you'll learn

  • How typed Events trigger async Steps and make a workflow's control flow
  • How to implement branching, bounded loops, fan-out, and fan-in
  • How to choose Workflows over a simple chain, graph, or agent loop
  • How to debug missing events, infinite retries, blocking steps, and duplicate side effects
  • Why Workflows provide orchestration but not automatic durability or exactly-once execution

Before you start

It starts innocently. A customer asks, “What is the refund window for my order?”

Your application retrieves the refund policy, checks the order date, notices that the policy excerpt is vague, retrieves again with a narrower query, and finally writes an answer. If the order database is slow, the application may need to wait. If the evidence is still weak, it may ask a human.

That is no longer a single prompt. It is a small program with branches, retries, and possibly parallel work.

The first version is usually a chain:

nodes = retrieve(query)
checked = check(nodes)
if not checked.good:
    nodes = retrieve(refined_query)
answer = synthesize(nodes)

This works until the fifth exception path arrives. Then the function becomes a traffic roundabout designed by someone who dislikes road signs.

LlamaIndex Workflows provide another model. Instead of one function directly calling the next, small async functions called Steps pass typed messages called Events to one another. The runtime schedules a step when an event it understands arrives.

The event is the handoff, and its type is the control-flow signal.

The mental model: a mailbox, not a call stack

A function call says, “Run this function now, then give its result to the next function.”

An event-driven workflow says, “Here is a Retrieved event. Run whichever step handles Retrieved when the runtime schedules it.”

A Step is an async Python function registered with @step. An Event is a typed object carrying the request or result another step needs. StartEvent begins a run; StopEvent ends one and carries its result.

The runtime keeps a per-run event system. A step waits for an event matching its declared input type, processes it, and returns another event. The runtime delivers an emitted event to every eligible step whose declared input type matches it, not necessarily to one step.

Thus:

StartEvent -> RetrieveRequest -> Retrieved -> EvidenceChecked -> StopEvent

The handoff is explicit data. An event can include the query, attempt number, and retrieved nodes. A trace can record it, and a test can construct it directly without recreating the whole preceding call stack.

StartEventRetrieveCheckAnswerStopEventRetrievedAnswerReadyStopEventretry request
The event types describe the handoffs. A retry is an event sent to an earlier step, not a special loop construct.

The important word is typed. Retrieved and AnswerReady are different messages even if both contain text. The type gives the runtime a routing key and gives humans a vocabulary for the work’s state.

A small Workflow, one event at a time

Here is a refund workflow. The retrieval, scoring, and synthesis functions stand in for application code.

import asyncio

from llama_index.core.workflow import (
    Context,
    Event,
    StartEvent,
    StopEvent,
    Workflow,
    step,
)


class RetrieveRequest(Event):
    query: str
    attempt: int = 0


class Retrieved(Event):
    query: str
    attempt: int
    nodes: list


class AnswerReady(Event):
    nodes: list


class HumanReviewRequired(Event):
    query: str
    nodes: list
    coverage: float
    attempts: int


class RefundWorkflow(Workflow):
    def __init__(self, index, **kwargs):
        super().__init__(**kwargs)
        self.index = index

    @step
    async def begin(self, ev: StartEvent) -> RetrieveRequest:
        return RetrieveRequest(query=ev.query, attempt=0)

    @step
    async def retrieve(self, ev: RetrieveRequest) -> Retrieved:
        retriever = self.index.as_retriever()
        nodes = await asyncio.to_thread(retriever.retrieve, ev.query)
        return Retrieved(query=ev.query, attempt=ev.attempt, nodes=nodes)

    @step
    async def check(
        self, ev: Retrieved
    ) -> RetrieveRequest | AnswerReady | HumanReviewRequired:
        coverage = score_refund_evidence(ev.query, ev.nodes)

        if coverage < 0.80 and ev.attempt < 1:
            return RetrieveRequest(
                query=ev.query + " official policy and purchase date",
                attempt=ev.attempt + 1,
            )

        if coverage < 0.80:
            return HumanReviewRequired(
                query=ev.query,
                nodes=ev.nodes,
                coverage=coverage,
                attempts=ev.attempt + 1,
            )

        return AnswerReady(nodes=ev.nodes)

    @step
    async def answer(self, ev: AnswerReady) -> StopEvent:
        return StopEvent(result=synthesize_refund_answer(ev.nodes))

    @step
    async def review(self, ev: HumanReviewRequired) -> StopEvent:
        return StopEvent(result={
            "status": "human_review_required",
            "query": ev.query,
            "nodes": ev.nodes,
            "coverage": ev.coverage,
            "attempts": ev.attempts,
        })

# result = await RefundWorkflow(index).run(
#     query="Can I get a refund for my order?"
# )

Read it from the types, not the method order. begin emits RetrieveRequest; retrieve consumes it and emits Retrieved; check then chooses among three destinations:

  • RetrieveRequest: retry retrieval.
  • AnswerReady: synthesize an answer.
  • HumanReviewRequired: stop and escalate.

There is no direct self.check_step(...) call or explicit edge declaration. The event types form the edges.

attempt is the loop’s seat belt. The first retrieval may be retried once. If the second attempt still scores below 0.80, the workflow emits HumanReviewRequired rather than pretending the evidence is sufficient. The terminal result preserves the query, nodes, score, and attempt count.

Keep evidence scoring separate from workflow mechanics. It can be a deterministic check, a constrained LLM judge, or both. An LLM saying it is confident is not proof that the evidence contains the refund period, order date, and applicable exception.

The worked path

Suppose the customer asks about an order placed on 3 August.

The first retrieval returns five nodes containing 820 tokens. Four describe general returns, but none clearly says whether digital purchases are excluded. The evidence check returns 0.62. With 0.80 as the minimum, the workflow emits:

"refund window" + "official policy and purchase date"

The second retrieval returns five nodes containing 760 tokens. One says “refunds allowed within 30 days”; another says “digital downloads are excluded after download.” Coverage is 0.91, so the workflow emits AnswerReady.

The timing is:

  • First retrieval: 180 ms
  • Evidence check: 12 ms
  • Second retrieval: 180 ms
  • Final synthesis: 220 ms

Total: about 592 ms before network variance. A first-pass success would take about 412 ms, so the retry adds 180 ms and another retrieval charge. That may be worthwhile for a support answer, but not for autocomplete.

The score and threshold are application policy, not magic LlamaIndex features. The Workflow makes that policy visible: retrieve, measure, retry once, then answer or escalate.

Events should carry the context their consumers need. Retrieved includes the query and attempt number so check does not depend on shared mutable state. That matters when multiple runs execute concurrently.

Branching, loops, and parallel work

Branching means returning different event types. Use names that describe business meaning, such as PaymentAuthorized, PaymentNeedsReview, and PaymentDeclined, rather than BooleanResult.

A loop is an event sent to an earlier step. A production loop needs:

  1. A bound, such as two attempts.
  2. A reason for retrying.
  3. A terminal path when the bound is reached.

The terminal path should not quietly pretend success. Escalate, or return a degraded answer with an explicit warning, according to the cost of being wrong.

Fan-out starts independent work in parallel; fan-in collects it. Three independent calls taking 140 ms each take an idealized 420 ms sequentially, but about 140 ms concurrently, plus overhead. Use Context to inject events and collect results:

from llama_index.core.workflow import Context

@step
async def fan_out(self, ctx: Context, ev: StartEvent) -> None:
    ctx.send_event(PolicyRequest(query=ev.query))
    ctx.send_event(OrderRequest(order_id=ev.order_id))
    ctx.send_event(PaymentRequest(order_id=ev.order_id))

@step
async def collect(
    self, ctx: Context, ev: PolicyResult | OrderResult | PaymentResult
) -> FactsReady | None:
    results = ctx.collect_events(
        ev, [PolicyResult, OrderResult, PaymentResult]
    )
    if results is None:
        return None
    return FactsReady.from_results(results)

send_event does not call a step directly. The runtime schedules eligible consumers. collect_events does not block waiting for future events: it returns None until the expected set has arrived, then emits the combined result. Include a stable batch_id in each result when a run can have overlapping fan-outs, and verify that collected results belong together.

Fan-out is for independent work, not dependent calls or unsafe side effects. Three reads may run together; three charge-card operations should not. Also, async def does not make blocking code non-blocking. Use an async client or move synchronous work to a bounded thread pool, as the retriever does with asyncio.to_thread.

What the runtime does not do

The runtime provides scheduling and routing, not business semantics. It does not know whether evidence is relevant, a retry is safe, or an answer should reach a customer.

For a read-only RAG answer, rerunning may be harmless. For “issue a refund,” it is not. Use an idempotency key, record the provider’s result, and make the side-effecting step safe to repeat. Workflows express the approval path; they do not provide exactly-once execution for an external payment API.

Treat event payloads as small contracts: include stable identifiers, attempt counts, and the fields the consumer needs. Avoid putting database sessions or open connections in events.

Failure modes

The run hangs. A branch may emit an event with no consumer, a collector may be waiting for a result that never arrived, or a loop may reach an unhandled state. Log the run ID, event type, producer, and attempt number. Verify that every terminal branch reaches StopEvent or intentional review.

Retries continue indefinitely. Add a maximum attempt count and record why each retry happened. A retry should change the query, retrieval strategy, or tool parameters. If it is identical to the previous attempt, it is only adding cost.

Unrelated requests become slow. A synchronous SDK inside an async step blocks the event loop. Use an async client, a bounded thread pool, and timeouts around external calls.

Side effects happen twice. A provider may receive a request while your process loses the response and retries. Assume at-least-once behavior, use idempotency keys, and persist operation state outside the transient run. Also check that multiple steps are not unintentionally consuming the same event type.

When to use Workflows

Use a Workflow for real control-flow structure: bounded retries, human approval, parallel calls, fan-in, or a sequence of typed stages. Use a plain function for a short fixed sequence and a chain abstraction when the path is linear. Use an explicit state graph when named states, persistence, and visual topology are central. Use an agent when the model must choose tools or steps dynamically.

A Workflow can contain an agent, and an agent can call a Workflow. The cost is indirection: several event classes, scheduler machinery, and tracing can be harder to read than one function. That cost is justified only when the control flow needs it.

In one breath

A LlamaIndex Workflow is an event-driven program. Steps are async functions, and Events are typed handoffs. A step consumes one event type and emits another, making event types the control-flow edges. Multiple eligible consumers can receive the same event.

Branching returns different event types. A loop sends an event to an earlier step. Fan-out starts independent events concurrently; fan-in collects their results. Bound loops, make payloads complete, and treat external side effects as repeatable unless you have designed idempotency.

Workflows suit multi-stage orchestration, not every two-line chain. They provide orchestration, not durability or exactly-once side effects.

Quick check

Quick check

0/3
Q1In a LlamaIndex Workflow, what causes eligible next steps to run?
Q2A retrieval check keeps emitting a new retrieval request. What production control is essential?
Q3Transfer: three independent database and retrieval calls each take about 140 milliseconds. When could fan-out help, and what could still make it a bad idea?

Next

See how these workflows become tool-using agents: LlamaIndex agents. For more complex orchestration patterns, compare sequential, parallel, and loop workflows and the broader agent architecture.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
How do function/tool calling and LLM agents work at a high level?

Tool calling extends the LLM's output space to include structured function invocations. The model emits a JSON object naming a tool and its arguments; the runtime executes the tool and feeds the result back as a new message. An agent is a loop that repeats this cycle — observe, think, act — until the task is complete or a stopping condition is met.

In LlamaIndex, what are nodes and query engines, and how is RAG exposed as a tool to an agent?

Nodes are the retrievable pieces of source content, carrying text, metadata, and relationships. A query engine retrieves relevant nodes and synthesizes an answer; wrapping it in QueryEngineTool lets an agent choose that RAG pipeline as a tool.

What is an AI agent, and how does it differ from a single LLM call?

An AI agent is an application that lets an LLM choose and execute validated tools in a bounded loop, carrying observations and state forward until it reaches a goal or needs approval. A single LLM call produces one response or tool-call proposal and stops; it does not itself provide the loop, live-system access, memory, or side effects.

What is tool use or function calling in LLMs, and how do you design good tools for an agent?

Tool use lets an LLM emit a structured request for an external function, which the application validates, authorizes, executes, and returns to the model. Reliable tools have clear descriptions, narrow scope, strict typed inputs, least-privilege access, idempotency, and useful structured errors.

Related lessons

Explore further