NodeSets
The packaging unit โ one class, a shared lifecycle, three server modes, and an optional env panel
A node is one function on the canvas; a NodeSet is how related nodes ship together. It groups nodes that share a lifecycle (a loaded model, a simulator handle), registers them under a ${nodeset}__${node} prefix, and loads or unloads atomically. Its one extra power is running in a different process or conda environment than the framework โ so a node that needs Python 3.8 + habitat-sim can sit beside one that needs the 3.11 backend. This page owns packaging, lifecycle, and the server modes; the node class itself is Base Canvas Node, and the control panel an env nodeset attaches is Env Panels.
1. What a NodeSet is, and when to make one
A NodeSet is a BaseNodeSet subclass that returns a list of node instances from get_tools(). Reach for one when nodes share expensive initialisation (load a model once, use it from several nodes), when they form a natural group that should load and unload as a unit (an environment's reset/step/observe/evaluate), or when they need process / environment isolation (a different conda env, a crash-prone simulator). A handful of unrelated utility nodes don't need a NodeSet โ just drop them in a scanned directory.
2. The smallest NodeSet
from app.components import BaseNodeSet class GreeterNodeSet(BaseNodeSet): name = "greeter" # = the workspace file stem; the __ prefix description = "A two-node demo set" # ({nodeset}__{node}) routes catalog + auto-load def get_tools(self): # the one required method return [HelloNode(), GoodbyeNode()]
Drop the file under workspace/nodesets/, load it in the NodeSet Manager, and greeter__hello / greeter__goodbye appear in the catalog. Open a graph that uses them and the frontend auto-loads the set from the __ prefix.
3. Three server modes at a glance
The same BaseNodeSet runs three ways. Which one is chosen is mostly automatic โ it follows server_python and the eval worker count:
| Mode | When | How it's selected |
|---|---|---|
| Local | Same conda env as the backend; lowest latency. | Default (server_python = None). |
| Auto-hosted | Needs a different Python / env (habitat-sim 0.1.7, a model's deps). | Set server_python to that interpreter โ the registry auto-routes to server mode and spawns the subprocess for you. |
| Manual ServerNodeSet | Non-Python server, custom protocol, or a remote machine. | Subclass BaseNodeSet + BaseServer and run it yourself. |
Independently, an eval run with worker_count > 1 forces server mode so each worker gets its own process (ยง9).
4. The BaseNodeSet contract
One required attribute (name) and one required method (get_tools); the rest are optional ClassVars:
| ClassVar | Default | Role |
|---|---|---|
name | (required) | Unique id; must equal the workspace file stem. Becomes the ${name}__ node prefix. |
description | "" | One-line summary for the Manager. |
server_python | None | Interpreter for server mode; non-None auto-routes to auto-hosted (ยง7). |
parallelism | "shared" | shared (one instance) or replicated (one subprocess per eval worker) โ ยง9, ADR-server-003. |
env_panel | None | A BaseEnvPanel subclass to register on load (ยง12). |
default_per_step_budget_sec | 30.0 | Per-episode eval timeout = max_steps ร this. |
replay_parser | None | Path to a sibling BaseReplayParser module for log replay. |
5. Lifecycle hooks
On load, the registry constructs the NodeSet, calls get_tools(), awaits initialize(**kwargs), and registers the tools; on unload it awaits shutdown(). Both lifecycle hooks default to no-ops โ override them only when the set holds a heavyweight resource:
class EnvHabitatNodeSet(BaseNodeSet): name = "env_habitat" env_panel = HabitatEnvPanel # control-plane panel (see env-panels) parallelism = "replicated" # stateful sim: one subprocess per eval worker async def initialize(self, **kwargs): self._sim = load_habitat() # heavy, held for the nodeset's lifetime def get_tools(self): return [ResetHabitatTool(), StepDiscreteHabitatTool(), ObserveEgocentricHabitatTool(), EvaluateHabitatTool()] async def shutdown(self): self._sim.close()
Env NodeSets also override get_eval_metadata() to expose splits, episode counts, metric names, and the per-episode step_budget the batch runner reads (Env Panels ยง5).
A NodeSet can also own state containers by overriding get_containers() (default []): these live in the nodeset's own process, are shared by reference by its nodes, and surface only a read-only preview across the boundary โ the production “nodeset-owned container” path documented in State Containers ยง9.
6. Local mode
The default. The NodeSet runs in the framework process and conda env; tool calls are plain in-process method calls with no serialisation. Use it for anything whose dependencies coexist with the backend's. Nothing to configure โ leave server_python = None.
7. Auto-hosted mode
When a NodeSet needs a different interpreter, set server_python to it. The registry then auto-routes the set to server mode (ADR-server-001): it spawns python -m app.server.auto_host under that interpreter, the child's AutoServerApp introspects get_tools() and serves a /manifest, and the parent generates proxy nodes that forward over HTTP while keeping the original node_type (so graphs are portable across modes).
class ModelSamNodeSet(BaseNodeSet): name = "model_sam" # different interpreter โ the registry auto-routes this nodeset to SERVER mode: # it spawns `python -m app.server.auto_host` under that env, reads /manifest, # and generates proxy nodes that keep the original node_type. server_python = "python" def get_tools(self): return [SamSegmentPointTool(), SamSegmentBoxTool(), SamAutoMaskTool()]
Import-boundary gotcha. The framework process must be able to import the NodeSet module to read its server_python โ so the heavy, env-specific imports (habitat_sim, torch with CUDA pinned to the other env) must be deferred inside initialize()/tool bodies, not at module top level. The framework imports the class; the subprocess does the heavy lifting. Server-mode proxy node types currently carry an srv_ prefix internally (tracked as TODO #33) โ graphs still bind the unprefixed node_type.
8. Manual ServerNodeSet
For a non-Python server, a custom wire protocol, or a model on another machine, subclass BaseNodeSet + BaseServer (ServerNodeSet) and run it yourself. The framework connects to it as a remote and generates the same proxy nodes. Key BaseServer ClassVars: command (str | list[str] โ the argv-list form is preferred, no shell), port, startup_timeout (default 1800s), auto_restart. No in-tree NodeSet uses this path today โ auto-hosted covers every Python case โ so treat it as the escape hatch for genuinely foreign servers.
9. Parallelism (ADR-server-003)
The parallelism ClassVar decides what happens when an eval runs multiple workers. shared (default) keeps a single instance โ fine for stateless tools, which may even rendezvous K callers through the batched-inference server. replicated spawns N independent tagged subprocesses, one per worker โ required when the NodeSet holds per-worker state (a simulator's scene, an agent pose), which is why every env NodeSet sets it. A worker_count > 1 request forces server mode regardless, since the workers need separate processes.
10. Registry & REST
WorkspaceComponentRegistry.load_nodeset(name, mode, worker_count) drives loading: resolve the class (scan workspace/nodesets/ if needed) โ auto-route to server mode if server_python is set or worker_count > 1 โ for local, get_tools() + initialize() + register tools + register the env panel; for server, spawn the auto_host subprocess(es), fetch /manifest, generate proxies, register the remote env-panel proxy. The REST surface lives under /api/components (file app/api/platform/components.py):
| Endpoint | Behaviour |
|---|---|
GET /api/components/nodesets | List discovered + live nodesets. |
POST /api/components/nodesets/{name}/load?mode= | Load (optionally forcing a mode). |
POST /api/components/nodesets/{name}/unload | Unload + shutdown(). |
GET /api/components/nodesets/{name}/eval-metadata | The get_eval_metadata() dict. |
GET/PUT /api/components/nodesets/{name}/source | Read / write one source file of the NodeSet (path-guarded to its own dir, existing .py only; PUT is 409-conflict- and ast.parse-gated โ the nodeset watcher then hot-reloads local NodeSets, server-mode is only flagged stale). Resolution + guard live in app/components/nodeset_source.py. |
GET/PUT /api/components/nodesets/{name}/source/scoped?node_type= | Per-node slice for the canvas Source tab: module-level globals, the functions the node's class transitively references, and the class itself, each with its line range; PUT splices the edited segments back and syntax-checks the whole file before writing. |
GET /api/components/node-schemas | All node specs (the catalog feed โ Base Canvas Node ยง9). |
GET /api/components/servers + /{name}/start|stop|restart | Lifecycle of server-mode subprocesses. |
11. File layout
NodeSets live under workspace/nodesets/, organised by role: env/ (simulators), method/, policy/, model/ (e.g. model_sam), common/, other/. A simple set is one .py file (env_habitat.py); a complex one is a folder (env/env_libero/, policy/policy_adapter_vla/). The name ClassVar must equal the file (or folder) stem โ that's the contract that ties name, the ${name}__ node prefix, and auto-load together. Placement and naming rules are codified in .claude/standard/nodeset-layout.md.
12. env_panel attachment
An env NodeSet exposes its out-of-loop controls (suite, episode, Play/Stop) by setting the env_panel ClassVar to a BaseEnvPanel subclass. On load, _register_env_panel_for instantiates it, stamps a _context ({mode, server_url, nodeset_name}), and registers it โ or, in server mode, registers a RemoteEnvPanelProxy over the bridge. That's all that belongs here; the panel's field/action contract, on_load, and signal side-effects are Env Panels, and how its episode/step operations map onto run scopes is Env & Episode Lifecycle.
13. Cookbook
- Wrap a model โ one NodeSet, load the model in
initialize(), return the inference nodes fromget_tools(); if the model needs its own env, setserver_python(e.g.model_samwithSamSegmentPointTool/SamSegmentBoxTool/SamAutoMaskTool). - Run in a different conda env โ set
server_pythonto that interpreter; keep heavy imports insideinitialize()/tool bodies so the framework can still import the class (ยง7). - Debug a load failure โ check the Manager's server panel and the
auto_hostsubprocess log; a module-top-level heavy import is the usual culprit.