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 grasp block. Read anchor["width"] and anchor["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):

  1. Resolve the grab anchor at a chosen joint angle.

    load_object_anchor(id, "lid_open", joint_config={"lid_joint": 0.0})
  2. 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=…,
    )
  3. 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

This 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 +Z

The 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 a print_log naming 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>/ with robotic_code.py and metadata.yaml (plus modules.py if 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 with update_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.yaml skill_id matches the function name; tags name the object and arm.
  • python3 -m py_compile skills/<skill_id>/robotic_code.py passes, then verify in sim.

Where to go next


Did this page help you?