Build Reliable LLM Workflows with ClearFlow: A Practical 3,000-Word Guide
“
Reading time: ~12 minutes
Table of Contents
-
What Exactly Is ClearFlow? -
Why Not Just Write Plain Python? -
One-Command Installation & Your First 60-Second “Hello LLM” -
The Three Core Concepts—Node, NodeResult, Flow -
End-to-End Walkthrough: A Multi-Step Data Pipeline -
Testing, Debugging & Lessons From the Trenches -
ClearFlow vs. PocketFlow: Side-by-Side Facts -
Frequently Asked Questions (FAQ) -
Where to Go Next
1. What Exactly Is ClearFlow?
ClearFlow is a tiny, type-safe, async-first workflow engine for language-model applications.
Everything you need is contained in a single 166-line file with zero runtime dependencies. You bring your own LLM client—OpenAI, Anthropic, or a local model—and ClearFlow handles routing, state immutability, and graceful termination.
Key Traits
-
Explicit routing – every outcome maps to exactly one next step -
Immutable state – each node receives a frozen copy, eliminating side effects -
Single exit rule – one mandatory None
route prevents runaway jobs -
Tiny API surface – only three concepts: Node
,NodeResult
,Flow
-
100 % test coverage – every line is exercised in CI -
Zero runtime dependencies – the install footprint is < 5 kB
2. Why Not Just Write Plain Python?
Imagine a typical script:
# pseudo-code, the naive way
result = llm.chat(messages)
if result.ok:
processed = result.text * 2
save(processed)
else:
log_error(result.error)
Problems you will meet:
3. One-Command Installation & Your First 60-Second “Hello LLM”
3.1 Install
# If you do not have uv yet
pip install --user uv # or: pipx install uv
# Install ClearFlow from PyPI
pip install clearflow
3.2 The Smallest Working Example
Below you will build a chatbot that replies “Hello!” to any incoming “Hi”.
It demonstrates state definition, node creation, flow assembly, and async execution—all in 25 lines.
import asyncio
from typing import TypedDict
from clearflow import Flow, Node, NodeResult
# 1. Define the shape of your state
class ChatState(TypedDict):
messages: list[dict[str, str]]
# 2. Create a node that appends an assistant reply
class ChatNode(Node[ChatState]):
async def exec(self, state: ChatState) -> NodeResult[ChatState]:
# In real code, call your LLM here
# reply = await openai_client.chat.completions.create(...)
reply = {"role": "assistant", "content": "Hello!"}
new_state: ChatState = {"messages": [*state["messages"], reply]}
return NodeResult(new_state, outcome="success")
# 3. Wire the nodes into a flow
chat = ChatNode()
flow = (
Flow[ChatState]("ChatBot")
.start_with(chat)
.route(chat, "success", None) # “success” terminates the flow
.build()
)
# 4. Run it
async def main():
result = await flow({"messages": [{"role": "user", "content": "Hi"}]})
print(result.state["messages"][-1]["content"]) # -> Hello!
if __name__ == "__main__":
asyncio.run(main())
Run the file and you will see:
Hello!
4. The Three Core Concepts—Node, NodeResult, Flow
4.1 Node: the smallest unit of work
A node is any class that inherits from Node[T]
and implements at least one required method.
class MyNode(Node[int]):
async def prep(self, state: int) -> int:
# Optional: validation or enrichment
return state
async def exec(self, state: int) -> NodeResult[int]:
# Required: the actual business logic
return NodeResult(state + 1, outcome="ok")
async def post(self, result: NodeResult[int]) -> NodeResult[int]:
# Optional: logging, metrics, cleanup
return result
Guidelines:
-
Keep each node stateless; it only receives a copy of the data. -
All three methods are async—feel free to use await
inside.
4.2 NodeResult: a simple envelope
NodeResult(new_state, outcome="continue")
-
new_state
is the immutable snapshot passed to the next node. -
outcome
is a plain string used by the flow router.
4.3 Flow: the declarative map
Think of a flow as a train timetable: every station (node) and every possible departure (outcome) must be listed.
flow = (
Flow[int]("MyFlow")
.start_with(validate)
.route(validate, "ok", process)
.route(validate, "bad", error_handler)
.route(process, "done", None) # single termination
.build()
)
Rules:
-
Exactly one route must end with None
—this is your single exit gate. -
A flow is itself a node; you can nest flows inside larger flows.
5. End-to-End Walkthrough: A Multi-Step Data Pipeline
Scenario:
-
Accept an integer from the user. -
If negative, print an error and stop. -
If non-negative, double it and print the result.
5.1 Define the state
class State(TypedDict):
value: int
5.2 Implement the three nodes
class Validate(Node[State]):
async def exec(self, s: State) -> NodeResult[State]:
if s["value"] >= 0:
return NodeResult(s, "valid")
return NodeResult(s, "invalid")
class Process(Node[State]):
async def exec(self, s: State) -> NodeResult[State]:
return NodeResult({"value": s["value"] * 2}, "success")
class Output(Node[State]):
async def exec(self, s: State) -> NodeResult[State]:
print("Final:", s["value"])
return NodeResult(s, "done")
5.3 Assemble and run
flow = (
Flow[State]("Pipeline")
.start_with(Validate())
.route("Validate", "valid", Process())
.route("Validate", "invalid", Output())
.route("Process", "success", Output())
.route("Output", "done", None)
.build()
)
# First run
await flow({"value": 21})
# Console: Final: 42
# Second run
await flow({"value": -5})
# Console: Final: -5
6. Testing, Debugging & Lessons From the Trenches
6.1 Unit-testing a node
Because nodes are pure async functions, you can test them like algebra:
import pytest
from clearflow import Node, NodeResult
class Increment(Node[int]):
async def exec(self, x: int) -> NodeResult[int]:
return NodeResult(x + 1, "ok")
@pytest.mark.asyncio
async def test_increment():
res = await Increment()(0)
assert res.state == 1 and res.outcome == "ok"
6.2 Debugging checklist
-
Forgot a route? The .build()
call will raiseValueError: exactly one None route required
. -
State looks wrong? Add a print
inpost
; the state is frozen, so you can safely log the entire object. -
Asyncio warning? Ensure you run the flow inside asyncio.run()
or an event loop.
7. ClearFlow vs. PocketFlow: Side-by-Side Facts
In short:
-
Choose ClearFlow when type safety, explicit control, and long-term maintainability matter. -
Choose PocketFlow when you need the shortest script possible and are comfortable with a shared state.
8. Frequently Asked Questions (FAQ)
Q1: Does ClearFlow support parallel or concurrent nodes?
A: Nodes are async, so you can use asyncio.gather
inside any node. The framework itself does not yet provide built-in parallel-split syntax.
Q2: Can I embed a flow inside another flow?
A: Yes. After calling .build()
, a flow is a node. Pass it to .route()
like any other node.
Q3: How do I plug in OpenAI or Claude?
A: Inside the node’s exec
method, call your preferred SDK and place the returned text into the new state. ClearFlow does not ship with any HTTP client.
Q4: Is Python 3.13 mandatory?
A: The examples use 3.13-style generics. On 3.10+ you can switch to from typing import List, Dict
.
Q5: How do I log in production?
A: Use the optional post
method. Because the state is immutable, you can safely serialize and persist it without worrying about concurrent mutations.
9. Where to Go Next
If you have reached this point, you already know enough to ship a small production service. Recommended next steps:
-
Read the source: clearflow.py
is 166 lines—perfect for a coffee break. -
Browse examples: the examples/chat
andexamples/structured_output
folders in the repository. -
Clone for development: git clone https://github.com/consent-ai/ClearFlow.git cd ClearFlow uv sync --group dev ./quality-check.sh
May your workflows stay predictable and your debugging sessions short.