Defining a graph
A graph is JSON: a list of nodes and a list of edges. That is not an implementation detail, it is the reason this package can exist at all — what a version is gets decided over that blob and the agent definitions it points at, so a graph written in Python and a graph dragged around the canvas are the same kind of thing and get the same name.
You can write that JSON by hand. The DSL exists so you do not have to.
Two operators
Section titled “Two operators”from chatty_lab import Input, Llm, Output, build
flow = Input() >> Llm("draft", model="gpt-4o", prompt="Rewrite: {{input}}") >> Output()graph = build(flow)-
>>chains.a >> bmeans b runs after a, on what a produced. It builds one flow edge. -
|forks.a | bmeans a and b both read the same upstream, and run in parallel. Which is not a metaphor: branches with no dependency on each other land on the same topological level and are scheduled together. -
build()materialises. Nothing exists until then — a topology can be assembled in a loop, held in a variable, and inspected before it costs anything.
.to() is the method spelling of >>, for keyboards that fight the operator and for
lines where the word reads better:
flow = Input().to(reader).to(scorer).to(Output())Forking and collecting
Section titled “Forking and collecting”from chatty_lab import Input, Llm, Merge, Eval, Output, build
def seat(id: str, persona: str, model: str): return Llm(id, model=model, prompt="{{dataset_input}}", system=persona)
panel = ( Input() >> ( seat("internist", "You are a general internist.", "gpt-4o") | seat("pharmacologist", "You are a clinical pharmacologist.", "claude-sonnet-5") | seat("surgeon", "You are a surgeon.", "gemini-2.5-pro") ) >> Merge("gather", strategy="concatenate") >> Llm("chair", model="gpt-4o", prompt="Three opinions:\n\n{{gather}}\n\nDecide.") >> Eval("score", metrics=["exact_match"]) >> Output())Every branch of a fork feeds whatever comes after it, so the >> Merge(...) above wires
three edges into one node. .collect() is the same call under the name a reader means:
(Input() >> (a | b | c)).collect(Merge("gather", strategy="concatenate"))graph LR IN[input] --> I[internist] IN --> P[pharmacologist] IN --> S[surgeon] I --> G[gather] P --> G S --> G G --> C[chair] C --> E[score] E --> OUT[output]The rule that keeps the notation honest
Section titled “The rule that keeps the notation honest”>> and | build flow edges, and only flow edges. Everything else a graph can
express — a panel’s roster, a node’s failure branch — is named on the node that owns the
relationship and is never wired with an operator.
Rosters: members=
Section titled “Rosters: members=”Panel and Orchestrator convene agent definitions. Pass them as members=, and they
become Membership edges — structural references the scheduler does not follow.
from chatty_lab import Agent, Panel
Panel( "board", model="gpt-4o", max_rounds=3, members=[ Agent("internist", definition="8f21…"), Agent("pharmacologist", definition="c40b…"), Agent("surgeon", definition="1d9e…"), ],)Only agent nodes can be convened. A member of any other kind is refused at build().
Failure branches: on_fail=
Section titled “Failure branches: on_fail=”from chatty_lab import Llm, Retry
Llm( "answer", model="gpt-4o", prompt="{{dataset_input}}", on_error=Retry(2, continue_on_fail=True, timeout_secs=240), on_fail=Llm("fallback", model="gpt-4o-mini", prompt="{{dataset_input}}"),)Retry() is the error policy — retries, continue_on_fail, timeout_secs.
continue_on_fail is the one worth knowing: without it a single expert timing out takes
the whole row down with it. on_fail= is the node the Fail edge points at.
What build() refuses
Section titled “What build() refuses”TopologyError is raised here rather than surfaced from the server, because the
interesting cases are all accepted by the schema and simply do not run.
| Shape | Why it is refused |
|---|---|
An agent node in the flow | It is inert when executed. A chain through one silently does nothing — the failure that costs an afternoon. Convene it with members=, or use SubAgent to run one on data. |
A Panel or Orchestrator with no members | A debate with no participants. |
A non-agent node in members= | Only definitions can be convened. |
Two different nodes with the same id | Ids are unique within a graph. |
The same step object reached twice is not an error — it is one node, wired from both places, sitting at the deepest level anything feeds it from. That is how a diamond is spelled:
fetch = Tool("fetch", tool_name="web_search")summary = Llm("summary", model="gpt-4o", prompt="{{fetch}}")critique = Llm("critique", model="gpt-4o", prompt="{{fetch}}")
flow = Input() >> fetch >> (summary | critique) >> Merge("both", strategy="json_object")Exposing parameters to experiments
Section titled “Exposing parameters to experiments”An experiment sweeps a graph’s parameters. Which fields it may sweep is declared on the
node, as exposes=:
Llm( "answer", model="gpt-4o", prompt="{{dataset_input}}", temperature=0.2, exposes=["model", "temperature"],)The bare field name is enough: the control the experiment builder offers is guessed from
the name — model gets a model picker, prompt_template a prompt editor, temperature a
number. When the guess is wrong, or the label should read as something else, spell it out:
from chatty_lab import expose
exposes=[expose("system_prompt", label="Persona", param_type="prompt")]What comes out
Section titled “What comes out”build() returns a plain dict — the same payload Workdir.commit takes and the same
one the canvas produces:
graph = build(flow)
graph["nodes"][0]# {'id': 'input', 'node_type': 'input', 'label': 'input',# 'config': {'type': 'input'}, 'error_policy': {...},# 'exposed_params': [], 'position': {'x': 80.0, 'y': 80.0}}
graph["edges"][0]# {'id': 'e_input__draft', 'source': 'input', 'target': 'draft',# 'label': None, 'edge_type': 'data'}Positions come from the walk: one step right per topological level, one step down per sibling within it. Deliberately that dumb — a graph declared here has to open in the Studio without every node stacked on the origin, and anyone who wants it prettier drags it and commits, which is a normal commit.
Naming a graph without a store
Section titled “Naming a graph without a store”Two functions answer questions about a blob with no history anywhere near it:
from chatty_lab import digest_of, agents_referenced_by
agents_referenced_by(graph) # ['8f21…', 'c40b…'] — every definition it pinsdigest_of(graph, definitions) # Digest — what this graph is called
digest_of(graph, definitions) == digest_of(other, definitions) # same graph?definitions maps each agent id to its definition. An id mapped to None is a reference
that dangled, which is a different fact from an agent nobody mentioned — and the two
produce different digests, on purpose.