Skip to content

Receiving Control

A robot becomes useful when it does something with the input it receives. There are two sources of control:

  1. operate.adamohq.com — gamepad and VR input from the hosted operator, published as ROS envelopes containing CDR-encoded payloads on control/joy and control/cdr/xr_tracking.
  2. Your own custom operator — a teleop program you wrote yourself, publishing whatever topics and payload format you like (typically JSON). See Building Your Own Operator.

For both, the SDK primitive is the same: subscribe to a key expression and react to incoming samples.

The web app publishes a Joy message to {robot}/control/joy whenever the gamepad changes. The payload is a ROS envelope whose inner payload is a CDR-encoded sensor_msgs/msg/Joy.

import struct
import adamo
def decode_ros_envelope(payload: bytes):
offset = 0
topic_len = struct.unpack_from(">I", payload, offset)[0]
offset += 4
topic = payload[offset : offset + topic_len].decode("utf-8")
offset += topic_len
type_len = struct.unpack_from(">I", payload, offset)[0]
offset += 4
type_name = payload[offset : offset + type_len].decode("utf-8")
offset += type_len
return topic, type_name, payload[offset:]
class MinimalCdrReader:
def __init__(self, payload: bytes):
if len(payload) < 4 or payload[:2] != b"\x00\x01":
raise ValueError("expected little-endian CDR payload")
self.payload = payload
self.offset = 4
self.payload_start = 4
def align(self, size: int):
relative = self.offset - self.payload_start
self.offset += (size - (relative % size)) % size
def read_i32(self) -> int:
self.align(4)
value = struct.unpack_from("<i", self.payload, self.offset)[0]
self.offset += 4
return value
def read_u32(self) -> int:
self.align(4)
value = struct.unpack_from("<I", self.payload, self.offset)[0]
self.offset += 4
return value
def read_f32(self) -> float:
self.align(4)
value = struct.unpack_from("<f", self.payload, self.offset)[0]
self.offset += 4
return value
def read_string(self) -> str:
length = self.read_u32() # includes null terminator
value = self.payload[self.offset : self.offset + length - 1].decode("utf-8")
self.offset += length
return value
def decode_joy(cdr_payload: bytes):
reader = MinimalCdrReader(cdr_payload)
reader.read_i32() # header.stamp.sec
reader.read_u32() # header.stamp.nanosec
reader.read_string() # header.frame_id
axes = [reader.read_f32() for _ in range(reader.read_u32())]
buttons = [reader.read_i32() for _ in range(reader.read_u32())]
return axes, buttons
robot = adamo.Robot(api_key="ak_...", name="my-arm")
@robot.on("my-arm", "control/joy", decode=None)
def on_joy(payload: bytes):
topic, type_name, cdr_payload = decode_ros_envelope(payload)
if topic != "/joy" or type_name != "sensor_msgs/msg/Joy":
return
axes, buttons = decode_joy(cdr_payload)
# axes[0..1] = left stick X/Y, axes[2..3] = right stick X/Y, etc.
drive(axes[0], axes[1])
robot.run()

The standard W3C / Xbox button and axis mapping is documented in the TypeScript SDK reference.

When a viewer enters immersive VR mode on a stereo track, the headset publishes pose data continuously:

Inner topicPayloadDescription
/head_posegeometry_msgs/msg/PoseStampedHeadset pose
/controller/leftgeometry_msgs/msg/PoseStampedLeft controller grip (wrist/hold pose)
/controller/rightgeometry_msgs/msg/PoseStampedRight controller grip (wrist/hold pose)
/controller/{handedness}/tipgeometry_msgs/msg/PoseStampedController tip — the aim pose (WebXR targetRaySpace)
/controller/{handedness}/joysensor_msgs/msg/JoyXR controller axes and buttons

The hosted UI sends XR data on {robot}/control/cdr/xr_tracking. Each payload is a ROS envelope whose inner topic is /head_pose, /controller/{handedness}, /controller/{handedness}/tip, or /controller/{handedness}/joy.

Save this as adamo_xr_to_ros.py:

