AgentCanvas / Pages / Developer Guide / Nodesets / Model / pySLAM
2026-07-09

The pySLAM NodeSet (ModelPySlamNodeSet, workspace/nodesets/model/model_pyslam/) wraps pySLAM (Luigi Freda, GPL-3.0) as a streaming visual-SLAM session. Where VGGT reconstructs geometry from N views in one feed-forward pass, pySLAM ingests an RGB-D stream one frame at a time and incrementally tracks the camera pose while accumulating a sparse 3-D map β€” classic feature SLAM (ORB features + DBoW3 loop closure, C++ core), a moving camera building a map as it goes. Feed it a policy's egocentric frames and it reconstructs the walked scene alongside the run.

The nodeset has eleven nodes in three groups: the streaming session above (five nodes β€” the four core verbs plus a dense volumetric map); three stateless perception nodes that use pySLAM's standalone classes as pure functions β€” feature extraction, feature matching, and evo trajectory evaluation (ATE / RPE); and three neural full-surface nodes β€” dense depth prediction, semantic segmentation, and feed-forward multi-view reconstruction (DUSt3R / MASt3R / VGGT). The last two groups share the same container but never touch the map. The aim is to expose pySLAM's whole capability surface β€” AgentCanvas supporting pySLAM-the-model, not a curated subset for one downstream graph.

env: container β€” agentcanvas/pyslam:cpu-fixed for the SLAM / feature / depth / semantic path, agentcanvas/pyslam:cuda (adds compiled multi-view backends) for reconstruct_multiview, rootless Docker, not a conda env Β· backend: GPL-3.0 pySLAM behind an HTTP bridge, never imported in the framework process Β· front-end: ORB2 features + DBOW3 loop closure, RGB-D by default Β· GPU via rootless CDI, on by default

pySLAM as the protagonist on a classic RGB-D benchmark. The pyslam_tum_slam graph streams TUM fr3/long_office_household frame-by-frame into a live SLAM session β€” no simulator, no policy, CPU-only: raw RGB + depth, the estimated camera path (blue) SE3-fitted onto ground truth (grey) top-down, and the sparse 3-D map densifying in real time; then an orbit of the finished map.
PrimitivePurpose
model_pyslam__reset(Re)start the session. The first call builds the Slam instance from the camera intrinsics + presets; later calls clear the map (reset_session) for a new episode, never rebuild.
model_pyslam__trackFeed one RGB(-D) frame β†’ pose (4Γ—4 world-from-camera) + tracking_state + num_map_points. The map accumulates as a side effect.
model_pyslam__get_trajectoryThe accumulated camera trajectory β€” one 4Γ—4 pose per successfully tracked frame, plus the total frame count.
model_pyslam__get_mapExport the sparse map (3-D landmarks) to a host-side .npz handle β€” the path travels the wire, never the geometry.
model_pyslam__get_dense_mapExport the dense volumetric map (fused voxel cloud / TSDF mesh) from pySLAM's volumetric integrator. Requires PYSLAM_VOLUMETRIC=1; empty handle otherwise.
Stateless perception β€” pure functions over pySLAM's standalone classes, no map state:
model_pyslam__extract_featuresRGB β†’ keypoints (NΓ—6) + descriptors (NΓ—D). pySLAM's local-feature front-end as a standalone tool.
model_pyslam__match_featuresTwo descriptor blocks β†’ matched index pairs (Hamming / L2 auto from dtype).
model_pyslam__eval_trajectoryEstimated vs ground-truth trajectory β†’ ATE + RPE statistics (evo, Umeyama-aligned).
Neural full-surface β€” pySLAM's dense-perception bank on the GPU, stateless:
model_pyslam__predict_depthRGB (+ right image) β†’ dense metric depth map. Depth-Anything V2 / Depth Pro / MASt3R (mono) or SGBM / RAFT-Stereo / CREStereo (stereo).
model_pyslam__segment_semanticRGB β†’ per-pixel semantic (+ instance) map. DeepLabV3 / SegFormer / CLIP / Detic / YOLO.
model_pyslam__reconstruct_multiviewN overlapping RGB views β†’ fused 3-D scene handle + per-view camera poses. DUSt3R / MASt3R / VGGT family (:cuda image).

1. The container bridge

