cCarebun Reading‹ All reading
Carebun Interactive/Agents whiteboardpattern catalogueglossary Study view

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.

11 min read

On this page
  1. Text as the source of truth
  2. Forward edges wait, back edges jump
  3. Width known only at run time: fanout
  4. Sub-graphs as nodes
  5. Drawing it
  6. Designing from the output contract
  7. Priya designs backwards
  8. Try it yourself
  9. Common questions
  10. Carry this
  11. Check yourself

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, c is an edge; a ?label-> b is a router. Six lines draw most graphs.
  • Forward edges are dependencies, back edges are jumps, routers are jumps decided at run time.
  • fanout for width known only at run time; sub-graphs for composition; as_node for 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.