AgentCanvas / Pages / Developer Guide / Design Docs / Operations / Server Mode
2026-06-17

Server mode lets an external system โ€” one that needs a different Python env, a non-Python runtime, or a separate machine โ€” appear on the canvas as ordinary nodes. The system runs as a standalone HTTP service that exposes typed functions through a /manifest; AgentCanvas launches it, reads the manifest, and auto-generates proxy nodes that forward each firing over the wire. From the executor's side a proxy node is indistinguishable from a local one. This page owns the server classes, their lifecycle, and the parallelism gate; the nodeset that gets auto-hosted is NodeSets, and the control panel it may carry is Env Panels.

The two sides โ€” one class per process.

Server process (any language) ServerApp builds the FastAPI service /manifest ยท /call ยท /health ยท /env-panel AgentCanvas process BaseServer launches & monitors the subprocess Popen ยท /health poll ยท proxy-node gen start() HTTP

1. What server mode is, and when you need it

Reach for server mode whenever a capability can't live inside the 3.11 backend process. Four drivers recur:

The common thread: move the system to its own process and talk to it over HTTP with typed wire data. If a capability runs fine in the backend env, it does not need a server โ€” a plain local nodeset is simpler and lower-latency.

2. The smallest server

You rarely write a server by hand. The canonical path is to let a nodeset auto-host itself โ€” set one ClassVar and the registry spawns the subprocess, reads the manifest, and generates the proxy nodes for you:

class HabitatNodeSet(BaseNodeSet):
    name = "env_habitat"
    server_python = "python"  # โ† that's the whole trigger
    # get_tools() returns the observe / step / evaluate nodes as usual

Load it and env_habitat__observe, env_habitat__step, โ€ฆ appear in the catalog as proxy nodes, backed by a subprocess running that interpreter. You only write a ServerApp by hand (ยง8) โ€” or a ServerNodeSet โ€” when you need a non-Python service, a custom protocol, or a remote machine. The auto-host machinery is ยง10.

3. The two sides at a glance

A server-mode nodeset runs as two cooperating classes, one in each process:

