Wire Type System
The typed-port system β the registry, the compatibility rule, LIST[T], serialisation, and canvas colours
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.
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(anint/strindex into the env's declared valid set β no fixed 0β3) + newCONTROL(continuous motor command).POSEdoubles as a goal-pose/teleport action,TEXTas an EQA answer. - Canonical format is the contract:
WIRE_FORMAT_SPEC(inwire_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 ofDISCRETE_ACTION),OBSERVATION,STEP_RESULTare kept registered for backward-compat but slated for removal β see Β§13.
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
ANY, the two types are equal, or it is exactly T β LIST[T]. Everything else β including LIST[T] β T β is rejected.
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:
| Type | Canonical format (the ONE on-wire shape) | What it is |
|---|---|---|
IMAGE | np.ndarray (H, W, 3) uint8, RGB, 0β255 | RGB frame β producer converts float/CHW |
DEPTH | np.ndarray (H, W) float32, metres, invalid = 0.0 | Depth map β never normalised on-wire |
POSE | dict{position:[x,y,z] m, orientation:[x,y,z,w] unit quat} | Agent pose / goal-pose action (renamed from STATE, ADR-dataflow-004) |
DISCRETE_ACTION | int | str β index/id into the env's declared valid set | Discrete action (VLN 0β3, RxR 0β5, MP3D viewpoint id) |
CONTROL | dict{pos:[3], rot:[3] axis-angle rad, gripper:[-1,1] (+1=open), joint_position?:[N]} | Continuous motor command (VLA / manipulation) |
TEXT | str | Prompts, responses, instructions, EQA answers |
BOOL | bool | Boolean signal (e.g. done / terminated) |
METRICS | dict[str, float] | SPL / SR / nDTW / SDTW |
ANY | arbitrary β no format contract | Escape 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.
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 side | Edge | What 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:
| # | Case | Result |
|---|---|---|
| 1 | source or target missing / empty | allowed (schema-less edges fall through to runtime) |
| 2 | ANY on either side | allowed (escape hatch, symmetric) |
| 3 | types equal | allowed |
| 4 | scalar T β LIST[U] | allowed iff T == U, else type mismatch |
| 5 | LIST[T] β scalar | rejected β lossy connection rejected |
| 6 | both-list or both-scalar, different inner types | rejected β 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:
- Canvas drag (frontend, live):
isCompatibleWireConnection(layoutUtils.ts) runs insideuseFlowStore.onConnectbefore an edge enters the store. On a mismatch the drag is silently dropped (React Flow snaps the wire back) β it mirrors the Β§6 algebra exactly, strippingLIST[β¦]before comparing inner types. One asymmetry vs the backend: it does not canonicalise deprecated aliases, so a legacyACTIONβ migratedDISCRETE_ACTIONedge β which the backend accepts viacanonical_wire_type(Β§13) β is rejected on the canvas during the migration window. - Graph load (backend, hard 400):
validate_graph_connectivity(graph_def.py) runs atPOST /api/navigate/runandPOST /api/eval/v2/start. Alongside its required-but-unwired-port check it now callsvalidate_edge_wire_types, raising HTTP 400 on a mismatch. At load time only builtin/iterIn types are resolvable (nodesets are loaded just after), so this layer hard-checks the builtin/iterIn edges. - Runtime (backend, warn-only β staged): after
ensure_nodesets_for_graphpopulatesNODE_HANDLERS, the run/eval paths re-runvalidate_edge_wire_typesover the whole graph (nodeset envβmethod edges now resolvable) and log any mismatch. This is warn-only pending a clean-sweep before promotion to a hard 400. - Standalone dev tool:
python -m app.tools.validate_graph <name|--all>statically imports each nodeset module to read its declared ports (no simulator, no GPU, no backend β heavy runtimes load lazily) and runs the samewire_type_reportcore. The dev endpointPOST /api/graphs/validateexposes the same check (non-raising; optional?ensureloads nodesets for full coverage). - Runtime value guards (opt-in): the
is_valid_*guards inwire_types.pyare advisory β a handler may call them to assert its inputs, but nothing enforces them automatically.
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:
| Type | In-process (node β node) | WebSocket (backend β UI) | HTTP (framework β server-mode nodeset) |
|---|---|---|---|
IMAGE | raw np.ndarray | base64 PNG (lossless uint8) | base64 PNG (lossless uint8, compact) |
DEPTH | raw np.ndarray | base64 PNG heatmap (lossy, display-only) | __ndarray__ marker (lossless float32 metres) |
DISCRETE_ACTION / BOOL | int|str / bool | number-or-string / boolean | number-or-string / boolean |
POSE / CONTROL / METRICS | dict | object | object (nested arrays β __ndarray__) |
| any other dict holding ndarrays | native | object | __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:
- Display codecs (lossy, display-only) β
app/standard/wire_types.py:image_to_base64/base64_to_image,depth_to_base64/base64_to_depth, andserialize_for_display(wire_type, value)which viewer sinks use to buildviewer_datapayloads.depth_to_base64normalises float32 metres to a 0β255 heatmap β fine for the viewer, never for the data path. - HTTP transport (data path, lossless) β
app/server/serialization.py:serialize_value/deserialize_valueround-trip wire values for server-mode/call/{fn}traffic.IMAGEgoes to a base64 PNG (lossless for uint8, compresses natural frames); everything else numpy β includingDEPTHβ rides the{"__ndarray__": β¦, "dtype": β¦, "shape": β¦}marker via_make_json_safe/_restore_ndarrays(np.frombufferon the receiver), 4Γ smaller and lossless versus nested JSON lists.
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):
| Type | Colour | Hex |
|---|---|---|
IMAGE | green | #22c55e |
DEPTH | cyan | #06b6d4 |
ACTION | amber | #f59e0b |
POSE | violet | #8b5cf6 |
TEXT | grey | #6b7280 |
BOOL | red | #ef4444 |
METRICS | blue | #3b82f6 |
OBSERVATION | green | #22c55e |
STEP_RESULT | pink | #ec4899 |
ANY | slate | #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.)
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:
- Register the constant in
WIRE_TYPESand add its canonical-format line toWIRE_FORMAT_SPEC(app/standard/wire_types.py) β the spec is the contract a node author trusts. ForLIST[T]support you do nothing β the modifier wraps any inner type automatically. - Colour it: add a key to
WIRE_COLORS(layoutUtils.ts), else handles render slate. Add it to thePortListEditordropdown if authors should be able to pick it. - Serialise it (only if it isn't already JSON-safe): add a branch to
serialize_for_display/ the HTTP transport, and optionally ais_valid_<type>guard.
12. File reference
| File | Role |
|---|---|
agentcanvas/backend/app/standard/wire_types.py | WIRE_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.py | PortDef dataclass (name, wire_type, description, optional) + BaseCanvasNode |
agentcanvas/backend/app/graph_def.py | validate_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.py | standalone 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.py | HTTP transport β serialize_value/deserialize_value, __ndarray__ markers |
β¦/canvas/nodes/agentloop/inner/layouts/layoutUtils.ts | WIRE_COLORS + getWireColor (frontend handle colours, LIST[β¦]-aware) + isCompatibleWireConnection |
β¦/canvas/nodes/agentloop/inner/layouts/PortListEditor.tsx | The port_list editor β inner-type dropdown + list checkbox |
agentcanvas/backend/app/agent_loop/state_containers.py | STATE_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.
| Deprecated | Replacement | Why it's safe to keep for now |
|---|---|---|
ACTION | DISCRETE_ACTION | WIRE_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[β¦]). |
OBSERVATION | flat rgb IMAGE + depth DEPTH ports | Dead β 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_RESULT | gym tuple: reward + terminated/truncated BOOL + info ANY | Dead β superseded by the flat gym-step ports across all env nodesets. |
Staged removal, per batch:
- Backend catalog + spec (done 2026-06-13): add
DISCRETE_ACTION/CONTROL+WIRE_FORMAT_SPEC, aliasACTION, loosen/extend theis_valid_*guards, canonicalise invalidate_edge_wire_type. - Nodeset PortDefs: flip
ACTIONβDISCRETE_ACTIONacross env/policy/method nodesets; introduceCONTROLon the VLA envs (env_simpler,env_libero) and the policy-adapter pipeline (env_adapter+policy_adapter_vla) in place of the JSON-in-TEXTaction smuggling; converge each producer onto the Β§3 canonical formats. - Frontend: add
DISCRETE_ACTION/CONTROLtoWIRE_COLORS+ thePortListEditordropdown (Β§9). - Drop the deprecated names from
WIRE_TYPES+WIRE_TYPE_ALIASESonce no PortDef references them; promote the runtime whole-graph check (Β§7) from warn to hard 400 after a clean sweep.