add_node / add_edge / add_conditional_edges: filling in the blueprint
Responsibilities
StateGraph exposes three core registration methods: add_node wraps a function / Runnable as a node (add_node:662), add_edge adds a deterministic edge (add_edge:915), and add_conditional_edges adds a branching condition (add_conditional_edges:969). All three return Self, so they can be chained: builder.add_node("a", a).add_node("b", b).add_edge("a", "b").add_conditional_edges("b", route). They only append to builder.nodes / builder.edges / builder.branches; the actual translation into PregelNode + channel subscriptions happens at compile() time via attach_node / attach_edge / attach_branch.
They sit at the outermost edge of the user API: nearly every line you write when building a graph calls one of these three methods. add_node is the most complex — it infers input_schema from the function signature, infers destinations from a Literal[...] return type, and wraps the error_handler as a separate __error_handler__{node} node (error handler injection:856-870). add_edge is straightforward: it goes either into the edges set or into the waiting_edges set (multi-start edge, waits for all starts to complete). add_conditional_edges is the thinnest: it wraps path as a Runnable, then hands it to BranchSpec.from_path (from_path:89) to compute the ends dict, stored in branches[source].
Design motivation
Why does add_node infer so much from the function signature?
- Less boilerplate: when
input_schemais not passed explicitly, it is inferred from the type annotation of the first parameter (inferred input schema:815-825) — sodef my_node(state: MyState): ...automatically usesMyState. When the return type isLiteral["a", "b", "__end__"]it is automatically treated as destinations (Literal destinations:840-846), no need to writedestinations=.... - Node name can be omitted: the
nodeparameter can be either a string or the function itself; a function object uses__name__as the node name (node name inference:768-773) — aRunnableusesget_name(). error_handleris a separate node (error_handler node:857-870) — it is not a field on the spec, but a regular node named__error_handler__{node}is generated, and a pointer is recorded in theerror_handler_nodefield. This way the handler is itself a Pregel node and enjoys all node capabilities: retry, metadata, tracing.- Unified multi-in-edge semantics:
add_edge(["a", "b"], "c")does not mean "when a finishes run c, and when b finishes also run c"; it means "run c only after both a and b finish" (multi-start semantics:917-921), stored in thewaiting_edgesset and translated at compile time into aNamedBarrierValuechannel. - Conditional edges can omit path_map: when
path_mapis not passed, it is inferred from the return typeLiteral["a", "b"]of thepathfunction (Literal infers path_map:103-115) — sodef route(state) -> Literal["a", "b"]: ...just works. When neither is provided, graph visualization assumes the branch may jump to any node (warning:994-997). - Same-name branches on the same node raise (
branch duplicate name:1009-1012) — a node may have multiple conditional edges, but each one's condition name must be unique, becausebranches[source]is a dict keyed by name.
Key files
add_node signature:662-676— acceptsnode/action/defer/metadata/input_schema/retry_policy/cache_policy/error_handler/destinations/timeout.node name inference:768-790— string used directly; function uses__name__;Runnableusesget_name(). Reserved namesSTART/END/NS_SEP/NS_ENDraise.signature type inference:803-848— infersinferred_input_schemaandCommand[Literal[...]]destinations from the type hints of__call__.error_handler injection:856-870— generates the__error_handler__{node}standalone node stored inself.nodes, with fieldis_error_handler=True.store StateNodeSpec:872-907— stores the spec intoself.nodesunder one of three tiers:input_schema/inferred_input_schema/self.state_schema.add_edge:915-967— single start goes into theedgesset, multi-start goes into thewaiting_edgesset; the start cannot beENDand the end cannot beSTART.add_conditional_edges:969-1017— wrapspathas a Runnable,BranchSpec.from_pathcomputesends, stored inself.branches[source][name].BranchSpec.from_path:83-120— handles three forms ofpath_map:dict/list/ inferred from the return typeLiteralwhen neither is given; also infersinput_schema.add_sequence:1019-1044— registers a series of nodes in one call (syntactic sugar; internally still loopsadd_node).attach_edge:1537-1561— compile time: single-start edges add aChannelWriteto the start node's writers pointing at the end channel; multi-start edges register aNamedBarrierValuechannel as the join.attach_branch:1563-1596— compile time: translatesBranchSpecintoChannelWrite.register_writer, deciding which target channel to write based onends.
Data flow
The real work in add_node happens in two sections — inferring the input schema and the destinations in the return type, then storing the spec into self.nodes:
if (
isfunction(action)
or ismethod(action)
or ismethod(getattr(action, "__call__", None))
) and (
hints := get_type_hints(getattr(action, "__call__"))
or get_type_hints(action)
):
if input_schema is None:
first_parameter_name = next(
iter(inspect.signature(cast(FunctionType, action)).parameters.keys())
)
if input_hint := hints.get(first_parameter_name):
if isinstance(input_hint, type) and get_type_hints(input_hint):
inferred_input_schema = input_hint
if rtn := hints.get("return"):
rtn_origin = get_origin(rtn)
if rtn_origin is Union:
rtn_args = get_args(rtn)
for arg in rtn_args:
arg_origin = get_origin(arg)
if arg_origin is Command:
rtn = arg
rtn_origin = arg_origin
break
if (
rtn_origin is Command
and (rargs := get_args(rtn))
and get_origin(rargs[0]) is Literal
and (vals := get_args(rargs[0]))
):
ends = valsThe spec is then stored in the self.nodes dict under one of three tiers based on the inference result — explicit input_schema / inferred inferred_input_schema / fallback to self.state_schema:
if input_schema is not None:
self.nodes[node] = StateNodeSpec[NodeInputT, ContextT](
coerce_to_runnable(action, name=node, trace=False),
metadata,
input_schema=input_schema,
retry_policy=retry_policy,
cache_policy=cache_policy,
error_handler_node=handler_node_name,
ends=ends,
defer=defer,
timeout=timeout,
)add_conditional_edges is very thin — the core is wrapping path as a Runnable and letting BranchSpec.from_path compute the ends dict:
path = coerce_to_runnable(path, name=None, trace=True)
name = path.name or "condition"
if name in self.branches[source]:
raise ValueError(
f"Branch with name `{path.name}` already exists for node `{source}`"
)
self.branches[source][name] = BranchSpec.from_path(path, path_map, True)
if schema := self.branches[source][name].input_schema:
self._add_schema(schema)
return self(add_conditional_edges implementation:1005-1017)
Boundaries and failure modes
- Duplicate node names raise directly (
duplicate name:792-793) —Nodealready present; overrides are not allowed. - Reserved node names (
reserved:794-801) —START/END/NS_SEP(|) /NS_END(:) cannot be used; the latter two would breakcheckpoint_nsconcatenation. add_edgewith startENDraises (END cannot be start:939-940); likewiseSTARTcannot be the end (START cannot be end:941-942) — these two constants are the graph's entry and exit and cannot be reversed.- Multi-in-edges require all starts to be
add_noded first (multi-in-edge validation:956-964) — otherwiseattach_edgeat compile time cannot find the start node'swriters. - Same-name branches on the same node raise (
branch duplicate name:1009-1012), but multiple differently-named branches can coexist — this is the foundation of conditional + deterministic mixed routing. add_nodeon an already-compiled graph only warns (compiled warning:778-782); changes are not reflected in the already-compiled instance — a typical ordering pitfall.
Summary
add_node / add_edge / add_conditional_edges are StateGraph's write interface; they are not complicated in themselves. The complex part is the "magic" of inferring schemas and destinations from function signatures. Once you understand these three methods, you have essentially read all the mutable APIs of StateGraph. The next step is how compile translates these specs into PregelNode; for the runtime model see Pregel engine.