Class Side Responsibility
ServerApp Server process Builds a FastAPI service from get_functions(). Handles serialization. Optionally mounts /env-panel/* routes when get_env_panel_instance() returns a non-None env panel. Server authors subclass this.
AutoServerApp Server process Subclass of ServerApp that auto-generates ServerFunction entries by introspecting a BaseNodeSet's nodes. Canonical path for all auto-hosted nodesets. Also instantiates the nodeset's BaseEnvPanel (if declared) and hosts it via the env panel bridge.
BaseServer AgentCanvas process Launches the subprocess, tracks the PID, polls /health, fetches /manifest. The framework uses this.

4. Lifecycle

4.1 Server States

stopped starting connected unreachable start() /health OK /health fail timeout / process exits auto_restart โ†’ start()

A /health OK while connected keeps it connected (recovery is silent); stop() returns any state to stopped.

4.2 How start() works

  1. subprocess.Popen(command, shell=isinstance(command, str)) โ€” a list command (preferred) spawns with no shell, so PR_SET_PDEATHSIG binds directly to the python child (ยง4.4); a str command keeps the legacy /bin/sh -c path. Records the PID and starts a new session (os.setsid).
  2. _wait_for_health() โ€” polls GET /health, interval 0.5 s backing off to 3.0 s. Liveness-driven: a dead subprocess fast-fails immediately, and startup_timeout is only the ceiling for a pathological hang.
  3. On first healthy probe, server.connected = True and server.status = "connected".

4.3 How stop() works

  1. os.killpg(SIGTERM) โ€” signals the whole process group; waits up to 5 s for a graceful exit.
  2. os.killpg(SIGKILL) โ€” if anything is still alive after the grace window.
  3. Resets state: server.connected = False, server.pid = None, server.status = "stopped".

4.4 Crash-safe cleanup

stop() only runs when the framework is in control โ€” graceful shutdown, REST /stop, normal exit. If the backend dies a way that bypasses cleanup hooks โ€” kill -9, OOM-kill, segfault, kernel panic, force-quit before stop() is called โ€” every auto_host subprocess becomes an init-orphan (PPID=1) and keeps running with its GPU memory and ports still held, blocking the next backend start. This was a recurring failure mode for stateful nodesets (e.g. a 14 GiB Prismatic VLM left squatting on a 24 GiB GPU after a crashed eval).

The framework defends against this with Linux PR_SET_PDEATHSIG: each auto_host child arms SIGTERM as its parent-death signal at startup. The kernel then guarantees that when the immediate parent dies any way, this process receives SIGTERM within milliseconds โ€” no user-space code in the parent has to run.

Two arming points (belt-and-suspenders, since the spawn chain typically layers bash -c and a conda run wrapper between uvicorn and the python child, and tail-exec optimisations vary by shell):

Where What Covers
_preexec_setsid_pdeathsig() in base_server.py runs in the spawned child after fork, before exec โ€” combines os.setsid() with prctl(PR_SET_PDEATHSIG, SIGTERM). Survives exec() (cleared only for setuid binaries). the shell-wrapper layer; the prctl persists into the python child via the standard exec inheritance rule
_arm_pdeathsig() in auto_host.py main() re-arms inside the python interpreter at module entry reparenting after shell tail-exec; any spawn-chain shape that falls outside the preexec_fn's reach

Either arming point catches the typical case; together they catch all spawn-chain shapes. End-to-end test (SIGKILL the backend with active auto_host children): all children die within ~300 ms, GPU memory released, ports freed.

Scope and limits

The framework-side stop() (ยง4.3) remains the primary cleanup path; PDEATHSIG is the kernel-level backstop for the fraction of crash modes where stop() does not get to run.

5. YAML Config

Servers are registered via YAML files in workspace/servers/:

# workspace/servers/habitat.yaml
url: http://localhost:9100
enabled: true          # false to skip during scan
managed: true          # true = framework starts/stops the process
command: "python -m app.server.examples.habitat_server --port 9100"
working_dir: /path/to/vlnworkspace/agentcanvas/backend
startup_timeout: 60    # seconds to wait for /health
auto_restart: false    # restart on crash
Field Required Default Purpose
url yes โ€” Base URL of the server
enabled no true Skip if false
managed no false Framework starts/stops the process
command if managed โ€” Bash command to start the service
working_dir no โ€” Working directory for subprocess
port no โ€” Port the service binds (parsed from url if omitted)
host no โ€” Host override for the subprocess bind
description no "" Human label surfaced in GET /api/components/servers
startup_timeout no 30 (YAML) Seconds to wait for /health. Path-dependent default: the YAML scan uses 30; auto-hosted nodesets fall back to the BaseServer class default 1800 (30 min, sized for HF weight downloads) via getattr(nodeset_cls, "startup_timeout", 1800)
auto_restart no false Restart on crash

5.1 Three deployment modes

Mode Config Who starts the process
Managed managed: true, command: ... Framework calls subprocess.Popen(command)
Manual managed: false (or omitted) User starts server themselves, framework connects
Remote url: http://gpu-machine:9100 Process runs on another machine entirely

All three share the same manifest protocol and proxy node generation.

6. REST API (on AgentCanvas)

Endpoint Method Purpose
/api/components/servers GET List all servers with live status
/api/components/servers/{name}/start POST Start a managed server
/api/components/servers/{name}/stop POST Stop a managed server
/api/components/servers/{name}/restart POST Restart a managed server

Example response from GET /api/components/servers:

[
  {
    "name": "habitat",
    "description": "Habitat-Sim VLN-CE environment",
    "url": "http://localhost:9100",
    "status": "connected",
    "pid": 12345,
    "connected": true,
    "error": null,
    "auto_restart": false,
    "nodes": ["env_habitat__observe", "env_habitat__step", ...]
  }
]

7. Manifest Protocol (on the server)

Every server exposes these endpoints:

Endpoint Method Purpose
/manifest GET Returns function schemas with typed ports
/call/{function_name} POST Invoke function: {inputs, config} โ†’ {outputs}
/health GET Liveness check: {status: "ok", name, version}

If the server hosts an env panel, it also exposes the env panel bridge routes โ€” see Section 8.1.

7.1 Wire type serialization

Same wire types as the canvas (see Wire Types). Transport is msgpack (serialization.py ยท pack_body / unpack_body): the whole {"inputs", "config"} body is packed in one shot, and the binary types JSON can't hold โ€” ndarray, torch.Tensor, PIL.Image โ€” ride a single blob ExtType as raw bytes (no base64), while bytes use msgpack's native bin type. Decoding is type-driven and degrades when the receiving env lacks a type (a torch/PIL blob decoded in an env without torch/PIL comes back as ndarray rather than crashing โ€” cross-boundary = cross-env, so rebuilding a wrapper type is not free). /call negotiates by Content-Type and still accepts the legacy JSON encoding (__ndarray__ base64 markers) during the migration window. (Before 2026-06-15 this path was JSON+base64 only, so opaque non-JSON-safe values 500'd โ€” see roadmap #67.)

7.2 Dynamic Fire-List over the wire

A server-side DynamicFireListNode emits a variable number of child firings, not a fixed output dict. To carry that across HTTP, the server serializes the result as a single envelope {"__firelist__": {...}} (server_app.py ยท call_function); the proxy's forward detects the marker and unwraps it back into a fire-list (proxy.py ยท forward). This keeps the dynamic-fan-out node type working identically in local and server modes โ€” the executor sees the same fire-list either way. No node-author action is required; the envelope is internal to the serialization boundary.

7.3 Reverse channel: log/error push

A subprocess is otherwise write-only over /call, so its logs and handler errors never reached the canvas โ€” a handler exception was demoted to a swallowed {"error": ...} value by the proxy. The subprocess now POSTs structured events back to the executor's POST /api/internal/events (server/event_push.py buffers + flushes at call boundaries; api/execution/internal_events.py receives), which republishes each on the ErrorBus โ†’ error_event WebSocket frame, so server-node logs/errors surface on the canvas like local-node ones. Handler exceptions are first-classed (pushed with node/execution scope + traceback) instead of being swallowed; a WARNING+ logging bridge forwards general subprocess logs. The executor base URL is handed to each subprocess via AGENTCANVAS_EXECUTOR_URL (resolved from Settings); the channel is a no-op when unset. See roadmap #54.

8. ServerApp (building the service)

Server authors subclass ServerApp to build the HTTP service:

from app.server import ServerApp, ServerFunction, PortSchema

class MySLAMApp(ServerApp):
    name = "slam"
    port = 9001

    def get_functions(self):
        return [
            ServerFunction(
                name="localize",
                input_ports=[PortSchema("rgb", "IMAGE"), PortSchema("depth", "DEPTH")],
                output_ports=[PortSchema("position", "STATE")],
                handler=self.localize,
            ),
        ]

    async def localize(self, inputs: dict, config: dict) -> dict:
        rgb = inputs["rgb"]   # np.ndarray, auto-deserialized
        return {"position": {"position": [1, 0, 2], "orientation": [0, 0, 0, 1]}}

if __name__ == "__main__":
    MySLAMApp().serve()

ServerApp._build_app() registers get_functions() as POST /call/{fn} routes and unconditionally mounts GET /manifest and GET /health. It then calls get_env_panel_instance() โ€” if the result is non-None the env panel bridge routes are also mounted (see below).

8.1 Env panel bridge routes (ADR-server-002)

When a server hosts a BaseEnvPanel, _build_app conditionally mounts five additional routes:

Endpoint Method Purpose
/env-panel/info GET Name, display_name, fields, actions
/env-panel/state GET Current state via panel.on_load()
/env-panel/options/{field} GET Dynamic option list via panel.get_options(field)
/env-panel/field/{field} POST Field-change callback: {value: ...}
/env-panel/action/{action} POST Action callback: {params: {...}}

These routes are only mounted when get_env_panel_instance() returns non-None. For AutoServerApp, the env panel is instantiated in __init__ from nodeset_cls.env_panel (so _build_app can see it at app-construction time, before on_startup runs). On the AgentCanvas side, _register_remote_env_panel fetches /env-panel/info and registers a RemoteEnvPanelProxy that forwards every BaseEnvPanel call over HTTP โ€” making the canvas control panel work identically in local and server modes.

9. Habitat Example

Two classes in app/server/examples/habitat_server.py:

9.1 HabitatApp (ServerApp) โ€” the actual service

Wraps HabitatEnvManager methods as server functions:

Function In Out
observe (seed) rgb (IMAGE), depth (DEPTH)
step action (ACTION) rgb, depth, pose, action, done, metrics
get_state (seed) pose (POSE)
episode_info (seed) instruction (TEXT), episode_id (TEXT)
panorama (seed) composite (IMAGE), scene (TEXT)

Run directly: python -m app.server.examples.habitat_server --port 9100

9.2 HabitatServer (BaseServer) โ€” the process manager

Defines Habitat-specific launch properties:

class HabitatServer(BaseServer):
    name = "habitat"
    port = 9100
    startup_timeout = 60   # Habitat init loads 3D scenes โ€” slow
    command = "python -m app.server.examples.habitat_server --port 9100"
    working_dir = ".../agentcanvas/backend"

Used by WorkspaceComponentRegistry when YAML has managed: true.

10. Auto-Generated Servers (ADR-server-001)

Instead of manually writing a ServerApp subclass, any BaseNodeSet can be auto-hosted in server mode. AutoServerApp is the canonical path โ€” it introspects the nodeset's nodes and auto-generates ServerFunction entries from their input_ports/output_ports. It is also responsible for instantiating and hosting the nodeset's BaseEnvPanel (if declared) via the env panel bridge.

10.1 How It Works

  1. BaseNodeSet.get_tools() yields the BaseCanvasNode instances with their input_ports/output_ports.
  2. AutoServerApp converts each PortDef โ†’ PortSchema automatically and serves the standard /manifest, /call/{fn}, /health protocol.
  3. If nodeset_cls.env_panel is set, it is instantiated in __init__ and the /env-panel/* routes are mounted via ServerApp._build_app.

AutoServerApp.on_startup initialises the nodeset (await self._nodeset.initialize()) and starts the BatchedInferenceServer. on_shutdown drains the batched server then shuts down the nodeset.

10.2 CLI Usage

# By dotted module path (preferred for package nodesets with __init__.py):
python -m app.server.auto_host \
    --module workspace.nodesets.model.model_sam \
    --class SamNodeSet \
    --port 9200 [--host 0.0.0.0]

# By file path (single-file nodesets):
python -m app.server.auto_host \
    --file /path/to/workspace/nodesets/model/model_sam.py \
    --class SamNodeSet \
    --port 9200

The registry auto-selects --module for package nodesets (those exposed via an __init__.py) and --file for single-file ones.

10.3 Registry Integration

The WorkspaceComponentRegistry launches auto-hosted servers transparently via _load_nodeset_as_server (e.g. POST /api/components/nodesets/sam/load?mode=server):

  1. Finds the nodeset class and source file via inspect.getfile().
  2. Picks a free port.
  3. Spawns python -m app.server.auto_host --file โ€ฆ --class โ€ฆ --port โ€ฆ.
  4. Waits for /health, fetches /manifest.
  5. Generates proxy nodes โ€” identical to YAML-based servers.
  6. If the nodeset has an env panel, calls _register_remote_env_panel to fetch /env-panel/info and register a RemoteEnvPanelProxy locally.

When to use manual ServerApp instead: non-Python servers, custom protocols, or servers that need logic beyond what forward() provides.

There is also an ephemeral spawn path โ€” load_nodeset_ephemeral / unload_nodeset_ephemeral (registry.py) โ€” which starts a tagged auto-server without registering proxy node classes in the local NODE_HANDLERS. The subprocess-side eval bootstrap uses it: the worker already has the manifest baked in and only needs the live process, not a fresh round of proxy-class generation.

10.4 Auto-host ClassVar knobs

Two nodeset ClassVars steer how the registry spawns the server subprocess:

ClassVar Default Effect
server_python None (= sys.executable) Interpreter for the subprocess. Setting it non-None auto-routes the nodeset to server mode regardless of the requested mode (registry.py ยท load_nodeset) โ€” the parallel of the worker_count > 1 gate. Resolved from a named conda env (e.g. Habitat's Python 3.8 ac-vlnce env).
server_env {} Extra environment variables merged into the subprocess env (e.g. an LD_PRELOAD shim). Read via getattr at spawn time (registry.py ยท _load_nodeset_as_server, registry.py ยท load_nodeset_ephemeral); not a declared ClassVar on BaseNodeSet.

11. Worker-Pool Multi-Server (ADR-eval-002 PB)

For parallel evaluation, the registry can spawn N independent subprocesses for the same nodeset, each on its own port. This is controlled by the worker_count parameter on load_nodeset and ensure_nodesets_for_graph.

# Spawn 4 parallel env workers (e.g. for batched eval)
await registry.load_nodeset("env_habitat", worker_count=4)

worker_count > 1 forces mode="server" regardless of the request โ€” fan-out only makes sense for isolated subprocesses.

11.0 Parallelism contract gate (ADR-server-003)

Before reading the fan-out mechanics, check the nodeset's parallelism ClassVar. Whether worker_count > 1 actually spawns N subprocesses depends on it:

class BaseNodeSet(ABC):
    ...
    parallelism: ClassVar[Literal["shared", "replicated"]] = "shared"     # bases.py ยท BaseNodeSet.parallelism
ModeSubprocesses at worker_count=NEnv PanelsTypical occupants
shared (default) 1 (singleton โ€” fan-out ignored) 1 untagged env panel ("env_X") Inference-shaped nodesets that don't own per-episode state โ€” policy_adapter_vlnce, policy_adapter_vla, voxposer, navgpt_mp3d_tools (BLIP-2/RCNN wrappers, lazy-loaded under threading.Lock). All workers share one subprocess
replicated N (one per worker) N tagged env panels ("env_X#0" โ€ฆ "env_X#N-1") Env nodesets that own mutable per-episode state โ€” env_habitat, env_matterport3d, env_hmeqa, env_simpler, env_libero. Routed via EnvWorkerPool.WorkerHandle's env_panel_overrides + server_url_overrides

Picking the wrong mode is a class of bug worth knowing about: marking an env-shaped nodeset shared serialises every worker through one simulator (no parallel speedup, episode state collides); marking a model-shaped nodeset replicated spawns N copies of a multi-GB VRAM model (OOM at any non-trivial worker_count). When in doubt: env โ†’ replicated, model/inference โ†’ shared. ยง11.1 onwards describes the replicated path; shared takes the ยง4 singleton lifecycle.

11.1 Spawn and port allocation

All N ports are allocated up front (via _find_free_port) before any subprocess starts, to minimise bind-race collisions. For each worker k:

11.2 Per-worker URL routing (PB-1.5)

Each proxy node's forward method (generated in proxy.py::_make_forward) consults ctx._executor.get_server_url(nodeset_name) at call time. When the eval runner sets a per-worker URL override (e.g. pointing worker 2 to http://localhost:9102), the proxy uses that URL instead of the baked-in default. With no override (canvas Play / single-worker eval) behaviour is bit-identical to pre-PB-1.5.

11.3 Tagged RemoteEnvPanelProxy

If the nodeset declares an env_panel, _register_remote_env_panel is called for each worker with tag=k. The resulting RemoteEnvPanelProxy gets name = f"{nodeset_name}#{k}" so each worker's canvas control panel can address its own subprocess via get_env_panel(f"{nodeset_name}#{k}").

Tagged registration raises on failure; the outer try/except block rolls back all already-started servers (see below).

11.4 Rollback on partial failure

If any worker fails to start or its RemoteEnvPanelProxy registration fails, the registry stops all already-started workers, removes their _auto_servers entries, and de-registers their proxy node types before re-raising the exception โ€” leaving no half-initialised state.

12. Batched Inference (ADR-eval-002 PC)

For GPU-heavy nodes like policy_adapter_vla__predict, sending one sample per HTTP request wastes throughput. The batched inference tier rendezvous K concurrent requests inside the server subprocess, calls the underlying handler once with all K samples stacked, then scatters K result slices back to the K awaiting callers.

The rendezvous is server-side (inside the AutoServerApp subprocess) because that is where K concurrent proxy calls from K LoopRunner instances naturally converge. This avoids a separate inference-server subprocess โ€” one AutoServerApp per nodeset, one subprocess, one /health.

12.1 BatchedInferenceServer

BatchedInferenceServer lives inside each AutoServerApp instance. It lazily creates one _BatchQueue per batch key. Handlers are registered eagerly at get_functions() time via _make_handler when type(tool).batched is True.

Key constants:

Symbol Value Meaning
SAMPLES_KEY "_samples" Marker: tool.forward receives {_samples: [inputs_dict, ...]}
OUTPUTS_KEY "_outputs" Marker: tool.forward returns {_outputs: [outputs_dict, ...]}
flush_timeout_ms 50 (default) Milliseconds to wait before flushing a partial batch

12.2 Batch key and flush rule

The batch key is (function_name, config_hash) where config_hash is an 8-char SHA-1 of the sorted JSON config. Two callers with equivalent configs (any key order) share a batch queue. Two policy nodes pointing at different checkpoints get separate queues.

The flush timer is a restart-on-each-submit timer: each new submission cancels and restarts the flush_timeout_ms countdown. A late caller joins the in-flight batch instead of forcing a partial flush early โ€” keeping the rendezvous correct even when workers arrive at uneven times.

12.3 Node opt-in

Two ClassVar fields on BaseCanvasNode control batching:

class PolicyCMAForward(BaseCanvasNode):
    batched: ClassVar[bool] = True
    batch_dim: ClassVar[str] = ""   # name of the input port carrying the batch axis

batched = False (default) โ€” _make_handler creates a plain async handler that calls tool.forward once per request. batched = True โ€” the handler submits to the shared BatchedInferenceServer and awaits its slice. register_node validates the combination at scan time.

The retired policy_cma__forward was the first node to opt in; today's user is policy_adapter_vla__predict.

12.4 Handler contract

A batched node's forward receives:

inputs = {SAMPLES_KEY: [inputs_dict_0, inputs_dict_1, ..., inputs_dict_K-1]}

and must return:

{OUTPUTS_KEY: [outputs_dict_0, outputs_dict_1, ..., outputs_dict_K-1]}

The list lengths must match. The node owns per-port stacking semantics (e.g. torch tensors stacked along batch_dim, lists of dicts gathered as lists). RNN hidden states must travel as explicit hidden_in/hidden_out ports so they ride the wire and remain with the caller across steps โ€” the server holds no per-worker state.

13. File Reference

File Side Role
app/server/server_app.py Server ServerApp base class + FastAPI route builder (manifest, call, health, env panel bridge)
app/server/auto_server_app.py Server AutoServerApp โ€” canonical auto-host for any BaseNodeSet; owns BatchedInferenceServer
app/server/base_server.py Framework BaseServer process launcher + monitor
app/server/manifest.py Both PortSchema, FunctionSchema, ServerManifest
app/server/serialization.py Both Wire type HTTP serialization (standalone)
app/server/proxy.py Framework create_proxy_node(), generate_proxy_nodes(), per-worker URL routing (PB-1.5)
app/server/nodeset.py Framework ServerNodeSet โ€” BaseNodeSet + BaseServer combined for YAML-registered servers
app/server/batched_inference.py Server BatchedInferenceServer, _BatchQueue, BatchedClient, SAMPLES_KEY/OUTPUTS_KEY
app/server/examples/habitat_server.py Both HabitatApp + HabitatServer
app/components/registry.py Framework _load_nodeset_as_server(worker_count=N), _register_remote_env_panel, _scan_and_register_servers()
app/api/platform/components.py Framework REST endpoints for server management (mounted at /api/components)
app/server/_loopback_proxy.py Framework loopback_httpx_kwargs() โ€” bypasses any ambient HTTP proxy for localhost server traffic (health, manifest, /call, env-panel)
app/server/auto_host.py Framework CLI entry point for auto-hosted servers (--module/--file, --class, --port, --host)
workspace/servers/*.yaml Config Server registration + lifecycle config
AgentCanvas docs