
The SO-100 has five arm joints plus a gripper. Measured from the official URDF: what it can reach and orient, which tasks are impossible rather than hard, and how to design around it.
The SO-100 has six servos. Five of them move the arm and the sixth opens and closes the jaws. That sounds like plenty until the first time you ask it to plug a cable into a socket that faces sideways, and the arm arrives at the socket with the gripper pointing 40 degrees off. You add episodes. You retrain. It still arrives 40 degrees off, because no amount of data can add a joint.
What you need to know
- •The SO-100 and SO-101 have five arm joints plus a one-axis gripper. Six independent joints are the minimum for placing a rigid body at an arbitrary position and orientation, so one freedom is missing by construction.
- •It is a specific freedom, not a vague weakness. shoulder_lift, elbow_flex and wrist_flex all turn about parallel axes, and wrist_roll lies in the plane those axes are perpendicular to. Everything past the base sweeps one vertical plane.
- •Consequence: the gripper approach direction can never leave that plane. Its horizontal component always points at, or away from, the base column.
- •Measured on the official SO-101 URDF over 20000 random joint configurations, the out-of-plane component of the tool axis never exceeded 1.0e-05. That is a structural constraint, not a calibration error.
- •Ask for an approach 30 degrees off the radial line and the best the arm can do is 28.2 degrees off. You get back almost exactly what you asked for, as error.
- •Top-down grasps escape the constraint, because a vertical tool axis lies in every vertical plane and wrist_roll then controls jaw yaw directly. That is why tabletop pick-and-place works and side insertion does not.
- •LeRobot's own inverse kinematics treats the end-effector as a soft task with position weight 1.0 and orientation weight 0.01. Position wins by a factor of 100 because orientation cannot be honoured.
- •The fix is never more data. It is rotating the workpiece, choosing top-down approaches, adding a mobile base, or adding a second arm.
Six numbers, five joints
A rigid body floating in space needs six numbers to pin down completely: three for where it is and three for how it is turned. Those six numbers are the degrees of freedom of a free body in three dimensions. A robot arm that can independently set all six can put its end-effector anywhere in its workspace at any orientation. An arm with five independent joints can set at most five of them, and which five is decided by the geometry of the joint axes, not by the software.
| Pose component | What it controls | Available on a 5-joint SO-100? |
|---|---|---|
| x | Position along the table, left to right | Yes |
| y | Position along the table, near to far | Yes |
| z | Height above the table | Yes |
| Pitch (elevation of the tool axis) | Whether the gripper points down, forward or up | Yes, set by the flex chain |
| Roll (rotation about the tool axis) | Which way the jaw faces around its own approach line | Yes, set by wrist_roll |
| Yaw (azimuth of the tool axis) | Which compass direction the gripper approaches from | No, locked to the arm plane |
The last row is the whole article. Every SO-100 style arm can put the jaws at any reachable point and can tilt them up or down and spin them about their own axis. What it cannot do is choose the compass direction from which it approaches. That is fixed the moment you choose the target position.
What is actually inside the arm
The joint list is not a matter of opinion. Here it is as LeRobot 0.6.2 declares it, from the SO-100 and SO-101 follower driver. The same six names, the same bus ids, the same servo model:
motors={
"shoulder_pan": Motor(1, "sts3215", norm_mode_body),
"shoulder_lift": Motor(2, "sts3215", norm_mode_body),
"elbow_flex": Motor(3, "sts3215", norm_mode_body),
"wrist_flex": Motor(4, "sts3215", norm_mode_body),
"wrist_roll": Motor(5, "sts3215", norm_mode_body),
"gripper": Motor(6, "sts3215", MotorNormMode.RANGE_0_100),
}Five joints move the arm. The sixth is the jaw, which does not move the end-effector anywhere, it only changes the gap between the fingers. Google DeepMind's MuJoCo Menagerie, which ships a converted model of this arm, describes it plainly as a 5DOF arm. The Koch v1.1 uses Dynamixel XL430 and XL330 servos instead of Feetech, but the joint names and the topology are identical, so everything below applies to it unchanged.
| Joint | Axis at the zero pose (base frame) | SO-101 range | SO-100 range | Role |
|---|---|---|---|---|
| shoulder_pan | (0, 0, -1), vertical | -110.00 to 110.00 deg | -114.59 to 114.59 deg | Aims the arm plane |
| shoulder_lift | (0, 1, 0), horizontal | -100.00 to 100.00 deg | 0.00 to 200.54 deg | In-plane |
| elbow_flex | (0, 1, 0), horizontal | -96.83 to 96.83 deg | -180.00 to 0.00 deg | In-plane |
| wrist_flex | (0, 1, 0), horizontal | -95.00 to 95.00 deg | -143.24 to 68.75 deg | In-plane |
| wrist_roll | (-1, 0, 0), along the tool | -157.21 to 162.79 deg | -180.00 to 180.00 deg | Spins the jaw |
| gripper | jaw hinge | -10.00 to 100.00 deg | -11.46 to 114.59 deg | Opens and closes |
Parsed on 2026-08-24 from Simulation/SO101/so101_new_calib.urdf and Simulation/SO100/so100.urdf in TheRobotStudio/SO-ARM100. The two files use different zero conventions, so compare the widths rather than the endpoints: SO-101 gives 220, 200, 194, 190 and 320 degrees of travel; SO-100 gives 229, 201, 180, 212 and 360. Both files list the same upper arm and forearm offsets, 0.11257 m and 0.1349 m, so the main links match; what the two URDFs actually disagree about is the declared joint travel.
The one constraint that explains everything
Look again at the axis column. shoulder_lift, elbow_flex and wrist_flex all turn about the same direction, (0, 1, 0). Three parallel revolute axes form a planar chain: whatever they do, the links stay in one plane perpendicular to those axes. shoulder_pan turns about the vertical and aims that plane. wrist_roll turns about (-1, 0, 0), which lies inside the plane, so spinning it rotates the jaw about the tool axis without ever tipping the tool axis out of the plane.
So the arm is a planar three-link manipulator living in a vertical plane, mounted on a turntable, with a roll joint at the end. Position within the plane costs two freedoms, in-plane pitch costs a third, the turntable is a fourth and roll is the fifth. Nothing is left over to swing the tool axis sideways out of the plane.
The gripper approach axis of an SO-100, SO-101 or Koch v1.1 always lies in the vertical plane swept by the arm, and that plane is determined by shoulder_pan alone.
This is checkable rather than arguable. The script below parses the official URDF, builds forward kinematics from the joint origins and axes, then samples 20000 random configurations inside the joint limits and measures how far the tool axis ever gets out of the arm plane.
import re
import numpy as np
S = open("so101_new_calib.urdf").read()
J = {}
for m in re.finditer(r'<joint name="([^"]+)" type="([^"]+)">(.*?)</joint>', S, re.S):
name, typ, body = m.groups()
o = re.search(r'<origin xyz="([^"]*)" rpy="([^"]*)"', body)
a = re.search(r'<axis xyz="([^"]*)"', body)
J[name] = dict(
xyz=np.array([float(v) for v in o.group(1).split()]),
rpy=np.array([float(v) for v in o.group(2).split()]),
axis=np.array([float(v) for v in a.group(1).split()]) if a else np.zeros(3),
rev=(typ == "revolute"),
)
CHAIN = ["shoulder_pan", "shoulder_lift", "elbow_flex",
"wrist_flex", "wrist_roll", "gripper_frame_joint"]
def rot_rpy(r, p, y):
c, s = np.cos, np.sin
return (np.array([[c(y), -s(y), 0], [s(y), c(y), 0], [0, 0, 1]])
@ np.array([[c(p), 0, s(p)], [0, 1, 0], [-s(p), 0, c(p)]])
@ np.array([[1, 0, 0], [0, c(r), -s(r)], [0, s(r), c(r)]]))
def rot_axis(a, t):
a = a / np.linalg.norm(a)
K = np.array([[0, -a[2], a[1]], [a[2], 0, -a[0]], [-a[1], a[0], 0]])
return np.eye(3) + np.sin(t) * K + (1 - np.cos(t)) * (K @ K)
def fk(q):
T, frames = np.eye(4), {}
for i, name in enumerate(CHAIN):
j = J[name]
A = np.eye(4)
A[:3, :3], A[:3, 3] = rot_rpy(*j["rpy"]), j["xyz"]
T = T @ A
if j["rev"]:
B = np.eye(4)
B[:3, :3] = rot_axis(j["axis"], q[i])
T = T @ B
frames[name] = T.copy()
return T, frames
print("axis directions at the zero pose (base frame):")
_, F0 = fk(np.zeros(6))
for name in CHAIN[:5]:
print(" %-14s %s" % (name, np.round(F0[name][:3, :3] @ J[name]["axis"], 4)))
rng = np.random.default_rng(0)
lo = np.array([-1.91986, -1.74533, -1.69, -1.65806, -2.74385, 0.0])
hi = np.array([1.91986, 1.74533, 1.69, 1.65806, 2.84121, 0.0])
worst = 0.0
for q in rng.uniform(lo, hi, size=(20000, 6)):
T, F = fk(q)
normal = F["shoulder_pan"][:3, :3] @ np.array([0.0, 1.0, 0.0])
worst = max(worst, abs(float(T[:3, 2] @ normal)))
print("max |tool_axis . plane_normal| over 20000 poses: %.1e" % worst)pip install numpy scipy
curl -sLO https://raw.githubusercontent.com/TheRobotStudio/SO-ARM100/main/Simulation/SO101/so101_new_calib.urdf
python check_plane.py
# axis directions at the zero pose (base frame):
# shoulder_pan [-0. 0. -1.]
# shoulder_lift [-0. 1. 0.]
# elbow_flex [-0. 1. 0.]
# wrist_flex [-0. 1. 0.]
# wrist_roll [-1. -0. -0.]
# max |tool_axis . plane_normal| over 20000 poses: 1.0e-05One honest wrinkle: the plane is not quite through the pan axis. The shoulder is mounted 18.3 mm to one side, so the approach line is off-radial by a fixed 3.5 degrees at a 0.30 m working radius and by 7.0 degrees at 0.15 m. That offset is a constant of the mechanism, not something you can steer.
Hard versus impossible, measured
Take a point 0.30 m out from the shoulder-pan axis and 0.15 m up, and ask for a horizontal approach at a series of azimuths away from the radial line. Solve for the best joint configuration each time, weighting position 1000 times higher than orientation so the solver is allowed to sacrifice orientation freely. The residual orientation error is what the mechanism physically cannot deliver.
| Approach asked for, off the radial line | Best position error | Best approach-axis error | Verdict |
|---|---|---|---|
| 0 deg (radial) | 0.00 mm | 0.0 deg | Exact |
| 5 deg | 0.20 mm | 3.4 deg | Marginal |
| 10 deg | 0.49 mm | 8.4 deg | Grasp starts slipping |
| 20 deg | 1.04 mm | 18.3 deg | Fails on anything tight |
| 30 deg | 1.57 mm | 28.2 deg | Impossible |
| 45 deg | 2.28 mm | 43.0 deg | Impossible |
| 60 deg | 2.83 mm | 57.9 deg | Impossible |
| 90 deg | 3.33 mm | 87.8 deg | Impossible |
The error tracks the request almost one for one. There is no elbow-up trick, no clever redundancy resolution, no wrist singularity to exploit. The small discount, 28.2 instead of 30, is the 18.3 mm shoulder offset plus a millimetre and a half of position slop. That is the entire budget you have.
A policy that keeps missing a sideways insertion looks exactly like an under-trained policy. Loss is low, the arm moves confidently, it arrives near the target and then noses in at the wrong angle. Before you record another 50 episodes, run the reachability check above on the actual grasp pose. If the answer is 25 degrees of unavoidable error, more data will only teach the model to reach the wrong pose faster. See policy only works in one setup and gripper does not close for the symptoms that get confused with this.
Why tabletop picking still works
There is one escape, and it is the reason a five-joint arm is useful at all. If the tool axis points straight down, it lies in every vertical plane at once, so the plane constraint stops binding. wrist_roll is then vertical too, and spinning it sets the jaw yaw directly. A top-down grasp of an object lying at any rotation on the table is fully controllable.
The catch is that a straight-down approach is not reachable everywhere. The flex chain has to fold the wrist through nearly 90 degrees, and wrist_flex only has 95 degrees of travel each way on the SO-101. Here is the reachable set for the two standard approach directions, solved on a grid in front of the arm:
| Radius from pan axis | Height above base plate | Straight-down approach | Horizontal radial approach |
|---|---|---|---|
| 0.15 m | 0.05 m | Exact | 42.9 deg error |
| 0.20 m | 0.05 m | Exact | 11.6 deg error |
| 0.25 m | 0.05 m | Exact | 3.9 deg error |
| 0.30 m | 0.05 m | 9.8 deg error | Exact |
| 0.20 m | 0.25 m | 50.2 deg error | Exact |
| 0.25 m | 0.15 m | 22.0 deg error | Exact |
| 0.35 m | 0.15 m | 41.6 deg error | Exact |
| 0.40 m | 0.25 m | 80.6 deg error | Exact |
Read that as a design rule rather than a table of numbers. Top-down picking works close in and low down, roughly 0.15 m to 0.25 m out and near the table surface. Horizontal reaching works further out and higher up. The band in between, around 0.25 m out at mid height, is where neither approach is clean, and it is exactly where people tend to put the object because it looks comfortable.
- Maximum horizontal radius from the shoulder-pan axis, optimised over the SO-101 joint limits: 0.4408 m.
- Tool-frame height range relative to the base plate: -0.2249 m to 0.5270 m, so the arm can reach below its own mounting level if you clamp it to a table edge.
- Link offsets: 0.11257 m upper arm, 0.1349 m forearm, 0.0637 m wrist, 0.0985 m from the roll axis to the gripper frame.

