AgentCanvas / Pages / Developer Guide / Design Docs / Graph / Backend Overview
2026-07-05

This is the "you are here" map of the backend: not what every file holds (that's codebase-map) and not the platform's structure (that's architecture), but the backend as a set of running objects β€” who owns whom, what's a singleton vs per-run, and how a graph actually gets executed. The hard fact that ties it together: almost everything is per-process and reached through one ProcessServices singleton; the only cross-object rendezvous is a single global table, NODE_HANDLERS. Read this first, then follow the links into the deep dives.

Read top to bottom β€” one spine. Both entry paths, Canvas Play and Batch Eval, converge on the same LoopRunner β†’ GraphExecutor engine; only the wrapping above it differs. The engine fires nodes by querying NODE_HANDLERS β€” the one table the WorkspaceComponentRegistry fills and ProcessServices holds β€” and there is one such stack per process (the backend, and every eval subprocess).

Canvas Play in this process Β· singleton Batch Eval own subprocess Β· per episode LoopRunner β†’ GraphExecutor ONE engine β€” node firing identical on both paths fires: NODE_HANDLERS.get(type) NODE_HANDLERS node_type β†’ class β€” the one rendezvous table writes: register_node() WorkspaceComponentRegistry scans workspace/ Β· owns the env servers holds ProcessServices Β· get_services() ONE per process β€” backend + every eval subprocess has its own

1. What the backend is

The backend (agentcanvas/backend/app/) is a pure execution engine with zero domain knowledge (ADR-platform-001): it discovers domain code from workspace/ at runtime and runs graphs against it. As a running system it is a handful of per-process singletons hung off one ProcessServices, plus short-lived per-run objects that the API spins up on demand. Three things explain almost everything:

Follow one Play click

The smallest end-to-end trace β€” what one POST /api/navigate/run touches, in order:

  1. run.py Β· run_loop grabs ExecutionGuard (canvas), validates the graph, and asks get_services().workspace_component_registry.ensure_nodesets_for_graph() to make sure every node type is in NODE_HANDLERS.
  2. get_loop_runner() returns the singleton LoopRunner; a fire-and-forget asyncio task calls runner.run(graph).
  3. LoopRunner.run() builds a fresh GraphExecutor and awaits it.
  4. GraphExecutor.run() drives the ready-queue: for each node, NODE_HANDLERS[type].forward(). Server-mode nodes call out over HTTP to a registry-owned auto_host subprocess.
  5. Outputs stream over WebSocket (broadcast()); the guard releases when run() returns.

That's the canvas path in brief β€” the full request path, interactive controls, and live streaming are in canvas-play; the headless eval path is batch-eval.

2. The per-process model

This is the single most load-bearing fact, and the one that surprises people: get_services() is a module global, so "singleton" means per Python process, not per system.

So "who owns the registry / who owns ProcessServices" has the same answer in every process β€” that process's get_services() β€” and the chain tops out at the OS process (uvicorn for the backend, eval_subprocess_main for an eval run). There is no class above ProcessServices.

3. Process-global singletons

Everything in this table is created once per process and lives for the whole process. "Reached via" is how any code gets a handle.

ObjectRoleReached via / built
ProcessServicesThe per-process state container. Holds the registry + scheduler.state.py Β· get_services() (module singleton, lazy)
WorkspaceComponentRegistryDiscovers nodesets/nodes in workspace/, writes NODE_HANDLERS, owns server-mode subprocesses (via BaseServer), handles load/unload + hot-reload.ProcessServices.workspace_component_registry β€” built in ProcessServices.__init__
JobSchedulerEval admission (per-resource measured gate: calibrated estimate vs measured free VRAM/RAM minus reservations, declared fallback; canvas lock free) + FIFO queue; spawns each eval run as a subprocess.ProcessServices.job_scheduler β€” built later in main.py Β· lifespan (needs VRAM); None until then
LoopRunner (canvas)The canvas Play runner β€” lifecycle shell (pause/stop/resume) that builds a GraphExecutor per run.loop_runner.py Β· get_loop_runner() (module singleton)
NODE_HANDLERSGlobal table node_type β†’ BaseCanvasNode class. The seam between "installed" (registry) and "running" (executor).builtin_nodes.py module global; register_node() mutates it
ExecutionGuardSingle-tenant lock β€” only one of canvas/eval active at a time (in-process).state.py Β· ExecutionGuard (class-level statics)
ErrorBusIn-process pub/sub; every exception/log/HTTP-error becomes an error_event WS frame + Report-tab entry.errors.py Β· get_bus()
WS broadcast setThe connected WebSocket clients; broadcast() fans messages to all.state.py Β· _ws_connections + broadcast()
WatchersBackground tasks: graph-dir watcher (push graphs_changed) + nodeset-source watcher (hot-reload .py edits).services/graph_watcher, nodeset_watcher β€” lifespan tasks

4. Per-run objects & the one seam

Per-run objects are created by the API/orchestrator and discarded at end-of-run. They are not owned by the registry β€” they only ever read its output table.

ObjectLifetimeWhere
LoopRunner (per run)Canvas: the singleton, reused. Eval: fresh per episode.eval_batch.py Β· _run_one_episode (eval) / the singleton (canvas)
GraphExecutorOne per LoopRunner.run() β€” built fresh, discarded at end.graph_executor.py Β· GraphExecutor.run
ExecutionLoggerOne per run/episode (JSONL + assets).logging/logger.py
BatchEvalRunner / EnvWorkerPoolOne per eval run (eval-only).eval_batch.py / env_worker_pool.py

The seam. The registry and the executor never call each other β€” they meet at NODE_HANDLERS. The LoopRunner doesn't touch the table at all.

WorkspaceComponentRegistry startup + hot-reload NODE_HANDLERS node_type β†’ class GraphExecutor per run Β· fire time writes register_node() reads .get(type).forward()

Concretely: register_node() + the register path in registry.py populate NODE_HANDLERS (last-write-wins; unload evicts); GraphExecutor does NODE_HANDLERS.get(node.type) then .forward() (graph_executor.py). The executor reaches the registry directly in exactly one narrow place β€” to wire up a RemoteContainerProxy when a node is granted a state container that lives in another process (the cross-nodeset container-access path, via get_services().workspace_component_registry); otherwise the table is the whole contract. This is why the registry is 1 and GraphExecutors are N: N executors all read the one table the one registry filled. For the firing engine itself, see graph-executor.

The two run paths

A graph reaches GraphExecutor by one of two routes β€” each now its own code-grounded deep doc, so this hub only names them:

Both converge on the same LoopRunner β†’ GraphExecutor engine and fire against the same NODE_HANDLERS table (Β§4 above) β€” the divergence is entirely in the wrapping. The class-by-class comparison of the two paths is execution-paths.

5. Startup & shutdown (lifespan)

The whole runtime is wired in main.py Β· lifespan (the FastAPI startup/shutdown hook). Order matters:

  1. ErrorBus first β€” attach the event loop, subscribe the WS fan-out, install the log bridge (so every later subsystem's logs surface).
  2. state = get_services() β†’ constructs ProcessServices β†’ constructs WorkspaceComponentRegistry (empty so far).
  3. workspace_component_registry.scan_all() β€” discover workspace/ components β†’ fills NODE_HANDLERS.
  4. await workspace_component_registry.initialize_all() β€” initialize nodesets (the slow part).
  5. Build JobScheduler (query VRAM, resolve outputs/eval_runs/), wire the canvas-lock callback + registry, reconcile aborted runs.
  6. Launch background tasks: the scheduler loop (1 s tick β€” eval admit/reap + orphaned-canvas-guard reaper), the graph watcher, the nodeset-source watcher.
  7. yield β€” serve requests.
  8. On shutdown: cancel the tasks, job_scheduler.shutdown(), then workspace_component_registry.shutdown_all() (stops all server subprocesses).

6. Where to go deeper

You want…Read
How a run is driven, both paths, class-by-classexecution-paths
The interactive canvas run β€” in-process, live, pausablecanvas-play
The firing engine β€” ready-queue, iteration, terminationgraph-executor Β· loop-control-system
Batch eval internals β€” workers, parallelism, persistencebatch-eval
Server-mode subprocesses β€” auto_host, manifest, lifecycleserver-mode
How a node author plugs into NODE_HANDLERSbase-canvas-node Β· nodesets
Where every file lives (per-package)codebase-map
Platform structure (incl. frontend) + tech stackarchitecture

7. Gotchas (where the clean model bites)

"Singleton" is per-process, not global. N processes = N ProcessServicess = N registries = N NODE_HANDLERS tables. An eval subprocess cannot see the backend's in-memory objects; it re-scans from disk and attaches to shared servers over HTTP (register_remote_nodeset). Reasoning that assumes "one global registry" is wrong the moment eval forks.
JobScheduler is built late. ProcessServices.__init__ sets job_scheduler = None; it only becomes real inside lifespan (after VRAM detection). Code that reaches get_services().job_scheduler before startup completes gets None.
NODE_HANDLERS is mutable global state. Hot-reload and unload mutate it in place (last-write-wins; evict-on-unload). A node deleted from disk can linger in the table until a reload or restart (registry.py notes this). It is process-local, so the table the eval subprocess fires against is the one it scanned, not the backend's.
Canvas is single-tenant. The canvas LoopRunner is a process singleton behind ExecutionGuard β€” you cannot run two canvas graphs at once. Eval avoids the contention by running in its own subprocess (default); the legacy in-process eval shares the very same guard.
AgentCanvas docs