Base Canvas Node
The one base class every node subclasses — lifecycle, ports, config, UI, and the dynamic-firelist spawner
There is exactly one node base class. A tool, a skill, a neural policy, a whole agent — all of them are a BaseCanvasNode subclass, and the executor treats every subclass identically. This page is the developer's-eye view of that class: the contract you implement, its lifecycle, and the attributes that shape its ports and on-canvas UI. The system around it — registry, catalog, rendering — is Canvas System; packaging several nodes together is NodeSets.
1. What it is
BaseCanvasNode (in agentcanvas/backend/app/components/bases.py) is an ABC. Subclassing it is the only way to put behaviour on the canvas — there is no separate BaseTool / BaseSkill / BaseAgent / BaseEnv hierarchy; those distinctions are just category labels on one class. The contract is deliberately tiny: a subclass must set a node_type string and implement an async forward(). Everything else has a default.
2. The smallest working node
A complete, canvas-ready node — no TypeScript, no registration boilerplate beyond living in a scanned directory:
from app.components import BaseCanvasNode, PortDef class CoinFlipNode(BaseCanvasNode): node_type = "coin_flip" # the only attribute with no default display_name = "Coin Flip" category = "tool" output_ports = [PortDef("result", "TEXT", "heads or tails")] async def forward(self, inputs, ctx): # the only required method return {"result": "heads"}
It fires once (no required inputs), emits result on its one wire, and the frontend renders it from the schema alone (§9). forward takes inputs (a {port_name: value} dict of filled input ports) and ctx (the per-node state proxy), and returns a {port_name: value} dict.
3. The lifecycle in one figure
The executor builds one instance per graph node, optionally warms it up, fires it as many times as the dataflow requires, and tears it down at the end:
Both initialize() and shutdown() are optional async hooks (no-ops by default) — override them when the node holds a model, a GPU context, or any expensive resource. _resolve_ports (§5) is consulted at build time to get the live port lists; _self_log/log (§8) let a node record internals around each fire. That's the whole lifecycle.
4. Class attributes in full
Every attribute is a ClassVar with a default except node_type. Set the ones you need:
| Attribute | Default | Role |
|---|---|---|
node_type | (required) | Unique type key in graph JSON; the registry lookup key. |
display_name | "" | Human label in the catalog / on the node. |
description | "" | Tooltip / search text. |
category | "custom" | Catalog grouping & default colour source. |
icon | "" | Lucide icon name. |
kind | "block" | block (atomic) · composite (has a children graph) · control (structural — iterIn/iterOut/graphIn/graphOut). |
input_ports / output_ports | [] | Class-level PortDef lists (§5). Empty for dynamic-port nodes. |
children | None | GraphDefinition subgraph for composites. |
config_schema / default_config | {} | The node's config contract and its drop-time defaults. |
ui_config | NodeUIConfig() | How the generic renderer draws it (§7). |
batched / batch_dim | False / "" | Batched-inference opt-in (§10, ADR-028). |
Per-instance config from the graph JSON lands on self.config, and the node's id on self.node_id — both set by the executor before the first forward. (There is no final_fire attribute: after-loop membership is derived from wiring, not a flag — §6.)
5. Ports & per-instance resolution
A PortDef has four fields: name (the React Flow handle id and the inputs/return key), wire_type (see Wire Types), description, and optional — a node won't fire until every non-optional input port holds a value. Most nodes just declare static input_ports/output_ports.
Nodes whose ports depend on their config (the LLM call, the pivots) leave the class lists empty and override _resolve_ports(cls, config) — the classmethod the validator and executor call to get the live (inputs, outputs) for one instance (ADR-dataflow-003):
class LLMCallNode(BaseCanvasNode): node_type = "llmCall" ports_mode = "input" # frontend hint: this node owns its input list @classmethod def _resolve_ports(cls, config): # build the live (inputs, outputs) from this instance's config.ports ports = [PortDef(p["name"], p["wire_type"]) for p in config.get("ports", [])] return (ports, [PortDef("response", "TEXT")])
ports_mode is not a base-class attribute — it's an optional per-subclass hint the schema serializer reads with a getattr(cls, "ports_mode", "mirror") default, telling the frontend port editor which side is author-editable. The values seen in builtins are "source" (iterIn — owns its output list), "sink" (iterOut, viewers — owns its input list), "input" (llmCall), and the implicit "mirror".
6. kind & the after-loop story
kind tells the executor how to treat the node structurally. block nodes are ordinary functions. composite nodes carry a children graph and are flattened away before execution (Graph System §5). control nodes are the structural machinery — iterIn, iterOut, graphIn, graphOut (matching §4; nullSource is categorised control but its kind is actually block — it's an entry node, not structural).
One historical wrinkle worth stating because older docs got it wrong: a node that runs once after the loop (a verdict, a post-loop summary) is not declared by any flag. After-loop membership is purely topological — a node is after-loop iff it is wired downstream of a loop iterOut's final_* handles. The old final_fire ClassVar (retired 2026-05-21) and its successor config.post_loop flag (retired 2026-06-11) are both gone. See Loop Control §6.
7. NodeUIConfig, ConfigField, DisplayField
A node renders on the canvas with zero TypeScript because the generic renderer reads its ui_config. NodeUIConfig sets the frame (color, layout = block or strip, width/min_height/rounding/min_width/max_width) and carries two widget lists:
config_fields— editable inline controls. EachConfigFieldhas afield_type:label·slider(usesmin/max/step) ·text·select(usesoptions) ·toggle·textarea. Aport_listvariant edits dynamic ports and can show a per-port persist toggle.display_fields— read-only runtime widgets fed from WebSocket output: eachDisplayFieldhas adisplay_typeofimage_viewer,log_list, ormetric_table.
from app.components import BaseCanvasNode, PortDef from app.components.bases import NodeUIConfig, ConfigField class ThresholdNode(BaseCanvasNode): node_type = "threshold" display_name = "Threshold" category = "processing" icon = "Filter" input_ports = [PortDef("score", "ANY", "incoming score")] output_ports = [PortDef("pass", "BOOL", "score >= cutoff")] default_config = {"cutoff": 0.5} ui_config = NodeUIConfig(config_fields=[ ConfigField("cutoff", "slider", label="Cutoff", min=0, max=1, step=0.05), ]) async def forward(self, inputs, ctx): cutoff = self.config.get("cutoff", 0.5) # per-instance config from the JSON return {"pass": inputs["score"] >= cutoff}
8. forward() & ctx in depth
forward(self, inputs, ctx) is where the work happens. inputs holds only the filled ports; read instance config from self.config. The ctx proxy exposes per-node persistent state and, for nodes granted access, state containers: ctx.containers[id] and ctx.graph_state (see State Containers) — a node sees only containers it holds a grant to. Arbitrary attributes on ctx persist across that node's fires (ctx.foo = …) as private scratch state.
For internals the executor can't observe from outside — an assembled prompt, a token count — call self._self_log(key, value) inside forward; the executor collects them via log() after the fire and surfaces them in the execution log. Override log() to filter or summarise.
9. The schema the frontend consumes
The bridge to the canvas is one endpoint: GET /api/components/node-schemas walks NODE_HANDLERS and emits, per node, {type, display_name, description, category, icon, kind, ports_mode, config_schema, default_config, ui_config, input_ports[], output_ports[]} — each port serialised as {name, wire_type, description, optional}. That JSON is everything the generic renderer needs; how the frontend turns it into a rendered node is Canvas System's subject.
10. Batched inference
A neural-policy node that should run K parallel rollouts through one GPU batch opts in by setting batched = True and batch_dim = "<input port name>" (ADR-028). The registry then swaps the in-process handler for a BatchedClient: K worker callers rendezvous in a BatchedInferenceServer, and the node's forward() is invoked once per flush with inputs stacked along batch_dim. The server is pure-functional — any per-call state (RNN hidden states) must travel on the wire as explicit ports, not on self. register_node validates the two attributes at scan time.
11. DynamicFireListNode — the spawner
One subclass changes the forward contract: DynamicFireListNode returns a FireList instead of a dict. The engine fires each FireSpec sequentially against the live registry, then calls aggregate(child_results) to collapse the children's outputs into this node's ports. The children are ephemeral — they appear in the log with this node as parent but never enter the static graph or the ready-queue (ADR-executor-004).
class FanOutNode(DynamicFireListNode): node_type = "fan_out" output_ports = [PortDef("answers", "LIST[TEXT]", "one per spawned child")] async def forward(self, inputs, ctx) -> FireList: return FireList(specs=[ FireSpec(node_type="llmCall", inputs={"prompt": q}) for q in inputs["questions"] ]) def aggregate(self, child_results): # collapse children → output ports return {"answers": [r["response"] for r in child_results]}
For server-mode spawners (where the framework holds only a proxy without the aggregate method), set a declarative FireList.aggregator recipe instead — {"kind": "merge_all"}, {"kind": "passthrough_index", "index": N}, etc. — so the engine can collapse the children without a second round-trip. Children fire sequentially and a child returning its own FireList is rejected (no nested spawning in this version).
12. Registration & boundaries
A subclass becomes live by being in a scanned directory: built-ins are collected into NODE_HANDLERS in builtin_nodes.py; workspace nodes are added by register_node(cls) (last-write-wins on node_type, with the batched-attribute validation of §10). How discovery, the catalog, and rendering work as a system is Canvas System; bundling several nodes with a shared lifecycle and an env panel is NodeSets. This page is just the one class.
13. Cookbook
- Add a node — subclass
BaseCanvasNode, setnode_type+ implementforward, drop the file in the rightworkspace/directory (see NodeSets for placement), reload. It appears in the catalog with no frontend work. - Give it config UI — add a
ui_configwithconfig_fields(§7); the values land onself.configat run time. - Dynamic ports — leave the class port lists empty, override
_resolve_ports(§5), set aports_modehint. - Debug a node that never fires — check every non-optional input port has an incoming edge (the load-time validator catches this; see Graph System §4.2).