LeRobot already assumes you cannot honour orientation
Its kinematics wrapper (see the robot docs for the supported arms) uses placo, targets the frame named gripper_frame_link, and solves the end-effector pose as a weighted soft task rather than a hard constraint:
def inverse_kinematics(
self,
current_joint_pos: np.ndarray,
desired_ee_pose: np.ndarray,
position_weight: float = 1.0,
orientation_weight: float = 0.01,
) -> np.ndarray:
...
self.tip_frame.configure(self.target_frame_name, "soft", position_weight, orientation_weight)The processor that turns Cartesian actions into joint commands carries the same default and says why in its docstring: set the weight to zero for position-only IK on under-actuated arms, and a small non-zero value gives soft-orientation IK on the 5-DOF SO-101, where the wrist tracks orientation only partially. Upstream has already decided that on this arm, orientation is a suggestion.
- One fewer servo, one fewer 3D-printed housing, one fewer thing to calibrate and one fewer failure point in the daisy chain.
- A shorter, stiffer wrist. Six-joint hobby wrists are the floppiest part of a low-cost arm, and a missing joint is a missing source of backlash.
- The joint vector is small, so an ACT or SmolVLA action head stays tiny and inference stays fast.
- No wrist singularity to plan around. The classic three-axis spherical wrist loses a rank when two axes line up; this arm has no such configuration to trip over.
- The constraint is exactly predictable, so you can design a task that never touches it.
- Approach azimuth is not yours to choose. Whatever the target position, the compass direction of the approach comes with it.
- Anything with a fixed insertion axis that is not radial and not vertical is out: wall sockets, side-loading slots, horizontal shafts.
- No in-hand reorientation. The jaw is one axis, so if the object comes out of the grasp wrong, you cannot fix it without putting it down.
- Regrasping costs time and is a common place for a policy to lose the object.
- Cluttered scenes hurt more, because you cannot reach around an obstacle from a different side.
Tasks that are impossible rather than hard
The useful distinction when you plan a data collection session: hard tasks get better with more episodes, impossible ones do not. Here is how a handful of common demo tasks sort out.
| Task | Required approach | Verdict on a 5-joint arm |
|---|---|---|
| Pick a cube off a table and drop it in a bin | Straight down, any jaw yaw | Works, this is the sweet spot |
| Stack two blocks | Straight down twice | Works |
| Put a marker in a cup | Straight down, cup is rotationally symmetric | Works |
| Pick up a mug by the handle, handle pointing at the base | Radial horizontal | Works |
| Pick up a mug by the handle, handle pointing sideways | Horizontal, 90 deg off radial | Impossible without moving the mug or the arm |
| Plug a USB stick into a socket facing the arm | Radial horizontal | Works, subject to precision |
| Plug a USB stick into a socket on a side wall | Horizontal, off radial | Impossible |
| Turn a door handle on a vertical door face | Horizontal radial push plus roll about a horizontal axis | Usually works if the door faces the arm |
| Turn a valve whose axis is horizontal and tangential | Roll about a tangential axis | Impossible |
| Unscrew a bottle cap on a bottle standing upright | Straight down plus roll about vertical | Works, limited to one 320 deg wrist_roll sweep |
| Wipe a vertical surface that runs radially away from the base | Tool normal to the surface, tangential | Impossible |
| Fold a cloth flat on the table | Straight down, any yaw | Works |
Notice the pattern. If the task's required approach is vertical, or radial, or rotationally symmetric about one of those, the arm is fine. If the task specifies a fixed non-radial horizontal axis, no policy will ever learn it. Sorting your task list this way before you start recording episodes is fifteen minutes that saves a weekend.
Check your own arm in twenty minutes
Numbers from a URDF are the nominal machine. Your arm has its own travel after calibration, its own cable routing that clips a few degrees off wrist_roll, and its own mounting height. LeRobot ships a tool for exactly this.
- 1Install LeRobot with the kinematics extra
The IK and FK helpers live behind an optional dependency because placo pulls in Pinocchio. Without this extra, the kinematics import fails at runtime rather than at install time.
bashpip install "lerobot[kinematics]" # resolves placo>=0.9.6,<0.9.16 plus pinned urdfdom and tinyxml2 wheels - 2Grab the URDF the tool expects
The upstream example in lerobot_find_joint_limits.py points at the SO-101 calibrated URDF even while its --robot.type is so100_follower. That is the pairing to copy: the two arms share the upper arm and forearm offsets.
bashcurl -sLO https://raw.githubusercontent.com/TheRobotStudio/SO-ARM100/main/Simulation/SO101/so101_new_calib.urdf - 3Find your real joint and end-effector bounds
This runs a live leader-follower loop, does 5 seconds of warmup, then records the min and max of every joint and of the end-effector position while you drive the arm through its full travel. Push every joint to both stops.
bashlerobot-find-joint-limits \ --robot.type=so101_follower \ --robot.port=/dev/ttyACM0 \ --robot.id=my_follower \ --teleop.type=so101_leader \ --teleop.port=/dev/ttyACM1 \ --teleop.id=my_leader \ --urdf_path=./so101_new_calib.urdf \ --target_frame_name=gripper \ --warmup_time_s=5 \ --teleop_time_s=30 \ --control_loop_fps=30 - 4Run the plane check with your measured limits
Paste the min and max you just measured into the lo and hi arrays of check_plane.py. The residual will still be about 1e-05, which is the point: tightening the limits cannot create the missing freedom.
bashpython check_plane.py - 5Test the specific grasp pose you care about
Append the sweep below, set TARGET to the actual grasp point in metres in the base frame, and read off the approach error for the azimuth your task needs. Anything above about 10 degrees means redesign the fixture, not the policy.
pythonfrom scipy.optimize import minimize LO = np.array([-1.91986, -1.74533, -1.69, -1.65806, -2.74385]) HI = np.array([1.91986, 1.74533, 1.69, 1.65806, 2.84121]) BOUNDS = list(zip(LO, HI)) TARGET = np.array([0.3388, 0.0, 0.15]) # 0.30 m out from the pan axis, 0.15 m up def best_pose(want_axis, restarts=120): def cost(q): T, _ = fk(list(q) + [0.0]) return 1000 * np.sum((T[:3, 3] - TARGET) ** 2) + np.sum((T[:3, 2] - want_axis) ** 2) sols = [minimize(cost, rng.uniform(LO, HI), bounds=BOUNDS) for _ in range(restarts)] q = min(sols, key=lambda r: r.fun).x T, _ = fk(list(q) + [0.0]) return (np.linalg.norm(T[:3, 3] - TARGET) * 1000, np.degrees(np.arccos(np.clip(T[:3, 2] @ want_axis, -1, 1)))) for off in (0, 5, 10, 20, 30, 45, 60, 90): r = np.radians(off) mm, deg = best_pose(np.array([np.cos(r), np.sin(r), 0.0])) print("off-radial %2d deg -> position %.2f mm, approach %.1f deg" % (off, mm, deg))
You own the arm, you install LeRobot, you wire the URDF path into your scripts and you keep the kinematics extra working across upgrades. Everything above runs on your own machine.
- Build or buy the arm and calibrate it with
lerobot-calibrate. - Install
lerobot[kinematics]and pull the SO-101 URDF from TheRobotStudio repo. - Run
lerobot-find-joint-limitsto get your real travel and end-effector bounds. - Run the plane check and the azimuth sweep on your intended grasp poses.
- Redesign any fixture whose approach comes out more than about 10 degrees off.
- Record with
lerobot-record, train, and evaluate on the arm.
The SO-100 and SO-101 run Feetech STS3215 bus servos on a 7.4 V rail. Putting 12 V on them destroys them. This is the single most expensive mistake on a first build, and it costs the whole set of six. Parts cost is around 110 to 150 EUR for an SO-100 and 130 to 170 EUR for an SO-101, so a wrong power supply is a real loss.
lerobot-record \
--robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=my_follower \
--teleop.type=so101_leader --teleop.port=/dev/ttyACM1 --teleop.id=my_leader \
--dataset.repo_id=me/topdown_pick --dataset.num_episodes=50Same question, less setup. The arm pages carry the joint layout and the workspace, the live arm lets you feel the plane constraint by hand before you buy anything, and the training path is a form rather than a CLI.
- Drive a real SO-100 in the browser from the live queue, no signup, and try to approach a target sideways. You will feel the constraint in about a minute.
- Read the arm specs on the SO-100 page and compare against LeKiwi if you need the extra freedom.
- Record episodes with the desktop client from the download page, which writes LeRobot-format datasets straight from a teleop session.
- Browse the public dataset directory for SO-100 tasks that already respect the constraint, and copy their scene layout.
- Pick a model on the policies page and start a run from the ACT on SO-100 guide or its GR00T and SmolVLA equivalents.
- Run the trained checkpoint back on the arm.
Nothing here adds a joint. A cloud GPU, a 3B parameter VLA and 500 episodes leave the kinematics exactly where they were. The platform helps you record and train faster; the geometry is still yours to design around. Remote inference has its own honest limit too: the control loop is 20 to 485 ms per action step depending on the model, and public-internet round trips on top of that turn a working policy into a hesitant one. It is viable for slow pick-and-place, not for fast reactive motion.
Five ways to design around the missing joint
Every one of these works by making the required approach radial or vertical, or by adding real freedom somewhere else in the system. None of them involves the model.
| Approach | What it changes | Cost |
|---|---|---|
| Rotate the workpiece, not the wrist | Mount the socket, fixture or shelf so its insertion axis points at the base column | Free, but the scene layout becomes part of the task definition |
| Design top-down only | Restrict every grasp to a vertical approach, keep objects 0.15 to 0.25 m out and near the table | Rules out side insertion entirely |
| Put the object on a turntable | A cheap passive lazy susan lets a human or a second actuator pre-orient the object | One more thing in the scene the policy has to see |
| Add a mobile base | A holonomic base gives back x, y and yaw, so the arm plane can be aimed anywhere | Base positioning error lands on top of arm error |
| Add a second arm | The second arm holds and reorients while the first works, which is what humans do | Doubles the hardware and the data collection effort |
The mobile base option is worth spelling out because LeKiwi does exactly this and is supported here. Its driver declares the same six arm motors with an arm_ prefix plus three wheel motors, base_left_wheel, base_back_wheel and base_right_wheel, on ids 7 to 9. Three omniwheels on a 0.125 m base radius give a holonomic planar base: x, y and yaw. The yaw is the freedom the arm is missing, so a LeKiwi can drive around an object and approach from any azimuth. It does not get you in-hand reorientation, and base odometry error stacks on top of arm error, but for reach-around tasks it is the honest fix. The side by side comparison covers the trade.

