The AY-Robots SO-100 hub page, the starting point for building, teleoperating and training an SO-100 class arm, which is the arm LeKiwi mounts on its holonomic base.
lekiwimobile manipulationholonomic drivelerobotso-100action vector

LeKiwi Mobile Base Explained: Action Vector, Recording, Training

AY-Robots ResearchAugust 23, 202619 min read

LeKiwi puts an SO-100 class arm on a three-wheel holonomic base. What the extra base dimensions do to the action vector, and what really changes when you record and train.

LeKiwi is an SO-100 class arm bolted onto a three-wheel omnidirectional base with a Raspberry Pi 5 in between. The arm half you know: six Feetech STS3215 servos driving five joints and a gripper, the same degrees of freedom and the same calibration dance as any SO-100. The other half changes the shape of the problem. The robot stops being six servo positions and becomes nine dimensions, three of them velocities in different units, measured rather than commanded, with no position feedback anywhere in the loop.

This is about that vector: where it comes from, what the kiwi drive matrix computes, which numbers are hard limits, and what changes when you record episodes and fine-tune a policy. Everything below was read out of lerobot main in August 2026 (pyproject on main reads version 0.6.2; the newest tagged release is v0.6.1, published 3 August 2026) and the LeKiwi build repository. Where docs and code disagree, and twice they do, the code wins.

What you need to know

  • The action vector has nine entries: six arm joint positions plus x.vel and y.vel in m/s and theta.vel in deg/s. The observation state has the same nine.
  • The base entries are velocities, not positions. No odometry, no pose. A policy can only know where it is from the camera images.
  • The wheels sit at 150, -90 and 30 degrees. One 3x3 matrix maps body velocity to wheel speed; its inverse decodes the feedback back.
  • A 3000-tick wheel cap limits the base to about 0.27 m/s forward and 0.23 m/s sideways. The published table promises 0.4 m/s and the code does not deliver it.
  • Two defaults eat your first evening: the host exits after 30 seconds, and the base halts if no command arrives for 500 ms.
  • SmolVLA and Pi0.5 pad state and action to 32 dims, so nine costs nothing. ACT reads the dataset dimension. Only GR00T needs a modality config with a base slice.

What LeKiwi actually is

LeKiwi v1 is maintained by SIG Robotics at the University of Illinois with the Hugging Face LeRobot team. It is a build repository, not a product: bill of materials, STLs, assembly guide, Fusion 360 model, exported URDF. The arm on top is an SO-ARM101, sharing servo family and code paths with the SO-100. Here it is a compatible arm rather than the reference one; the side-by-side is at SO-100 vs LeKiwi.

SubsystemLeKiwi v1Detail that matters later
ArmSO-ARM101, six STS3215ids 1 to 6, position mode
Basethree 4 inch omni wheels, 120 degrees apartkiwi drive, holonomic
Base motorsthree STS3215 on the same busids 7, 8, 9, velocity mode
ComputeRaspberry Pi 5, 4 GB in the BOMruns the host loop
Camerasone workspace, one wrist, USBdefaults /dev/video0 and /dev/video2
Power12 V 5 A Li-ion, or a USB-C laptop power banka third build is wired
LinkZeroMQ, 5555 out and 5556 backcommands out, JPEG frames back

The upstream bill of materials quotes three builds. Full totals include two arms, because leader-follower teleoperation needs a leader as well as the follower on the base.

BuildUSEUContents
12 V, complete482 USD545.80 EURbase plus two arms, 12 V servos
5 V, complete499 USD526.00 EURbase plus two arms, 7.4 V servos
Base only, 12 V251.50 USD307.80 EURif you own two arms
Base only, 5 V248 USD295.00 EUReasiest first build
Base only, wired184 USD235.00 EURcheapest, tethered

Those exclude 3D printing. The parts cost listed here for a LeKiwi is about 400 to 500 EUR, the same territory. Either way it lands at roughly three times the parts cost of an SO-101 (about 130 to 170 EUR), and the difference is base, battery and Pi.

7.4 V servos and 12 V rails do not mix

