The AY-Robots SO-100 hub page showing the arm, its specifications and the guides that use it
SO-100kinematicsrobot armworkspacehardware

Degrees of Freedom: What Five Joints and a Gripper Cannot Do

AY-Robots ResearchAugust 23, 202621 min read

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 componentWhat it controlsAvailable on a 5-joint SO-100?
xPosition along the table, left to rightYes
yPosition along the table, near to farYes
zHeight above the tableYes
Pitch (elevation of the tool axis)Whether the gripper points down, forward or upYes, set by the flex chain
Roll (rotation about the tool axis)Which way the jaw faces around its own approach lineYes, set by wrist_roll
Yaw (azimuth of the tool axis)Which compass direction the gripper approaches fromNo, 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:

python
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),
}
src/lerobot/robots/so_follower/so_follower.py, lerobot 0.6.2. SO-100 and SO-101 share one driver class.

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.

JointAxis at the zero pose (base frame)SO-101 rangeSO-100 rangeRole
shoulder_pan(0, 0, -1), vertical-110.00 to 110.00 deg-114.59 to 114.59 degAims the arm plane
shoulder_lift(0, 1, 0), horizontal-100.00 to 100.00 deg0.00 to 200.54 degIn-plane
elbow_flex(0, 1, 0), horizontal-96.83 to 96.83 deg-180.00 to 0.00 degIn-plane
wrist_flex(0, 1, 0), horizontal-95.00 to 95.00 deg-143.24 to 68.75 degIn-plane
wrist_roll(-1, 0, 0), along the tool-157.21 to 162.79 deg-180.00 to 180.00 degSpins the jaw
gripperjaw hinge-10.00 to 100.00 deg-11.46 to 114.59 degOpens and closes
Where these numbers come from

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.

Stated as one sentence

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.

python
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)
Save as check_plane.py next to so101_new_calib.urdf. Needs only numpy.
bash
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-05
Output reproduced on 2026-08-24. 1.0e-05 is 0.0006 degrees, which is rounding, not freedom.

One 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 lineBest position errorBest approach-axis errorVerdict
0 deg (radial)0.00 mm0.0 degExact
5 deg0.20 mm3.4 degMarginal
10 deg0.49 mm8.4 degGrasp starts slipping
20 deg1.04 mm18.3 degFails on anything tight
30 deg1.57 mm28.2 degImpossible
45 deg2.28 mm43.0 degImpossible
60 deg2.83 mm57.9 degImpossible
90 deg3.33 mm87.8 degImpossible

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.

The trap that eats a day

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 axisHeight above base plateStraight-down approachHorizontal radial approach
0.15 m0.05 mExact42.9 deg error
0.20 m0.05 mExact11.6 deg error
0.25 m0.05 mExact3.9 deg error
0.30 m0.05 m9.8 deg errorExact
0.20 m0.25 m50.2 deg errorExact
0.25 m0.15 m22.0 deg errorExact
0.35 m0.15 m41.6 deg errorExact
0.40 m0.25 m80.6 deg errorExact

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.
The AY-Robots glossary entry for the LeRobot dataset format, showing the recorded observation and action fields
Every episode you record stores the six joint values, not a Cartesian pose. The dataset can only ever contain poses the arm could physically hold.

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:

python
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)
src/lerobot/model/kinematics.py, lerobot 0.6.2. Orientation is weighted 100 times lower than position.

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.

Living with five joints
What you gain
  • 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.
What you give up
  • 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.

TaskRequired approachVerdict on a 5-joint arm
Pick a cube off a table and drop it in a binStraight down, any jaw yawWorks, this is the sweet spot
Stack two blocksStraight down twiceWorks
Put a marker in a cupStraight down, cup is rotationally symmetricWorks
Pick up a mug by the handle, handle pointing at the baseRadial horizontalWorks
Pick up a mug by the handle, handle pointing sidewaysHorizontal, 90 deg off radialImpossible without moving the mug or the arm
Plug a USB stick into a socket facing the armRadial horizontalWorks, subject to precision
Plug a USB stick into a socket on a side wallHorizontal, off radialImpossible
Turn a door handle on a vertical door faceHorizontal radial push plus roll about a horizontal axisUsually works if the door faces the arm
Turn a valve whose axis is horizontal and tangentialRoll about a tangential axisImpossible
Unscrew a bottle cap on a bottle standing uprightStraight down plus roll about verticalWorks, limited to one 320 deg wrist_roll sweep
Wipe a vertical surface that runs radially away from the baseTool normal to the surface, tangentialImpossible
Fold a cloth flat on the tableStraight down, any yawWorks

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.

  1. 1
    Install 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.

    bash
    pip install "lerobot[kinematics]"
    # resolves placo>=0.9.6,<0.9.16 plus pinned urdfdom and tinyxml2 wheels
  2. 2
    Grab 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.

    bash
    curl -sLO https://raw.githubusercontent.com/TheRobotStudio/SO-ARM100/main/Simulation/SO101/so101_new_calib.urdf
  3. 3
    Find 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.

    bash
    lerobot-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
  4. 4
    Run 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.

    bash
    python check_plane.py
  5. 5
    Test 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.

    python
    from 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.

  1. Build or buy the arm and calibrate it with lerobot-calibrate.
  2. Install lerobot[kinematics] and pull the SO-101 URDF from TheRobotStudio repo.
  3. Run lerobot-find-joint-limits to get your real travel and end-effector bounds.
  4. Run the plane check and the azimuth sweep on your intended grasp poses.
  5. Redesign any fixture whose approach comes out more than about 10 degrees off.
  6. Record with lerobot-record, train, and evaluate on the arm.
Feed the servos 7.4 V

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.

bash
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=50
Verified entry point in lerobot 0.6.2 pyproject.toml.

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.

ApproachWhat it changesCost
Rotate the workpiece, not the wristMount the socket, fixture or shelf so its insertion axis points at the base columnFree, but the scene layout becomes part of the task definition
Design top-down onlyRestrict every grasp to a vertical approach, keep objects 0.15 to 0.25 m out and near the tableRules out side insertion entirely
Put the object on a turntableA cheap passive lazy susan lets a human or a second actuator pre-orient the objectOne more thing in the scene the policy has to see
Add a mobile baseA holonomic base gives back x, y and yaw, so the arm plane can be aimed anywhereBase positioning error lands on top of arm error
Add a second armThe second arm holds and reorients while the first works, which is what humans doDoubles 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.

The AY-Robots policies comparison table listing GR00T N1.7, GR00T N1.5, Pi0.5, SmolVLA and ACT with parameters, GPU tier, inference latency and minimum episodes
Five trainable policies with their real numbers. None of the differences between them touches the kinematic constraint.

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.

Two related failure modes that are not this

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.

The AY-Robots arena leaderboard, a sortable table of 85 vision-language-action models with 332 benchmark results linked to their sources
85 models and 332 benchmark results in the arena. Benchmark scores come from other embodiments; none of them predict what your five joints can reach.

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 arm
Is 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.

Ready for high-quality robotics data?

AY-Robots connects your robots to skilled operators worldwide.

Get Started