What this means when you train a policy
An imitation learning policy on this arm outputs the six joint targets directly. It never sees a Cartesian pose, so it cannot command an infeasible one. That sounds reassuring and is actually the problem: the infeasibility shows up silently in the data instead.
- During recording, the human teleoperator physically cannot demonstrate the impossible approach, so they unconsciously substitute the nearest possible one.
- The LeRobot dataset therefore contains a consistent, confident demonstration of a grasp that misses.
- Training loss goes down normally, because the policy is learning a coherent mapping. Nothing in the loss curve flags a kinematic problem.
- At evaluation the arm reproduces the compromise pose accurately and the object slips, which reads as a grasp quality problem.
- Adding episodes reduces variance around the wrong pose, which can make the failure look more deterministic and therefore more like a bug.
The diagnostic is not in the loss curve, it is in geometry. Before blaming the ACT chunk size or the camera setup, take one frame from a successful human demonstration, read the six joint values, run forward kinematics, and check whether the pose the human actually used is the pose the task needs. Half the time it is not, and the human was quietly compensating. Our guide on collecting high quality VLA training data covers the recording side of this; the full SO-100 guide covers the build and teleoperation setup.
If a single joint refuses to move past a point, that is travel or calibration, see joint stops early. If the arm reaches the pose and then sags under load, that is torque and gravity at extension, see arm twitches then sags. The kinematic constraint in this article produces a clean, repeatable angular offset with no motor complaints at all.

