AgentCanvas / Pages / Developer Guide / Design Docs / Surfaces / SDK Builder Contract
2026-07-12

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.

catalog / describe / typed stubs spawn-free workspace scan your script add · connect · loop Graph builder NodeHandle · PortRef graph_sdk.py GraphDefinition NodeDef · EdgeDef graph_def.py run() iterIn synth → check → load_nodesets GraphExecutor → RunResult graph_executor.py graph_to_code() — regenerate a standalone builder script (graph_sdk_codegen.py) on_event ← RunEvent stream sync / async · in-loop

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):

CallProducesRules
add(node_type, id=None, **config)NodeDefnode_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)EdgeDefsrc.out("p")sourceHandle, dst.in_("p")targetHandle; fan-out = multiple edges from one out-port
graph_in(name) / graph_out(name)boundary NodeDefsgraph_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 grantthe same shared-state blackboards + grants the canvas draws
hook(event, command, …)HookDeflifecycle shell hooks — semantics owned by the engine's extension points
composite(id, subgraph)NodeDef with subgraphnested 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:

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.

LevelFlagWhat it verifies
resolution checkcheck=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 passvalidate=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

Node errors do not raise out of 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).
Two metric channels, one of them often empty. 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.
The builder validates structure, not values. 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

FileRole
app/graph_sdk.pyGraph / NodeHandle / PortRef / Loop / RunEvent / RunResult / EvalResult; _check_resolved; catalog / describe / nodesets / generate_node_stubs
app/graph_def.pythe dataclasses everything assembles into; validate_graph_connectivity; _synthesize_iterin_ports
app/graph_sdk_codegen.pygraph_to_code() — the reverse compiler
app/agent_loop/graph_executor.py · _emitthe event emission seam on_event taps
agentcanvas/nodes/generated NodeProxy stubs — the typed authoring surface
AgentCanvas docs