#!/usr/bin/env python3
import argparse
from functools import partial
import struct
import adamo
import rclpy
from geometry_msgs.msg import PoseStamped
from rclpy.serialization import deserialize_message
from sensor_msgs.msg import Joy
ROS_TYPES = {
"geometry_msgs/msg/PoseStamped": PoseStamped,
"sensor_msgs/msg/Joy": Joy,
}
def decode_ros_envelope(payload: bytes):
offset = 0
topic_len = struct.unpack_from(">I", payload, offset)[0]
offset += 4
topic = payload[offset : offset + topic_len].decode("utf-8")
offset += topic_len
type_len = struct.unpack_from(">I", payload, offset)[0]
offset += 4
type_name = payload[offset : offset + type_len].decode("utf-8")
offset += type_len
return topic, type_name, payload[offset:]
def publish_xr_sample(sample, *, node, publishers):
inner_topic, type_name, cdr_payload = decode_ros_envelope(sample.payload)
msg_type = ROS_TYPES.get(type_name)
if msg_type is None:
node.get_logger().warn(f"Ignoring unsupported XR type: {type_name}")
return
ros_topic = f"/adamo/xr{inner_topic}"
if ros_topic not in publishers:
publishers[ros_topic] = node.create_publisher(msg_type, ros_topic, 10)
node.get_logger().info(f"Publishing XR {inner_topic} -> {ros_topic}")
publishers[ros_topic].publish(deserialize_message(cdr_payload, msg_type))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--api-key", required=True)
parser.add_argument("--robot", required=True)
args = parser.parse_args()
rclpy.init()
node = rclpy.create_node("adamo_xr_bridge")
publishers = {}
session = adamo.connect(api_key=args.api_key)
sub = session.subscribe(
f"{args.robot}/control/cdr/xr_tracking",
callback=partial(publish_xr_sample, node=node, publishers=publishers),
)
node.get_logger().info(f"Bridging {args.robot}/control/cdr/xr_tracking")
try:
rclpy.spin(node)
finally:
sub.close()
session.close()
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()

Run it from a ROS-sourced shell:

Terminal window
python3 adamo_xr_to_ros.py --api-key ak_... --robot my-arm

It republishes to ROS topics such as /adamo/xr/head_pose, /adamo/xr/controller/left, and /adamo/xr/controller/left/joy. If you do not run ROS, subscribe to {robot}/control/cdr/xr_tracking, strip the ROS envelope with decode_ros_envelope, then dispatch by the inner topic and decode the remaining cdr_payload with your CDR decoder.

When you write your own operator (XR app, mobile, GELLO leader, …), you choose the topic name and the payload format. JSON is the default in the Python SDK and works across all three languages.

The example below is the robot-side receiver that pairs with the bimanual XR operator on the Building Your Own Operator page.

The @robot.on(...) decorator subscribes and decodes JSON in one step. Brace-wrapped path segments capture the matched value as a keyword argument.

import adamo
robot = adamo.Robot(api_key="ak_...", name="my-arm")
# Cameras streamed back to the operator
robot.attach_video("wrist_left", device="/dev/video0")
robot.attach_video("wrist_right", device="/dev/video1")
robot.attach_video("head", shm="head_cam")
# Bimanual hand controllers — {side} captures "left" or "right"
@robot.on("xr-operator", "control/xr/hand/{side}", priority=250)
def hand(msg, side):
move_arm(side, msg["pos"], msg["quat"])
set_gripper(side, msg["trigger"])
# Head pose — separate handler
@robot.on("xr-operator", "control/xr/head", priority=250)
def on_head(msg):
update_head_tracking(msg["pos"], msg["quat"])
robot.run()

The first argument to @robot.on(...) is the broadcaster name — the operator publishing those topics. The decorator subscribes to {broadcaster}/{track} under the hood.

For labelling data collection, the hosted UI can emit task start/stop signals — the operator picks a named task and marks when it begins and ends. This is opt-in (Settings → Tasks → “Show task list” in the web app) and is a standalone signal: it does not touch the recording pipeline. Use it to segment and tag what your robot is doing.

The signal is published on:

{robot}/tasks/signal

reliably (DATA priority), as UTF-8 JSON:

{
"action": "start",
"task_id": "d69c5a80-9f03-4d8a-b32a-3819cd00a7c9",
"task_name": "Solder my own hand",
"ts": 1780686715804
}

action is "start" or "stop". task_id is the task’s UUID (matches the task in the web app); task_name is the human-readable label (may be absent); ts is Date.now() in milliseconds. Subscribe to */tasks/signal to catch every robot in the org — the robot is the first key segment.

import adamo, json
session = adamo.connect(api_key="ak_...")
def on_task(sample):
sig = json.loads(sample.payload)
robot = sample.key.split("/")[0]
print(robot, sig["action"], sig["task_id"], sig.get("task_name"))
sub = session.subscribe("*/tasks/signal", callback=on_task)