pySLAM is GPL-3.0 and carries a native C++ core (g2o, Pangolin, DBoW3) pinned to Python 3.11.9. Rather than vendor that source or build a sibling conda env, the nodeset keeps the whole dependency behind a container boundary β€” the same treatment habitat_sim gets. Three layers sit between a canvas node and the SLAM math; the framework side never runs import pyslam, and every per-frame call is one HTTP round-trip to a shim running inside the container.

AGENTCANVAS Β· canvas graph + GraphExecutor env Β· observe rgb Β· depth Β· pose pySLAM nodes reset Β· track Β· get_map viewer nodes trajectory Β· point-cloud rgb Β· depth pose Β· handle the GraphExecutor fires each node per step Β· wires are typed NODESET Β· framework side Β· agentcanvas env β€” never imports pyslam fresh node instance executor builds per step _NODESET singleton set by initialize() PySlamContainerClient docker run/rm Β· HTTP DOCKER CONTAINER Β· pyslam venv Β· GPL-3.0 stays here FastAPI shim _server.py PySlamSession _backend.py Β· 1 thread Β· map pySLAM Β· Slam ORB2 + DBoW3 + g2o Β· C++ executor fires the node HTTP Β· JSON light / npz bytes ↑ results β†’ viewers
How pySLAM enters AgentCanvas. Top β€” on the canvas it is ordinary nodes: an env feeds rgb/depth into the pySLAM nodes, whose pose and map handle flow on typed wires into the viewer nodes, and the GraphExecutor fires each per step. Middle β€” firing a pySLAM node reaches the shared session through the _NODESET singleton, then the client, which never imports pySLAM and instead docker runs the container and talks HTTP. Bottom β€” the shim forwards to the session driving Slam. Results ride back up the wires into the viewers (right), so the container's output re-enters the graph.
LayerFileWhereRole
Nodes + NodeSet__init__.pyframeworkEleven canvas nodes (five session verbs + three stateless perception + three neural full-surface); the nodeset owns the container lifecycle and holds the client as its session.
Client_client.pyframeworkPySlamContainerClient β€” presents the exact surface of a PySlamSession but forwards every call over HTTP; docker run/rm; writes map handles host-side.
Shim_server.pycontainerA small FastAPI app β€” the only place import pyslam is legal. Holds one session; exposes /reset, /track, /get_map, …
Session_backend.pycontainerPySlamSession β€” drives one pySLAM Slam pinned to a single worker thread (pySLAM's background threads have thread affinity).

The nodeset directory is bind-mounted read-only into the container, so _server.py/_backend.py run inside without baking our code into the image β€” only fastapi/uvicorn and pySLAM live there. Container access is forced onto the user's rootless Docker daemon (DOCKER_HOST/XDG_RUNTIME_DIR set to the user socket), so the whole thing needs no sudo.

One call, end to end. The graph executor fires a freshly-constructed node each step, so a node cannot hold the session on self β€” it reaches the shared client through the process-level singleton _NODESET that initialize() set (Β§3). A single track then flows node β†’ client β†’ shim β†’ session: the client serialises the frame and POSTs it over the loopback port; the shim hands it to the PySlamSession, which marshals the call onto its one worker thread and returns the pose as JSON. The single thread is not incidental β€” pySLAM's local-mapping / loop-closing / volumetric background threads are bound to the thread that constructed Slam, so every track must run on that same thread (the same single-thread-affinity discipline the GL/physics env nodesets use).

What rides the wire. Two payload classes cross the bridge differently. Light results (pose, tracking state, point counts) travel as JSON; heavy geometry β€” frames in, sparse / dense maps and multi-view clouds out β€” travels as raw .npz bytes over an octet-stream, never base64-in-JSON. And on the graph wire only a handle path travels: the client writes the returned .npz to a host-side file (framework-user-owned, sidestepping rootless-Docker uid remapping) and passes that path downstream, so a multi-thousand-point map never inflates the executor's in-memory state.

Endpoint (POST unless noted)Body β†’ response
GET /health→ {ok, built} — the readiness probe the client polls after docker run
/configure_cameraJSON intrinsics β†’ {ok} β€” pins the pinhole camera
/start · /reset · /close→ {ok} — build Slam · clear the map · quit (join bg threads)
/tracknpz(rgb[, depth[, ts]]) β†’ JSON pose Β· tracking_state Β· num_map_points
/get_trajectory→ JSON poses · states · num_frames
/get_map Β· /get_dense_mapβ†’ npz bytes (points, …) + X-Num-* headers; the client writes the host handle
Stateless perception β€” no session, npz in / npz out:
/extract_features Β· /match_features Β· /predict_depth Β· /segment_semantic Β· /reconstruct_multiviewnpz(images / descriptors) β†’ npz(results) + X-* count headers
/eval_trajectoryJSON(poses_est, poses_gt) β†’ JSON(ate, rpe) β€” pure math, no arrays

The session's shape is fixed at launch, not per call. Sensor mode, feature / loop presets, volumetric integration, indoor/outdoor regime β€” all chosen when the container starts: initialize() passes them as -e environment variables on docker run, and the shim reads them when it lazily builds the session (_get_session()). That is why those knobs live as environment variables (Β§7) rather than call arguments β€” they define the container, while only per-frame data (images, depth) rides the HTTP body. The per-node Reset config fields override the env defaults right before the first configure_camera.


2. A streaming session, not a stateless pass

Every other foundation-model nodeset here is a stateless primitive (parallelism = "shared", one server across eval workers). pySLAM is the exception: its map is mutable per-episode state that accumulates across track calls, so it is a session. Consequences:

The three Tier-2 perception nodes (Β§5) are the counterexample within this nodeset: they are stateless pure functions that reuse the same container but never touch the Slam object, so β€” unlike track / get_map β€” they do not gate on the session being built.


3. Weaving a passive observer into the dataflow

A SLAM observer riding alongside a navigation policy is easy to wire wrong, because two graph-executor facts fight the naΓ―ve topology:

The shipped example, vln/unverified/pyslam_slam_probe.json, is a pure-policy VLN-CE navigator (CMA policy, no LLM) with pySLAM observing: the policy drives, pySLAM reconstructs the walked scene, and the wiring above keeps the observer alive.


4. Camera intrinsics & depth scale

Two alignment details make an environment's RGB-D stream usable by pySLAM:


5. Canvas Nodes

model_pyslam__reset

FieldDetail
Inputstrigger, intrinsics (ANY β€” {fx,fy,cx,cy,width,height}), cam_width, cam_height, cam_hfov (all optional)
Outputsepisode_ok (BOOL), info (TEXT β€” session state JSON, or {"error": …})
Configsensor_type (rgbd / mono / stereo) Β· feature_preset (default ORB2) Β· loop_preset (default DBOW3, or off) Β· cam_hfov Β· depth_scale
Backend callfirst fire: configure_camera β†’ start (builds Slam); every fire: reset_session to clear the map

model_pyslam__track

FieldDetail
Inputsrgb (IMAGE β€” HxWx3 uint8 or .npy path), depth (DEPTH β€” metres, RGB-D only, opt), timestamp (ANY, opt), gt_pose (ANY β€” env observe.pose, captured frame-aligned for eval_trajectory, opt)
Outputspose (POSE β€” 4Γ—4 world-from-camera, or null if LOST), tracking_state (TEXT), num_map_points (ANY)
Backend calldepth resample β†’ depth Γ— depth_scale β†’ Slam.track(rgb, None, depth, id, ts); pose from tracking.cur_R / cur_t
{
  "pose": [[1.0, 0.0, 0.0, 0.12],
           [0.0, 1.0, 0.0, 0.03],
           [0.0, 0.0, 1.0, 0.58],
           [0.0, 0.0, 0.0, 1.0]],
  "tracking_state": "OK",          
  "num_map_points": 1862
}

tracking_state walks NOT_INITIALIZED β†’ OK (β†’ LOST if track drops).

model_pyslam__get_trajectory

FieldDetail
Inputstrigger (ANY, opt)
Outputsposes (ANY β€” list of 4Γ—4, estimated), num_frames (ANY β€” total frames fed to track), gt_poses (ANY β€” frame-aligned ground truth captured by track, for eval_trajectory)

model_pyslam__get_map

FieldDetail
Inputstrigger (ANY, opt)
Outputsmap_handle (TEXT β€” path to a .npz), num_points (ANY), num_keyframes (ANY)
Backend callshim ships points as .npz bytes; the client writes them host-side (framework-user-owned, sidestepping rootless-Docker uid remapping)
{
  "map_handle": "outputs/pyslam_maps/map_0001_001862.npz",
  "num_points": 1862,
  "num_keyframes": 12
}

The .npz holds a points array of shape (N, 3) β€” the sparse landmark cloud in world coordinates.

model_pyslam__get_dense_map

Export the dense map β€” not the sparse landmark cloud but pySLAM's own volumetric integrator output, a fused voxel point cloud or TSDF mesh built over the tracked keyframes. A session verb: it requires the session to be built with volumetric integration on (PYSLAM_VOLUMETRIC=1) and returns an empty handle otherwise.

FieldDetail
Inputstrigger (ANY, opt)
Outputsdense_handle (TEXT β€” path to a .npz, or empty), num_points (ANY β€” fused voxel points), num_vertices (ANY β€” TSDF mesh vertices)
Configvia env: PYSLAM_VOLUMETRIC Β· PYSLAM_VOLUMETRIC_TYPE (VOXEL_GRID / TSDF / VOXEL_SEMANTIC_GRID) Β· PYSLAM_ENV (indoor / outdoor depth-truncation)
Backend callflush the integrator's keyframe queue and wait for it to drain (the integrator is a separate process), then add_update_output_task + pop_output β†’ point cloud / mesh arrays

The drain-wait matters: the volumetric integrator consumes keyframes on its own process via multiprocessing queues, so popping an output before the queue drains returns a partial or empty map. The node blocks until the queue is empty, then extracts a complete volume (VOXEL_GRID is the verified default).

The first five nodes are the streaming session; the next three are stateless perception β€” they reuse the container but hold no map state, so they never gate on the session being built.

model_pyslam__extract_features

pySLAM's local-feature front-end as a standalone tool β€” detect keypoints and compute descriptors on one image.

FieldDetail
Inputsrgb (IMAGE β€” HxWx3 uint8 or .npy path)
Outputskeypoints (ANY β€” NΓ—6 [x, y, size, angle, response, octave]), descriptors (ANY β€” NΓ—D; uint8 for ORB, float for SIFT), num_keypoints (ANY)
Configdetector (default ORB β€” OpenCV, no weights; also SIFT / AKAZE / BRISK / SuperPoint / XFeat) Β· descriptor (blank = same as detector) Β· num_features
Backend callfeature_manager_factory(...).detectAndCompute

ORB2 (the ORB-SLAM2 interface) needs a C++ binding the CPU image lacks β€” use plain ORB; learned detectors auto-download a checkpoint on first use.

model_pyslam__match_features

Match two descriptor blocks (from Extract Features) β†’ matched index pairs. Classical matchers run on the arrays alone β€” no images, no weights.

FieldDetail
Inputsdescriptors_a (ANY β€” NΓ—D), descriptors_b (ANY β€” MΓ—D)
Outputsidxs_a (ANY), idxs_b (ANY β€” aligned matched indices), num_matches (ANY)
Configmatcher_type (BF / FLANN) Β· ratio_test (Lowe, default 0.7)
Backend callfeature_matcher_factory(...).match; distance auto from dtype (Hamming for binary ORB/AKAZE, L2 for SIFT)

model_pyslam__eval_trajectory

Score an estimated trajectory against ground truth β€” ATE + RPE via evo. Pairs naturally with Get Trajectory. Pure math, no weights, no GPU.

FieldDetail
Inputsposes_est (ANY β€” list of 4Γ—4), poses_gt (ANY β€” list of 4Γ—4), is_monocular (ANY, opt β€” overrides config)
Outputsate_rmse (ANY β€” metres), rpe_rmse (ANY β€” metres, Ξ”=1 frame), metrics (TEXT β€” full ATE + RPE stats JSON)
Configis_monocular β€” off = metric SE3 align; on = Sim3 scale align
Backend callutilities.evaluation.evaluate_evo (Umeyama-align β†’ APE) + evo RPE

A near-straight-line trajectory (a straight-corridor episode) makes Umeyama alignment rank-deficient, so ate_rmse can come back null (evo cannot align) while rpe_rmse still returns via a no-align fallback β€” an evo limitation, not a fault.

The last three nodes are neural full-surface β€” pySLAM ships a bank of learned dense-perception backends behind one factory each, and exposing pySLAM's whole surface means these get first-class nodes too. Like the stateless perception nodes they hold no map state, but they run neural nets on the GPU, so the first call loads (or downloads) a checkpoint. (The streaming session's own algorithm variety β€” feature detector, loop detector, sensor mode β€” is reached by config passthrough on the Reset node, not by new nodes.)

