Skip to content

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.

from chatty_lab import Input, Llm, Output, build
flow = Input() >> Llm("draft", model="gpt-4o", prompt="Rewrite: {{input}}") >> Output()
graph = build(flow)
  1. >> chains. a >> b means b runs after a, on what a produced. It builds one flow edge.

  2. | forks. a | b means 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.

  3. 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())
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]

>> 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.

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().

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.

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.

ShapeWhy it is refused
An agent node in the flowIt 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 membersA debate with no participants.
A non-agent node in members=Only definitions can be convened.
Two different nodes with the same idIds 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")

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")]

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.

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 pins
digest_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.