5 External Control (Beta)

The new External Control software is in beta. Reach out to [email protected] for information about joining.

External control lets an outside program drive the arm directly. You open an external-control session over HTTP, stream setpoints to the arm for as long as that session lasts, and read the arm’s live state back the whole time.

There are three ways to speak it. All three open the same kind of session and are governed by the same safety rules:

The ROS2 bridge and the Python SDK are both thin adapters over the UDP protocol, so everything in the Control and Safety Semantics section applies whichever one you choose. The setpoints are the same on all three, with one exception: only the Python SDK and direct UDP can attach an optional twist to a pose setpoint, because geometry_msgs/PoseStamped, the ROS2 pose message, has no velocity field.

WARNING – Your Code Moves the Arm

External control streams motion commands straight from your program to the joints, with no routine, planner, or operator in between. Guard the cell and keep people clear while you are developing against it. External control is Beta and access is granted case by case, so contact [email protected] to have it enabled.

5.1 How External Control Relates to the ROS2 Realtime API

The ROS2 Setup and ROS2 Python Scripting sections earlier in this chapter describe the ROS2 Realtime API: topics under /<BOT_ID>/ro1/hardware/..., switched on and off with sdk.ros.control.update_ros_control_state(), which wraps GET and POST /api/v1/movement/ros/state.

External control is a newer, session-based interface. Its topics live under /<robotId>/external/..., it is switched on and off with POST /api/v1/external-control/start and POST /api/v1/external-control/stop, and it adds two non-ROS transports alongside the ROS2 one.

Which interface your robot exposes depends on the software it is running. On a robot running the external-control stack, the older movement/ros/state endpoint returns 500 with ControlSourceManager is enabled. That response is the signal to use external-control/start instead.