The STS3215 ships in a 7.4 V and a 12 V variant that look identical. The 7.4 V part is rated 16.5 kg.cm stall torque at 6 V, the 12 V part 30 kg.cm. Feeding 12 V into a 7.4 V servo destroys it. Upstream keeps each build on one voltage: the 5 V build uses 7.4 V servos throughout, the 12 V build uses 12 V servos throughout and steps the battery down to 5 V only for the Pi. The arm catalog here lists LeKiwi as a 7.4 V arm on a 12 V base, so check which servo variant is actually in your kit before you wire anything. If a joint has already gone quiet, start at servo not responding.

The kiwi drive is one 3x3 matrix

A kiwi drive is three omni wheels 120 degrees apart. Free rollers across each axis let a wheel be dragged sideways while it drives, so the three constraints together leave the chassis all three planar freedoms at once: forward, sideways, yaw. A differential-drive robot has to turn before it moves sideways. LeKiwi does not. The forward model is four lines.

python
# src/lerobot/robots/lekiwi/lekiwi.py, _body_to_wheel_raw defaults: wheel_radius=0.05, base_radius=0.125
angles = np.radians(np.array([240, 0, 120]) - 90)      # -> 150, -90, 30 degrees
m = np.array([[np.cos(a), np.sin(a), base_radius] for a in angles])

wheel_linear_speeds  = m.dot(np.array([x, y, theta_rad]))   # m/s at the rim
wheel_angular_speeds = wheel_linear_speeds / wheel_radius   # rad/s
theta arrives in deg/s and is converted first. The third column is each wheel's moment arm.
WheelMotor idMount angleRow of M: x, y, theta
base_left_wheel7150 deg-0.866, 0.500, 0.125
base_back_wheel8-90 deg0.000, -1.000, 0.125
base_right_wheel930 deg0.866, 0.500, 0.125

The determinant is 0.325, so the matrix inverts cleanly and runs backwards to turn measured wheel speeds into a body velocity. That inverse is how x.vel, y.vel and theta.vel reach the observation. It also says forward speed comes only from the left and right wheels, and yaw is a third of the wheel sum over the base radius.

Then the part nobody documents. Wheel commands become raw ticks at 4096 per revolution, and if any wheel exceeds max_raw = 3000 all three scale down proportionally. 3000 ticks is 263.7 deg/s, or 0.230 m/s at a 0.05 m rim. Push that back through the matrix and you get the real envelope.

CommandPeak wheel speedScaleWhat the base does
0.10 m/s forward0.087 m/s1.000.10 m/s
0.20 m/s forward0.173 m/s1.000.20 m/s
0.30 m/s forward0.260 m/s0.8860.27 m/s
0.30 m/s sideways0.300 m/s0.7670.23 m/s
90 deg/s spin0.196 m/s1.0090 deg/s
0.30 m/s plus 90 deg/s0.456 m/s0.504about half of both
Proportional scaling is right, and it hides things

Scaling all three wheels by one factor keeps the direction of travel through clipping and only drops the magnitude, so a diagonal command never bends into a curve. The cost: the base silently goes slower than asked, worst when driving and spinning at once, where a full command comes out at roughly half strength.

That matters when reading a recorded LeRobot dataset. The stored action is the requested velocity before scaling; the state is decoded from what the wheels reported. Wherever the request exceeded the cap, action and state disagree by construction, and no tuning closes that gap.

Six positions, three velocities, one vector

Both observation_features and action_features on the LeKiwi class return the same dictionary. That one decision drives most of what follows.

python
# both the observation state and the action are this, in this order
(
    "arm_shoulder_pan.pos",
    "arm_shoulder_lift.pos",
    "arm_elbow_flex.pos",
    "arm_wrist_flex.pos",
    "arm_wrist_roll.pos",
    "arm_gripper.pos",
    "x.vel",
    "y.vel",
    "theta.vel",
)
_state_ft in lekiwi.py. hw_to_dataset_features turns it into observation.state (9,) and action (9,).
IndexKeyUnitKindOrigin
0-4arm_shoulder_pan.pos ... arm_wrist_roll.posdegreespositionPresent_Position, ids 1 to 5
5arm_gripper.pos0 to 100positionPresent_Position, id 6, normalised 0 to 100
6x.velm/svelocityM inverse of Present_Velocity, ids 7 to 9
7y.velm/svelocitysame three readings
8theta.veldeg/svelocitysame three readings

