Management stack for ROS 2 robot deployments. cyclo_manager exposes a FastAPI control plane, a Next.js web UI, and a pip-installable CLI that orchestrates Docker containers on the robot host.
Each managed robot container runs an s6-overlay agent (Unix domain socket). cyclo_manager talks to those agents to list and control s6 services, while also using the Docker API for container lifecycle, terminals, and host-level operations via cyclo_host_agent.
| Component | Role |
|---|---|
| cyclo_manager (this repo, API image) | REST + WebSocket API on port 8081 |
| cyclo_manager_ui | Web UI on port 3000 |
| cyclo-manager (PyPI CLI) | cyclo_manager up / down / update; installs and refreshes the cyclo_host_agent systemd service |
| cyclo_host_agent | Host-side agent (UDS): system/CPU statistics, files, ROBOTIS-GIT repo updates and cyclo_manager package updates |
In-container agent (cyclo_manager.agent) |
FastAPI + s6 client inside each robot container |
┌─────────────────────────────────────────────────────────────────┐
│ cyclo_manager container (FastAPI :8081) │
│ REST / WebSocket ──► agent UDS Docker SDK ──► docker.sock │
│ └──► host_agent UDS (repo / system update) │
│ └──► rclpy (ROS_DOMAIN_ID) │
└─────────────────────────────────────────────────────────────────┘
│ UDS (/agents/...) │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Robot containers │ │ Host │
│ (e.g. ai_worker) │ │ cyclo_host_agent │
│ s6_agent.sock │ │ + git repos ~ │
└──────────────────┘ └──────────────────┘
Agent socket paths on the host live under /var/run/robotis/agent_sockets/ and are bind-mounted into the API container as /agents/.
pip install cyclo-manager
cyclo_manager up- Starts cyclo_manager and UI containers from pre-built images.
- Creates (but does not start) Zenoh and noVNC containers by default.
- Installs cyclo_host_agent as a systemd service. On normal-user hosts, it runs as the invoking login user; on root-only devices, it runs as root.
On the robot host, open http://127.0.0.1:3000 (UI) and http://127.0.0.1:8081/docs (API). From another device, use the robot's hostname or IP. The browser must also be able to reach the API; see UI API address configuration.
CLI details: cyclo_manager_cli/README.md
From the repository root:
docker compose -f docker-compose.dev.yml up- Mounts
./config.ymland local source for API/UI hot reload. - Builds the API/UI from local source; Zenoh/noVNC use the compose definitions in this repository.
- Requires agent sockets on the host at
/var/run/robotis/agent_sockets/. - Unlike the pip launcher, this command starts all services in the dev compose file.
- The dev UI currently sets
NEXT_PUBLIC_API_URL=http://127.0.0.1:8081. For access from another device, override it as described in the UI guide.
The dev compose file does not install or reload the host agent. Host statistics,
file operations and repo updates use the separately installed cyclo_host_agent
service. Backend/UI hot reload does not update that service's Python installation.
The API reads CONFIG_FILE (default config.yml). The pip CLI sets CYCLO_MANAGER_CONFIG_FILE to its bundled config and mounts that file into the API container as /app/config.yml. The pip CLI has no -c / --config flag; use the dev compose file for custom local mounts.
| Key | Description |
|---|---|
supported_robot_containers |
Containers eligible for System/Jog selection and Jog profile detection (e.g. ai_worker, open_manipulator). Each must be a key in sockets (not host_agent). |
sockets |
Map of logical name → agent socket path as seen inside the API container (typically under /agents/...). Include robot/service containers and host_agent. |
s6 service names are not listed in config. System profiles define the service
names used by the UI; the Jog status GET checks the existing agent status endpoints
for ai_worker_bringup and open_manipulator_bringup.
AI Worker Leader Bringup supports A2 (default), A1, and LG2 through
avatar_bringup. A2 is the UI default; requests that omit robot_type use LG2
for compatibility with older clients. A1/A2 settings match their ffw_bringup launch files,
including the leader input YAML, mock hardware/sensor commands, and left/right
trigger neutral positions in radians. Blank input-file and trigger overrides
use the installed package defaults. The AI Worker image must include the
corresponding launch files and s6 service dispatch, and its s6-agent must accept
a1/a2 as the leader type.
supported_robot_containers:
- ai_worker
- open_manipulator
sockets:
ai_worker: "/agents/ai_worker/s6_agent.sock"
open_manipulator: "/agents/open_manipulator/s6_agent.sock"
cyclo_intelligence: "/agents/cyclo_intelligence/s6_agent.sock"
host_agent: "/agents/host/host_agent.sock"Bundled copy for pip installs: cyclo_manager_cli/cyclo_manager_cli/config/config.yml
The manager Dockerfiles configure ROS_DOMAIN_ID=30 and
RMW_IMPLEMENTATION=rmw_zenoh_cpp in the shell startup file used by the API.
The manager and robot must use compatible ROS settings and a reachable Zenoh router.
Both compose files persist recordings with
${HOME}/cyclo_manager_ros_bags:/cyclo_manager_ros_bags and set
RECORDINGS_DIR=/cyclo_manager_ros_bags. HOME is resolved on the host when Compose
runs. Without RECORDINGS_DIR, the server also defaults to /cyclo_manager_ros_bags.
Container recreation preserves bags in the mounted host directory. See
Record & Play storage for the file layout
and required ROS bag packages.
| Page | Path | Notes |
|---|---|---|
| Apps hub | /app |
Links to Cyclo Manager (dashboard) and Cyclo Intelligence (port 7080) |
| Dashboard | /dashboard |
Host stats, Docker containers/images, logs, bashrc, version management (host git repos + s6 agent compatibility) |
| System | /{container}/system |
s6 bringup, launch args, URDF viewer, live and historical service logs (recent 5,000 lines/download), robot status |
| Jog | /{container}/jog |
Selected container's profile + dynamic URDF/controller feedback; /jog opens container selection |
| Record & Play | /record-play |
Record trajectory topics to rosbag2/MCAP; playback with start-pose transition, speed selection and repeat returns |
| Topics | /topics |
ROS 2 topic browser; live data via WebSocket |
| Terminal | /terminal |
Multi-tab bash into running containers (?container={name} optional) |
| Files | /files |
Host file browser, search, uploads, text editing and diff (scoped to host agent file root; see Security) |
| noVNC | /novnc |
Remote display (when novnc-server is running) |
The desktop sidebar and mobile menu share a flat navigation list on all routes
except /app. The System and Jog buttons use GET /containers?running=true
to get configured, running robot containers in one lightweight request: one match opens directly,
multiple matches offer a choice, and no match reports that no robot container is
running. Jog keeps the selected container in its URL and queries only its bringup
status on demand. Record & Play opens directly and uses recorded command topics and ROS joint
feedback independently of bringup status.
Dashboard system statistics and the CPU process list refresh every second. Their CPU summaries use the same host-agent average of the latest three one-second samples; individual process rows use a separate 0.2-second measurement window.
Jog covers controls, command routing and stop behavior. Record & Play covers capture, playback, repetition and storage. Recording and playback continue after leaving that page; leaving Jog stops its session. Stop leader publishing before Jog or playback: the manager coordinates its own motion sessions, but does not arbitrate with external publishers.
UI details: cyclo_manager_ui/README_UI.md
Interactive docs: http://<host>:8081/docs
| Area | Method & path | Notes |
|---|---|---|
| Root | GET / |
API metadata |
| Config | GET /containers |
Supported robot containers; ?running=true returns only running candidates for System/Jog without image inspection |
GET /containers/agents/status |
Container s6 agent version compatibility | |
POST /containers/{container}/agent/update |
Checkout agent code to the manager version and restart the container | |
| System | GET /system/info, GET /system/status, GET /system/processes |
Hostname, internet, CPU/memory/disk, top processes |
GET /system/serial-ports |
Serial device candidates from the host /dev tree |
|
| Services | GET /{container}/services/{service}/status |
Single s6 service status |
POST /{container}/services/{service} |
up / down / restart; Start leaves running services unchanged and clears stale s6-rc state before starting stopped services; Stop/Restart stop registered <service>-pipeline bundles; optional launch_args, robot_type (AI Worker: SG2/BG2/SH5/BH5/F1/F2/Mobile; Open Manipulator: OMY/OMX) |
|
GET /{container}/services/{service}/logs/download |
Download current s6 log file (ANSI stripped) | |
GET /{container}/services/{service}/logs/history |
List dated logs and agent history support | |
GET /{container}/services/{service}/logs/history/{log_id} |
Read recent or preceding log pages with before and lines |
|
GET /{container}/services/{service}/logs/history/{log_id}/download |
Stream the selected execution's original log file | |
DELETE /{container}/services/{service}/logs |
Truncate s6 log file | |
| Container | GET, PUT /{container}/bashrc |
Via docker exec |
| Docker | GET /docker/containers |
Optional ?all=true |
GET /docker/images |
List images with size, tags, and container usage | |
POST /docker/images/prune |
Remove dangling images | |
DELETE /docker/images/{image_id} |
Delete an unused image | |
POST /docker/{name} |
start / stop / restart | |
GET /docker/{name}/logs |
Engine logs; optional ?tail=100 |
|
GET /docker/{name}/top |
Process list | |
DELETE /docker/{name}/processes/{pid} |
Signal process; optional ?signal=SIGTERM |
|
| Terminal | WebSocket /terminal/{name}/ws |
PTY bash; optional session_id query param |
DELETE /terminal/{name}/{session_id} |
Kill session | |
| ROS 2 | GET /ros2/topics |
Run discovery; list topics with availability |
GET /ros2/topics/{topic} |
Latest cached message (JSON); read-only | |
GET /ros2/topics/{topic}/available |
Cheap liveness check (no JSON conversion of payload) | |
GET /ros2/topics/{topic}/info |
ros2 topic info -v output |
|
GET /ros2/robot-description |
One-shot URDF; optional topic (default /robot_description); independent transient-local subscription bypasses shared cache and is released after receipt, disconnect or 5 s timeout |
|
| Jog | GET /{container}/bringup_status |
Selected container's bringup/profile status; Jog pages poll every 2 s, requests for the same container share a 1 s cache |
| Record & Play | GET /record_play |
Shared job state, discovered trajectory groups, saved recordings and storage path |
GET /record_play/status |
Read-only job status | |
POST /record_play/record |
Start recording selected topics; bringup and active publishers are not required | |
POST /record_play/play |
Validate joint feedback, URDF and bag, move to start pose, then play at 0.5×–1.5× (default 1×); repeats is total passes, 0 for infinite; arrival_tolerance_deg is 3 (default) or 5; arrival_tolerance_cm is 1 (default), 2 or 3 for linear joints, with 0.3 s in tolerance required at each arrival |
|
POST /record_play/stop |
Stop/save the active job; optional owner-scoped request | |
DELETE /record_play/recordings/{recording_id} |
Permanently delete a saved recording and its rosbag files; reject deletion while any recording/playback job is active | |
| Host | GET /host/repos, GET /host/repos/updates |
Managed host git repos |
GET /host/repos/{name}/branch, GET /host/repos/{name}/status |
Branch check and local-change status | |
POST /host/repos/{name}/update |
git pull workflow | |
POST /host/repos/{name}/container/stop, .../start |
Stop/start related containers during update | |
GET /host/repos/{name}/container/start/status |
Poll container helper startup progress | |
POST /host/update |
Start one-click cyclo_manager package update through the host agent |
|
GET /host/update/status |
Poll package update phase, output, and error | |
GET /host/version |
Running cyclo_host_agent package version |
|
GET /host/files/tree |
List one directory under the host file root; optional ?path= and ?show_hidden=true |
|
GET /host/files/read |
Read a UTF-8 text file (?path=) |
|
GET /host/files/diff |
Git diff for a file (?path=) |
|
GET /host/files/search |
Search filenames under path; requires query, optional show_hidden and limit |
|
POST /host/files/write |
Save a text file; optional expected_modified for conflict detection |
|
POST /host/files/create |
Create a file or directory | |
POST /host/files/rename |
Rename a file or directory | |
POST /host/files/upload |
Upload raw file bytes; filename and destination directory path query parameters, optional overwrite |
|
DELETE /host/files |
Delete a file or directory; optional ?recursive=true for folders |
|
| Version | GET /version |
Installed vs PyPI cyclo-manager; optional ?check_latest=false |
| WebSocket | /ws/{container}/services/{service}/logs |
Live s6 logs (agent NDJSON stream → browser) |
/ws/ros2/topics/{topic} |
Live topic data (see below) | |
/ws/ros2/system-status |
Repeated battery and camera query parameters; battery percentages and camera publisher presence every 2 s; no camera image subscriptions |
|
/ws/jog?container={container} |
Ordered Jog input and feedback bound to the selected container; profile resolved by the server |
The manager proxies the agent endpoints below at
/{container}/services/{service}/logs/history, with the same /{log_id} and
/{log_id}/download suffixes. Pages and byte cursors pass through unchanged;
downloads are streamed without buffering the whole file. System uses these routes
for the History selector and recent-log snapshots. Existing current-log download
and clear endpoints remain available; the UI does not truncate saved logs.
The manager's list response adds supported: true. An agent without the history
endpoint (404 with detail: "Not Found", or 405/501) returns supported: false,
an empty history, and
current_log_id: null; transport failures and server errors remain errors.
An empty list with supported: true means no dated logs are available.
Check this flag before offering history reads/downloads on older agents.
| Method & path | Behavior |
|---|---|
GET /services/{name}/logs/history |
Return history newest first (log_id, size_bytes) and current_log_id from the current symlink. This is not a running-state indicator. Missing or legacy-only log directories return an empty list. |
GET /services/{name}/logs/history/{log_id} |
Read the last lines (default 500, maximum 5000), capped at 256 KiB of source bytes. Pass before=<start_cursor> to read the preceding page. |
GET /services/{name}/logs/history/{log_id}/download |
Download original bytes, including ANSI sequences, up to the size captured when the file is opened. Later appends are not included. |
Log IDs are directory names such as 2026_10_14_09_45 or 2026_10_14_09_45_2
under /var/log/{name}. Numeric suffixes are sorted numerically. The timestamp
uses the logger's local time; no timezone or exit metadata is inferred.
Pages return log_id, logs, start_cursor, end_cursor, has_more_before,
starts_mid_line, and ends_mid_line. Cursors are byte offsets in the original
file, with an exclusive end; do not calculate them from decoded string lengths.
Prepend each preceding page's text without inserting separators. Pages prefer
whole lines, but a line exceeding the byte cap is returned in fragments with
the boundary flags set. UTF-8 characters are not split across pages; an
incomplete trailing character still being written is deferred. Invalid UTF-8
is displayed with replacement characters. ANSI sequences are preserved, so
renderers should handle escapes across page boundaries.
Appending to the file does not change earlier cursors. A missing/retained-away log returns 404; a cursor beyond the current file size returns 409, requiring the log to be reloaded. Invalid log IDs or cursors inside a UTF-8 character return 400, and invalid query ranges return 422. Symlinked run folders/files and non-regular log files are not exposed. History reads do not create folders, truncate files, or change retention.
The manager WebSocket /ws/{container}/services/{service}/logs retains the legacy
{type, data} format for logs and error; other agent events are not forwarded.
It reads current even while the service is stopped.
Use ?live=true&tail=5000 for dated live logs. The agent equivalent is
GET /services/{name}/logs/stream?live=true. A fresh connection shows recent logs
only while the producer or logger is running, capped at 256 KiB. Resume with both
log_id and cursor=<end_cursor>; cursors count original bytes, not characters.
The manager forwards the following agent events without duplicating log text:
type |
Fields and behavior |
|---|---|
reset |
log_id, cursor, reason (initial, new_log, truncated, replaced). Clear the view and adopt this position. |
logs |
log_id, cursor, logs, start_cursor, end_cursor. Append exactly; chunks may end mid-line. |
state |
log_id, cursor, state: running, stopping, stopped, waiting, or unknown. |
heartbeat |
log_id, cursor, state; sent after 15 seconds without other events. |
error |
message, source, retryable. Read/status errors also include log_id, cursor, state: unknown; repeated identical errors are suppressed per connection. |
recovered |
log_id, cursor, source. Clear only the error for that operation. |
The manager first sends capabilities with history: true or false. An older
agent triggers reset with reason: legacy; dated resume coordinates are discarded
and the current-file stream is used. A live=true client still receives raw events
(logs/message, not data), but legacy agents cannot supply execution metadata.
The System log panel uses this extended mode. New agents also read fixed-path
current files from older services, including while stopped, with log_id: null.
These services have no dated history; reconnecting reads a fresh tail instead of
resuming an unidentified file. Dated logs retain the running-state and resume rules above.
Invalid cursors and other permanent request errors close the WebSocket with code
1008 after an error event; clients must not automatically reconnect with the same
request. Temporary HTTP failures (408, 429, 5xx) and transport failures retry after
one second. Agent error events with retryable: false also end the connection.
Older agents that omit this flag cannot identify terminal in-stream errors.
Reconnections resume the last forwarded reset/log position. If current changed
while disconnected, the old execution's remaining bytes are not replayed;
retrieve them using its log_id through the history API. While continuously
connected, changing the dated current link drains the old open file before reset.
File growth is checked every 100 ms and service state every second. Read failures retry after one second; status failures do not delay log reads. Closing the viewer stops these reads and releases the upstream connection, even while idle. It does not stop logging. The manager does not collect or cache logs in the background.
Jog robot selection: The existing container router's GET /{container}/bringup_status checks only the selected
container's existing s6 service status and
reads /run/robot_type through Docker when a bringup process is first observed or
changes. It reuses the type while that process remains unchanged. There is no
background bringup monitor; requests use a lightweight Docker list without image
inspection. The type selects the AI Worker, OMY or OMX command routes. Record & Play
uses discovered trajectory topics and ROS feedback without Docker/s6 status checks;
known topics receive recommendations. No s6-agent update or new agent endpoint is
required. Joint limits come from URDF. Jog resolves controller membership through
ROS feedback; playback uses recorded topics and joint names. Only one running follower
is supported on the shared feedback namespace. Jog uses
/ws/jog?container={container}; the selected container determines the profile.
Each container has its own
status cache, so other browser selections cannot change an existing Jog target.
Container selection does not isolate ROS traffic; Jog rejects multiple publishers
on shared feedback topics and ambiguous controller mappings.
See motion profiles.
Service logs: System provides current logs and a right-aligned History dropdown. Both views request the most recent 5,000 lines (agent byte caps still apply), with an additional 4 Mi character limit in the UI. Scrolling upward only browses loaded logs; it does not request older pages. Live output follows the bottom and removes the oldest lines as new logs arrive, even while scrolled upward. Reading older output does not pause the stream; those lines disappear when they leave the retained window. Reconnect is offered when the connection stops. History lists dated executions and their sizes. Selecting one closes the live connection and fetches a fresh snapshot, including when selecting the same execution again; Reload also refreshes it. Only the displayed log is kept in memory. Select Current logs to return to live output. Download retrieves the full displayed execution (original bytes), or the legacy current log (ANSI stripped). These view limits do not modify the stored files. Older agents keep live logs without history.
ROS 2 WebSocket behavior: Each connection registers a unique subscription owner after resolving the topic message type (known types, discovery, or existing subscription). Connections share one ROS subscription and cache per topic. The API pushes {topic, msg_type, data, available} when data changes (throttled); metadata_only=true sends availability without payloads. Disconnecting releases only that connection's ownership. The ROS subscription and cache are removed when the last owner leaves. GET /ros2/topics/{topic} only reads the cache; the former REST subscribe/unsubscribe endpoints have been removed.
Observer recovery: Topic and System status sockets send ready after successful subscription setup, even without ROS data. Only ready clears the last error and resets backoff. Structured errors include code and retryable: invalid requests/types and type conflicts stop automatic retries (close 1008); temporary failures retry (close 1013). Retries use jittered 1, 2, 4, 8, 16, then at most 30-second delays. UI observers retain the error and offer Reconnect; leaving the page cancels retries.
HTTP observations: For the one-shot robot-description read, invalid topics and subscription type conflicts return 400, temporary subscription or bridge failures return 503, and timeout returns 504. Each request creates an independent transient-local subscription and waits for a retained delivery instead of reusing the shared cache, so a previous bringup's URDF is not returned from that cache. The temporary subscription is removed on completion, failure, timeout or disconnect without affecting other viewers. Camera status uses ROS graph publisher presence via the System status WebSocket; there is no camera frame-check endpoint or image subscription. Publisher presence does not prove that frames are being delivered.
Docker routes return 503 if docker.sock is unavailable. ROS routes require a running rclpy node and matching ROS_DOMAIN_ID.
cyclo_manager/
├── cyclo_manager/ # FastAPI backend + in-container agent
├── cyclo_manager_ui/ # Next.js UI
├── cyclo_manager_cli/ # PyPI package (cyclo-manager)
├── docs/ # Jog, Record & Play and code structure
├── config.yml # Dev / example config
├── docker-compose.dev.yml
└── README.md
See Code structure for backend, host-agent and UI module responsibilities, UI validation commands and manual verification guidance.
The API is unauthenticated by default and mounts docker.sock (high privilege). Restrict network access, tighten CORS in production, and treat /docs, WebSockets, and /host/* as sensitive when exposed.
Host file API (/host/files/*): Proxied to cyclo_host_agent and can read, write, create, rename, upload, and delete files under the configured host agent workspace (CYCLO_HOST_AGENT_WORKSPACE). By default this is the host agent service user's home directory, except root-only devices with /data/docker, where /data/docker is used automatically. Treat this as full access to that directory tree when the UI or API is reachable.
- CONTRIBUTING.md
- LICENSE (Apache-2.0)