Skip to main content
This tutorial builds a picking cell where the item name is the only configuration. An order arrives as JSON, something like {"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, and CYBERWAVE_API_KEY exported.
Develop in cw.affect("simulation") until every number below is calibrated. Switch to cw.affect("live") only with a clear workspace and a hand on the power switch. Leader and follower use different power supplies, 5 V and 12 V. Do not swap them.
1

Connect, then set the real joint names and limits

Some SO-101 twins expose their joints as _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.
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.
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 /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:
The general defense: stop treating a command as proof, and read the joints back.
Telemetry comes back in radians while plans are in degrees. A commanded (11.89, 59.24, -62.00, -87.25) degrees reads back as (0.20, 1.04, -1.05, -1.52), the same pose within half a degree, off by 57x if compared raw.
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 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 /dev/videoN number, since it reshuffles on replug.
Set the pixel format before the resolution, and check the frame size you got back. This camera offers 1920x1080 over MJPEG only, and asking for the size alone quietly gave 640x480 for weeks. A wrong-sized frame still looks fine, but it invalidates every saved pixel coordinate, since the homography and bin rectangles are stored in pixels.
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.
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. mlmodels is a property on the client instance, not a module-level function.
detect_points returns [{"point": [y, x], "label": ...}], in y, x order, on a 0 to 1000 grid, independent of your image size. Get it backward and every pick mirrors about the diagonal: plausible numbers, wrong object, no error message anywhere.
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.
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:
The tool heading is q2 + q3 - q4, not q2 + q3 + q4. wrist_flex is inverted relative to shoulder_lift and elbow_flex. Get the sign wrong and the arm still tracks the commanded angles to within half a degree, while the gripper points straight up into a mechanical stop. A round trip test will not catch this either, since flipping the sign in both FK and IK still passes. Pin the convention with its own assertion, like assert q2 + q3 - q4 == -90 for a top-down pick.
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:
alerts.create(...) has no message keyword, and that guess raises a TypeError at the end of a good run. Details go in description. The item name shows up in the alert’s name, shown in the Alerts list, not in the email Cyberwave sends, which has a static body.
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.
Every warning above shares one shape: no error message, just a plausible-looking wrong result. On a physical system the expensive failures are silent, and the only defense is to measure the result instead of trusting the call.

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_grasps or plan_steps and 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.