Graph SDK Builder Contract
Graph · NodeHandle · PortRef · loop() persist rules · two-level checking · typed stubs · on_event emission · to_code
The builder is deliberately thin: every method assembles the same GraphDefinition dataclasses the canvas emits and the JSON stores, and run() hands them to the same GraphExecutor the Play button drives — the SDK adds no execution machinery. Its hard promise is identity: a graph built in code, drawn on the canvas, or loaded from JSON is indistinguishable to the engine, and to_code() closes the loop by regenerating a builder script from any of them. This page specifies the assembly rules; engine semantics stay with their owners — graph-executor, loop-control-system, batch-eval. Feature-level tour: capability page #10.
1. What it does
Three object kinds do all the work. Graph owns a private GraphDefinition and mutates it; g.add(…) returns a NodeHandle (a node id plus port accessors); handle.out("port") / handle.in_("port") return PortRefs, and g.connect(src, dst) turns a PortRef pair into an EdgeDef. Nothing is deferred or symbolic — after every call the underlying dataclasses are complete, so g.to_dict() at any moment is valid canvas JSON, and Graph.from_definition(gd) re-wraps any existing graph for further editing.
The smallest build
g = Graph(name="demo") a = g.add("demo_seven", id="a", x=7) # kwargs → NodeDef.config o = g.graph_out("r") # boundary node; port name = output key g.connect(a.out("y"), o.in_("value")) # PortRef pair → EdgeDef print(g.run()["r"]) # check → executor → RunResult
2. Assembly rules
Each builder call maps to one dataclass mutation — the full table (graph_sdk.py · Graph):
| Call | Produces | Rules |
|---|---|---|
add(node_type, id=None, **config) | NodeDef | node_type is a registered string key or any class with a node_type attribute (§4); id auto-generates as "<type>_<n>"; every extra kwarg lands in NodeDef.config — config, not port wiring |
connect(src_ref, dst_ref) | EdgeDef | src.out("p") → sourceHandle, dst.in_("p") → targetHandle; fan-out = multiple edges from one out-port |
graph_in(name) / graph_out(name) | boundary NodeDefs | graph_in's out-port carries the given name inward; graph_out's in-port is value and the name becomes the run-output key |
container(id, states=…) / grant(node, container_id) | ContainerDef / access grant | the same shared-state blackboards + grants the canvas draws |
hook(event, command, …) | HookDef | lifecycle shell hooks — semantics owned by the engine's extension points |
composite(id, subgraph) | NodeDef with subgraph | nested Graph embedded as a composite node; flatten_graph() expands it before execution |
loop(init=…, carry=…) | paired iterIn/iterOut nodes | §3 — the one piece of sugar that encodes non-obvious flags |
3. loop() — what the sugar actually sets
The loop helper exists because the raw iterIn/iterOut flags are a known foot-gun; it writes them so you don't. g.loop(init=…, carry=…) (graph_sdk.py · Graph.loop) adds the paired iterIn/iterOut nodes and returns a Loop handle whose methods wire both sides at once:
initports are seed-only: fed once at run start, persisted unchanged across every step — the helper sets the persist flag that, when forgotten by hand, starves the port from iteration 1 onward.carryports are written back each step byiterOutand re-entered on the next: seeded on step 0, then the carry supersedes — the init side deliberately does not persist, so a stale seed can never shadow a fresh carry.loop.seed(name, ref)wires the run-start value,loop.feed(name, ref)wires both the init and carry entry sides to a consumer,loop.carry(name, ref)wires the per-step write-back,loop.stop(ref)wires the termination signal.
Step semantics — what fires when, budget, termination — are the engine's, specified in loop-control-system; this page only owes you what the builder writes into the definition.
4. Two-level checking
Every run() / eval() is checked by default; the checks differ in depth, and both fail before anything executes.
| Level | Flag | What it verifies |
|---|---|---|
| resolution check | check=True (default) | graph_sdk.py · _check_resolved: every node type has a registered handler, every wired port exists on its node, every required input port is fed. Runs after nodeset loading, so server-proxy node types are covered. Aggregates all problems into one GraphValidationError, with did-you-mean hints (difflib). Conservative by construction — composites (expanded later) and machine-wired iterIn/iterOut ports are skipped, so a well-formed graph never false-positives. |
| connectivity pass | validate=True (opt-in) | graph_def.py · validate_graph_connectivity — the deeper structural pass over the assembled definition |
5. Typed authoring — stubs are frozen catalog output
agentcanvas.nodes is the workspace catalog, compiled to importable symbols. generate_node_stubs() runs the same spawn-free scan as catalog() / describe() / nodesets() and emits one NodeProxy subclass per node type into agentcanvas/nodes/. g.add() accepts either form — the registered string key, or any class carrying a node_type attribute (graph_sdk.py · Graph.add); passing a NodeProxy subclass additionally returns a typed handle whose out/in_ autocomplete the port names. The two forms produce identical NodeDefs. Being generated files, the stubs are a snapshot: they go stale when workspace nodesets change ports, until regenerated — staleness shows up as the resolution check disagreeing with the stub's port list.
6. on_event — where the events come from
The executor already dispatched every lifecycle moment to shell hooks; on_event surfaces the same moments as Python objects. GraphExecutor.__init__ takes an on_event callback; graph_executor.py · _emit builds a plain event dict, awaits the callback if it returns an awaitable, and swallows callback exceptions with a warning — an observer can never kill a run. Emission points: graph_start after the GraphStart hook, node_start in _fire_node once inputs are resolved, terminal node_finish/node_error with duration_ms, and graph_complete/graph_error at run end. The SDK wraps the user callback so it receives a typed RunEvent (graph_sdk.py · RunEvent._from_dict — unknown keys filtered, so executor-side additions never break callers). Two contract points: the callback runs inside the executor's event loop (keep it cheap), and inputs/outputs on node events are the live engine dicts, not copies — read, don't mutate.
7. to_code — the round trip
graph_to_code(gd) / Graph.to_code() (graph_sdk_codegen.py) compiles any GraphDefinition — canvas-authored, JSON-loaded, or code-built — into a standalone script that rebuilds the same topology through this builder API. The round trip is semantically exact: node types + configs, the wire multiset, containers, grants, and hooks survive; cosmetic properties (canvas layout positions) do not. Verified against the reference MapGPT-MP3D graph.
8. Where it deviates from the mental model
run(). The engine records a failing node and completes the run — RunResult.outputs simply lacks that node's contribution. "The run either works or throws" is the wrong model: assert on outputs or watch for a node_error event via on_event (this is how the SDK's own replay-exhaustion test asserts failure).result.outputs["metrics"] (the graphOut channel) is the reliable read for single runs; result.metrics (the session channel) stays empty for graphs whose evaluate node emits metrics without a top-level {done, metrics} — an upstream metric-outlet split, documented on the capability page's status table, not a builder defect.add() accepts any config kwargs — nothing checks them against the node's config_schema at build time; a typo'd config key travels silently into NodeDef.config and surfaces only in node behaviour. The resolution check covers types and ports, not config.9. Key files
| File | Role |
|---|---|
app/graph_sdk.py | Graph / NodeHandle / PortRef / Loop / RunEvent / RunResult / EvalResult; _check_resolved; catalog / describe / nodesets / generate_node_stubs |
app/graph_def.py | the dataclasses everything assembles into; validate_graph_connectivity; _synthesize_iterin_ports |
app/graph_sdk_codegen.py | graph_to_code() — the reverse compiler |
app/agent_loop/graph_executor.py · _emit | the event emission seam on_event taps |
agentcanvas/nodes/ | generated NodeProxy stubs — the typed authoring surface |