Degrees for the arm is a default aligned with the SO-100 in May 2026 (use_degrees = True). Older LeKiwi datasets used a normalised -100 to 100 range, so check units before trusting a recording from the public directory.

Three ways the base dimensions are not joints

  1. They are velocities, so there is nothing to integrate. No pose, no odometry, no map is stored. Integrating x.vel drifts, because omni rollers slip and nothing corrects it. The policy locates itself from pixels alone.
  2. State and action are the same quantity. On the arm, state is a measured position and action a target, separated by a real tracking error. On the base they are the decoded and the commanded body velocity, one step apart. A policy that copies state into action scores well on those columns while learning nothing, and the loss curve looks healthy. See loss falls, policy does nothing.
  3. They are zero most of the time. The base drives for a second, then holds still while the arm works. Every normaliser has to cope with a spike at zero and thin tails, the shape min-max and mean-std both handle badly.
Check the base columns before you train

Load the parquet, take the three base columns, print the fraction of frames where all three are exactly zero. Above roughly 90 percent and you have an arm dataset with three noisy columns attached. Fix it in the teleoperation protocol, not in loss weights: record episodes where driving is part of the task, not ones where you drove into position and then started the clock.

The AY-Robots failure-mode index listing robot problems such as arm not detected, gripper does not close and dataset rejected as v3, each linking to a fix page.
Most LeKiwi trouble is ordinary SO-100 trouble with an extra bus segment attached.

What changes when you record

Recording is a two-machine affair. A host on the Pi owns the serial bus and the cameras; a client on your laptop owns the leader arm, the keyboard and the dataset writer. ZeroMQ carries commands on 5555 with CONFLATE set, so only the newest survives a backlog, and multipart observations on 5556 with a two-deep queue that sheds stale frames. The host loop is capped at 30 Hz.

  1. 1
    Install the lekiwi extra on both machines

    It pulls in the Feetech SDK and pyzmq. Needed on the Pi and the laptop.

    bash
    pip install -e ".[lekiwi]"
  2. 2
    Set all nine motor ids

    One control board serves the whole robot, so ids must be unique across arm and base. The helper walks the arm down from 6 to 1, then the wheels from 9 to 7.

    bash
    lerobot-find-port
    
    lerobot-setup-motors \
        --robot.type=lekiwi \
        --robot.port=/dev/ttyACM0
  3. 3
    Calibrate the arm. The wheels do not need it

    Wheels and wrist roll count as full-turn motors: offset 0, range 0 to 4095, no interaction. Run it over SSH on the Pi.

    bash
    lerobot-calibrate \
        --robot.type=lekiwi \
        --robot.id=my_awesome_kiwi
  4. 4
    Start the host, and give it more than 30 seconds

    connection_time_s defaults to 30 and the loop exits when it elapses.

    bash
    python -m lerobot.robots.lekiwi.lekiwi_host \
        --robot.id=my_awesome_kiwi \
        --host.connection_time_s=3600
  5. 5
    Teleoperate from the laptop

    Edit remote_ip first. The leader arm drives the six joints; w, a, s, d drive the base, z and x spin it, r and f step the speed preset.

    bash
    python examples/lekiwi/teleoperate.py
  6. 6
    Record episodes

    Set remote_ip, HF_REPO_ID, TASK_DESCRIPTION, NUM_EPISODES (default 2), EPISODE_TIME_SEC (30) and RESET_TIME_SEC (10) at the top. FPS is 30.

    bash
    python examples/lekiwi/record.py
Three defaults that cost people an evening

1. The host quits after 30 seconds. The loop is while duration < connection_time_s, default 30. Pass --host.connection_time_s. 2. lerobot-record cannot record LeKiwi. The CLI takes one --teleop.type, but LeKiwi needs an arm teleoperator and a keyboard; record_loop takes a list and refuses anything else with "Currently only supported for LeKiwi robot". Use examples/lekiwi/record.py. 3. Base keyboard teleop needs a global key backend. X11, Windows, or macOS with Input Monitoring. Not Wayland, not headless SSH, and it fails quietly: the arm follows, the base never moves.