Tasks and sets are normally created in the web app, but you can also manage them over a small REST API — handy for pre-creating a set before a collection run so the task_id is known up front. Authenticate with your org API key (the same ak_… key the SDKs use): exchange it for a short-lived bearer token, then call the task endpoints. The token response also gives you your org_id.

Terminal window
RESP=$(curl -s -X POST https://api.adamohq.com/api/keys/token -H "X-API-Key: ak_...")
TOKEN=$(jq -r .token <<<"$RESP")
ORG=$(jq -r .org_id <<<"$RESP")

All endpoints are under https://api.adamohq.com/api/orgs/{org_id} and take/return JSON:

MethodPathBodyDescription
GET/task-setsList sets, each with its tasks (ordered by position)
POST/task-sets{name, position?}Create a set
GET/task-sets/{set_id}Get one set with its tasks
DELETE/task-sets/{set_id}Delete a set (and its tasks)
POST/task-sets/{set_id}/tasks{name, position?}Add a task
PATCH/task-sets/{set_id}/tasks/{task_id}{name?, position?}Rename / reorder a task
DELETE/task-sets/{set_id}/tasks/{task_id}Remove a task
Terminal window
# Create a set, then add a task to it
SET=$(curl -s -X POST https://api.adamohq.com/api/orgs/$ORG/task-sets \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Pick and place"}' | jq -r .id)
curl -X POST https://api.adamohq.com/api/orgs/$ORG/task-sets/$SET/tasks \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Grasp the cube"}'

The task_id returned here is the same UUID that appears in the task signal above, so a script can lay out a set and the operator just selects and records.

A bilateral teleop setup sends measured follower joint efforts back to the leader on a real-time control topic. The leader keeps only the newest sample, scales it negatively, then applies it in the leader arm’s external-effort mode.

The example below isolates the feedback path. Replace the two hardware-driver functions with calls to your robot API:

  • follower side: read measured external efforts from the follower arm
  • leader side: apply external efforts to the leader arm

The payload is eight big-endian f64 values: [timestamp_seconds, effort_0, ..., effort_6].

import os
import struct
import sys
import time
import adamo
N_JOINTS = 7
RATE_HZ = 100.0
GAIN = 0.1
ROBOT = os.getenv("ADAMO_ROBOT_NAME", "my-arm")
EFFORT_TOPIC = f"{ROBOT}/control/force_feedback/follower_effort"
def now_seconds(session):
return session.fabric_now_us() / 1_000_000.0
def pack_efforts(timestamp, efforts):
if len(efforts) != N_JOINTS:
raise ValueError(f"expected {N_JOINTS} joint efforts")
return struct.pack("!" + "d" * (1 + N_JOINTS), timestamp, *efforts)
def unpack_efforts(payload):
if len(payload) != 8 * (1 + N_JOINTS):
raise ValueError(f"bad effort payload size: {len(payload)}")
values = struct.unpack("!" + "d" * (1 + N_JOINTS), payload)
return values[0], list(values[1:])
def read_follower_external_efforts():
raise NotImplementedError("read joint efforts from your follower driver")
def apply_leader_external_efforts(efforts):
raise NotImplementedError("send joint efforts to your leader driver")
def run_follower(session):
with session.publisher(
EFFORT_TOPIC,
priority=250,
express=True,
reliable=False,
) as pub:
while True:
efforts = read_follower_external_efforts()
pub.put(pack_efforts(now_seconds(session), efforts))
time.sleep(1.0 / RATE_HZ)
def run_leader(session):
teleop_started_at = now_seconds(session)
with session.subscribe(EFFORT_TOPIC) as sub:
while True:
latest = None
while True:
sample = sub.try_recv()
if sample is None:
break
latest = sample
if latest is not None:
timestamp, measured = unpack_efforts(latest.payload)
if timestamp >= teleop_started_at:
applied = [-GAIN * effort for effort in measured]
apply_leader_external_efforts(applied)
time.sleep(1.0 / RATE_HZ)
session = adamo.connect(
api_key=os.environ["ADAMO_API_KEY"],
)
mode = sys.argv[1] if len(sys.argv) > 1 else "leader"
if mode == "follower":
run_follower(session)
else:
run_leader(session)

Control topics should be published with REAL_TIME priority and dropped on congestion — a command that arrives 200 ms late is worse than no command at all. The robot side doesn’t choose priority on subscribe (the router uses the publisher’s choice), but it’s worth knowing the convention so your own operator programs follow it.

robot.publish("control/joy", priority=250, express=True)
# 0–255 mapped to 8 priority classes; ≥240 is REAL_TIME.

Robots publish a 1 Hz heartbeat on {robot}/heartbeat carrying latency stats — the network regime classified by the robot’s congestion forecaster, jitter, loss rate, and queuing delay. Use it as a safety gate before applying teleop commands: stop the robot when heartbeats go stale or the regime degrades.

import time
from adamo.stats import Regime
last_beat = time.monotonic()
def on_stats(stats):
global last_beat
last_beat = time.monotonic()
if stats.regime is not Regime.STABLE:
slow_down()
sub = session.watch_latency("my-arm", on_stats)
# In the control loop: two missed heartbeats = stale link
if time.monotonic() - last_beat > 2.5:
stop_robot()
import { watchLatest } from "@adamo/fleet";
let lastBeat = performance.now();
const hb = await watchLatest(session, `adamo/${org}/my-arm/heartbeat`);
hb.onMessage(() => { lastBeat = performance.now(); });
// In the control loop: two missed heartbeats = stale link
if (performance.now() - lastBeat > 2500) stopRobot();

For dashboards, the parsed stats expose regime, jitter_ms, garch_sigma_ms, target_bitrate_kbps, loss_rate, and queuing_delay_ms — see the per-SDK LatencyStats reference.

See Building Your Own Operator for the publisher side end-to-end.

The current adamo-ts/examples/web frontend publishes operator input as real-time, best-effort messages. The exact key and payload depend on the input mode.

Raw keyboard state is published on:

{robot}/control/json/keyboard

The payload is UTF-8 JSON:

{
"key": "w",
"code": "KeyW",
"action": "down",
"stamp": 1770210000000
}

key is the browser KeyboardEvent.key value, so it reflects the active keyboard layout and modifiers. code is the browser KeyboardEvent.code value, so it identifies the physical key position. action is "down" or "up", and stamp is Date.now() in milliseconds.

The hosted UI suppresses browser auto-repeat events. While a physical key remains held, it republishes a "down" heartbeat about every 100 ms. Receivers should treat the heartbeat as a dead-man signal and stop the associated motion if it expires, rather than relying only on a single "up" edge.

When the UI is using CDR mode, the bytes on the Adamo key are a small ROS envelope followed by the CDR-encoded ROS message:

u32 topic_length_be
utf8 topic
u32 type_length_be
utf8 type
bytes cdr_payload

The topic and type fields describe the inner ROS message. The cdr_payload is the serialized message named by type.

Gamepad input is published on:

adamo/{org}/{robot}/control/joy

By default the payload is a ROS envelope with:

Inner fieldValue
topic/joy
typesensor_msgs/msg/Joy
payloadCDR sensor_msgs/msg/Joy

The Joy message uses header.frame_id = "joy" and contains six axes plus 21 button slots. Standard W3C / Xbox controllers populate buttons 0..16; buttons 17..20 are reserved and normally remain 0.

AxisMeaningRange
axes[0]Left stick X-1 left, +1 right
axes[1]Left stick Y-1 up, +1 down
axes[2]Right stick X-1 left, +1 right
axes[3]Right stick Y-1 up, +1 down
axes[4]Left trigger analog0 released, 1 pressed
axes[5]Right trigger analog0 released, 1 pressed
ButtonXbox / W3C control
buttons[0]A
buttons[1]B
buttons[2]X
buttons[3]Y
buttons[4]LB
buttons[5]RB
buttons[6]LT pressed
buttons[7]RT pressed
buttons[8]Back / Select
buttons[9]Start
buttons[10]Left stick click
buttons[11]Right stick click
buttons[12]D-pad up
buttons[13]D-pad down
buttons[14]D-pad left
buttons[15]D-pad right
buttons[16]Guide / Xbox

If joystick serialization is switched to JSON in the UI, the same key carries:

{
"type": "JoystickCommand",
"sequence_id": 42,
"stamp": 1710000000.123,
"axes": [0, 0, 0, 0, 0, 0],
"buttons": [0, 0, 0]
}

The Logitech Extreme 3D Pro uses the same control/joy key and payload format, but the axes are mapped as a joystick:

AxisMeaning
axes[0]Stick X
axes[1]Stick Y
axes[2]Twist / rudder
axes[3]Throttle, with forward usually negative
axes[4]Unused, normally 0
axes[5]Unused, normally 0

Buttons 0..11 are copied from the physical buttons. Buttons 12..15 are the hat switch when the browser reports it as buttons.

XR tracking is published on one Adamo key:

adamo/{org}/{robot}/control/cdr/xr_tracking

Each publish contains one ROS envelope. Consumers should decode the envelope and dispatch by the inner topic:

Inner topicTypeContents
/head_posegeometry_msgs/msg/PoseStampedHead pose
/controller/{handedness}geometry_msgs/msg/PoseStampedPhysical controller grip pose (the wrist/hold point), or hand wrist pose when hand tracking is active without a physical controller
/controller/{handedness}/tipgeometry_msgs/msg/PoseStampedPhysical controller tip — the aim pose from WebXR targetRaySpace. Published alongside the grip pose above: grip is where the hand holds the controller, tip is where it points. Omitted for a frame if the runtime reports no target-ray pose.
/controller/{handedness}/joysensor_msgs/msg/JoyXR controller axes and buttons
/hand/{handedness}geometry_msgs/msg/PoseArray25 hand joint poses when hand tracking is enabled

{handedness} is normally left or right. Pose headers use frame_id = "xr_origin". Positions are in meters in the WebXR local-floor reference space. Orientations are published as ROS quaternions {x, y, z, w}; the frontend converts from WebXR’s internal [w, x, y, z] order before encoding the ROS message.

XR controller Joy messages use:

const rawAxisCount = xrGamepad.axes.length;
axes = [
...xrGamepad.axes,
...xrGamepad.buttons.map((button) => button.value),
];
buttons = xrGamepad.buttons.map((button) => button.pressed ? 1 : 0);

The raw axis and button order is the order reported by the WebXR runtime for that controller. The UI does not remap XR controller buttons to Xbox-style button indices.

For controllers using the WebXR xr-standard mapping, the Joy values are:

Joy valueWebXR valueMeaning
axes[0]xrGamepad.axes[0]Primary touchpad X, or placeholder 0
axes[1]xrGamepad.axes[1]Primary touchpad Y, or placeholder 0
axes[2]xrGamepad.axes[2]Primary thumbstick X
axes[3]xrGamepad.axes[3]Primary thumbstick Y
buttons[0]xrGamepad.buttons[0].pressedPrimary trigger pressed
axes[rawAxisCount + 0]xrGamepad.buttons[0].valuePrimary trigger analog value
buttons[1]xrGamepad.buttons[1].pressedGrip / squeeze pressed
axes[rawAxisCount + 1]xrGamepad.buttons[1].valueGrip / squeeze analog value
buttons[2]xrGamepad.buttons[2].pressedPrimary touchpad pressed, if present
axes[rawAxisCount + 2]xrGamepad.buttons[2].valuePrimary touchpad button value
buttons[3]xrGamepad.buttons[3].pressedPrimary thumbstick pressed, if present
axes[rawAxisCount + 3]xrGamepad.buttons[3].valuePrimary thumbstick button value
buttons[4]xrGamepad.buttons[4].pressedFirst extra button. On current Quest/Pico-style controllers this is usually X on left hand and A on right hand.
axes[rawAxisCount + 4]xrGamepad.buttons[4].valueFirst extra button value
buttons[5]xrGamepad.buttons[5].pressedSecond extra button. On current Quest/Pico-style controllers this is usually Y on left hand and B on right hand.
axes[rawAxisCount + 5]xrGamepad.buttons[5].valueSecond extra button value

rawAxisCount is commonly 4 for thumbstick controllers, so the trigger analog value is commonly axes[4], grip is axes[5], thumbstick button value is axes[7], and the first extra face button value is axes[8]. Check axes.length - buttons.length if you want to derive the offset at runtime.

Hand tracking publishes PoseArray joints in this fixed order:

wrist,
thumb-metacarpal, thumb-phalanx-proximal, thumb-phalanx-distal, thumb-tip,
index-finger-metacarpal, index-finger-phalanx-proximal, index-finger-phalanx-intermediate, index-finger-phalanx-distal, index-finger-tip,
middle-finger-metacarpal, middle-finger-phalanx-proximal, middle-finger-phalanx-intermediate, middle-finger-phalanx-distal, middle-finger-tip,
ring-finger-metacarpal, ring-finger-phalanx-proximal, ring-finger-phalanx-intermediate, ring-finger-phalanx-distal, ring-finger-tip,
pinky-finger-metacarpal, pinky-finger-phalanx-proximal, pinky-finger-phalanx-intermediate, pinky-finger-phalanx-distal, pinky-finger-tip