{"order_id": "SO-1042", "item": "red marker", "bin": "A"}. The
SO-101 finds that item with an open-vocabulary vision-language model, picks it, drops it in the
named bin, then looks again to confirm it landed. Success raises an order_fulfilled alert,
failure retries once and then raises an error alert.
No dataset, no fine-tune, no class list. A new item is a new string.
That second look is what makes this an agent, not a recorded trajectory. Fulfillment is gated on
visual evidence, not on the motion plan finishing.
Community tutorial. Contributed by Devan Sedmak through the Cyberwave Builders Program
(Cohort 2). Full source lives in
devansedmak/so101-zeroshot-picking-cell.What is verified where. Perception, camera calibration, joint limits, and sign conventions
were measured on a physical SO-101. The order loop runs against the simulated twin, and live
hardware was driven read-only plus a few scripted poses. Where a number is assumed, this page
says so.
What you build
Everything left of the executor is plain Python, testable offline. The same code targets the simulated twin or the real arm by switching one call.Before you start
- An SO-101 paired to Cyberwave, guided calibration already run on the follower.
- A USB camera fixed rigidly overhead, not on the wrist. A planar homography needs a camera that does not move relative to the table.
- Low-profile, nameable objects (markers, erasers, glue sticks) on a light mat, plus labeled bins.
- Python 3.10+,
pip install "cyberwave[camera]" numpy, andCYBERWAVE_API_KEYexported.
1
Connect, then set the real joint names and limits
Some SO-101 twins expose their joints as You will also clamp every commanded angle later, so clamp to numbers the arm actually has, not a
guess. The guided calibration already run is stored on the twin:These ranges come back in radians. Convert to degrees, keep a small margin inside the mechanical
stop, and use that as your limit table. A hand-picked guess of plus or minus 60 degrees once
clamped a mid-workspace elbow solution of -83 degrees down to -60, landing short with no error
anywhere. The real range there was plus or minus 96.6 degrees.The gripper needs its own check, not an assumed sign. On this arm 0 degrees is fully shut and
higher is more open, the opposite of the other joints’ signed range. Before any live run, command
the gripper alone, nothing in the jaws, and watch which way it moves.
_1.._6 rather than 1..6. Sending a command to a
name the twin does not have is a silent no-op. The arm never moves and nothing logs it. Keep
canonical keys ("1".."6", the servo IDs) in your own code, and translate to the twin’s real
names only at the SDK boundary:joints.set(...) takes radians by default. Pass degrees=True or a 30 in your plan becomes 30
radians on the wire.2
A command acknowledgement is not motion
This is the most expensive lesson on this page. Publishing a pose returns success whether or not
anything is listening: the twin looks online, MQTT is connected, and the arm does not move. The
edge driver’s container mounts The general defense: stop treating a command as proof, and read the joints back.Cannot tell must never be reported as fine, so a missing telemetry read raises instead of
returning an empty, all-good drift dict. That honesty lives in code: a dashboard stage only turns
green on a
/dev as a snapshot taken at container creation, so an arm plugged
in afterward stays invisible. Plug the hardware in first, then restart the edge service:verify_pose result with no drift, and stays commanded, unverified otherwise.3
Capture one still frame
Everything downstream starts from one frame, so make that the most reliable call in the system. A
one-shot grab beats a live stream and is easy to retry. Exclude the laptop’s own camera by sysfs
name, and never hardcode a Two more things mattered as much as any code: mount the camera straight down, off the arm’s own
table, and focus the lens by hand.
/dev/videoN number, since it reshuffles on replug.4
Ask the VLM to point at the ordered item
This is the zero-shot part: the order’s item string is the prompt, nothing is pre-registered.
Decode this in exactly one function so nothing downstream re-derives the convention:Splitting the network call from the parsing pays off, since the parser is where the bug lives and
both the pick and the verification step share the fix.
mlmodels is a property on the client instance, not a module-level function.5
Solve IK, then clamp and ramp every command
A top-down pick does not need a general six-axis solver. Pan the base at the target, solve a
planar two-link reach, and hold the gripper vertical:Take
L1, L2, L3, and BASE_H from the official SO-101 URDF, not a ruler. Ruler estimates on
one build had L3 sixty millimeters short, enough to drive the gripper into the table on the
first live pick, and the round-trip test still passed since it only proves the algebra agrees
with itself.Between the solver and the SDK, assume the plan is wrong. Validate the whole plan first, clamp
immediately before each SDK call rather than at plan time, and step toward the target in small
ramped increments instead of jumping. Let the solver return its true answer, not a pre-clamped
one, so a bad result logs loudly. Then call verify_pose from step 2: clamping protects the
hardware, only the encoders say where the arm went.6
Close the loop: verify, alert, and serve it over HTTP
After placing, re-capture and ask the same VLM the same question, then check geometrically
whether the answer lands inside the bin. Bins do not move, so their pixel rectangles are
calibration data, not perception: survey two opposite corners per bin once and store them beside
the homography.Wire that check into the order loop with one retry, then report through a twin alert:A verifier that raises should degrade the run, not crash it: a camera hiccup should read as “not
verified”, never as a lost order.The last piece is a receiver that turns an HTTP order into a run of this loop. Serialize
fulfillment behind a lock, since one arm cannot run two orders at once. Return
200 for
fulfilled, 409 for well-formed but unfulfilled, 422 for a bad payload, 500 for a runner that
raised.What this does not cover
- No depth sensing. A tall object breaks the planar assumption, since the VLM points at its top face and the homography maps that pixel as if it were on the table.
- No force or tactile feedback. There is no force, load, or current signal on this hardware. The grasp closes to one fixed angle. Verification catches the outcome, not the cause.
- No collision-aware planning. The IK is a reach solver, and knows nothing about the bins, the mat, or the arm’s own body.
- No multi-object disambiguation. “The red marker” picks the first matching detection.
- Bins are taught, not perceived. Only the item side is zero-shot.
- The jaw-to-arm angular offset is assumed, not measured. A wrong constant biases every grasp the same way, with no scatter to reveal it. Measure it, don’t tune until picks work.
Next steps
- Swap the fixed approach pose for
predict_graspsorplan_stepsand compare against the solver. - Put a Workflow in front of the receiver so orders arrive from your WMS, not
curl. - Log every verification frame beside its verdict, the fastest evidence you can debug a bad pick from.