model_pyslam__predict_depth

Dense metric depth from a single image β€” pySLAM's depth-estimator bank as a standalone tool.

FieldDetail
Inputsrgb (IMAGE), image_right (IMAGE β€” stereo estimators only, opt)
Outputsdepth (DEPTH β€” HxW float32, metres), depth_range (ANY β€” [min, max]), estimator (TEXT)
Configestimator (Depth-Anything V2 / Depth Pro / MASt3R mono Β· SGBM / RAFT-Stereo / CREStereo stereo) Β· environment (indoor / outdoor) Β· min_depth Β· max_depth
Backend calldepth_estimator_factory(...).infer(rgb, right) β†’ depth map; device=None picks CUDA

Mono estimators use rgb alone; stereo ones also consume image_right. SGBM is pure OpenCV/CPU; the learned estimators run on the GPU and auto-download a checkpoint on first use.

model_pyslam__segment_semantic

Per-pixel semantic segmentation β€” pySLAM's segmentation bank as a standalone tool.

FieldDetail
Inputsrgb (IMAGE)
Outputssemantics (ANY β€” HxW int labels, or HxWxC / HxWxD volume), instances (ANY β€” HxW, or null), num_classes (ANY)
Configmodel (DeepLabV3 / SegFormer / CLIP / Detic / YOLO / EOV-Seg) Β· feature_type (label / probability / feature vector) Β· dataset (Cityscapes / ADE20K / VOC / NYU40)
Backend callsemantic_segmentation_factory(...).infer(rgb) β†’ SemanticSegmentationOutput; device=None picks CUDA