Two places where the docs and the code disagree

1. Speed. The LeRobot LeKiwi page lists modes at 0.4, 0.25 and 0.1 m/s. LeKiwiClient.speed_levels in the same repository holds 0.1, 0.2 and 0.3 m/s at 30, 60 and 90 deg/s, and the comment beside it still says "e.g. 0.1, 0.25, or 0.4". Clipping puts the fast preset near 0.27 m/s anyway. 2. Recording length. The same page tells you to raise NB_CYCLES_CLIENT_CONNECTION to record for longer. No such constant exists in examples/lekiwi/record.py; the knobs that are actually there are NUM_EPISODES and EPISODE_TIME_SEC. Annotate datasets from the config, not from the page.

One detail that surprises people later: the default cameras have different shapes. Front is 640 by 480 rotated 180; wrist is declared 480 wide by 640 tall and rotated 90, so LeRobot captures landscape and hands you a portrait frame. In the dataset that is (480, 640, 3) for front and (640, 480, 3) for wrist. Policies resize, but a portrait wrist view crops differently than you expect, and depth cameras are rejected outright: the transport carries colour only. See camera not detected when one never appears.

What changes when you train

Less than you would think for four of the five policies. Nine dimensions are small enough that the modern stacks absorb them silently, and only GR00T N1.7 asks you to describe the vector.

PolicyHow it handles 9 dimsWhat you change
ACTheads sized from the dataset, no paddingnothing
SmolVLApads state and action to max_state_dim = 32nothing
Pi0.5pads to 32 too, normalises with quantilesnothing
GR00T N1.7 / N1.5reads named slices from meta/modality.jsonadd a base slice and one ActionConfig

For ACT and SmolVLA you point lerobot-train at the dataset and the shapes follow. A nine-dimensional LeKiwi recording trains with the same command line as a six-dimensional SO-100 one.

bash
lerobot-train \
  --dataset.repo_id=${HF_USER}/lekiwi_pick_and_carry \
  --policy.type=act \
  --output_dir=outputs/train/act_lekiwi \
  --job_name=act_lekiwi \
  --policy.device=cuda \
  --policy.repo_id=${HF_USER}/act_lekiwi
The dimensionality never appears on the command line.

GR00T is the exception, and in two ways. It loads LeRobot v2.0 or v2.1 only, so a v3.0 recording has to be converted down before the loader will touch it (dataset rejected as v3). And it looks up named slices in meta/modality.json and transforms each separately, so a custom robot gets registered under the NEW_EMBODIMENT tag with its config path passed to the fine-tuning entry point.

json
{
  "state": {
    "single_arm": {"start": 0, "end": 5},
    "gripper":    {"start": 5, "end": 6},
    "base":       {"start": 6, "end": 9}
  },
  "action": {
    "single_arm": {"start": 0, "end": 5},
    "gripper":    {"start": 5, "end": 6},
    "base":       {"start": 6, "end": 9}
  },
  "video": {
    "front": {"original_key": "observation.images.front"},
    "wrist": {"original_key": "observation.images.wrist"}
  },
  "annotation": {
    "human.task_description": {"original_key": "task_index"}
  }
}
The SO-100 example shipped with Isaac-GR00T stops at index 6. The base slice is the addition.
python
"action": ModalityConfig(
    delta_indices=list(range(0, 16)),
    modality_keys=["single_arm", "gripper", "base"],
    action_configs=[
        ActionConfig(rep=ActionRepresentation.RELATIVE, type=ActionType.NON_EEF,
                     format=ActionFormat.DEFAULT),   # arm: delta from current state
        ActionConfig(rep=ActionRepresentation.ABSOLUTE, type=ActionType.NON_EEF,
                     format=ActionFormat.DEFAULT),   # gripper: target position
        ActionConfig(rep=ActionRepresentation.ABSOLUTE, type=ActionType.NON_EEF,
                     format=ActionFormat.DEFAULT),   # base: already a velocity
    ],
)
action_configs is positional: entry i applies to modality_keys[i]. Wrong order applies the wrong representation silently.
Never mark the base slice RELATIVE

