VLA · Phase 9 · VLA Deployment and Robot Integration
9.2 — Action Adapters and Robot-Specific Interfaces
Translating a model’s outputs into a particular robot’s commands.
The model and the robot do not speak the same language. This lesson builds the small, testable translator between them and shows how to keep per-robot differences in one reviewable table.
Claims about papers, models and textbooks carry numbered references, listed with what each one is cited for in the References. Code marked “Toy model” is our own illustration, not the cited system.
Learning objectives
After this lesson, you should be able to:
- List the steps of an action adapter.
- Build an adapter with clipping, inverse kinematics and a limit check.
- Keep per-robot differences in a table.
- Write golden tests for sign and unit conventions.
- De-normalise the model output
- Convert units and clip the size
- Convert hand motion to joint motion
- Check the limits, or reject
- Convert the gripper and send
The one-sentence idea
A small, boring translator with tests.
Signs and units are where the bugs are.
The model speaks one language, the robot another
A model outputs numbers in the action definition of its training data (lesson 4.1): perhaps small hand motions plus a gripper value, normalised (lesson 4.3). Your robot accepts something else: joint targets in radians, a gripper width in millimetres, at a particular rate. An action adapter is the piece of software in between. It is small, but it is where a large share of integration bugs live, because a wrong sign or unit moves a real arm. Mixed-robot datasets have the same problem on the training side: RDT-1B maps different robots into one named action layout with a mask so that the physical meaning of each number is preserved.[1] An adapter does the reverse mapping on the way out.
An adapter is a chain of small, testable steps
Typical steps: de-normalise, convert units, clip the motion size, convert from hand motion to joint targets (inverse kinematics), check joint limits, convert the gripper. Here is a complete adapter for the two-joint arm of earlier lessons:
import numpy as np
L1, L2 = 1.0, 1.0
joint_limits = np.array([[-2.5, 2.5], [-2.8, 2.8]]) # radians
def forward(q): return np.array([L1*np.cos(q[0]) + L2*np.cos(q[0]+q[1]), L1*np.sin(q[0]) + L2*np.sin(q[0]+q[1])])
def jacobian(q):
eps = 1e-6
return np.column_stack([(forward(q + eps*np.eye(2)[i]) - forward(q)) / eps for i in range(2)])
class Adapter:
max_step = 0.05 # metres per command
def __call__(self, q, model_action):
move = model_action[:2] * 0.02 # de-normalise: one unit = 2 cm
norm = np.linalg.norm(move)
if norm > self.max_step: move = move * self.max_step / norm # clip the size of the motion
dq = np.linalg.solve(jacobian(q), move) # hand motion -> joint motion
target = q + dq
if np.any(target < joint_limits[:, 0]) or np.any(target > joint_limits[:, 1]):
return None, "rejected: joint limit"
gripper_mm = 80.0 * (1.0 - float(np.clip(model_action[2], 0, 1))) # model: 1 = closed, 0 = open; robot: width in mm
return (target, gripper_mm), "ok"
adapter = Adapter()
q = np.array([0.4, 1.2])
for action in ([1.0, 0.0, 0.0], [10.0, 10.0, 1.0]):
result, status = adapter(q, np.array(action))
if result is None: print(action, "->", status)
else: print(action, "-> joint targets (rad)", np.round(result[0], 4).tolist(), "| gripper width (mm)", result[1], "|", status)Output:
[1.0, 0.0, 0.0] -> joint targets (rad) [0.3994, 1.1809] | gripper width (mm) 80.0 | ok [10.0, 10.0, 1.0] -> joint targets (rad) [0.4368, 1.1135] | gripper width (mm) 0.0 | ok
Toy model. A planar two-joint arm and made-up unit conversions. The structure, small steps with a rejection path, is what to copy.
The second action asked for a very large motion; the adapter clipped it to 5 cm before converting. Where the resulting joint target would violate a limit, the adapter returns a rejection instead of a command; lesson 9.4 builds on that.
One model, several robots: a table, not a pile of ifs
When the same model drives more than one robot, keep each robot's differences in a table of explicit settings rather than scattered conditionals. The adapter code stays the same; the table is reviewed by someone who knows the robot:
robots = {
"arm_a": {"step_scale_m": 0.02, "gripper_open_mm": 80.0, "invert_y": False, "rate_hz": 10},
"arm_b": {"step_scale_m": 0.01, "gripper_open_mm": 110.0, "invert_y": True, "rate_hz": 20},
}
def to_command(robot, action):
s = robots[robot]
dx, dy, grip = action
dy = -dy if s["invert_y"] else dy
return {"dx_m": dx * s["step_scale_m"], "dy_m": dy * s["step_scale_m"], "gripper_mm": s["gripper_open_mm"] * (1 - grip)}
action = (1.0, 1.0, 0.0)
for name in robots:
print(name, "->", to_command(name, action))Output:
arm_a -> {'dx_m': 0.02, 'dy_m': 0.02, 'gripper_mm': 80.0}
arm_b -> {'dx_m': 0.01, 'dy_m': -0.01, 'gripper_mm': 110.0}The same model output becomes different commands, which is correct. Each entry (the scale, the sign, the gripper range) should come from the robot's documentation or a measurement, written next to the value.
Test adapters with simple known cases
Adapters are easy to test and very much worth testing. A zero action should not move the robot. A positive x action should move the hand in the positive x direction. An out-of-range action should be clipped. And a sign-convention check protects you from the most common mistake:
import numpy as np
def hand_motion(action, scale=0.02, max_step=0.05):
move = np.array(action[:2]) * scale
n = np.linalg.norm(move)
return move if n <= max_step else move * max_step / n
tests = {
"zero action does not move": np.allclose(hand_motion([0, 0, 0]), 0),
"positive x moves in +x": hand_motion([1, 0, 0])[0] > 0,
"negative y moves in -y": hand_motion([0, -1, 0])[1] < 0,
"huge action is clipped to 5 cm": np.isclose(np.linalg.norm(hand_motion([100, 100, 0])), 0.05),
"direction is kept when clipping": np.isclose(hand_motion([100, 0, 0])[0], 0.05),
}
for name, passed in tests.items():
print(("PASS" if passed else "FAIL"), name)Output:
PASS zero action does not move PASS positive x moves in +x PASS negative y moves in -y PASS huge action is clipped to 5 cm PASS direction is kept when clipping
Run such tests in simulation first and with the robot powered but held, before any free motion. A good adapter is boring.
Common misconceptions
Misconception 1 “The model output can go straight to the motors.”
Correction It is in the training data’s definition, not the robot’s.
Misconception 2 “An adapter is trivial.”
Correction A wrong sign moves a real arm the wrong way.
Misconception 3 “Per-robot conditionals scattered in code are fine.”
Correction A single table can be reviewed.
Engineering takeaways
- A chain of small steps.
- Reject rather than send a bad command.
- Per-robot table.
- Golden tests.
Make the translator boring and tested.
Knowledge check
Try each question first, then reveal the explanation.
Question 1
Why clip the motion size in the adapter?
Explanation
To bound what any model output can make the robot do.
Question 2
Why use a table of per-robot settings?
Explanation
It can be reviewed in one place by someone who knows the robot.
Question 3
Name a golden test for an adapter.
Explanation
A zero action does not move; +x action moves in +x.
References
Numbered notes in the text, like [1], point here. Each entry says what it is cited for and how that was checked. Papers and books can be revised or misread, so follow the links before relying on a detail. Code marked “Toy model” is our own illustration of an idea, not the code of the cited system.
(2025). RDT-1B: a diffusion foundation model for bimanual manipulation. ICLR 2025 / arXiv. arXiv:2410.07864
Cited for:
- Introduces a physically interpretable unified action space that represents different robots’ actions in one format while preserving the physical meaning of each original action, to help transfer physical knowledge across robots. Checked against the paper’s abstract.
What’s next
Next: coordinate frames and camera calibration.