AgentCanvas / Pages / Developer Guide / Design Docs / Graph / Wire Type System
2026-06-16 12:00

Every port on every node carries a wire type β€” a short string like IMAGE, TEXT, or CONTROL. The type does four jobs: it pins one canonical format (dtype / shape / range / units) so a consumer never defensively normalises, it decides which ports may connect, it picks how the value is serialised when it crosses a process boundary, and it colours the handle on the canvas. This page is the authority on the type system; for the EdgeDef/PortDef schema that holds these strings, see Graph System Β§2.1.

v2 redesign (2026-06-13) β€” embodied-spanning catalog + format contracts. The original catalog was VLN-shaped: ACTION meant int 0–3, which only fit discrete nav. v2 makes the catalog span VLN / EQA / VLA / manipulation and hardens the format contract per type:
  • Action split by kind: ACTION β†’ DISCRETE_ACTION (an int/str index into the env's declared valid set β€” no fixed 0–3) + new CONTROL (continuous motor command). POSE doubles as a goal-pose/teleport action, TEXT as an EQA answer.
  • Canonical format is the contract: WIRE_FORMAT_SPEC (in wire_types.py) is the single source of truth β€” IMAGE = uint8 RGB 0–255, DEPTH = float32 metres, etc. Producers must emit exactly this; not runtime-enforced (the load validator still checks only type-name compatibility, Β§7).
  • Deprecations: ACTION (alias of DISCRETE_ACTION), OBSERVATION, STEP_RESULT are kept registered for backward-compat but slated for removal β€” see Β§13.
Migration of nodesets/graphs is staged in batches; until then legacy ACTION ports stay valid via WIRE_TYPE_ALIASES.

1. What a wire type is

A wire type is a label on a port, nothing more β€” there is no class hierarchy, no generics, no inheritance. The vocabulary is a flat set of nine current inner types (plus three deprecated names kept for backward-compat) and one modifier (LIST[T]). A node author writes the label as a plain string in a PortDef; the framework reads it at four moments β€” edge creation (frontend compatibility), graph load (backend compatibility, Β§7), transport (serialisation), and render (colour). Values on the wire are not runtime-checked against their type by default: a node can technically emit anything (Β§7), so the canonical format (Β§3) is a producer-side contract, not a guard.

2. The compatibility rule, in one sentence

Two ports may connect iff either side is ANY, the two types are equal, or it is exactly T β†’ LIST[T]. Everything else β€” including LIST[T] β†’ T β€” is rejected.
IMAGEIMAGEequal β€” allowed ANYTEXTANY on either side β€” allowed IMAGELIST[IMAGE]T β†’ LIST[T] β€” auto-wrapped LIST[IMAGE]IMAGElossy β€” rejected green = connects Β· red dashed = blocked

The full algebra and the source function are in Β§6; the surprising part β€” where this rule is actually enforced β€” is in Β§7.

3. The registry at a glance

The canonical set lives in agentcanvas/backend/app/standard/wire_types.py as WIRE_TYPES; the format column is the machine-readable WIRE_FORMAT_SPEC in the same file. Nine current inner types, plus the LIST[T] modifier:

TypeCanonical format (the ONE on-wire shape)What it is
IMAGEnp.ndarray (H, W, 3) uint8, RGB, 0–255RGB frame β€” producer converts float/CHW
DEPTHnp.ndarray (H, W) float32, metres, invalid = 0.0Depth map β€” never normalised on-wire
POSEdict{position:[x,y,z] m, orientation:[x,y,z,w] unit quat}Agent pose / goal-pose action (renamed from STATE, ADR-dataflow-004)
DISCRETE_ACTIONint | str β€” index/id into the env's declared valid setDiscrete action (VLN 0–3, RxR 0–5, MP3D viewpoint id)
CONTROLdict{pos:[3], rot:[3] axis-angle rad, gripper:[-1,1] (+1=open), joint_position?:[N]}Continuous motor command (VLA / manipulation)
TEXTstrPrompts, responses, instructions, EQA answers
BOOLboolBoolean signal (e.g. done / terminated)
METRICSdict[str, float]SPL / SR / nDTW / SDTW
ANYarbitrary β€” no format contractEscape hatch for unstructured / prototype data + gym info
LIST[T]list[T]Modifier β€” wraps any inner T so a port carries many values per firing (Β§5)

Deprecated (still in WIRE_TYPES so legacy graphs load; see Β§13): ACTION (β†’ DISCRETE_ACTION via WIRE_TYPE_ALIASES), and the dead composites OBSERVATION / STEP_RESULT (no nodeset emits them β€” flat rgb/depth ports + the gym tuple reward/terminated/truncated/info won).

That is the whole type system. The rest of this page is detail: what each type's value looks like (Β§4), how the list modifier behaves (Β§5), the exact compatibility algebra and where it's enforced (Β§6–§7), how values cross process boundaries (Β§8), how handles are coloured (Β§9), how this relates to the separate state value types (Β§10), and the deprecation/migration plan (Β§13).

4. Per-type semantics

Most types are obvious from the table. The two action types and the two fixed-shape dicts are worth pinning down:

POSE     = {"position": [x, y, z], "orientation": [qx, qy, qz, qw]}
CONTROL  = {"pos": [dx, dy, dz], "rot": [rx, ry, rz], "gripper": 1.0}  # rot = axis-angle rad; gripper +1 open / -1 close
METRICS  = {"spl": 0.42, "sr": 1.0, "ndtw": 0.65, "sdtw": 0.65}

DISCRETE_ACTION is an int or str index/id into the env's declared valid set β€” the env owns the set, the type no longer hardwires 0–3. So discrete Habitat nav (0=STOP, 1=FORWARD, 2=TURN_LEFT, 3=TURN_RIGHT), RxR (0–5), and MP3D panoramic viewpoint-id selection all share this one type. CONTROL is a named-field continuous motor command (delta end-effector + gripper, optionally absolute joint positions) β€” deliberately a self-describing dict rather than a bare 7-vector, so it isn't a raw-ndarray escape. POSE doubles as a "go-to-pose / teleport" action (greedy-follower nav, HM-EQA free-pose), and TEXT doubles as an EQA free-text answer. The STATE β†’ POSE rename (ADR-dataflow-004) freed the word β€œstate” to mean a stored memory slot (state containers). The deprecated composites OBSERVATION (IMAGE+DEPTH) and STEP_RESULT (OBSERVATION+POSE+BOOL+METRICS?) are documented in Β§13.

Why DEPTH is float32 metres, not a normalised proxy. Two reasons. (1) Geometry consumers β€” TSDF integration, point-cloud projection, metric waypoint planning β€” need true metric depth, not a per-frame-normalised [0,1] map. (2) A class of models encodes high-dynamic-range metric depth that an 8-bit / normalised representation would destroy. Vision Banana (Google DeepMind, 2026 β€” an instruction-tuned Nano Banana Pro / Gemini-3 image model) is the sharp example: it estimates metric depth via a strictly reversible power-law mapping of metric distance 0β†’βˆž onto the edges of the RGB colour cube, then decodes that pseudo-colour map back to exact metres. The decoded signal spans the full metric range with fine precision β€” precisely what float32 (transported losslessly, Β§8) preserves and what the old normalise-to-PNG depth path obliterated.

5. The LIST[T] modifier

LIST[T] (ADR-dataflow-005) is a modifier, not a new type: any inner T can be wrapped so a port carries list[T]. It exists for multi-image VLM calls (MapGPT), multi-LLM debate transcripts (DiscussNav), and population fan-in for search operators.

The design rule is consumer-side coercion: producers stay scalar (a node that emits one T keeps its port T), and a consumer opts in by declaring its input LIST[T]. Whatever arrives, the executor guarantees the handler sees a list[T]:

Producer sideEdgeWhat the consumer's handler receives
T (one producer)T β†’ LIST[T][value] β€” wrapped
T Γ— N (fan-in)NΓ—T β†’ LIST[T][v1, …, vN] β€” concatenated in edge-declaration order
LIST[T] (one producer)LIST[T] β†’ LIST[T]passthrough
LIST[T] Γ— N (fan-in)NΓ—LIST[T] β†’ LIST[T]flattened + concatenated

The coercion happens in exactly one place β€” the executor's port-binding seam, _route_value_to_port in graph_executor.py. Producers, viewers, state containers, and serializers are all unchanged. Fan-in order follows the graph JSON's edge order, which keeps positional prompt references (β€œImage 3 is the third viewpoint”) stable across runs.

Use it when a node genuinely produces or consumes many items per firing. Don't wrap a scalar producer β€œjust in case” β€” the executor wraps at the consumer, so it's redundant. Nested LIST[LIST[T]] is not supported; flatten in the producer or route through a composite.

Declaring a list port in Python is just the wrapped string; on the canvas, the PortListEditor offers an inner type plus a list checkbox, and the saved wire_type becomes LIST[<inner>]. List handles render with a doubled outer ring in the inner type's colour (Β§9).

6. The compatibility algebra in full

validate_edge_wire_type(source_type, target_type) β†’ (ok, reason) in graph_def.py is the canonical rule. Six cases, in order:

#CaseResult
1source or target missing / emptyallowed (schema-less edges fall through to runtime)
2ANY on either sideallowed (escape hatch, symmetric)
3types equalallowed
4scalar T β†’ LIST[U]allowed iff T == U, else type mismatch
5LIST[T] β†’ scalarrejected β€” lossy connection rejected
6both-list or both-scalar, different inner typesrejected β€” type mismatch

Note the asymmetry between cases 4 and 5: widening one value into a singleton list is safe and automatic, but narrowing a list to a scalar would silently drop data, so it is refused.

7. Validation β€” and where it actually happens

Type compatibility is enforced at graph load and at the canvas, with runtime coverage staged. As of 2026-06-13 validate_edge_wire_type is wired into the backend load path via validate_edge_wire_types / wire_type_report β€” it is no longer dead code. Coverage is bounded by where a port's declared type can be resolved: builtin and iterIn/iterOut ports are always resolvable; a nodeset node's ports are only resolvable once its nodeset is loaded (its proxy class is registered in NODE_HANDLERS). An unresolved port is treated as allowed, so coverage degrades safely rather than producing false rejections.

What enforces what:

is_valid_image(obj)            # ndarray, ndim==3, shape[2]==3
is_valid_depth(obj)            # ndarray, ndim==2
is_valid_discrete_action(obj)  # int | str β€” env owns the valid set
is_valid_control(obj)          # dict with pos/rot/gripper/joint_position
is_valid_pose(obj)             # dict with "position" and "orientation"
is_valid_list_of("IMAGE", obj) # list whose items each pass is_valid_image

Implication for code-generated graphs. A graph authored on the canvas can never reach the backend wire-mismatched. But a graph produced by code β€” most importantly the AAS architect, which edits graphs through the API rather than the canvas β€” gets no wire-type safety net at load: a DEPTH β†’ TEXT edge is accepted and only surfaces (if at all) as a runtime error inside a handler. Treat the frontend rule as the contract; if you generate graphs, run them through the Β§6 algebra yourself.

8. Serialisation across boundaries

The same wire type takes a different concrete form on each of three transports. Authors never serialise by hand β€” the framework converts at the boundary, keyed on wire_type:

TypeIn-process (node ↔ node)WebSocket (backend β†’ UI)HTTP (framework ↔ server-mode nodeset)
IMAGEraw np.ndarraybase64 PNG (lossless uint8)base64 PNG (lossless uint8, compact)
DEPTHraw np.ndarraybase64 PNG heatmap (lossy, display-only)__ndarray__ marker (lossless float32 metres)
DISCRETE_ACTION / BOOLint|str / boolnumber-or-string / booleannumber-or-string / boolean
POSE / CONTROL / METRICSdictobjectobject (nested arrays β†’ __ndarray__)
any other dict holding ndarraysnativeobject__ndarray__ markers

In-process there is no serialisation at all β€” the executor moves result["rgb"] into the next node's pending inputs by reference. Over WebSocket and HTTP, JSON can't hold a numpy array, so two helper layers handle it:

Why DEPTH does not use PNG on the data path. PNG is 8/16-bit integer β€” it cannot represent float32 metres. The earlier serialize_value DEPTH branch encoded depth as a normalised PNG, so a server-mode env's depth arrived at a parent-process consumer as a [0,1] array with the metric scale destroyed (a silent correctness bug for any geometry/TSDF/projection node). Fixed 2026-06-13: DEPTH now transports via the lossless __ndarray__ marker; the PNG depth codec is display-only and the dead copy in serialization.py was removed.

The single rule behind all of this: one canonical format per type, and the data path is always lossless. Native Python in-process, JSON-safe over the network (PNG for images, __ndarray__ for every other array), lossy encodings confined to the display path β€” so the same handler code runs, with the same numeric values, whether a node is in-process or remote.

9. Canvas colours

A handle's colour is its wire type, not its node category β€” don't confuse the two: handle = wire type, node frame = category (the frame tint comes from CATEGORY_STYLES in the same file). The canonical map is WIRE_COLORS in layoutUtils.ts; getWireColor strips a LIST[…] wrapper before lookup, so a LIST[IMAGE] handle uses the IMAGE colour with a doubled ring (Β§5):

TypeColourHex
IMAGEgreen#22c55e
DEPTHcyan#06b6d4
ACTIONamber#f59e0b
POSEviolet#8b5cf6
TEXTgrey#6b7280
BOOLred#ef4444
METRICSblue#3b82f6
OBSERVATIONgreen#22c55e
STEP_RESULTpink#ec4899
ANYslate#9ca3af

Any type without an entry falls back to the ANY slate. (POSE and STEP_RESULT were added to WIRE_COLORS on 2026-06-13 β€” before that, the STATE β†’ POSE rename had left POSE handles rendering as slate.)

v2 colours pending the frontend migration. DISCRETE_ACTION and CONTROL are backend-only as of the 2026-06-13 catalog change β€” WIRE_COLORS (frontend) has no entry yet, so their handles render slate until the frontend batch lands. Until then a legacy ACTION handle keeps its amber. Suggested when migrating: DISCRETE_ACTION inherits ACTION's amber #f59e0b; CONTROL takes a distinct hue (e.g. orange #fb923c) to read apart from discrete actions.

10. Wire types vs state value types

A wire type travels through an edge once per firing β€” ephemeral, per-step. A state value type is the type of a slot in a state container β€” persistent memory that accumulates across iterations. The two registries overlap but are not the same: STATE_VALUE_TYPES drops the raw frame types (a container holds memory, not pixels) and adds domain memory types (POINTCLOUD, OCCUPANCY_MAP, EMBEDDING, EPISODE_CONTEXT).

STATE_VALUE_TYPES = {                  # state_containers.py β€” memory, not raw frames
    "TEXT", "BOOL", "METRICS", "POSE",
    "POINTCLOUD", "OCCUPANCY_MAP", "EMBEDDING", "EPISODE_CONTEXT",
    "ANY",
}
_REMOVED_VALUE_TYPES = {               # rejected at build_container() with a ValueError
    "STATE", "IMAGE", "DEPTH", "ACTION", "OBSERVATION", "STEP_RESULT",
}

Depth on the reducers, lifetimes, and grants is in State Containers; this contrast is all that belongs here.

11. Adding a new wire type

Three touch-points, all mechanical:

  1. Register the constant in WIRE_TYPES and add its canonical-format line to WIRE_FORMAT_SPEC (app/standard/wire_types.py) β€” the spec is the contract a node author trusts. For LIST[T] support you do nothing β€” the modifier wraps any inner type automatically.
  2. Colour it: add a key to WIRE_COLORS (layoutUtils.ts), else handles render slate. Add it to the PortListEditor dropdown if authors should be able to pick it.
  3. Serialise it (only if it isn't already JSON-safe): add a branch to serialize_for_display / the HTTP transport, and optionally a is_valid_<type> guard.

12. File reference

FileRole
agentcanvas/backend/app/standard/wire_types.pyWIRE_TYPES registry + WIRE_FORMAT_SPEC (canonical-format contract) + WIRE_TYPE_ALIASES/canonical_wire_type (deprecated-name resolution), LIST[T] helpers (is_list_type/unwrap_list/wrap_list), type guards, image codecs, serialize_for_display
agentcanvas/backend/app/components/bases.pyPortDef dataclass (name, wire_type, description, optional) + BaseCanvasNode
agentcanvas/backend/app/graph_def.pyvalidate_edge_wire_type (the algebra) + wire_type_report/validate_edge_wire_types (whole-graph check, the shared core) + validate_graph_connectivity (required-port + wire-type check at load)
agentcanvas/backend/app/tools/validate_graph.pystandalone dev CLI β€” static nodeset port introspection + wire_type_report
agentcanvas/backend/app/agent_loop/graph_executor.py_route_value_to_port β€” the single LIST[T] coercion seam
agentcanvas/backend/app/server/serialization.pyHTTP transport β€” serialize_value/deserialize_value, __ndarray__ markers
…/canvas/nodes/agentloop/inner/layouts/layoutUtils.tsWIRE_COLORS + getWireColor (frontend handle colours, LIST[…]-aware) + isCompatibleWireConnection
…/canvas/nodes/agentloop/inner/layouts/PortListEditor.tsxThe port_list editor β€” inner-type dropdown + list checkbox
agentcanvas/backend/app/agent_loop/state_containers.pySTATE_VALUE_TYPES β€” the separate state-memory registry (Β§10)

13. Deprecation & migration plan

The v2 catalog change (2026-06-13) is additive and backward-compatible β€” the new types ship and the old names stay registered so every existing graph still loads. Nodeset/graph migration runs in later batches.

DeprecatedReplacementWhy it's safe to keep for now
ACTIONDISCRETE_ACTIONWIRE_TYPE_ALIASES = {ACTION: DISCRETE_ACTION}; canonical_wire_type resolves both sides of an edge before the Β§6 compatibility check, so a legacy ACTION port connects to a migrated DISCRETE_ACTION port (and vice-versa, and under LIST[…]).
OBSERVATIONflat rgb IMAGE + depth DEPTH portsDead β€” no nodeset emits or consumes it (every env splits observations into flat ports). Kept in WIRE_TYPES only so a stray legacy declaration wouldn't fail load.
STEP_RESULTgym tuple: reward + terminated/truncated BOOL + info ANYDead β€” superseded by the flat gym-step ports across all env nodesets.

Staged removal, per batch:

  1. Backend catalog + spec (done 2026-06-13): add DISCRETE_ACTION / CONTROL + WIRE_FORMAT_SPEC, alias ACTION, loosen/extend the is_valid_* guards, canonicalise in validate_edge_wire_type.
  2. Nodeset PortDefs: flip ACTION β†’ DISCRETE_ACTION across env/policy/method nodesets; introduce CONTROL on the VLA envs (env_simpler, env_libero) and the policy-adapter pipeline (env_adapter + policy_adapter_vla) in place of the JSON-in-TEXT action smuggling; converge each producer onto the Β§3 canonical formats.
  3. Frontend: add DISCRETE_ACTION / CONTROL to WIRE_COLORS + the PortListEditor dropdown (Β§9).
  4. Drop the deprecated names from WIRE_TYPES + WIRE_TYPE_ALIASES once no PortDef references them; promote the runtime whole-graph check (Β§7) from warn to hard 400 after a clean sweep.
AgentCanvas docs