That last point is worth keeping in mind when you compare models. Benchmark numbers in the arena were produced on whatever hardware each paper used, often a 7-DoF Franka or a bimanual ALOHA. A high score there says something about representation quality and nothing about whether the policy can reach your socket. Model choice and kinematics are independent axes, and only one of them is fixed by your hardware.
Feel the constraint before you buy an arm
Drive a real SO-100 in the browser, no signup, and try to approach a target from the side. The same page compares the five trainable policies and shows what a training run costs.
Try a real armIs the SO-100 a 5-DoF or a 6-DoF arm?▾
Both descriptions are in circulation because vendors count servos and roboticists count arm freedoms. The arm has six servos: five that move the end-effector plus a gripper. In kinematic terms that is a 5-DoF arm with a one-axis gripper, which is how MuJoCo Menagerie labels it and how LeRobot's own source refers to the SO-101. A shop listing calling it a 6-axis arm is counting the jaw.
Can I add a sixth joint to the SO-100?▾
Mechanically you would need a roll or yaw joint whose axis is not parallel to the existing flex axes, typically inserted between wrist_flex and wrist_roll to make a proper three-axis wrist. That means new printed parts, a seventh servo on the bus and a new URDF, and it is not something LeRobot supports out of the box. The stock driver hard-codes exactly six motor ids. In practice, people who need the freedom move to a mobile base or a different arm rather than modify the wrist.
Does a bigger model like GR00T N1.7 or Pi0.5 help with the missing degree of freedom?▾
No. All five trainable policies here output joint targets in the arm's own space, so the constraint is identical for a roughly 80 M parameter ACT and a roughly 3 B parameter GR00T N1.7. Bigger models help with perception, language grounding and multi-task generalisation. They cannot produce an approach direction the mechanism cannot hold.
Why does my policy get close and then approach at the wrong angle?▾
Run the azimuth sweep in this article on the actual grasp pose. If the required approach is more than roughly 10 degrees off the radial line from the shoulder-pan axis, the arm cannot hold it and the policy is reproducing the closest reachable compromise. If the sweep says the pose is reachable, then it is a data or perception problem and more episodes or a better wrist camera may help.
Does the constraint apply to the SO-101 and Koch v1.1 too?▾
Yes. All three share the same joint topology: one vertical pan joint, three parallel flex joints and a roll joint in the arm plane. The SO-101 differs from the SO-100 in declared joint travel and in how its leader arm is geared, and the Koch v1.1 uses Dynamixel servos instead of Feetech, but the axis directions are the same and so is the plane constraint. LeKiwi uses the same arm and only escapes the constraint because its holonomic base can re-aim the whole arm.
How do I pick task poses that are definitely reachable?▾
Two rules cover most cases. Keep top-down grasps roughly 0.15 to 0.25 m out from the shoulder-pan axis and near the table surface. Keep horizontal grasps roughly 0.25 to 0.40 m out and at mid height, with the object's insertion axis pointing at the base column. Anything that needs a fixed horizontal approach at a large angle to the radial line should be re-fixtured before you record a single episode.
Sources
- TheRobotStudio/SO-ARM100: Standard Open Arm 100 and 101, bill of materials and 3D print files
- so101_new_calib.urdf: the calibrated SO-101 URDF with joint axes, origins and limits
- so100.urdf: the SO-100 URDF with the original joint ranges
- LeRobot SOFollower driver: the six STS3215 motors shared by SO-100 and SO-101
- LeRobot RobotKinematics: placo-based FK and IK with position_weight 1.0 and orientation_weight 0.01
- LeRobot InverseKinematicsEEToJoints: soft-orientation IK for the 5-DOF SO-101
- lerobot-find-joint-limits: measure real joint travel and end-effector bounds by teleoperation
- LeRobot 0.6.2 pyproject.toml: CLI entry points and the placo kinematics extra
- Hugging Face LeRobot docs: SO-101 assembly, motor gearing and calibration commands
- MuJoCo Menagerie trs_so_arm100: DeepMind's converted model, described as a 5DOF arm
- placo: the whole-body kinematics solver LeRobot uses for FK and IK
- Analytical inverse kinematics for 5-DOF humanoid manipulator under arbitrarily specified unconstrained orientation of end-effector (Robotica, 2014)
- An analysis of the inverse kinematics for a 5-DOF manipulator (International Journal of Automation and Computing, 2005)
- Automatic Geometric Decomposition for Analytical Inverse Kinematics (Ostermeier, Kuelz, Althoff, 2024)
- Modern Robotics: Mechanics, Planning, and Control (Lynch and Park), free textbook on rigid-body pose and manipulator mobility
Ready for high-quality robotics data?
AY-Robots connects your robots to skilled operators worldwide.
Get Started