Part III · Building graphs
Chapter 11
Drawing and designing a graph
The diagram is the code: a six-line text form, back edges that jump instead of waiting, map nodes for run-time width, sub-graphs, export to Mermaid and excalidraw, and a design method that starts from the output contract.
On this page
In this chapter, we will learn to write a graph the way it is drawn, and to design one
from the end backwards. agl has a text form where a -> b, c is an edge and
a ?label-> b is a router, so the picture on the whiteboard and the code in the file are the
same object. We will learn the one subtlety in it, back edges, meet fanout and sub-graphs,
draw any graph as Mermaid or an excalidraw file, and follow a five-step method from output contract to
running graph. By the end you will draw a graph in text and know which arrows are dependencies and which
are jumps.
Text as the source of truth
g = Graph.from_text("""
START -> plan
plan -> write_a, write_b, write_c # fan-out: one wave, three parallel loops
write_a, write_b, write_c -> edit # fan-in: edit waits for all three
edit -> review
review ?pass-> END # router: a function of the state picks the label
review ?fail-> edit # back edge: re-trigger edit with feedback
""", nodes={"plan": plan_node, "write_a": ..., "edit": edit_node, "review": review},
routers={"review": lambda s: "pass" if s["ok"] else "fail"}, name="article")
Three rules. a, b -> c, d adds an edge from every source to every target.
a ?label-> b, c adds a router label on a whose targets are b and
c; the route function comes from routers. Anything after # is a
comment. Every name must exist in nodes, or the parser refuses before any model call is paid
for. The parser is thirty lines; its value is that a reviewer or a student can read the graph without
reading Python, and that the same text can be drawn.
Forward edges wait, back edges jump
An edge that closes a cycle re-triggers its target; it is not an input the target waits for. Consider a repair path:
START -> write write -> assemble assemble -> build_check build_check ?ok-> END build_check ?fail-> fix fix -> assemble <- closes a cycle: a back edge
If the engine counted fix as an input of assemble, then assemble
would wait for fix to fire at least once, which happens only after a failure, which can only
happen after assemble runs. The graph would stop after write with no error. So
the engine finds back edges first, with a depth-first search from START (follow each arrow as far as it
goes before trying the next one, like walking one corridor to its end before opening the next door) that
follows edges and router targets, and marks any edge pointing to a node still on the search stack (a node
you are still inside of on the current walk):
def back_edges(self) -> set[tuple[str, str]]:
out = defaultdict(list)
for s, d in self.edges: out[s].append(d)
for s, (_route, targets) in self.routers.items():
out[s].extend(d for ds in targets.values() for d in ds)
back, done, stack = set(), set(), []
def dfs(n):
stack.append(n)
for d in out[n]:
if d in stack: back.add((n, d))
elif d not in done: dfs(d)
stack.pop(); done.add(n)
dfs(START)
return back
deps[d] = {s for (s, d) in edges if (s, d) not in back_edges}
The search walks START, write, assemble, build_check, through the router to fix, along
fix -> assemble, and finds assemble on the stack: a back edge. It still fires
assemble when fix finishes; assemble no longer waits for it. Three
kinds of arrow, then: forward edges are dependencies, back edges are jumps, routers are jumps decided at
run time. Once those are distinct, every graph on the whiteboard reads unambiguously, including the loop
board itself, which is a one-node graph with a back edge.
Width known only at run time: fanout
def write_one(chapter: dict, state: dict) -> dict:
r = writer.run(f"Book: {state['plan']['title']}\nChapter {chapter['n']}: {chapter['title']} ...")
return {**chapter, "html": r.answer, "ok": r.passed}
g.node("write", fanout(write_one, over="plan_chapters", into="chapters", workers=6))
The outline node decides how many chapters there are; the write node runs one writer per chapter, up to six at a time, and collects the results into one list under one key. To the engine it is one node that happens to be busy inside. To the author it is the map step of map-reduce, and the node after it is the reduce.
Sub-graphs as nodes
inner = Graph("inner").node("x", lambda s: {"y": s["a"] * 2}).edge(START, "x")
outer = Graph("outer").node("in", inner).node("z", lambda s: {"z": s["y"] + 1}).edge(START, "in").edge("in", "z")
outer.run({"a": 5}) # y = 10, z = 11
The inner graph receives a copy of the state and returns the keys it added. A "research one company"
graph becomes one node of a "research fifty companies" graph, through fanout, without
changing a line inside it.
Drawing it
uv run agl draw examples/graph_fanout.py --mermaid
uv run agl draw examples/graph_fanout.py --excalidraw docs/article.excalidraw
flowchart LR START(( )) END(( )) plan[plan] write_a([write_a]) write_b([write_b]) write_c([write_c]) edit([edit]) review[review] START --> plan plan --> write_a plan --> write_b plan --> write_c write_a --> edit write_b --> edit write_c --> edit edit --> review review -- pass --> END review -- fail --> edit
Rounded shapes are loops, square ones plain functions, double-bordered ones sub-graphs. The excalidraw export lays nodes out in columns ordered so that every forward arrow points right (ignoring back edges, so repair nodes sit after the node they repair), draws routers as dashed arrows, and writes a file you can open and keep editing. The whiteboard this book follows is an excalidraw file; the graphs it inspired can be drawn back into the same format.
Designing from the output contract
Start at the end. The strongest evaluator is the program that will consume the output, so design backwards from what it accepts.
1. output contract what exactly must exist when the graph ends? a folder, a record, a reply. who checks it?
write that check as a function or a command. it is the last node.
2. stages what has to be true before that check can pass? name the stages; each is a node.
3. independence which stages do not need each other? they share a wave. which repeat per item? fanout.
4. decisions where does the path depend on the result? a router. what happens on fail? a repair node
that targets only what failed, with a back edge.
5. toolboxes give each node only the tools its stage needs. readers read; writers write; checkers run.
Then write the nodes. Most are a loop with a prompt template and an evaluator; some are plain Python; a few are another graph. Name every output key after the node that writes it. Run with stub nodes first (functions that return their own name) and read the path; the engine is fast enough that this costs nothing and catches every wiring mistake before a model is called.
Priya designs backwards
Priya's output contract is her site's build check: a book folder that build.py --check
accepts. That is her last node. Before it can pass, chapters must exist and be assembled into a
folder: two stages. Chapters do not depend on each other: a fanout. Each chapter must meet the site's
style rules before it is written to disk: a validator as each writer's evaluator. If the build fails,
only the chapters it names should be rewritten: a fix node on the fail path, with a back edge to
assemble. Writers get no tools; the build check gets a subprocess. She runs it with stubs, reads the path
outline, write, assemble, build_check, and only then plugs in the model.
Try it yourself
Write the whiteboard's graph board in text: a prompt node, six agent loops, a review and a final node,
with a router on review that sends a fail back to all six. Build it with stub nodes that return their own
name as a key, run it, print state["_path"]. Export it with excalidraw() and
open the file next to the original board.
Common questions
Why a text form and not just Python calls?
Both exist; the text calls the Python. The text form is for reading: a reviewer, a student or a future you sees the shape in six lines.
Could the engine avoid back edges by requiring routers for every loop-back?
It
could, and some engines do. It forbids the natural fix -> assemble line. The search costs
ten lines and lets people draw what they mean.
What if a graph has a cycle with no router at all?
It would spin.
max_visits stops each node after three runs and max_steps stops the run, and the
errors list says which node hit the ceiling. A cycle should always have a condition; draw a router.
Can fanout branches have different shapes?
fanout maps one function over a list. If the branches do different kinds of work, name them as separate nodes in a static fan-out, which also lets the trace show them separately.
Does the excalidraw export round-trip?
One way. The export writes a file Excalidraw can open and edit. Reading a drawing back into a graph is a parser and a naming convention away, and an exercise in Chapter 18.
Carry this
a -> b, cis an edge;a ?label-> bis a router. Six lines draw most graphs.- Forward edges are dependencies, back edges are jumps, routers are jumps decided at run time.
fanoutfor width known only at run time; sub-graphs for composition;as_nodefor loops.- Design backwards from the output contract; the program that consumes the output is the last node.
- Run with stub nodes first and read the path before any model is called.
Check yourself
1. Why must fix -> assemble not count as an input of assemble?
Answer
fix only runs after a failure; if assemble waited for fix to fire once, it could never run the first time and the graph would stall with no error.
2. How does the engine tell a back edge from a forward edge?
Answer
A depth-first search from START over edges and router targets; an edge to a node still on the search stack closes a cycle and is a back edge.
3. What is the first step of designing a graph, and why?
Answer
Write the output contract as a check: the program that will consume the output is the strongest evaluator, and it becomes the last node that the model cannot skip.