model_pyslam__reconstruct_multiview

Feed-forward multi-view 3-D reconstruction β€” pySLAM's scene-from-views stack (the DUSt3R / MASt3R / VGGT family). Give a list of β‰₯2 overlapping views; get a fused global point cloud plus per-view camera poses in one pass. Needs the agentcanvas/pyslam:cuda image (the multi-view backends carry compiled CUDA kernels).

FieldDetail
Inputsimages (ANY β€” a list of β‰₯2 overlapping RGB frames or paths)
Outputsscene_handle (TEXT β€” path to a .npz: points, colours, mesh, intrinsics), camera_poses (ANY β€” per-view 4Γ—4 cam-to-world), num_points (ANY), num_views (ANY)
Configbackend (MASt3R Β· DUSt3R Β· VGGT Β· VGGT-Robust Β· Fast3r Β· Depth-Anything V3) Β· as_pointcloud (point cloud, or mesh + cloud)
Backend callscene_from_views_factory(...).reconstruct(images, as_pointcloud) β†’ SceneFromViewsResult

Weights are external, not baked into the image (Β§6): MASt3R / MV-DUSt3R load checkpoints from a mounted data/models/pyslam/ folder; the HuggingFace-runtime backends (VGGT / VGGT-Robust / Depth-Anything V3 / Fast3r / pure DUSt3R) download into a mounted cache on first use. MV-DUSt3R is a known gap β€” a version drift in pySLAM's bundled copy leaves it unusable; the other backends cover multi-view. MASt3R is the verified default (a 104k-point Chateau reconstruction runs end-to-end).