In GR00T, RELATIVE stores the action as a delta from the matching state. Sensible for joint angles, and what the SO-100 example uses. For the base it is nonsense: the state is already a velocity, so a relative base action is an acceleration, which the robot does not consume. At deployment the processor adds the measured velocity back on, injecting wheel-estimate error straight into the command. Keep the base ABSOLUTE, and remember that changing delta_indices means re-running gr00t/data/stats.py or training dies with an IndexError.

Normalisation is where mixed units bite

ACT and SmolVLA normalise state and action with mean and standard deviation, Pi0.5 uses quantiles, GR00T defaults to min-max per slice. All of them rescale each dimension independently, which is why nobody has to think about degrees sitting beside metres per second. It works, with one side effect worth naming.

After z-scoring, all nine dimensions contribute roughly equally to the loss. The base dimensions claim a third of the gradient budget even when the base moves in a minority of frames, and their small standard deviation amplifies wheel-feedback noise into large normalised targets. Not a reason to drop the base, but a reason to record episodes where it earns its place and to check generalisation across setups before trusting the result.

The AY-Robots find-your-combination matrix with five policy models as rows and four robot arms including LeKiwi as columns, every cell linking to a training guide.
Every model-arm pair has its own guide, including /train/act-on-lekiwi and /train/groot-n1-7-on-lekiwi.

Two routes from a LeKiwi to a trained policy

You own the chain: Pi image, ZeroMQ link, dataset, GPU. The right choice if you are changing the robot itself, because every layer is a file you can edit.

  1. 1
    Build and wire

    Print, assemble, set nine motor ids, calibrate both arms. Budget a weekend, most of it printing.

    bash
    lerobot-setup-motors --robot.type=lekiwi --robot.port=/dev/ttyACM0
    lerobot-calibrate --robot.type=lekiwi --robot.id=my_awesome_kiwi
  2. 2
    Record 50 or more episodes

    Host on the Pi, client on the laptop. Edit the example script; the CLI will not do it.

    bash
    # Pi
    python -m lerobot.robots.lekiwi.lekiwi_host --robot.id=my_awesome_kiwi --host.connection_time_s=3600
    
    # laptop
    python examples/lekiwi/record.py
  3. 3
    Train on your own card or a rented one

    ACT and SmolVLA fit on 24 GB. GR00T and Pi0.5 need 80 GB.

    bash
    lerobot-train \
      --dataset.repo_id=${HF_USER}/lekiwi_pick_and_carry \
      --policy.type=smolvla \
      --output_dir=outputs/train/smolvla_lekiwi \
      --job_name=smolvla_lekiwi \
      --policy.device=cuda
  4. 4
    Serve next to the robot

    Same network as the Pi. The base watchdog does not care why a command was late.

    bash
    python examples/lekiwi/evaluate.py

The cost: a GPU you provision yourself, an afternoon on driver versions, and the GR00T modality config, the one piece with no mobile-base example to copy.

Is the base worth it?

Adding a holonomic base to an SO-100 class arm
What you gain
  • The workspace stops being a table. The arm reaches a shelf and a bin without re-clamping either.
  • Approach direction becomes free. The policy can strafe into alignment instead of a three-point turn.
  • Three extra dimensions are nearly free: SmolVLA and Pi0.5 pad to 32 anyway, ACT sizes itself from the data.
  • Same servo family, same bus, same calibration flow, same code paths as the arm you already run.
  • Kits exist, and the repository ships CAD, STLs and a URDF for simulation.
What it costs
  • Nine dimensions but still no pose. Navigation has to be solved from images, which needs far more data than manipulation.
  • Velocity actions do not replay. Re-running an episode restores the joint configuration, not the position on the floor.
  • The base moves the cameras, so every frame carries motion blur and viewpoint change a static rig never sees.
  • A 30 Hz host loop and a 500 ms watchdog put a hard floor under your latency budget.
  • Three more servos, a battery, a Pi, a charging routine, and a network link that fails on its own.

Where the base does not help

The base makes the data problem harder faster than it makes the robot more capable. Manipulation from 50 episodes works because the task is short, the camera fixed and the start state nearly identical each time. Add a base and you have added navigation, with a much larger state space, no pose observation and the same 50 episodes. Expect a policy that manipulates well once it is in the right place and cannot get itself there.

