AgentCanvas / Pages / Developer Guide / Design Docs / Components / Base Canvas Node
2026-06-17

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:

__init__config · node_id initialize()once · heavy setup forward(inputs, ctx)on ready → fire shutdown()once · cleanup re-fires while inputs arrive build:_resolve_ports per-fire:_self_log / log

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:

AttributeDefaultRole
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.
childrenNoneGraphDefinition subgraph for composites.
config_schema / default_config{}The node's config contract and its drop-time defaults.
ui_configNodeUIConfig()How the generic renderer draws it (§7).
batched / batch_dimFalse / ""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:

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

AgentCanvas docs