6. Deployment & lifecycle

FRAMEWORK Β· agentcanvas env CONTAINER Β· pyslam venv initialize() docker run -d Β· bind-mount Β· publish port container up shim boots Β· /health polled until ready reset() Β· first episode configure_camera β†’ start Slam built β€” lazily ORB2 + DBoW3 Β· bg threads spin up track(rgb, depth) Β· Γ—N one call per step Slam.track Β· 1 thread pose out Β· map grows get_map / get_trajectory after the loop npz bytes β†’ host handle only the path rides the graph wire reset() Β· next episode then loop back to track reset_session() clears map Β· keeps loop DB shutdown() /close β†’ docker rm -f Slam.quit() joins bg threads Β· container removed per-episode loop
The session lifecycle: each framework call (left) drives one container effect (right), top to bottom. The Slam instance is built lazily on the first reset; track/get_map/reset repeat per episode (the loop clears the map but reuses the built session and container); shutdown quits Slam and removes the container.

initialize() constructs the client and docker run -ds the image (nodeset dir bind-mounted at /opt/bridge, a free loopback port published), then polls /health until the shim answers β€” the blocking Docker call offloaded off the event loop. shutdown() posts /close (quits Slam, joins its background threads) then docker rm -fs the container. The image is built once via rootless Docker; pySLAM is cloned and compiled at build time, never entering the repo.