Then latency, where the arithmetic is concrete. The base stops if no command arrives for 500 ms. One policy step costs 20 ms for ACT, 152 ms for GR00T N1.7, 165 ms for GR00T N1.5, 245 ms for SmolVLA and 485 ms for Pi0.5 (inference latency). Send one command per inference and Pi0.5 lands within 15 ms of the watchdog before the network does anything.

The signature: arm keeps moving, base freezes

The watchdog calls stop_base(), which zeroes the wheel velocities and leaves the arm goals alone, so a latency problem does not look like one. It looks like a base that stutters while the arm carries on. The fix is not a faster model but action chunking: these policies predict 16 steps (GR00T), 50 (SmolVLA, Pi0.5) or 100 (ACT) at a time. One 485 ms Pi0.5 call covers 1.67 seconds of commands at 30 fps. Stream the chunk at the control rate instead of sending one and waiting.

The platform limit applies with extra force here. Remote inference over the public internet is viable for slow pick-and-place and not for fast reactive motion. On a mobile base the round trip that makes a grasp hesitant also makes the base overshoot, because a late velocity command keeps executing until the next replaces it. For anything past a demo, put inference on the same LAN as the Pi.

A scope note to close. The desktop client and browser teleoperation here are built around the arm; the LeKiwi base loop, keyboard mapping and ZeroMQ host stay upstream code you run yourself. This side gives you the part after the dataset: a guide per model and arm, rented GPUs by VRAM, and a served checkpoint. If somebody else drives while you build the dataset, the operator side is its own job. Background: the SO-100 hub, the SO-100 complete guide and collecting high-quality VLA data.

The AY-Robots teleoperator page headed Become a Robot Operator from anywhere in the world, with a photograph of the SO-100 arm operators drive remotely.
Data collection is labour, and on a mobile base it is more of it.

Train a policy on a LeKiwi

Every model and arm pair has its own guide: the GPU tier it needs, the dataset format it accepts, and the defaults the trainer really sends. GR00T N1.7, GR00T N1.5, Pi0.5, SmolVLA and ACT, each on a LeKiwi, each run start to finish.

Open the training matrix
How many dimensions does a LeKiwi action have?

Nine. Six arm joint positions (five joints in degrees plus a gripper on a 0 to 100 scale) and three base velocities: x.vel and y.vel in m/s, theta.vel in deg/s. The observation state carries the same nine in the same order, so observation.state and action both have shape (9,).

Can I use lerobot-record to record a LeKiwi dataset?

No. The CLI accepts a single --teleop.type, but LeKiwi needs an arm teleoperator and a keyboard at once. record_loop accepts a list and raises a ValueError for anything that is not exactly one KeyboardTeleop plus one arm leader on a lekiwi_client robot. Use examples/lekiwi/record.py.

Why does my LeKiwi stop after 30 seconds?

Not a crash. LeKiwiHostConfig.connection_time_s defaults to 30, the host loop runs while elapsed time is below it, then prints 'Cycle time reached' and disconnects. Start the host with --host.connection_time_s set higher, for example 3600.

Do I need to change anything to train SmolVLA or ACT on nine dimensions?

No. ACT sizes its heads from the dataset features, and SmolVLA and Pi0.5 pad state and action to 32 dimensions regardless. Only GR00T needs work: add a base slice covering indices 6 to 9 in meta/modality.json and give it an ActionConfig with ABSOLUTE representation.

How fast can the LeKiwi base actually drive?

Roughly 0.27 m/s forward and 0.23 m/s sideways. Wheel commands cap at 3000 raw ticks, which is 263.7 deg/s or 0.230 m/s at the 0.05 m rim, and anything above scales all three wheels down proportionally. The documented 0.4 m/s fast preset is unreachable, and the client config asks for 0.3 m/s.

Can I run inference in the cloud for a LeKiwi?

For slow tasks yes, for anything reactive no. The base halts if no command arrives within 500 ms and a Pi0.5 step alone is 485 ms. Predict a chunk, stream it back at the 30 Hz control rate, and keep the serving machine on the same LAN as the Pi.

Ready for high-quality robotics data?

AY-Robots connects your robots to skilled operators worldwide.

Get Started