TL;DR:

  • Burr models LLM applications as explicit state machines: a graph of actions (functions) connected by transitions, operating on a shared application state object
  • The framework ships with a local UI for tracing and replaying agent execution, built-in persistence hooks, and first-class support for streaming, async, and human-in-the-loop workflows
  • Burr is a strong fit for agent applications where you need to understand what happened during a run, replay from a checkpoint, or integrate human approval steps

Agent frameworks tend to abstract execution away from you. You declare what you want, and the framework decides how to orchestrate calls, manage state, and handle errors. This is useful until something goes wrong — and then the opacity that made development fast makes debugging slow. Burr takes the opposite stance: applications are explicit state machines where every state transition is visible, traceable, and replayable.

What Burr Is

Burr is an open-source Python framework developed by DAGWorks, the team behind Hamilton. It provides a structured way to build LLM applications and agents as directed graphs of actions, where each action reads from and writes to a shared application state.

The core model:

  • State: An immutable key-value store holding the current state of the application (conversation history, retrieved context, intermediate outputs)
  • Actions: Python functions (or classes) that take state as input and return updated state as output
  • Application: A graph that connects actions with transitions, defining which action can follow which

This is not hidden machinery — you define the graph explicitly, and you can see the full execution path in the built-in UI.

The Basics

A Burr application is built from actions decorated with @action:

from burr.core import action, State, ApplicationBuilder
from openai import OpenAI

client = OpenAI()

@action(reads=["messages"], writes=["messages"])
def process_user_input(state: State, user_message: str) -> State:
    return state.append(messages={"role": "user", "content": user_message})

@action(reads=["messages"], writes=["messages", "response"])
def call_llm(state: State) -> State:
    response = client.chat.completions.create(
        model="claude-sonnet-4-6",
        messages=state["messages"]
    )
    assistant_message = response.choices[0].message.content
    return state.append(
        messages={"role": "assistant", "content": assistant_message}
    ).update(response=assistant_message)

@action(reads=["response"], writes=[])
def display_response(state: State) -> State:
    print(f"Assistant: {state['response']}")
    return state

The reads and writes parameters declare what each action consumes and produces from state. Burr validates that the graph is consistent — if an action reads a key that no preceding action writes, it’s caught at construction time.

Building and Running the Application

from burr.core import ApplicationBuilder

app = (
    ApplicationBuilder()
    .with_actions(process_user_input, call_llm, display_response)
    .with_transitions(
        ("process_user_input", "call_llm"),
        ("call_llm", "display_response"),
    )
    .with_entrypoint("process_user_input")
    .with_state(messages=[])
    .build()
)

# Run to completion from entrypoint
action_name, result, final_state = app.run(
    halt_after=["display_response"],
    inputs={"user_message": "What is the capital of France?"}
)

app.run() executes the graph from the entrypoint, applying transitions and halting when it reaches a named action. The return includes the action that halted execution, its result, and the final state — all the information needed to inspect what happened.

Cyclic Graphs for Multi-Turn Agents

Real agent applications are cyclic: the LLM produces a response, you decide whether to continue, and if so you loop back. Burr supports this through conditional transitions:

def should_continue(state: State) -> str:
    if state["tool_calls"]:
        return "execute_tools"
    return "respond_to_user"

app = (
    ApplicationBuilder()
    .with_actions(
        get_user_input,
        call_llm_with_tools,
        execute_tools,
        respond_to_user
    )
    .with_transitions(
        ("get_user_input", "call_llm_with_tools"),
        ("call_llm_with_tools", should_continue),  # conditional transition
        ("execute_tools", "call_llm_with_tools"),  # loop back after tool execution
    )
    .with_entrypoint("get_user_input")
    .with_state(messages=[], tool_calls=None)
    .build()
)

The should_continue function receives the current state and returns the name of the next action. This is explicit: there is no hidden routing logic, and the branching condition lives in your code.

Streaming Output

For LLM applications where users expect streamed output, Burr actions can yield intermediate values:

from burr.core import action, State, StreamingResultContainer

@action(reads=["messages"], writes=["messages", "response"])
def call_llm_streaming(state: State) -> tuple[dict, State]:
    stream = client.chat.completions.create(
        model="claude-sonnet-4-6",
        messages=state["messages"],
        stream=True
    )
    
    complete_response = ""
    for chunk in stream:
        delta = chunk.choices[0].delta.content or ""
        complete_response += delta
        yield {"response_chunk": delta}, None  # yield intermediate, no state update yet
    
    # Final state update after stream completes
    return {"response": complete_response}, state.append(
        messages={"role": "assistant", "content": complete_response}
    ).update(response=complete_response)

The streaming action yields intermediate results to the caller while accumulating the full response, then commits a single state update when the stream ends.

Persistence and Checkpointing

Burr’s Persister hooks save state after each action, enabling applications to resume from the last checkpoint after a crash or timeout:

from burr.integrations.persisters.b_sqllite import SQLitePersister

persister = SQLitePersister.from_values("agent_runs.db")

app = (
    ApplicationBuilder()
    .with_actions(...)
    .with_transitions(...)
    .with_state(messages=[])
    .with_entrypoint("get_user_input")
    .with_state_persister(persister)
    .with_identifiers(app_id="session-001", partition_key="user-123")
    .build()
)

Applications with the same app_id resume from where they left off. The partition key scopes state by user, conversation, or any other dimension that makes sense for your application.

Human-in-the-Loop

Burr’s explicit halt mechanism makes human-in-the-loop workflows natural. The application halts at a specified action, your code handles the human interaction, then resumes:

@action(reads=["draft_output"], writes=["approved_output", "human_feedback"])
def human_review(state: State) -> State:
    # This action is called after the app halts here
    # The caller provides the approval via inputs
    pass

# First run: halt before human review
action_name, result, partial_state = app.run(
    halt_before=["human_review"]
)

# Present draft_output to user, collect feedback
feedback = input("Feedback (or press enter to approve): ")

# Resume from the halted state
action_name, result, final_state = app.run(
    halt_after=["finalize_output"],
    inputs={"feedback": feedback}
)

The Burr UI

Burr ships with a local tracking UI that renders the state machine graph, shows execution traces, and allows replaying runs from any checkpoint. Start it alongside your application:

pip install "burr[start]"
burr

The UI runs at http://localhost:7241 and connects to a local tracking server that captures telemetry from instrumented Burr applications. Each run appears as a trace with step-by-step state diffs — you can see exactly what each action read, what it wrote, and how state evolved across the execution.

For applications that encounter errors or produce unexpected outputs, the ability to replay from a specific checkpoint without re-running the full conversation is practically valuable during development.

When Burr Makes Sense

Burr earns its place when:

  • You need execution transparency: The state machine model means you can always answer “what did the application do and why” from the trace.
  • Your agent needs to checkpoint and resume: Persistence hooks make long-running workflows resilient to interruption.
  • Human-in-the-loop is a requirement: Explicit halt points are cleaner than wrapping callbacks around opaque execution.
  • You want framework independence for the LLM layer: Burr is agnostic about which LLM you call — actions are plain Python, so you can use any client library.

It is less compelling if you want a high-level abstraction over retrieval, tool calling, and memory management with minimal boilerplate. LangChain’s LangGraph or CrewAI give you more scaffolding for common agent patterns out of the box, at the cost of opacity. Burr gives you the graph and the tracing; the patterns are yours to implement.

References