GPU. The container is launched with --device nvidia.com/gpu=all so the neural backends (learned depth, semantic segmentation, multi-view reconstruction) run on the GPU β€” the classic SLAM core stays on CPU. Rootless Docker reaches the GPU through CDI (the legacy --gpus cgroup path fails unprivileged). GPU is on by default and falls back to CPU when unavailable β€” set PYSLAM_GPU=0 to force CPU.

Two images, auto-selected. cpu-fixed already carries a CUDA torch ("cpu" only means the SLAM C++ core is CPU-built), so the depth and semantic backends run on the GPU there with no separate image. The multi-view backends are the exception: they carry compiled CUDA kernels (curope) that must be built with nvcc present, so reconstruct_multiview needs the heavier agentcanvas/pyslam:cuda image (built by docker/Dockerfile.cuda). When PYSLAM_IMAGE is unset the client picks the image by GPU: GPU present β†’ :cuda (the whole surface works out of the box), no GPU β†’ :cpu-fixed (the leaner image; multi-view needs a GPU anyway). A GPU start that fails on a host without CDI also degrades to the CPU image. Pin PYSLAM_IMAGE to override.

Weights are external. Neither image bakes in model weights β€” they carry only code and compiled kernels. The multi-view checkpoints live in a host data/models/pyslam/ folder and mount read-only into the container at runtime (PYSLAM_WEIGHTS_DIR overrides the location); the HuggingFace-runtime backends download into a mounted cache on first use. Fetch the explicit checkpoints with the tracked scripts under docker/weights/:

cd workspace/nodesets/model/model_pyslam/docker/weights
bash download_mast3r.sh      # MASt3R (~2.5GB) β€” the verified default
bash download_all.sh         # mast3r + mvdust3r explicit checkpoints
export DOCKER_HOST="unix:///run/user/$(id -u)/docker.sock"
bash workspace/nodesets/model/model_pyslam/docker/build.sh agentcanvas/pyslam:cpu-fixed

Run the probe graph through the experiment wrapper. worker_count=1 is required β€” a higher count auto-routes the nodeset to server mode, which the container-bridge (local-mode) design does not support.

# One episode of the pure-policy probe graph, submitted to the slot backend.
# worker_count=1 is REQUIRED: >1 auto-routes the nodeset to server mode,
# which the container-bridge (local-mode) nodeset does not support.
AGENTCANVAS_BACKEND_URL=http://127.0.0.1:8002 \
  python .claude/commands/experiment/bin/submit.py \
  architect-cma pyslam_slam_probe \
  episode_count=1 worker_count=1 split=val_unseen step_budget=80

7. Environment Variables

VariableDefaultPurpose
PYSLAM_SENSORrgbdSensor mode β€” mono / stereo / rgbd
PYSLAM_FEATUREORB2A feature_tracker_configs preset name
PYSLAM_LOOPDBOW3A loop_detector_configs preset name, or off
PYSLAM_IMAGEauto (GPU β†’ :cuda, else :cpu-fixed)Pin the container image; unset picks by GPU availability
PYSLAM_ARTIFACT_DIRoutputs/pyslam_mapsHost dir where map / scene handles are written
PYSLAM_VOLUMETRIC0Enable the dense volumetric integrator for get_dense_map
PYSLAM_VOLUMETRIC_TYPEVOXEL_GRIDDense-map output β€” VOXEL_GRID / TSDF / VOXEL_SEMANTIC_GRID
PYSLAM_ENVINDOORDense depth-truncation regime β€” INDOOR / OUTDOOR
PYSLAM_WEIGHTS_DIRdata/models/pyslamHost folder of external multi-view weights mounted into the container
PYSLAM_GPU1GPU on/off; 0 forces CPU-only

Per-node config fields on the Reset node (sensor type, presets, HFOV, depth scale) override these env defaults.


8. Operational notes

AgentCanvas docs