Skill authoring patterns
The patterns that make skills reusable: anchor-driven geometry, articulated objects, and the relative-motion gotcha.
Authoring a Skill covers the anatomy: two files, one function. This page covers the patterns that decide whether a skill is reusable or has to be rewritten for every cell.
The rule underneath all of them:
Geometry comes from object-model anchors, not from numbers baked into the code. Re-teach an anchor and the motion follows, with no code change.
A skill that hardcodes move_arm(position=[0.42, -0.11, 0.30]) works exactly once, in one cell, until someone nudges the table. A skill that resolves that position from an anchor works everywhere the object is defined.
The anchor-driven pattern
Object inputs arrive as SkillObject; use object.id to address the world instance.
load_object_anchor(id, "anchor", joint_config=None) returns a dict with xyz, rpy, wxyz, standoff, width, gripper_variant, and object_id.
anchor_preapproach(anchor, default_standoff=…) returns a world-frame standoff point, backed off along the anchor's approach axis (the anchor's local −Z). The anchor's own standoff wins when it's set; otherwise your default applies.
grasp = load_object_anchor(obj.id, "grasp_shortside")
pre = anchor_preapproach(grasp, default_standoff=0.15)
move_arm(arm="right_arm", position=pre, orientation=grasp["rpy"], speed=40)
move_arm(arm="right_arm", position=grasp["xyz"], orientation=grasp["rpy"], speed=10)
set_gripper(arm="right_arm", width_m=grasp["width"])Gripper widths and standoffs come from the anchor's
graspblock. Readanchor["width"]andanchor["standoff"]. Don't hardcode them. They're part of how the object was taught, so they travel with it.
Note the two-stage approach: fast to the standoff point, slow to the grasp. That pattern shows up in nearly every pick.
Articulated objects
For anything with a moving part (a lid, a drawer, a lid_joint or drawer_joint):
-
Resolve the grab anchor at a chosen joint angle.
load_object_anchor(id, "lid_open", joint_config={"lid_joint": 0.0}) -
Sweep the arm between the two extremes. The same anchor evaluated at two angles traces the arc:
interpolate_anchor_to_anchor( arm, id, "lid_open", "lid_open", start_joint_config={"lid_joint": a}, end_joint_config={"lid_joint": b}, interpolate=True, steps=6, speed=…, ) -
Commit the joint in sim.
update_object_joint_config(id, {"lid_joint": b})
The skill owns the world-model sync. The sweep does not do it for you. If you skip
update_object_joint_config, the arm moves but the world model still believes the lid is shut, and the next skill plans against a stale world.
Set the precondition state at the start too. For example, model the drawer as open before a load. Read the joint presets (closed/open angles) from the object model rather than from memory.
Gotcha: move_relative is world-frame
move_relative is world-frameThis one causes real confusion, so it's worth stating plainly.
move_relative(arm, delta_xyz=…) adds the delta to the world-frame TCP position. [d, 0, 0] is always world +X, regardless of how the object is oriented. If you want an object-aligned slide (peeling a seal out along its approach axis, say), derive a unit direction from the anchors and scale it:
out = [grasp_pre[i] - grasp["xyz"][i] for i in range(3)] # grasp -> its preapproach
n = (out[0]**2 + out[1]**2 + out[2]**2) ** 0.5
udir = [c / n for c in out] if n > 1e-9 else [0.0, 0.0, 0.0]
move_relative(arm, delta_xyz=[dist * c for c in udir], speed=…) # object-aligned slide
move_relative(arm, delta_xyz=[0, 0, lift_m], speed=…) # lift stays world +ZThe lift on the last line is intentionally world-frame: "up" is up regardless of the object. Mixing the two deliberately is fine; mixing them by accident is the bug.
What stays hardcoded, and why
Not everything can come from an anchor. Calibrated joint poses, such as swing, stow, and intermediary clearance stances, aren't derivable from object geometry, because they're properties of the cell rather than of any object.
Keep them as named skill-local constants, or import them from utils.py (e.g. RIGHT_ARM_STOW_JOINTS), and comment that they're cell-calibrated so the next person knows why they're literals.
Holding a plate consistently
After closing the gripper on a plate, assert the canonical grip so it's held identically on every pick. Snap the plate's grasp anchor onto the gripper's pose, then attach it to the arm:
snap_object_anchor_to_world_pose(plate.id, "<grasp_anchor_name>", xyz, wxyz)
attach_object_to_arm(plate.id, arm)Snapping asserts where the object is rather than trusting where the physics left it, which is what keeps the grip identical run to run. Release with detach_object_from_arm(plate.id).
The reasoning behind the snap, and the matching convention for placing, is in How anchor snapping works.
Boilerplate
Every skill should:
- Open with
print_log(runlog=True, runlog_type="step_start"), then aprint_lognaming the skill and its key inputs. - Close with a completion
print_log. - Return
{"success": True}. - Prefer parameters with sensible defaults over inline magic numbers. Expose anchor names, speeds, and tunable distances so the skill can be reused rather than copied.
Authoring checklist
- New dir
skills/<skill_id>/withrobotic_code.pyandmetadata.yaml(plusmodules.pyif you need shared imports). - Geometry (poses, widths, standoffs) resolved from anchors, with nothing world-baked.
- Articulated parts resolved at a
joint_config, swept, then committed withupdate_object_joint_config. - Object-aligned relative slides derive their direction from anchors, not world axes.
- Calibrated swing/stow poses kept as commented skill-local constants.
-
metadata.yamlskill_idmatches the function name; tags name the object and arm. -
python3 -m py_compile skills/<skill_id>/robotic_code.pypasses, then verify in sim.
Where to go next
Updated 3 days ago