Graph System
The graph as an artifact β schema, legality, storage, composition, and the handoff to the engine
Every agent pipeline in AgentCanvas is one JSON document. This page follows that document through its whole life β what its schema is (Β§2), which node types it may reference (Β§3), what makes it legal (Β§4), how it nests (Β§5), where it lives on disk and in the UI (Β§6) β then reads one real artifact end to end (Β§7) and walks it to the API boundary where it is handed to the engine (Β§8).
Boundary. This page treats the graph as data at rest. The story ends at the moment a validated artifact is handed to GraphExecutor.run() β which flattens it (Β§5) in its build phase, before any node fires. Everything after β flattening, when nodes fire, how loops execute, how a run ends β is Graph Executor's subject; its Β§1 states the execution contract a drawn graph makes.
1. The artifact
Because it is only data, the same document moves freely between every surface of the system: saved to disk under workspace/graphs/ or workspace/graph_nodes/, sent over HTTP (POST /api/navigate/run, POST /api/graphs), embedded inside another graph via NodeDef.subgraph (Β§5), authored on the canvas, loaded from a file, or generated by code β including by the AAS architect loop, which edits graphs purely through the API.
The division of labour with the engine is the blueprint/factory split. The graph names what exists; only at fire time does the executor turn a type string into a live object:
# GraphExecutor._fire_node() β the only place type strings become objects handler_cls = NODE_HANDLERS.get(node.type) # registry lookup β None + warn if unknown instance = handler_cls() # fresh live instance instance.config = node.config # per-node knobs from the JSON result = await instance.forward(inputs, ctx)
This separation is what makes graphs portable: the same JSON runs on any backend that registers the same node types, and nothing about a saved graph can hold a stale object alive.
2. The schema
All model types live in agentcanvas/backend/app/graph_def.py as plain recursive @dataclasses, mirrored one-to-one by the frontend's types.ts. Two fragments carry almost all of the structure β a node reference and a wire:
{ "id": "predict", "type": "policy_adapter_vlnce__predict", "label": "Predict", "position": { "x": 420, "y": 96 }, "config": { "model_adapter": "cma" } } { "id": "e7", "source": "predict", "sourceHandle": "model_output", "target": "adapt_model_to_canonical", "targetHandle": "model_output" }
2.1 GraphDefinition, NodeDef, EdgeDef
GraphDefinition is the universal container β the same dataclass serves the root canvas graph, an agent-loop inner graph, a composite's subgraph, and a saved template:
| Field | Type | Meaning |
|---|---|---|
name, description | str | Identity shown in the Explorer and save dialogs. |
nodes | NodeDef[] | Node references β type + config, never instances. |
edges | EdgeDef[] | Typed wires, output port β input port. |
containers | ContainerDef[] | State containers β visible shared state (Β§2.2, ADR-dataflow-002). |
access_grants | AccessGrantDef[] | Node β container authorization β not wires (Β§2.2, ADR-dataflow-004). |
hooks | HookDef[] | Shell hooks at lifecycle events (Β§2.3, ADR-observability-002). |
step_budget | int | None | Per-episode iteration cap, default 500. The eval framework's resolver chain (eval_batch.py) may override it per episode from the env panel's on_load(). |
eval_graph | bool | Default true: the graph must declare β₯1 graphOut (Β§4.2). Demo / playground graphs opt out with false. |
kind | "graph" | "node" | Storage root + UX routing (Β§6, ADR-canvas-003). |
group | str | Explorer grouping label for graph nodes. |
presetId | str | None | Provenance of preset-derived graphs. |
NodeDef and EdgeDef are deliberately thin:
| Field | Meaning | |
|---|---|---|
| NodeDef | id | Unique within its graph. Conventionally ${type}_${hash8}, but any unique string works. |
type | Key into NODE_HANDLERS (Β§3) β a reference, not a class. | |
config | Per-instance knobs. May carry config.ports: PortDef[], the per-instance port override (ADR-dataflow-003). | |
subgraph | GraphDefinition | None β composite nesting (Β§5). | |
| EdgeDef | source, target | Node ids. |
sourceHandle, targetHandle | Output / input port names. Wire types are checked per edge by validate_edge_wire_type. |
(label and position on NodeDef are canvas presentation only.)
2.2 State containers and access grants
Shared state is part of the artifact, not a runtime side channel. A ContainerDef is a visible canvas element holding named StateDef entries; an AccessGrantDef authorizes one node to read/write one container. Grants render as dashed violet lines but are not wires: they carry no data and never make a node ready.
| StateDef field | Values |
|---|---|
type | accumulator Β· lastWrite Β· counter |
value_type | any name in the STATE_VALUE_TYPES registry |
lifetime | forever Β· step Β· episode Β· run Β· custom (with reset_on: str[]) |
config | per-type knobs, e.g. {max_size, initial_value} |
A container with the well-known id "graph_state" plays the role of the graph-level blackboard β but since ADR-dataflow-004 there is no auto-inject: every node that wants it needs its own grant.
2.3 Hooks
A HookDef attaches a shell command to a lifecycle event: event (PreNodeExecute / PostNodeExecute / GraphStart / GraphComplete / GraphError), command (stdin receives a JSON payload), match_node_type (*, exact, or prefix-glob), optional match_node_id, timeout_ms (default 1000), enabled. One caveat lives in Β§5: hooks on inner composites do not survive flattening.
3. The node-type vocabulary
NodeDef.type draws from two namespaces, distinguished by a double-underscore separator (ADR-components-003).
NodeSet nodes are ${nodeset}__${node} β the prefix is what lets the frontend auto-load the owning nodeset when a graph is opened (Β§6.4):
| Example type | NodeSet | Node |
|---|---|---|
env_habitat__observe | env_habitat | observe |
env_habitat__step_native | env_habitat | step_native |
policy_adapter_vlnce__predict | policy_adapter_vlnce | predict |
env_mp3d__reset | env_mp3d | reset |
Built-in nodes have no separator. The 14 registered in NODE_HANDLERS at import time (agent_loop/builtin_nodes.py) β roles only; the pivots' execution semantics live in Graph Executor Β§1.2:
| Node type | Role |
|---|---|
iterIn | Loop pivot, opens each iteration β init side takes run-start values into init_<X> handles (Β§4.1); output side feeds the loop body |
iterOut | Loop pivot, closes each iteration β collects carry ports + the stop halt input; its final_* side feeds the after-loop band |
graphIn / graphOut | Composite boundary I/O β rewired by flatten_graph (Β§5); graphOut doubles as the eval metric latch harvested by BatchEvalRunner |
llmCall | LLM / VLM completion via litellm |
textParse | Extract a keyword, regex capture, or JSON field from text |
historyLog | Accumulate per-step entries into formatted history text (resets per episode) |
nullSource | Emits None on a typed output β an entry node; fires once at run start |
note | Markdown annotation on the canvas (passive β no execution effect) |
imageViewer | Viewer sink β configurable IMAGE/DEPTH grid (ADR-components-007) |
textViewer | Viewer sink β latest TEXT block |
textScroll | Viewer sink β accumulating TEXT history (ADR-components-008) |
actionLog | Viewer sink β action history with step numbers |
metrics | Viewer sink β SPL/SR/nDTW/SDTW |
compositeNode is deliberately absent: it is a frontend-only shell carrying a subgraph, expanded by flatten_graph() before the executor ever sees it. The former initialize, termination, and outputPort types are removed β graphs that still carry the first two are rejected with migration hints (Β§4.2).
4. What makes a graph legal
This section is the authority on legality. Β§4.1 explains the one structure with non-trivial data shape β the pivot pair; Β§4.2 lists every rule the validator enforces; Β§4.3 covers the conventions that matter but are not machine-checked.
4.1 The pivot pair, data side
Loops are expressed by a paired iterIn/iterOut (ADR-dataflow-008 + ADR-dataflow-009). The authoring rule: run-start values (initial observation, instruction, episode config) go into iterIn's init side; values that change per iteration ride the loop-carry through iterOut. In the JSON, each pivot is two-sided:
iterInβ its input side is declared underconfig.initPorts; run-start values arrive as ordinary edges targeting the prefixedinit_<name>handles. Its output side exposesinit_<name>+iterout_<name>handles to the loop body.iterOutβ its input side takes the loop-carry values underconfig.ports, plus an optionalstopBOOL halt input. Its output side mirrors the carry ports asfinal_<name>handles plus a constantfinal_stopβ these feed the after-loop verdict stage. The pivots are linked byconfig.pairedWith; there is no canvas wire between them.
iterIn's effective config.ports is synthesised at graph load by _synthesize_iterin_ports from two writers β its own initPorts (handles prefixed init_, default persist=false) and the paired iterOut's config.ports (handles prefixed iterout_, default persist=true) β and is never hand-written; the legacy init_ports/loop_ports keys are rejected at load. Direct canvas edges into iterIn targeting an undeclared handle are also accepted as init-writer surrogates (handle name used as-is).
What these declarations mean at runtime β which value a consumer sees on which iteration, what persist does to a slot, when the final side delivers β is the execution contract, stated in Graph Executor Β§1.2 with the boundary mechanics in Β§4.3. The one trap worth flagging at authoring time: a name may appear on both sides (init_X for iter 0, iterout_X for iter β₯1) only if the init entry stays one-shot β Β§7.2 shows the canonical dual-wire example.
4.2 Load-time validation β the rule set
All rules are enforced by validate_graph_connectivity() (graph_def.py) at the API boundary (POST /api/navigate/run, POST /api/eval/v2/start); violations are batched into one HTTP 400 listing every error. Port requirements are resolved per instance β for each builtin the validator calls cls._resolve_ports(node.config) when present (so config.ports overrides are honoured), falling back to the class-level input_ports; nodeset types are skipped, since their handlers aren't registered at validation time.
| Rule | Guards against |
|---|---|
| Required ports wired β every required input port has an incoming edge. | A node parked forever, silently never firing. |
Removed types rejected β initialize (removed 2026-06-10, ADR-dataflow-008) and termination (removed 2026-06-11, ADR-dataflow-009) fail with migration hints: init values belong on iterIn's initPorts; the halt signal belongs on iterOut's stop input. | A stale graph failing later with an opaque unknown-node-type error. |
iterIn integrity β legacy init_ports/loop_ports schema rejected; pairedWith must point at an iterOut; outgoing edges may only reference synthesised handles (init_*/iterout_*, plus step). | Edges sourcing from handles that will never exist at runtime. |
iterOut integrity β non-empty config.ports (a pivot that transfers nothing is an author error); pairedWith must point at an iterIn; incoming edges may only target carry ports or stop; outgoing edges may only source from final_<name>/final_stop. | Per-iteration taps drawn off the pivot β those belong on body nodes. |
Scope topology (analyze_scopes) β cross-scope wires that bypass graphIn/graphOut, duplicate pairedWith, unpaired pivots. | Nested loops with ambiguous ownership of nodes. |
After-loop band purity β the band (downstream closure of the root iterOut's final_* edges) must be graph-scope, and band nodes may take inputs only from the final side or other band nodes. Anything the verdict needs must ride the pivot: expose it as a carry port, wire from final_<name>. | A band node fireable mid-loop β the last-write-wins metric bug class. |
Verdict source β in an eval graph, graphOut nodes with portName metrics/success must be fed from the band. Streaming graphOuts (per-step frames for the nav UI) and composite-archive latches (kind="node") are exempt. | A verdict reflecting whichever iteration wrote last instead of the terminal one (the TODO #64 bug class). |
Eval graphs declare output β eval_graph: true (the default) requires β₯1 graphOut. | An eval run that completes with nothing to harvest. |
The after-loop rules are structural guarantees, not style: they make the βverdict lags the terminal stepβ bug unwritable rather than merely discouraged β the why is in ADR-dataflow-009, the runtime counterpart in Graph Executor Β§5.1.
4.3 Conventions the validator does not enforce
LLM profiles are user-level config, never graph-level. Profile-bearing nodes (currently llmCall) must save with config.profile = "", which resolves to the user's active profile at run time. A profile binds a name to (provider, model, api_key, base_url) and lives in /api/profiles/ β per-deployment state, not portable topology. Hardcoding a name breaks portability (the graph fails for anyone without that exact profile; get_llm_config("missing") short-circuits to an empty response) and invites silent miscredit β a named profile can point at an exhausted or wrong-org key whose quota error surfaces only in the backend log. If a graph genuinely needs different LLMs in different roles, document it in a note node beside the llmCall and let the user pick per node in the UI.
5. Composition
A NodeDef may carry subgraph: GraphDefinition β itself a full graph, recursively. The executor never sees nesting: at execution time flatten_graph() (app/agent_loop/flatten.py) expands every composite into the parent namespace.
Concretely, flatten_graph() performs five renames and rewires:
- Inner node ids are prefixed
${composite_id}__${inner_id}. - Inner container ids are prefixed the same way.
- Inner
AccessGrantDefs getnode_idremapped through the same id map;container_idis prefixed. graphIn/graphOutboundary nodes are rewired β parent edges connect to the prefixed inner source/target (composites without an iterIn erase the boundary nodes entirely; loop-bearing composites keepgraphOutas a latch).- The composite shell node is removed.
A FlattenMap records flat-id β origin-composite-path so execution errors can be traced back to the drawing the author actually made.
Hooks are not propagated. flatten_graph carries no hooks into the flat result at all. The executor captures graph-level and global hooks before flattening β merge_hooks(global_hooks, graph.hooks, node_hooks) in graph_executor.py reads only the top-level graph's hooks, so shell hooks on inner composites are silently dropped. Define all hooks at the top level.
6. Storage and the two kinds
6.1 Graph vs Graph Node
One schema, two UX surfaces, routed by the kind field (ADR-canvas-003):
| Aspect | Graph (kind="graph") | Graph Node (kind="node") |
|---|---|---|
| Purpose | Editable canvas template | Frozen reusable composite |
| Storage root | workspace/graphs/ | workspace/graph_nodes/ |
| Explorer section | Graphs (amber) β double-click opens in a tab | Graph Nodes (indigo) β grouped by group; drag onto any canvas drops a compositeNode |
| Save path | Ctrl+S / βSaveβ β POST /api/graphs with kind: "graph" | βSave as Nodeβ β kind: "node" + a group picked from pill suggestions |
Both kinds appear in the Explorer, expand into their internal node tree (recursively for nested composites), and are served by the same API (Β§9.1) β kind alone decides the root directory and the UX affordances.
6.2 On disk
workspace/
βββ graphs/ β editable templates (kind="graph")
β βββ demo/ β subfolders are free-form; the API scans
β β βββ tutorial_demo.json recursively. Current convention:
β βββ examples/ <domain>/<verified|unverified>/
β βββ vln/
β β βββ verified/
β β β βββ straightforward.json
β β βββ unverified/
β β βββ navgpt_ce.json
β β βββ mapgpt_mp3d.json β¦
β βββ eqa/
β β βββ verified/ β openeqa_em_*.json
β β βββ unverified/ β explore_eqa_hmeqa.json β¦
β βββ vla/
β βββ unverified/ β voxposer_libero_decomposed.json β¦
βββ graph_nodes/ β archived composites (kind="node")
βββ voxposer/ β¦
A graph's id is its root-relative path. Folders are plain directories β list/create them via GET/POST /api/graphs/folders, and POST /api/graphs accepts an optional folder to save into one.
6.3 Instantiation and snapshot semantics
Dragging a graph node onto a canvas runs through four stages: the drag payload carries the entire GraphDefinition; the drop creates a React Flow node of type compositeNode with data.subgraph set to a full deep copy; CompositeCanvasNode renders its handles from the graphIn/graphOut nodes inside; and at run time getGraphForExecution() serializes the composite with .subgraph intact for the backend to flatten (Β§5).
This is copy, not reference β deliberately. A graph node is an archive: editing the source file later does not reach into canvases or parent graphs that already embedded it.
6.4 Opening a graph: nodeset auto-load
Opening a saved graph must produce a working canvas even on a fresh backend. nodesetLoader.ts parses the node types, extracts nodeset names from the __ prefixes (env_habitat, policy_adapter_vlnce, β¦), diffs them against GET /api/components/nodesets, auto-loads the missing ones, then fetches fresh schemas (GET /api/components/node-schemas) and injects _schema into each node's data β which is what lets the renderer derive ports and ui_config without hardcoding any node type.
7. A complete artifact: Straightforward (VLN-CE)
The simplest complete agent graph in the repo β a neural-policy loop driven by the 5-stage General Policy Adapter pipeline (env_adapter + policy_adapter_vlnce), with the policy's hidden state riding the loop-carry (ADR-eval-002). Full JSON at workspace/graphs/vln/verified/straightforward.json. It exercises every structure this page defined: init edges, the pivot pair, the stop halt input, and an after-loop band feeding the eval latch.
7.1 Node breakdown
| Node id | Type | Role |
|---|---|---|
env_habitat__observe_* | env_habitat__observe_egocentric | Entry node β step-0 observation, feeds the init edges |
iterIn_* | iterIn | Loop pivot β opens each iteration |
adapt_env_to_canonical β¦ adapt_canonical_to_env | env_adapter__vln_* + policy_adapter_vlnce__* | 5-stage adapter pipeline: envβcanonical β canonicalβmodel β predict (neural policy, batched inference) β modelβcanonical β canonicalβenv |
env_habitat__step_* | env_habitat__step_discrete | Execute action; terminated β iterOut.stop, info triggers the re-observe |
observe_loop | env_habitat__observe_egocentric | Re-observe after the step β feeds the loop-carry and the viewers |
iterOut_* | iterOut | Loop pivot β closes each iteration; final_* side feeds the verdict |
evaluate | env_habitat__evaluate | After-loop band β reads episode metrics from the env manager, triggered by final_stop |
output_port__metrics | graphOut | Eval metric latch (portName: "metrics") β harvested by BatchEvalRunner |
observationViewer_* / actionLog_* / metrics_* | imageViewer / actionLog / metrics | Viewer sinks β RGB+depth grid, action history, SR / SPL / nDTW |
7.2 The iterIn config, and the dual-wire pattern
{ "id": "iterIn_e5f6a7b8", "type": "iterIn", "config": { "version": 3, "pairedWith": "iterOut_c9d0e1f2", "initPorts": [ { "name": "rgb", "wire_type": "IMAGE", "persist": false }, { "name": "depth", "wire_type": "DEPTH", "persist": false }, { "name": "raw_obs", "wire_type": "TEXT", "persist": false }, { "name": "hidden", "wire_type": "ANY", "persist": false } ] } }
The init edges target the prefixed handles (env_habitat__observe.rgb β iterIn.init_rgb, β¦). The iterout_* half of the handle set comes from the paired iterOut's config.ports (rgb, depth, pose, action, raw_obs, hidden) β synthesised at load, never hand-written (Β§4.1). All four initPorts are one-shot (persist: false) because the same names are loop-carried from iter 1 onward.
The graph also carries the canonical dual-wire pattern: both init_raw_obs and iterout_raw_obs are wired into adapt_env_to_canonical.raw_obs β legal exactly because the init entry is one-shot, so the two writers never compete after iter 0.
7.3 How it runs
This page stops at the artifact. The run itself β how observe is discovered as an entry, how an iteration turns, how terminated β stop ends the loop, and how the final_stop edge delivers exactly once into evaluate β is the executor's story: Graph Executor Β§4.6 walks a loop of exactly this shape turn by turn, ready-queue and all.
8. The handoff
8.1 Run request
POST /api/navigate/run { "loop_definition": { "...": "GraphDefinition JSON" }, "execution_id": "exec_abc123", "step_delay_ms": 200 }
This endpoint is where the artifact leaves this page's jurisdiction. app/api/execution/run.py parses it (GraphDefinition.from_dict()), judges it (validate_graph_connectivity() β the full Β§4.2 rule set, HTTP 400 on violation), and hands the validated graph to LoopRunner β GraphExecutor.run(), which flattens it (flatten_graph(), Β§5) during build, before any node fires. Everything from that call onward β flattening, scheduling, guards, phases, how the run ends β is Graph Executor Β§2's territory.
Sibling lifecycle endpoints on the same prefix: POST /api/navigate/run/pause, /run/stop, GET /api/navigate/run/status, GET /api/navigate/run/checkpoints, POST /api/navigate/run/restore/{step}.
8.2 WebSocket events
What comes back during a run is also a data contract this page owns. The executor emits per-node events β there is no single consolidated nav_step dump; each viewer sink serializes its own wired inputs and broadcasts on its own node id (ADR-observability-003 / ADR-components-007 / ADR-components-008):
| Event type | Source | Payload |
|---|---|---|
nav_status | executor | {status, step} on run-start / -stop |
step_start, step_end | signal bus | broadcast at iteration boundaries (drive state-container lifetimes) |
viewer_data | each viewer sink (imageViewer, actionLog, metrics, textViewer, textScroll) | {node_id, step, fields: {...}} β per-firing serialized slice |
exec_log | executor exterior | per-node firing summary (suppressed in eval mode) |
eval_progress, eval_episode_done | batch eval | per-episode updates (tagged with worker_id when worker_count>1) |
Every event carries execution_id for multi-run routing on the frontend.
9. API and serialization
9.1 Storage endpoints
All graph storage lives behind /api/graphs (app/api/canvas/graphs.py); every payload crosses the boundary through GraphDefinition.from_dict() / to_dict(), so malformed JSON fails fast:
| Endpoint | Method | Behaviour |
|---|---|---|
/api/graphs | GET | List all β both roots scanned recursively, merged into one list |
/api/graphs/{id} | GET | Load β id is the root-relative path; searches both roots |
/api/graphs | POST | Save β routes to graphs/ or graph_nodes/ by kind; optional folder places it in a subfolder |
/api/graphs/folders | GET / POST | List / create subfolders under a kind root (hierarchical Explorer folders) |
/api/graphs/{id} | PUT | Overwrite β searches both roots |
/api/graphs/{id} | DELETE | Delete β searches both roots |
/api/graphs/layout | POST | Auto-layout (layout.py) β updates positions only |
9.2 Serialization in code
import json from app.graph_def import GraphDefinition with open("workspace/graphs/vln/verified/straightforward.json") as f: gd = GraphDefinition.from_dict(json.load(f)) gd.name # "Straightforward (VLN-CE)" len(gd.nodes) # 15 gd.nodes[0].type # "env_habitat__observe_egocentric" gd.kind # "graph" with open("workspace/graphs/copy.json", "w") as f: json.dump(gd.to_dict(), f, indent=2)
to_dict() drops empty lists and defaults for readability β kind="graph" and empty containers/access_grants/hooks simply don't appear in saved files. The models are plain dataclasses rather than pydantic by choice: the recursive self-reference (NodeDef.subgraph contains NodeDef) works cleanly with from __future__ import annotations, the explicit from_dict/to_dict pair gives full control over backward-compat aliases (the old graph_state field, the state_edges β access_grants rename), and the dependency count stays at zero.
10. File reference
| File | Role |
|---|---|
agentcanvas/backend/app/graph_def.py | GraphDefinition, NodeDef, EdgeDef, ContainerDef, StateDef, AccessGrantDef, HookDef + validate_edge_wire_type + validate_graph_connectivity |
agentcanvas/backend/app/agent_loop/flatten.py | flatten_graph() β recursive composite expansion, container/grant remapping, graphIn/graphOut rewiring, FlattenMap for error tracing |
agentcanvas/backend/app/agent_loop/scope_analysis.py | analyze_scopes() β scope forest + topology errors consumed by validation (Β§4.2) |
agentcanvas/backend/app/agent_loop/graph_executor.py | GraphExecutor β consumes the flat artifact at the handoff; everything inside it is Graph Executor's page |
agentcanvas/backend/app/agent_loop/builtin_nodes.py | NODE_HANDLERS registry + 14 built-in node classes (iterIn, iterOut, llmCall, textParse, historyLog, nullSource, note, graphIn/graphOut, 5 viewer sinks) |
agentcanvas/backend/app/agent_loop/loop_runner.py | LoopRunner β lifecycle wrapper (pause/stop/resume) |
agentcanvas/backend/app/api/canvas/graphs.py | CRUD + /layout + /folders β dual-root recursive storage, routing by kind |
agentcanvas/backend/app/api/execution/run.py | POST /api/navigate/run + pause/stop/status β validates at the boundary |
agentcanvas/backend/app/layout.py | Swim-lane auto-layout algorithm (init band + loop band + viewer row) |
agentcanvas/frontend/src/canvas/types.ts | Frontend mirrors of every dataclass in graph_def.py |
agentcanvas/frontend/src/canvas/graphConversion.ts | fromFlowNodes / toFlowNodes β React Flow β NodeDef |
agentcanvas/frontend/src/canvas/useFlowStore.ts | Tab-aware Zustand store β addNode(), loadGraph(), openGraphInTab(), getGraphForExecution() |
agentcanvas/frontend/src/canvas/nodesetLoader.ts | ensureNodesetsLoaded() β auto-load on graph open (Β§6.4) |
agentcanvas/frontend/src/canvas/portResolution.ts | resolveInstancePorts() β applies ports_mode + _resolve_ports semantics on the client |
agentcanvas/frontend/src/canvas/panels/ExplorerPanel.tsx | Explorer sidebar β category blocks, folder tree, drag/delete |
agentcanvas/frontend/src/canvas/panels/SaveGraphDialog.tsx | Save dialog β accepts kind + group + folder |
agentcanvas/frontend/src/canvas/nodes/composite/CompositeCanvasNode.tsx | Composite rendering β port derivation, preview, drill-in |
workspace/graphs/* Β· workspace/graph_nodes/* | Editable templates Β· archived composite snapshots (Β§6.2) |