The topics listed in the Command Topics and State Topics sections below are the complete /external/* set. The Jacobian and wrist IMU topics described in the Topic Reference section belong to the /ro1/hardware/... tree, not to this one.

5.2 How a Session Works

  1. POST /api/v1/external-control/start with the stream type you want, for example joints for live joint streaming. The robot takes control and, for a ROS2 session, brings up the ROS2 bridge.
  2. Publish setpoints to /<robotId>/external/<modality> and read state from /<robotId>/external/robot_*.
  3. POST /api/v1/external-control/stop when you are done. The robot releases control and stops safely.

Control is held for the lifetime of the session. There is no per-message engage handshake, and control is held until you call stop or the session ends on its own. If you stop publishing, what happens depends on the stream type. On a robot with the Streaming controller capability, described in the Prerequisites section, a joints or pose session decelerates the arm to rest after the command timeout, 500 ms by default, and the session ends. A jointTrajectory session holds position.

A session that ends on its own leaves the robot in a failure state, not idle, so you have to recover the robot before you can start again. The What Ends a Session and How to Recover section covers every case.

Every session has the same shape no matter how you drive it. One HTTP call opens the session and takes the control lease, you stream setpoints over one of three interchangeable transports, and a second HTTP call ends it.

 Your machine                          Standard Bots control box

 +-----------------+  HTTP start/stop  +----------------------------+
 | any HTTP client | ----------------> | api  (port 3000)           |
 +-----------------+                   | opens the session and      |
                                       | takes the control lease    |
                                       +-------------+--------------+
                                                     |
 --- then stream setpoints over ONE of these --------+---------------
                                                     |
 +-----------------+                   +-------------v--------------+
 | ros2 pub / sub  | <--ROS2 topics--> |                            |
 +-----------------+                   |                            |
 +-----------------+                   |  External Control service  |
 | Python SDK      | <--UDP :30100---> |                            |
 +-----------------+                   |                            |
 +-----------------+                   |                            |
 | your own client | <--UDP :30100---> |                            |
 +-----------------+                   +-------------+--------------+
                                                     |
                                                     v
                                            motion control -> arm

These are alternatives, not layers you stack, so pick one per session. All three reach the same external control service, and the ROS2 and Python SDK front ends are thin clients over the same protobuf-over-UDP protocol that the third one speaks directly.

Transport Reach for it when
ROS2 topics You already have a ROS2 stack to publish from. See the Command Topics and State Topics sections.
Python SDK You want live control from Python, without ROS2. See the Streaming from Python without ROS2 section.
Direct UDP You are working in C++ or another language. See the Direct UDP Interface section.

The ROS2 front end is brought up per session by a start with controlledBy set to rosHumble or rosJazzy, and it is torn down by stop. The UDP listener serves the other two transports. Either way, only one external client can hold the writer slot at a time.

5.3 Prerequisites

5.3.1 On the Robot

External control depends on robot-side capabilities that Standard Bots configures, so contact [email protected] if your robot does not have them turned on. The first three are required for any session. The last two vary from robot to robot and change how the same API behaves. start succeeds either way, so confirm with Standard Bots which of them your robot has before you build on them. Their italicized names are used throughout the External Control sections that follow.

Capability What it enables
Control arbitration Lets an external client hold the control lease that permits commanding the arm.
External-control sessions Enables the session that start and stop create.
ROS2 bridge Publishes the /external/* topics and relays them to the arm. Only needed for ROS2.
Streaming controller Routes joints and pose streams through the tracked streaming controller. servoConfig tuning, the command watchdog, and the velocity and twist fields work only with it. Without it, they are accepted and ignored.
Trajectory execution Executes multi-point trajectories: the .../trajectory and .../cancel topics, the UDP trajectory_command and cancel_trajectory messages, and the SDK’s send_trajectory() and send_cancel(). Where it is off, they are accepted and ignored.

NOTE – Streaming Controller or Trajectory Execution, Not Both

A robot has either Streaming controller or Trajectory execution, never both. start succeeds either way, so ask Standard Bots which one your robot has.

The bridge supports ROS2 Humble and Jazzy. The controlledBy field of start, set to rosHumble or rosJazzy, selects which one a session launches, so match it to the ROS2 distribution on your machine.

5.3.2 On Your Machine

Whichever transport you drive the arm with, you need:

If you are using ROS2, you also need:

WARNING – Domain, RMW, or Network Mismatch Is the Most Common Failure

A mismatch is the single most common reason topics never appear. If ros2 topic list shows nothing after start, check ROS_DOMAIN_ID, RMW_IMPLEMENTATION, and that you are on the robot’s wired subnet. If /external_control_ros_bridge does appear in ros2 node list but no /external/* topics do, your machine can reach only one of the addresses the robot advertises. Move to the wired subnet, or have the operator restrict the robot’s ROS2 interfaces to the one you can reach.

If you are using the Python SDK, you also need:

The realtime client speaks UDP directly, so none of the ROS2 or DDS setup above applies.

If you are writing your own client, you also need protoc and the external_control.proto contract, and UDP port 30100 on the control box has to be reachable. The contract is append-only, so fields that a robot does not know about are simply absent. Again, no ROS2 or DDS is involved.

5.4 Getting Started with Joint Streaming

This walkthrough uses streamType: "joints", which is live joint streaming. Pose streaming and jointTrajectory are covered in the Command Topics section.

5.4.1 Step 1 – Preflight: Robot ID, Idle State, and Brakes

The robot has to be idle, meaning no routine is running and it is not in Failure, and its brakes disengaged before you start a session. You can do this from the robot UI, with the Python SDK, or entirely over the same HTTP API. Every call below takes the same Authorization: Bearer <token> header as start.

First, get the robotId. It namespaces every external-control ROS2 topic in this section, and it is what <robotId> stands for throughout:

curl http://<robot-ip>:3000/api/v1/identity/bot_identity \
  -H "Authorization: Bearer <token>"

The response identifies the robot:

{
  "robotId": "...",
  "alias": "...",
  "robotOperationMode": "...",
  "applicationUrl": "..."
}

If ros2 topic list instead shows topics under /local/external/*, the robot has no identity configured, so contact Standard Bots.

Next, check that the robot is idle:

curl http://<robot-ip>:3000/api/v1/recovery/status \
  -H "Authorization: Bearer <token>"
# => { "status": "Idle", "failed": false }

status is the state that start tests. Idle means the robot is ready, and anything else is refused with 409 robot_not_idle. RoutineRunning means a routine or another session is running, so stop it first. Failure means a previous session ended on a fault, so recover the robot, as described in the Recovering from a Failure section, and then start.

Then check the brakes, and disengage them if needed:

curl http://<robot-ip>:3000/api/v1/movement/brakes \
  -H "Authorization: Bearer <token>"
# => { "state": "engaged" }  or  { "state": "disengaged" }

curl -X POST http://<robot-ip>:3000/api/v1/movement/brakes \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"state":"disengaged"}'

From Python, sdk.movement.brakes.get_brakes_state() and sdk.movement.brakes.unbrake() do the same thing.

Re-check the brakes after an E-stop or a power loss. GET /api/v1/status/health and GET /api/v1/status/control-mode are the general health and mode checks if you need to diagnose before starting.

5.4.2 Step 2 – Start the Session

curl -X POST http://<robot-ip>:3000/api/v1/external-control/start \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"controlledBy":"rosJazzy","streamType":"joints"}'

The response carries the identifier of the session that was created:

{ "routineId": "abc123..." }

The request body takes these fields:

curl -X POST http://<robot-ip>:3000/api/v1/external-control/start \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
        "controlledBy": "rosJazzy",
        "streamType": "joints",
        "servoConfig": {
          "tauMs": 20,
          "commandTimeoutMs": 500,
          "rampInDurationMs": 300,
          "positionSmoothingTimeConstantMs": 20
        }
      }'

Full semantics, defaults, and ranges are in the Tuning the Stream with servoConfig section.

WARNING – start Returns Before the Session Is Live

start returns before the control lease is taken and, for a ROS2 session, before the bridge is up, which takes a few seconds. Setpoints sent before then are dropped, never queued. On ROS2, wait until the /external/* topics appear and .../robot_joints is flowing, as shown in Step 3. On UDP or the Python SDK, poll GET /api/v1/recovery/status a second after start. RoutineRunning means the session is up, and Failure means it died on the way up, so recover the robot and then start again.

5.4.3 Step 3 – Confirm the Topics Are Live

With ROS_DOMAIN_ID and RMW_IMPLEMENTATION set to match the robot:

ros2 topic list | grep external
# /<robotId>/external/trajectory
# /<robotId>/external/pose
# /<robotId>/external/joints
# /<robotId>/external/gripper
# /<robotId>/external/cancel
# /<robotId>/external/robot_joints
# /<robotId>/external/robot_pose
# /<robotId>/external/robot_gripper
# /<robotId>/external/status

<robotId> is your robot’s identity, which is also the topic namespace. If you do not know it, ros2 topic list reveals it.

The bridge node is /external_control_ros_bridge. If the node is listed but no /external/* topics are, see the mismatch warning in the On Your Machine section. If the node never appears and the session is gone within seconds of start, the robot could not launch the bridge, as described in the Troubleshooting section.

5.4.4 Step 4 – Command the Arm

Stream joint setpoints as positions in radians, with the joints ordered joint0 through joint5. Note --rate rather than --once: joint streaming is a continuous stream of setpoints, and the arm tracks the latest one.

ros2 topic pub --rate 100 \
  /<robotId>/external/joints sensor_msgs/msg/JointState "{
  name: ['joint0','joint1','joint2','joint3','joint4','joint5'],
  position: [0.0, -1.57, 1.57, 0.0, 1.57, 0.0]
}"

Leave this command running, and run Steps 5 and 6 in a second terminal. Killing the publisher stalls the stream, and the session ends after commandTimeoutMs, 500 ms by default. The robot is then in Failure, so stop in Step 6 returns stopped: false, and you have to recover the robot, as described in the Recovering from a Failure section, before you can start again.

Joint order is positional, and name is ignored, as described in the Command Topics section. The arm eases from wherever it is to this target over rampInDurationMs, 300 ms by default, and then holds it for as long as the publisher keeps running. To actually move the arm, republish with changing position values at a steady rate. velocity is optional and is covered in the Command Topics section.

WARNING – Keep the Deltas Between Setpoints Small

Sending a single setpoint far from the arm’s current position is safe here only because the ramp-in smooths the approach. Once you are streaming for real, keep the delta between consecutive setpoints small.

5.4.5 Step 5 – Read the Arm’s State

# live joint state
ros2 topic echo /<robotId>/external/robot_joints

# engaged=true/false, for the bridge's writer slot only
ros2 topic echo /<robotId>/external/status

.../status says only whether the bridge holds the writer slot. It prints nothing on a healthy session, and it does not change on a protective stop or an E-stop, as described in the State Topics section. To find out why a session ended, use GET /api/v1/recovery/status, as described in the Reading Why a Session Ended section.

5.4.6 Step 6 – Stop the Session

From the second terminal, call stop while the publisher from Step 4 is still running:

curl -X POST http://<robot-ip>:3000/api/v1/external-control/stop \
  -H "Authorization: Bearer <token>"

The response confirms the teardown:

{ "stopped": true }

stop returns after teardown, once the arm has settled, so an immediate start is safe. Once stop has returned, you can stop the publisher. stopped is false when there was nothing to stop: the robot was idle, an operator’s routine is running, which stop never ends, or the session already ended on its own and the robot is in Failure. In that last case, recover the robot and then start.

5.5 Command Topics

These are the topics you publish to. All names sit under the namespace /<robotId>/external. Pick the streamType at start that matches the topic you will publish, as the warning below explains.

Topic Message type Fields used
.../trajectory trajectory_msgs/JointTrajectory joint_names, points[].positions in radians, points[].time_from_start
.../joints sensor_msgs/JointState position in radians, optional velocity in rad/s
.../pose geometry_msgs/PoseStamped pose.position in meters, pose.orientation as a quaternion
.../gripper std_msgs/Float64MultiArray data = [position] or [position, velocity]
.../cancel std_msgs/Empty none

Joint order is positional: index 0 is joint0 and index 5 is joint5, with positions in radians. .../joints ignores name and does not check the count. Only .../trajectory reads joint_names.

WARNING – One Motion Modality per Session

Use one motion modality per session, matching streamType. The control lease is granted for the modality you pass to start, and publishing to another motion topic is rejected by the robot’s control arbitration. If you start with streamType: jointTrajectory but publish to .../pose, those pose commands are rejected. To switch modality, stop the session and start a new one with the new streamType. Gripper and cancel are not gated this way and work in any session.

5.5.1 Streaming Joint Positions, with Optional Velocity

For live joint control, use streamType: joints and publish sensor_msgs/JointState messages carrying position in radians, ordered joint0 through joint5, streamed continuously at a steady rate of roughly 100 to 250 Hz.

With Streaming controller, your targets are low-pass filtered, the arm ramps into the first one rather than jumping to it, each arrival is blended over a smoothing window, and a stalled stream is watchdogged. All of these behaviors are tunable per session through servoConfig. Without the capability, positions are applied as they arrive, with no tuning and no watchdog.

velocity is optional. With Streaming controller, the arm advances the target at your velocity between setpoints, a first-order hold, which removes the stepped motion you get when streaming below the control rate. Omit the field and each setpoint is held until the next one arrives. Without the capability, velocity is ignored and the positions still apply.

5.5.2 Streaming Tool-Point Poses, with Optional Twist

For Cartesian control, use streamType: pose and stream tool center point setpoints continuously. This section assumes Streaming controller. Without it, a pose is an unvalidated flange target in the base frame, held indefinitely with no watchdog, and twist is ignored.

Frame and point. Poses are expressed in servoConfig.referenceFrame, which is either base, the default, or world, which also applies the robot’s configured base placement. The point driven is the robot’s Active Tooltip, or the flange if no tool is configured. The tool offset is read at start, so set the tooltip before starting.

WARNING – The Pose Readback Is the Flange

.../robot_pose and the UDP ServerMessage.pose report the flange, as described in the State Topics section. With a tool configured, do not seed your stream from the readback plus offsets, because your first target would be off by the tool length.

Rate and hold. Pose streaming is designed for rates from 10 Hz to 1 kHz. Between setpoints, the target advances along the setpoint’s twist, a first-order hold. With no twist the target stands still, so a slow stream produces a staircase. Supply a twist, or stream at 100 Hz or faster. A gap longer than commandTimeoutMs ends the session.

Twist. linear is the tool point’s velocity in m/s, and angular is the tool frame’s angular velocity in rad/s, both expressed in the referenceFrame axes. The twist has to be the derivative of the poses you stream, or the motion becomes a sawtooth. With a tool, keep in mind that the flange’s velocity is not the tool point’s velocity while the tool rotates. Twist is available on UDP and the Python SDK only, because the ROS2 geometry_msgs/PoseStamped message has no velocity field.

Ramp-in and smoothing. The arm ramps from its current tool pose to your first setpoint over rampInDurationMs, 300 ms by default and clocked from the first setpoint. It then tracks the stream through a first-order low-pass filter with the time constant tauMs, 20 ms by default. The ramp is linear in time, so covering 0.3 m in 300 ms is an approach of roughly 1 m/s, which is enough to trip the joint acceleration check. Size the ramp to the distance, or start the stream at the arm’s current tool pose. With rampInDurationMs: 0, a far first setpoint ends the session at once with TRACKING_ERROR_EXCEEDED.

The bounds behind these fault codes are listed in the Controller Faults on a Pose Stream section.

WARNING – A Non-Zero Twist Keeps the Target Moving

A non-zero twist keeps the target moving after your last setpoint, through the commandTimeoutMs window and the stop that follows. Send a zero twist when you intend to hold.

5.5.3 The Gripper Channel

.../gripper, and its UDP counterpart gripper_command, is a jog-shaped channel, not a pick command. It is gated on the writer slot, not on streamType.

Field Range Meaning
position 0 to 1 0 is fully closed and 1 is fully open, on the gripper’s configured stroke. The same scale applies to Robotiq 2F, OnRobot 2FG and 3FG, and Schunk EGx grippers.
velocity 0 to 1 Fraction of the gripper’s speed range. 0, or omitting the value, uses the gripper’s default.

Force is the gripper’s configured default, and a setpoint never waits for grip detection. Setpoints coalesce, so you can stream at any rate.

NOTE – No Gripper, No Channel

With no enabled, connected gripper, or with a Robotiq gripper that is still activating, commands are dropped silently and .../robot_gripper never publishes, so confirm that it produces data first. .../robot_gripper updates at 30 Hz during a session and 10 Hz otherwise.

5.6 State Topics

These are the topics the robot publishes and you subscribe to. They are republished once per robot state frame: about 200 Hz on a real arm, up to about 400 Hz where pose and joint updates arrive as separate frames, and 40 Hz in the simulator. They use the default reliable, volatile QoS with a depth of 10, and nothing is latched. header.stamp is the bridge’s receive time, not a robot timestamp.

Topic Message type Contents
.../robot_joints sensor_msgs/JointState Live name from joint0 to joint5, position in radians, velocity in rad/s, and effort in newton-meters, which is a filtered torque estimate.
.../robot_pose geometry_msgs/PoseStamped Live flange pose in the robot base frame, with position in meters and orientation as a quaternion. No tool offset and no base-placement transform are applied, regardless of servoConfig.referenceFrame. header.frame_id is the fixed label "world", but treat the data as base-frame.
.../robot_gripper std_msgs/Float64MultiArray data = [position], normalized to the range 0 to 1. Republished with every frame, and the value refreshes at 30 Hz during a session. Published only once the robot has reported a gripper. With none, the topic exists but never publishes.
.../status std_msgs/String engaged=<true/false> reason=<reason>, telling you whether the bridge holds the writer slot. Published on transitions only, and not latched. reason is auto-engaged when the bridge takes control, or send_<op>-failed when a command could not be delivered and the bridge dropped control. The bridge re-takes control on your next command.

What the .../status topic does and does not tell you:

5.7 External Control REST Reference

The base URL is the robot’s control box on port 3000, the same base URL used by the rest of the API. Both endpoints require an API token in the Authorization: Bearer <token> header.

5.7.1 Starting a Session

POST /api/v1/external-control/start starts an external-control session. It takes the control lease and, for rosHumble and rosJazzy, brings up the ROS2 bridge. It waits up to 2 seconds for the arm to be still, and then returns once the session routine is running. If the routine is not running within 15 seconds, the call fails with 500 cannot_play_routine. The call returns before the lease is held and the bridge is up, as described in Step 2 of the Getting Started with Joint Streaming section.

A request body that sets every field looks like this:

{
  "controlledBy": "rosJazzy",
  "streamType": "joints",
  "servoConfig": {
    "tauMs": 20,
    "commandTimeoutMs": 500,
    "rampInDurationMs": 300,
    "positionSmoothingTimeConstantMs": 20,
    "referenceFrame": "base",
    "motionScalingLimits": {
      "jointVelocityMax": [1.0, 1.0, 1.0, 2.0, 2.0, 2.0],
      "jointAccelerationMax": [4.0, 4.0, 4.0, 4.0, 4.0, 4.0]
    }
  }
}

The request body has three top-level fields:

Field Required Values
controlledBy yes api, rosHumble, or rosJazzy
streamType yes pose, joints, or jointTrajectory
servoConfig no Streaming tuning. Honored on joints and pose, and ignored on jointTrajectory. Every field inside it is individually optional, so omit what you do not want to change. See the Tuning the Stream with servoConfig section.

A missing or unknown value for controlledBy or streamType is a 400 request_failed_validation that names the field. The two required fields are checked before servoConfig.

A successful call returns 200 with the session identifier:

{ "routineId": "string" }

Every error has the same body shape:

{ "error": "<code>", "message": "<detail>" }

These are the errors you are most likely to see:

Status Meaning Fix
400 brakes_must_be_disengaged The brakes are engaged. This is checked first, so an E-stopped robot reports it. Disengage the brakes, as shown in Step 1, then retry.
400 request_failed_validation controlledBy or streamType is missing or not an allowed value, or a servoConfig field is out of range, the wrong type or shape, or null. The message names every offending field. Correct the named field.
409 robot_not_idle The message says which case applies: a routine or another session is running (state: RoutineRunning), a previous session ended on its own and the robot is in Failure, the arm has not come to rest within 2 seconds, or another session is already starting. If something is running, stop it. On Failure, recover the robot and then start. Otherwise, wait a moment and retry.
500 cannot_play_routine The session routine could not start, because the robot was already in Failure when play began, or the routine ended before it ran. Nothing is left running. Check GET /api/v1/recovery/status, recover the robot if needed, and retry.
500 internal_server_error An unexpected robot-side error, or Routines service is unavailable while the robot’s services start. Retry, and if the error persists, contact Standard Bots.
503 service_initializing The robot software is still starting. Retry after a few seconds.

A failed start leaves nothing running. A validation 400 names every offending field in its message, separated by semicolons. The message is prefixed with Invalid request: for the two required fields, or with Invalid servoConfig: for the tuning. For example, a message that names two servoConfig fields reads as follows, wrapped here to fit the page:

Invalid servoConfig: tauMs: Number must be less than or equal to 1000;
motionScalingLimits.jointVelocityMax: must be 6 values, one per joint

5.7.2 Tuning the Stream with servoConfig

servoConfig is optional tuning of the Streaming controller. It is honored on joints and pose, validated and then ignored on jointTrajectory, and ignored entirely on robots without the capability. Each field is optional and defaults independently, so you can send just the one you want to change. A field that belongs to the other stream type still has to be valid but has no effect, so one request shape works for both.

Field Default Range Applies to
tauMs 20 ms 1 to 1000 joints, pose
commandTimeoutMs 500 ms 1 to 2147483647 joints, pose
rampInDurationMs 300 ms 0 to 2147483647 joints, pose
positionSmoothingTimeConstantMs 20 ms 0 to 1000 joints
referenceFrame "base" "base" or "world" pose
motionScalingLimits absent Two six-value arrays pose

Each field works as follows:

All millisecond fields are integers. Out-of-range, wrong-type, wrong-length, and non-finite values are rejected with a 400 rather than clamped.

WARNING – Omit a Field to Get Its Default, and Never Send null

A null is a validation error, not a fallback. The Python SDK serializes every unset field, including servoConfig itself, as null, as described in the Starting and Stopping the Session from the SDK section.

NOTE – Misspelled Fields Fail Silently

Unrecognized keys inside servoConfig are dropped without an error, so a misspelled field name fails silently and you get the default instead. Check your spelling against the table above.

motionScalingLimits slows the stream rather than rejecting it. Every cycle, the commanded joint velocity is scaled so that no joint exceeds jointVelocityMax, and then rate-limited so that no joint exceeds jointAccelerationMax. The arm lags the stream until it catches up, and once the lag exceeds the tracking bound, the session ends with TRACKING_ERROR_EXCEEDED.

Independently of those caps, the arm’s own per-joint commanded-acceleration limit, 5 rad/s2 on RO1, still applies, and exceeding it ends the session with JOINT_COMMAND_OUT_OF_LIMITS. If you hit it, raise tauMs or tighten jointAccelerationMax. A small tauMs combined with a tight jointAccelerationMax can overshoot a stopped target. The overshoot is bounded, and the servol_overshoot.py SDK example predicts it.

While a session runs, the Settings > External control > Streaming page in the robot UI shows the effective tuning.

5.7.3 Stopping a Session

POST /api/v1/external-control/stop stops the running external-control session, releases the lease, and tears down the bridge. It takes no request body, and it returns 200 with:

{ "stopped": true }

stopped is false when there was nothing to stop: the robot was idle, an operator’s routine is running, which stop never ends, or the session already ended on its own and the robot is in Failure. In that last case, recover the robot and then start.

stop returns after teardown and settle, capped at 2 seconds, so an immediate start is safe. It ends the running session no matter who started it, whether that was you, another client, or the robot UI. There is no session owner and no owner heartbeat, so a session never ends just because its client went away. Watch for a session ending under you, where the topics vanish and GET /api/v1/recovery/status leaves RoutineRunning, rather than assuming the session is yours until you call stop.

An operator’s own routine is stopped from the robot UI or with POST /api/v1/routine-editor/routines/{id}/stop, not with this endpoint.

5.8 Control and Safety Semantics

5.9 What Ends a Session and How to Recover

Only stop ends a session cleanly. Every other end leaves the robot in Failure, not Idle, and “recover” below means the procedure in the Recovering from a Failure section. The last case in this list does not end the session at all.

5.9.1 Controller Faults on a Pose Stream

Each of these conditions stops the arm and then ends the session. The bounds are robot-side defaults, and the tuning fields are described in the Tuning the Stream with servoConfig section.

Code in failure.reason Condition and notes
STREAM_JUMP Two consecutive setpoints are too far apart. The bound is 0.1 m or 0.5 rad. The first setpoint is exempt, because the ramp-in absorbs it.
TRACKING_ERROR_EXCEEDED The target ran away from the arm. The bound is 0.4 m or 1.5 rad between the target and the tracked pose. Causes include an unreachable pose, motionScalingLimits set too tight, and a far first setpoint with rampInDurationMs: 0.
IN_SINGULARITY A singularity. A session may start at a singular pose and hold there, but once anything has moved, a target at or through a singularity faults. Plan clear of singularities, because the controller does not steer around them.
COMPUTATION The stop ramp has no IK solution. The stream is integrated in joint space, and IK runs only on the deceleration ramp. If there is no solution there, the arm stops faster, in joint space.
JOINT_COMMAND_OUT_OF_LIMITS The commanded joint acceleration is above the robot’s fixed per-joint limit. The message reads “A commanded joint position is outside the arm’s limits”, but on a pose stream this code means acceleration. Raise tauMs or add motionScalingLimits.
None A commanded joint position is beyond a joint’s range, or more than 0.2 rad from the measured position. This is a control-system stop, not a controller fault. The command is not scaled or clamped: the arm’s own validator refuses it, the arm stops gracefully, and the session ends with no code. The robot UI shows Robot encountered a control system issue: Invalid trajectory point.... Stay inside the joint ranges yourself.

5.9.2 Recovering from a Failure

While the robot is in Failure, stop returns { "stopped": false } and start returns 409 robot_not_idle with state: Failure. Nothing recovers automatically. Read the failure, recover, and start again:

curl http://<robot-ip>:3000/api/v1/recovery/status \
  -H "Authorization: Bearer <token>"
# => { "status": "Failure", "failed": true,
#      "failure": { "kind": "StepPlayFailure",
#                   "reason": "External Control Step failed: ...",
#                   "recovery_type": "Recoverable", ... } }

curl -X POST http://<robot-ip>:3000/api/v1/recovery/recover \
  -H "Authorization: Bearer <token>"
# => { "status": "Idle", "failed": false }

curl -X POST http://<robot-ip>:3000/api/v1/external-control/start \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"controlledBy":"rosJazzy","streamType":"joints"}'

recovery_type tells you whether recover will succeed on its own:

recovery_type Typical causes Who recovers
Recoverable A controller fault, a stalled stream, a pause, a position or velocity protective stop, or a collision deviation. You. Call recover, then start.
GuidedMode, ManualRecoveryMode, or NotRecoverable A collision, a torque protective stop, or a joint at its limit. A person at the robot, first. recover alone does not succeed.

An E-stop or an arm power loss needs no recover: the failure clears once the E-stop is released and power is back. The Recover button in the robot UI and sdk.recovery.recover.recover() in the Python SDK do the same as recover.

5.9.3 Reading Why a Session Ended

GET /api/v1/recovery/status keeps the reason until you recover:

{
  "status": "Failure",
  "failed": true,
  "failure": {
    "kind": "StepPlayFailure",
    "reason": "External Control Step failed: ...",
    "recovery_type": "Recoverable",
    "is_recoverable_with_wrist_button": false
  }
}

The full reason in that example is a single line, wrapped here to fit the page:

External Control Step failed: No new setpoint arrived in time, so the
arm stopped (code=STALENESS_TIMEOUT severity=FAULTED
clearableBy=CLEARABLE_BY_CLIENT)

failure.reason takes one of two shapes. Match on its text, not on kind, which is StepPlayFailure even for a collision.

When a controller fault or a stalled stream ended the session, the reason has this shape, where a stalled stream reads code=CLIENT_STREAM_TIMEOUT:

External Control Step failed: <message> (code=<ErrorCode>
severity=<Severity> clearableBy=<ClearableBy>)

When an operator pause or a pausing safety failure ended the session, the reason has this shape, where a pause reads the routine was paused:

External control session ended: <reason>. Start a new session to
resume streaming.

Both shapes are single lines in the actual response. Poll status to detect the end of a session: it moves from RoutineRunning to Failure or Idle.

5.10 Streaming from Python without ROS2

If you are not using ROS2, start the session with controlledBy: "api" and stream setpoints with the Standard Bots Python SDK’s realtime RobotStream client instead of publishing ROS topics. The semantics in the Control and Safety Semantics section apply unchanged. Install the standardbots package as described in the On Your Machine section. The realtime client and its value types import from one module:

from standardbots.realtime import (
    RobotStream, CartesianPose, CartesianTwist, JointState,
    Point, Quaternion, Vector3,
)

Session tuning still happens on the HTTP start call, not on the client. RobotStream.start() takes no configuration, so servoConfig has to go in the external-control/start body regardless of which of the three transports you then use to stream. The SDK can make that HTTP call for you, as shown in the Starting and Stopping the Session from the SDK section below.

5.10.1 RobotStream at a Glance

# Always UDP port 30100; the port is not configurable.
stream = RobotStream("<robot-ip>")

stream.start()                       # open the socket, subscribe as a reader
joints, ts_us = stream.get_joints()  # block for the next state broadcast

# ... POST /api/v1/external-control/start with controlledBy "api" ...
stream.take_control()                # become writer; RuntimeError if rejected
stream.send_joints(list(joints.position))
stream.release_control()
# ... POST /api/v1/external-control/stop ...

stream.stop()                        # unsubscribe; releases control if held

with RobotStream(ip) as stream: calls start() on entry and stop() on exit, but you still call take_control() and release_control() yourself.

The calls work as follows:

timestamp_us on every send_* call is an optional client send-time in microseconds, where 0 means unset. The timestamp_us returned with state is the robot’s state timestamp, as a Python float.

5.10.2 Order of Operations with the HTTP Session

The robot’s UDP listener runs without a session, as described in the Direct UDP Interface section, so start(), the get_* calls, and the on_* callbacks all work before the HTTP start. Use them to read the arm’s position and seed your first setpoint. take_control() also succeeds without a session, but setpoints sent outside a session are discarded with no error. Work in this order:

  1. Create the stream with RobotStream(ip) and call stream.start(), then read the arm with get_joints() or get_pose().
  2. POST /api/v1/external-control/start with controlledBy: "api" and your streamType and servoConfig, either with curl or from the SDK as shown below.
  3. Call stream.take_control(), and then stream setpoints at a steady rate.
  4. Call stream.release_control(), then POST /api/v1/external-control/stop, and finally call stream.stop().

WARNING – Stop the HTTP Session Before Closing the Stream

Closing the stream first lets the setpoint stream go stale, so a watchdogged session ends with a stream-timeout failure instead of a clean stop. See the What Ends a Session and How to Recover section.

5.10.3 Starting and Stopping the Session from the SDK

from standardbots import StandardBotsRobot, models

sdk = StandardBotsRobot(url="http://<robot-ip>:3000", token="<token>",
                        robot_kind=StandardBotsRobot.RobotKind.Live)

routine_id = sdk.external_control.start(models.StartExternalControlRequest(
    controlledBy=models.ExternalControlControlledBy.Api,
    streamType=models.ExternalControlStreamType.Joints,
    # Populate every field; see the warning below.
    servoConfig=models.ExternalControlServoConfig(...),
)).ok().routineId

# ... stream with RobotStream ...

# .stopped is False if no session was running
sdk.external_control.stop().ok()

.ok() returns the parsed body, or raises on any non-200 response with the robot’s error code. The error table in the Starting a Session section applies unchanged.

WARNING – The SDK Sends Unset Fields as null

The SDK sends every unset field as JSON null, and the robot rejects null in servoConfig. Omitting servoConfig, or any field inside it, fails start with 400 request_failed_validation. Either populate every ExternalControlServoConfig field, including the ones your stream type ignores, as servol_sphere_demo.py does, or make the two HTTP calls with another client and use the SDK only for RobotStream. Note that motionScalingLimits takes six positive float values per array, not int values.

5.10.4 Threading, Errors, and Liveness

start() runs one daemon receive thread that updates the latest state and runs your on_* callbacks. Keep callbacks short, and never call get_*, take_control(), or release_control() from inside one, because they wait on the very thread you are on. An exception raised in a callback is logged and swallowed.

send_* is fire-and-forget. It raises RuntimeError before start() or after stop(), and OSError on socket errors, but never for a dropped setpoint, because the robot reports none. Stream only after take_control() returns. The get_* calls raise TimeoutError after timeout, 5 seconds by default, which is the usual first symptom of a wrong IP address or a blocked UDP port 30100.

Liveness is handled for you. Subscribe is re-sent every second until the first broadcast arrives, and a Heartbeat goes out after 2 seconds of silence, inside the robot’s 5-second eviction window. Heartbeats keep the subscription alive, not the setpoint stream, so commandTimeoutMs still applies.

Frames arrive at about 200 Hz, with pose and joints together. gripper is present only once a connected gripper reports, so get_gripper() timing out while everything else works means there is no gripper, not that there is no connection. Broadcasts stop when the arm’s state stops, and there is no substitute heartbeat.

on_* registrations are permanent. There is no way to unregister one, and a function registered twice runs twice. Calling stop() and then start() on the same object gives you a clean slate with a new local port, so call take_control() again. is_running() goes false after stop() or after a socket failure.

5.10.5 Velocities and Twist

send_joints(velocities=...) and send_pose(twist=...) always put the field on the wire, but the arm uses it only with Streaming controller, as a first-order hold between setpoints, as described in the Command Topics section. On those robots, a velocities list whose length differs from positions, or any non-finite entry in velocities or twist, drops the whole setpoint silently. Sustained, that stalls the stream, and the session ends. On other robots the field is ignored.

5.10.6 Pose Readback Versus the Streamed Pose

get_pose() and on_pose return the wrist-flange pose in the robot base frame, on every robot. Without Streaming controller, send_pose() targets that same point. With it, send_pose() commands the active tool center point in servoConfig.referenceFrame, so seeding your stream from get_pose(), as robot_stream_poses.py does, moves the arm by the tool offset when the stream starts.

Seed from the REST tooltip pose instead, sdk.movement.position.get_arm_position().ok().tooltip_position, as servol_sphere_demo.py does, with referenceFrame set to that pose’s frame.

5.10.7 Trajectories and Cancel

send_trajectory() and send_cancel() act only on robots with Trajectory execution. On other robots the call returns normally and the arm holds, with nothing to tell the client. Where they do act, pass joint_names=("joint0", ..., "joint5") and exactly that many positions per point, or the trajectory is dropped silently.

5.10.8 SDK Examples

Runnable examples live in the examples/ directory of the package source. They hard-code ROBOT_IP = "localhost", and the demo also hard-codes ROBOT_URL and ROBOT_TOKEN, so edit them before running.

Example What it shows
robot_stream_joints.py A joints session that swings joint 0 with send_joints at 50 Hz. For your own streams, use a steady rate of roughly 100 to 250 Hz, as described in the Streaming Joint Positions, with Optional Velocity section.
robot_stream_poses.py A pose session that offsets the flange pose from get_pose() along X, Y, and Z. See the Pose Readback Versus the Streamed Pose section.
robot_stream_trajectory.py A jointTrajectory session that sends one 20-point send_trajectory out and back. Needs Trajectory execution.
robot_stream_gripper.py send_gripper from open to closed to open, with on_gripper feedback.
servol_sphere_demo.py A complete pose-streaming run: unbrake and home over REST, the HTTP start from the SDK with every servoConfig field, streaming targets, and the HTTP stop.
servol_overshoot.py Runs offline and predicts the worst-case overshoot for the limits used in the sphere demo.

Only servol_sphere_demo.py starts the HTTP session itself. The robot_stream_* scripts need a session of the matching streamType started first, as shown in the Getting Started with Joint Streaming section, and the gripper script needs a session of any type.

Not using Python either? Both the ROS2 bridge and the Python SDK sit on top of a documented protobuf-over-UDP protocol that you can speak directly from C++, or from any other language. That protocol is described next.

5.11 Direct UDP Interface

The ROS2 bridge and the Python SDK’s RobotStream are both thin clients over one lower-level transport: a protobuf-over-UDP stream served directly by the robot. If you are using neither ROS2 nor the Python SDK, for example because you are writing a C++ client, you talk to this interface directly.

The contract is external_control.proto, package external_control. Ask Standard Bots for the file, because the Python SDK ships only pre-generated bindings and not the source. Compile it for your language with protoc. The Python SDK, in standardbots/realtime/robot_stream.py and protocol.py, is the reference implementation.

5.11.1 Transport

Property Value
Protocol UDP
Server endpoint <robot-ip>:30100, on the control box.
Client endpoint Any local IPv4 port. Bind an ephemeral port with bind(("", 0)), and the server replies to your datagram’s source address. That IP address and port are your identity for the subscription, the writer slot, and liveness, so use one socket for the whole session. A new socket is a new, unsubscribed client, and the old writer slot frees only when the old identity is evicted, after 5 seconds. IPv6 is not supported.
Wire format One serialized protobuf message per datagram: ClientMessage from you to the robot, ServerMessage from the robot to you. There is no length prefix and no framing, so the UDP payload is the protobuf.
Datagram size One setpoint or state snapshot per packet, comfortably under one MTU. The reference client reads with recvfrom(65535).

Reading needs no session. Subscribe alone delivers state broadcasts whenever the robot’s external-control service is up.

Moving the arm needs a session. POST /api/v1/external-control/start with controlledBy: "api" takes the control lease, and then take_control claims the UDP writer slot. The two are independent: take_control succeeds without a session, but the robot then drops every arm setpoint and sends nothing back. gripper_command is the exception, as described in the Messages You Send section below. Call POST /api/v1/external-control/stop when you are done.

5.11.2 Reader and Writer Roles

A client is first a reader, and then optionally promotes itself to the single writer:

  1. Subscribe makes you a reader and starts the ServerMessage broadcasts.
  2. TakeControl requests exclusive write. The server replies with a ControlResponse.
  3. JointCommand, PoseCommand, and the other setpoints are applied, but only from the active writer.
  4. ReleaseControl puts you back to being a reader. You still receive state.
  5. Unsubscribe stops the broadcasts, and releases control first if you are the writer.

Two rules govern this:

5.11.3 Liveness and Heartbeats

The server evicts any client whose last accepted message is more than 5 seconds old. An accepted message is a subscribe, heartbeat, take_control, or release_control, or a setpoint from the current writer. Setpoints from a client that is not the writer, and malformed datagrams, refresh nothing.

NOTE – Eviction Is Not a Stop

Eviction frees only the UDP writer slot, and the HTTP session and its lease stay up. A watchdogged stream will already have ended the session long before 5 seconds pass. A jointTrajectory session, or a pose session without Streaming controller, holds position and stays open until you stop.

5.11.4 Messages You Send

ClientMessage is a oneof envelope, so set exactly one field per datagram.

Field Purpose
subscribe Register as a reader. Idempotent, and not acknowledged.
take_control Request exclusive write. Requires a prior subscribe.
release_control Relinquish write and stay a reader.
unsubscribe Stop broadcasts. Releases control first if you are the writer.
heartbeat Keepalive, as described above.
joint_command A joint-space setpoint.
pose_command A Cartesian pose setpoint, with an optional twist.
trajectory_command A whole multi-point path. Trajectory execution robots only.
gripper_command A gripper setpoint.
cancel_trajectory Abort the in-flight trajectory. Trajectory execution robots only, and a no-op otherwise.

take_control and release_control are each answered with a ControlResponse.

The command messages carry these payloads:

timestamp_us is an optional client send-time in microseconds, and 0 means unset.

These mirror the ROS2 command topics described in the Command Topics section. The one addition is the optional twist on pose_command, which ROS2 cannot send, because geometry_msgs/PoseStamped has no velocity field.

5.11.5 Messages You Receive

The server sends a ServerMessage to every subscribed reader on each arm-state update, and a separate one as the direct reply to take_control and release_control. Broadcasts follow the robot’s state rather than a timer, at 200 Hz by default, which is a per-robot setting. Sends closer together than 2 ms are coalesced into one frame that carries the latest value of every field, so pose and joints may be one tick apart.

WARNING – No Arm State, No Frames

The robot sends no idle heartbeat. If ServerMessage broadcasts stop arriving while your subscription is alive, the robot’s arm-state stream has stalled, so treat it as a link-down.

Field Contents
pose Live flange pose in the robot base frame. It is not the tool point, and it is not converted to servoConfig.referenceFrame.
joints Live JointState: name, position in radians, velocity in rad/s, effort in newton-meters.
gripper GripperState.position, normalized to the range 0 to 1. Present only once a connected gripper reports state. Updates at 10 Hz when idle and 30 Hz during a session.
timestamp_us Server send time in microseconds, on a monotonic clock. Use it for ordering and intervals only, and never compare it to your own clock.
control_response Present only on a reply to take_control or release_control, as { success, reason }. A reply carries nothing else.

Branch on has_control_response(): a reply answers your request, and a broadcast carries state. Never update your cached pose or joints from a reply.

5.11.6 Control Responses and Rejection Reasons

take_control and release_control are acknowledged with a ControlResponse carrying success and reason:

NOTE – Older Servers May Not Reply at All

Older servers may not send a ControlResponse. If you get no reply within a short timeout of roughly 2 seconds, treat the request as best-effort succeeded, which is what the reference client does for backward compatibility.

5.11.7 A Minimal C++ Client

Generate the protobuf bindings with protoc --cpp_out=. external_control.proto, then drive a POSIX UDP socket. Error handling is elided here:

#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include "external_control.pb.h"   // protoc-generated

int fd = ::socket(AF_INET, SOCK_DGRAM, 0);
sockaddr_in robot{};
robot.sin_family = AF_INET;
robot.sin_port   = htons(30100);
::inet_pton(AF_INET, "192.168.1.100", &robot.sin_addr);

auto send_msg = [&](const external_control::ClientMessage& m) {
  std::string buf = m.SerializeAsString();     // no length prefix
  ::sendto(fd, buf.data(), buf.size(), 0,
           (sockaddr*)&robot, sizeof(robot));
};

// After the HTTP start with controlledBy:"api":
//   1) Subscribe   2) TakeControl
{ external_control::ClientMessage m;
  m.mutable_subscribe();    send_msg(m); }
// ...Subscribe is unacknowledged: wait for one state
//    broadcast before sending TakeControl...
{ external_control::ClientMessage m;
  m.mutable_take_control(); send_msg(m); }
// ...read ServerMessage.control_response().success() before streaming...

// 3) Stream joint setpoints (position-only; add velocity only if
//    your robot has the Streaming controller capability)
{
  external_control::ClientMessage m;
  auto* jc = m.mutable_joint_command();
  for (double q : {0.0, -1.57, 1.57, 0.0, 1.57, 0.0})
    jc->add_position(q);
  send_msg(m);                                 // repeat at a steady rate
}

// Receive state and control responses
char rx[65535];
sockaddr_in from{}; socklen_t flen = sizeof(from);
ssize_t n = ::recvfrom(fd, rx, sizeof(rx), 0,
                       (sockaddr*)&from, &flen);
external_control::ServerMessage sm;
sm.ParseFromArray(rx, static_cast<int>(n));
// sm.has_control_response()
//   ? reply to take_control / release_control (no state)
//   : periodic state broadcast (pose, joints, gripper if any)

// On exit: ReleaseControl, then Unsubscribe, then the HTTP stop.

Stream joint_command at a steady rate of roughly 100 to 250 Hz with small per-tick position deltas, exactly as you would on the ROS2 .../joints path. The same safety and limit enforcement applies.

5.12 Troubleshooting

On the direct UDP interface:

5.13 Known Limitations