SimEX: Simulation-Integrated Robotics AutoResearch Coding agents that learn to control real robots by experimenting in simulation first

Coding agents are good at reasoning about goals in the digital world, but bringing them onto robots has been hard. SimEX uses simulation as a laboratory for coding agents to discover and refine reusable robot skills, enabling efficient adaptation to real hardware. No demonstrations, no hand-written skills, and about ten minutes of robot time per task.

In Stage 1, a probe-and-optimize harness drives a coding agent through open-ended cycles in a simulated sandbox: it probes the toolbox on tasks it invents itself, turns failures into a sub-goal, and optimizes the toolbox until that sub-goal passes. In Stage 2, the agent adapts that toolbox to the real robot with only five physical trials, replaying each trial in simulation to diagnose the failure and screening several candidate repairs before spending the next trial. See our paper for details.


Real-world qualitative results

Task completion on the physical robot

Real-world rollouts of the adapted toolbox on the physical dual-arm YAM robot. All clips are shown at 8× speed.

Barcode scanning

real robot · 8×

Barcode scanning · Rollout 1

real robot · 8×

Barcode scanning · Rollout 2

Toolbox and CaP script

Barcode scanning

“Clear the tote. The source tote holds two different objects. Read both barcodes at the scanner and put both objects in the drop-off tote.”

Toolbox toolbox.py 10 primitives called · 6887 lines
def find_source_tote(refresh: bool = False) -> dict | None:
  """Pose of the source tote, perceived from the wrist camera's depth; None if it has not
  been seen yet."""
  ...  # 10 lines

def scanned_objects() -> list[str]:
  """Names of the objects whose barcodes have been read so far in this rollout."""
  ...  # 15 lines

def view_source_tote(arm: str = "left", steps: int | None = None) -> dict:
  """Park the hand where the wrist camera looks into the source tote."""
  ...  # 42 lines

def find_objects(camera_name: str = "left_wrist_cam") -> list[dict]:
  """Every object in the source tote, segmented and measured from the wrist camera.
  Shape-agnostic."""
  if not self.has_depth(camera_name):
    ...
    return []
  pf = self.platform
  rgb = np.ascontiguousarray(np.asarray(pf.camera_image(camera_name)))
  ...
  depth = np.asarray(pf.depth_image(camera_name), dtype=np.float32)
  intr = pf.camera_intrinsics(camera_name)
  pose = self._camera_pose_opencv(camera_name)
  ...
  cavity = self._source_cavity_xy()
  floor_z = self._source_floor_z()
  finder = self._wrist_finder()
  sets, report = finder.object_point_sets(
    rgb,
    depth,
    intr,
    pose,
    ee,
    cavity_xy=cavity,
    floor_z=floor_z,
    ...
  )
  fits = [
    (self._fit_plan_shape(pts), pts, mid)
    for pts, mid in zip(sets, report["kept_mask_ids"], strict=True)
  ]
  fits = [f for f in fits if f[0] is not None]
  fits.sort(key=lambda f: (0 if f[0]["graspable"] else 1, f[0]["centre_xy"][0]))
  out: list[dict] = [f[0] for f in fits]
  ...
  self.last_perception = report
  ...
  return out

def wait(steps: int = 30) -> None:
  """Hold the current command for a number of env steps."""
  ...  # 6 lines

def set_gripper(arm: str, open_: bool, hold_steps: int = 5) -> None:
  """Open or close the gripper on the named arm."""
  ...  # 11 lines

def grasp_object(
    arm: str,
    obj: dict,
    grasp_z: float | None = None,
    seat_advance: float = 0.0,
    pre_slew: bool = True,
    vet: bool = True,
    narrow_jaws: bool = False,
) -> dict:
  """Grasp one object from find_objects(); centre, jaw azimuth and grasp height all come
  from its measurement."""
  self._held_obj = dict(obj)
  if self._grasp_attempts >= int(self._MAX_GRASP_ATTEMPTS):
    ...
    return {
      "ok": False,
      "reason": "attempt_cap",
      ...
    }
  self._grasp_attempts += 1
  ...
  top_z = float(obj["top_z"])
  psi = float(obj["grasp_azimuth_rad"])
  x, y = (float(v) for v in obj["centre_xy"])
  psi = self._round_grasp_azimuth(obj, psi)
  gz = (
    float(grasp_z)
    if grasp_z is not None
    else max(
      self._GRASP_Z_MIN,
      min(self._GRASP_Z_MAX, 0.5 * (self._CAVITY_FLOOR_Z + top_z)),
    )
  )
  gz -= float(self._GRASP_Z_BIAS_M)
  quat = self.topdown_grasp_quat(psi)
  ...
  pre_z = top_z + self._HAND_BELOW_SITE_M + self._PRE_GRASP_CLEAR_M
  ...
  if vet:
    b = self.plan_pose((x, y, gz), arm, target_quat=quat)
    a = self.plan_pose((x, y, pre_z), arm, target_quat=quat)
    if a["pos_err_m"] > self._HOVER_PLAN_LEAN_M:
      ...
      for lean_deg in self._LEAN_DEGS:
        lq = self._lean_toward_robot(self.topdown_grasp_quat(psi_l), lean_deg)
        la = self.plan_pose((x, y, pre_z), arm, target_quat=lq)
        lb = self.plan_pose((x, y, gz), arm, target_quat=lq)
        if (
          max(la["pos_err_m"], lb["pos_err_m"]) <= self._VET_POS_TOL_M
          and la["pos_err_m"] < a["pos_err_m"]
        ):
          quat, a, b, lean_used, psi = lq, la, lb, lean_deg, psi_l
          ...
          break
    ...
    if b["pos_err_m"] > self._VET_POS_TOL_M:
      ...
      return {
        "ok": False,
        "reason": "unreachable",
        ...
      }
  ...
  r = self.grasp_topdown(
    arm,
    (x, y),
    psi,
    grasp_z=gz,
    ...
    pre_z=pre_z,
    pre_iters=strain_iters,
    approach_quat=None if lean_used == 0.0 else quat,
    object_geometry=obj,
  )
  if (not r["ok"]) and r["reason"] == "pre_grasp" and lean_used == 0.0:
    ...
    r = self.grasp_topdown(
      ...
      approach_quat=lq,
      object_geometry=obj,
    )
    ...
  return r

def present_to_scanner(
    arm: str,
    max_steps: int = 60,
    dither_steps: int = 12,
    sweep_steps: int = 420,
    restore_wrist: bool = True,
) -> bool:
  """Hold the grasped object at the scanner and search its facing until a new read latches."""
  already_read = set(self.scanned_objects())
  ...
  quat_entry = self.platform.ee_quat(arm)
  tilted = False
  flat = self._is_flat(self._held_obj)
  ...
  def _beep() -> bool:
    return bool(set(self.scanned_objects()) - already_read)

  def _done(value: bool) -> bool:
    ...
    if restore_wrist and (value or tilted):
      if tilted:
        self.set_gripper(arm, False, hold_steps=2)
        self._tilt_wrist(arm, quat_entry)
      if value and abs(float(self.platform.joint_pos(arm)[5]) - j6_entry) > 0.02:
        self._ramp_joint(arm, 5, j6_entry, self._PRESENT_AZ_RAMP_STEPS)
      ...
    return value

  ox, oy, oz = self._READ_ORIGIN
  read_point = np.array([ox - self._PRESENT_AXIS_DEPTH, oy, oz])

  def _aim_payload(legs: int, iters: int) -> bool:
    for _ in range(legs):
      if _beep():
        return True
      pay = np.asarray(self._payload_point(arm), dtype=float)
      ee = np.asarray(self.platform.ee_pos(arm), dtype=float)
      goal = read_point + (ee - pay)
      err = float(np.linalg.norm(goal - ee))
      if err < self._PRESENT_AIM_TOL_M:
        return False
      ...
      self.move_ee_to(
        (float(leg[0]), float(leg[1]), float(leg[2])),
        arm,
        max_iters=iters,
        tol=self._PRESENT_AIM_TOL_M,
      )
    return _beep()

  if _aim_payload(6, max(30, int(max_steps))):
    ...
    return _done(True)
  ...
  def _azimuth_sweep(budget: int) -> bool:
    j6_0 = float(self.platform.joint_pos(arm)[5])
    ...
    for deg in self._PRESENT_AZ_STEPS_DEG:
      ...
      want = j6_0 + math.radians(deg)
      ...
      self._ramp_joint(arm, 5, want, self._PRESENT_AZ_RAMP_STEPS)
      self.platform.wait(3)
      ...
      if _beep():
        ...
        return True
    return False

  if flat:
    ...
  else:
    ...
    if _azimuth_sweep(int(sweep_steps) // 2):
      ...
      return _done(True)
    ...
  for tilt in self._PRESENT_TILT_DEG:
    rq = self._axis_angle_quat((0.0, 1.0, 0.0), math.radians(tilt))
    ...
    tilted = True
    ...
    t_out = self._tilt_wrist(arm, dq)
    ...
    if _beep():
      ...
      return _done(True)
    if t_out["ori_err_rad"] > 0.5:
      ...
      continue
    ...
    if _aim_payload(3, 60):
      ...
      return _done(True)
    ...
    if _azimuth_sweep(int(sweep_steps) // 3):
      ...
      return _done(True)
    ...
  return _done(_beep() or bool(already_read))

def regrasp_rotated(
    arm: str,
    delta_rad: float = math.pi / 2.0,
    settle_steps: int = 40,
) -> dict:
  """Set the held object back down in the source tote, re-perceive it, and grasp it again at
  a new azimuth."""
  ...
  src = self._tote_centre("source")
  put = None
  objs: list[dict] = []
  if (
    self._is_flat(self._held_obj)
    or (self._held_obj or {}).get("long_axis_rad") is not None
  ):
    put, objs = self._flip_until_down(arm, src, settle_steps=int(settle_steps))
    if put is not None and not put["ok"]:
      ...
      put = None
  if put is None:
    put = self.place_in_tote(
      arm, tote_xy=src, require_scan=False, return_over_source=False
    )
  out["put_down"] = put
  if not put["ok"]:
    out["reason"] = f"put_down_{put['reason']}"
    ...
    return out
  if not objs:
    self.platform.wait(int(settle_steps))
    ...
    ref = put.get("released_at") or self.platform.ee_pos(arm)
    objs = self._look_for_dropped(arm, (ref[0], ref[1]))
  out["found"] = len(objs)
  if not objs:
    out["reason"] = "not_found_after_put_down"
    ...
    return out
  ref = put.get("released_at") or self.platform.ee_pos(arm)
  obj = min(objs, key=lambda o: math.dist(o["centre_xy"], (ref[0], ref[1])))
  obj = dict(obj)
  if obj.get("long_axis_rad") is None:
    obj["grasp_azimuth_rad"] = (
      float(obj["grasp_azimuth_rad"]) + float(delta_rad)
    ) % math.pi
    ...
  self.set_gripper(arm, True, hold_steps=4)
  g = self.grasp_object(arm, obj)
  out["grasp"] = g
  out["ok"] = bool(g["ok"])
  out["reason"] = "held" if g["ok"] else f"grasp_{g['reason']}"
  ...
  return out

def place_in_tote(
    arm: str,
    tote_xy=None,
    require_scan: bool = True,
    scanned_before=None,
    release_z: float | None = None,
    carry_z: float | None = None,
    return_over_source: bool = True,
) -> dict:
  """Carry the held object over a tote and release it inside the cavity."""
  ...
  scanned_new = None
  if require_scan:
    before = set() if scanned_before is None else set(scanned_before)
    scanned_new = sorted(set(self.scanned_objects()) - before)
    if not scanned_new:
      self._log(
        "place_in_tote: REFUSING to release — no NEW scan since the grasp "
        "(pass require_scan=False only for a stow-without-scan task)"
      )
      return {
        "ok": False,
        "reason": "unscanned",
        ...
      }

  ...
  here = self.platform.ee_pos(arm)
  inside = (
    abs(float(here[0]) - cavity_centre[0]) <= hx
    and abs(float(here[1]) - cavity_centre[1]) <= hy
  )
  if not inside:
    ...
    return {
      "ok": False,
      "reason": "carry_short",
      ...
    }
  ...

The ten primitives this CaP calls, with the five that do the work cut to their key lines. Grasps are vetted; the stow refuses unscanned objects.

CaP script agent_script.py 41 of 79 lines
ARM = "left"
NEEDED = 2
MAX_PASSES = 8

stowed = 0
...

for _pass in range(MAX_PASSES):
    if stowed >= NEEDED:
        break
    objs = tb.find_objects()
    if not objs:
        tb.view_source_tote(ARM)
        tb.wait(5)
        objs = tb.find_objects()
    if not objs:
        break
    obj = objs[0]

    tb.set_gripper(ARM, True, hold_steps=4)
    g = tb.grasp_object(ARM, obj)
    if g["reason"] == "attempt_cap":
        break
    if not g["ok"]:
        ...
        continue

    before = set(tb.scanned_objects())
    tb.present_to_scanner(ARM)
    if not new_reads(before):
        r = tb.regrasp_rotated(ARM)
        ...
        tb.present_to_scanner(ARM)

    if not new_reads(before):
        tb.place_in_tote(ARM, tote_xy=source_xy(), require_scan=False)
        continue

    p = tb.place_in_tote(ARM, scanned_before=before)
    if p["ok"]:
        stowed += 1

Find, grasp, present to the scanner; a read counts once the scanned-code set grows. On a miss, regrasp rotated; if it still fails, return it to the source tote.

Plate to tote

real robot · 8×

Plate to tote · Rollout 1

real robot · 8×

Plate to tote · Rollout 2

Toolbox and CaP script

Plate to tote

“Clean the station by moving all four plates (green front-left, blue front-right, red back-left, yellow back-right) into the tote.”

Toolbox toolbox.py 3 primitives called · 1834 lines
def perceive_plates() -> list[dict]:
  """Park both arms, then detect every plate on the table; re-detects once if an expected
  colour is missing."""
  self.park_arms()
  plates = self.find_table_plates()
  seen = {d["name"] for d in plates}
  expected = {n for n in self._expected_names if n not in seen}
  if expected:
    ...
    self.wait(6)
    again = self.find_table_plates()
    for d in again:
      if d["name"] in expected and d["name"] not in seen:
        plates.append(d)
        seen.add(d["name"])
    self._last_plates = list(plates)
  self._expected_names = set(seen)
  return plates

def get_step_count() -> int:
  """Env steps elapsed in this rollout."""
  ...  # 3 lines

def deliver_plate(plate_xy, arm: str | None = None, tote_xy=None) -> dict:
  """Grasp a plate by its rim, carry it to the tote, release, then re-detect the table to
  confirm delivery."""
  arm = self.arm_for_plate(plate_xy) if arm is None else arm
  ...
  g = self.grasp_plate_rim((px, py), arm)
  if not g["grasped"] and g["plan"] is not None:
    other = "outboard" if g["plan"]["side"] == "inboard" else "inboard"
    ...
    g2 = self.grasp_plate_rim((px, py), arm, side=other)
    if g2["plan"] is not None:
      g = g2
  ...
  if not g["grasped"]:
    self.set_gripper(arm, True, hold_steps=2)
    out["plates"] = self.perceive_plates()
    return out
  ...
  MAX_PLACE_TRIES = 2
  for _try in range(MAX_PLACE_TRIES):
    p = self.place_in_tote(
      arm,
      tote_xy=self._tote_cache,
      flip=g["plan"]["flip"],
      side_used=g["plan"]["side"],
    )
    rise_loss = p.get("stage") == "rise"
    ...
    if rise_loss:
      self.stow_arm(arm)
    plates = self.perceive_plates()
    out["plates"] = plates
    ...
    remaining = [d for d in same if d not in inside]
    out["delivered"] = (not remaining) and not rise_loss
    if out["delivered"]:
      out["stage"] = "released"
      break
    out["stage"] = "lost_in_rise" if rise_loss else "lost_in_carry"
    ...
    if remaining and _try + 1 < MAX_PLACE_TRIES:
      ...
      g = self.grasp_plate_rim((rx, ry), arm)
      ...
      continue
    break
  ...
  return out

The three primitives this CaP calls. Perception re-detects missing colours; delivery grasps the rim, carries, releases, and counts the plate once it is gone.

CaP script agent_script.py 29 of 43 lines
targets = ["green", "blue", "red", "yellow"]
FRONT = {"green", "blue"}
LEFT_SIDE = {"green", "red"}

plates = tb.perceive_plates()

delivered = {}
for name in targets:
    cand = [d for d in plates if d["name"] == name]
    if not cand:
        cand = [d for d in plates if (d["world_xy"][1] > 0) == (name in LEFT_SIDE)]
        cand.sort(key=lambda d: d["world_xy"][0], reverse=(name not in FRONT))
    if not cand:
        delivered[name] = False
        continue

    xy = cand[0]["world_xy"]
    res = tb.deliver_plate(xy)
    plates = res["plates"]

    still = [d for d in plates if d["name"] == name]
    if still and not res["delivered"]:
        xy2 = still[0]["world_xy"]
        res = tb.deliver_plate(xy2)
        plates = res["plates"]
    delivered[name] = bool(res["delivered"])

print("summary:", delivered, "remaining on table:",
      [d["name"] for d in plates], "steps:", tb.get_step_count())

One perception call, then one delivery per colour in a fixed order, reusing the detections each delivery returns. A plate left on the table gets one retry.

Towel folding

real robot · 8×

Two-fold towel task · Rollout 1

real robot · 8×

Two-fold towel task · Rollout 2

Toolbox and CaP script

Towel folding

“Fold the towel twice, into quarters: fold it in half, then fold the result in half again along the other axis. Keep it on the table.”

Toolbox toolbox.py 3 primitives called · 1639 lines
def towel_frame(det: dict | None = None, camera_name: str = "top_cam") -> dict | None:
  """The towel's four edges keyed by world side (far, near, left, right), plus its centre,
  size and fold ratio."""
  if det is None:
    det = self.find_towel(camera_name)
  if det is None:
    return None
  c = [np.asarray(p, dtype=float) for p in det["corners"]]
  centre = np.mean(np.stack(c), axis=0)
  edges = []
  for i in range(4):
    a, b = c[i], c[(i + 1) % 4]
    mid = 0.5 * (a + b)
    out = mid - centre
    out = out / (np.linalg.norm(out) or 1.0)
    along = b - a
    length = float(np.linalg.norm(along))
    along = along / (length or 1.0)
    edges.append({"corners": [tuple(map(float, a)), tuple(map(float, b))], "mid": tuple(map(float, mid)),
                  "out": tuple(map(float, out)), "along": tuple(map(float, along)), "length": length})
  frame: dict = {}
  used: set = set()
  for side, d in sorted(_SIDE_DIR.items(), key=lambda kv: kv[0]):
    best, best_dot = None, -2.0
    for i, e in enumerate(edges):
      if i in used:
        continue
      dot = e["out"][0] * d[0] + e["out"][1] * d[1]
      if dot > best_dot:
        best, best_dot = i, dot
    used.add(best)
    frame[side] = edges[best]
  size_x = float(np.linalg.norm(np.subtract(frame["far"]["mid"], frame["near"]["mid"])))
  size_y = float(np.linalg.norm(np.subtract(frame["left"]["mid"], frame["right"]["mid"])))
  ...
  area = max(size_x * size_y, 1e-6)
  frame.update({
    "yaw_deg": float(math.degrees(math.atan2(frame["far"]["out"][1], frame["far"]["out"][0]))),
    "centre": tuple(map(float, centre)),
    "size_x": size_x,
    "size_y": size_y,
    ...
    "layers": int(max(1, round(0.34 * 0.34 / area))),
    "fold_ratio": float(0.34 * 0.34 / area),
    ...
  })
  ...
  return frame

def fold_edge(
    side: str,
    arms="auto",
    grasp="corners",
    carry_z: float | None = None,
    settle_steps: int = 20,
    verify: bool = True,
    inboard: float | None = None,
    guard: bool = True,
    correct: bool = True,
    tighten: bool = False,
    sweep: float | None = None,
    across=None,
) -> dict:
  """Fold the towel in half by carrying one edge onto the opposite edge. Closed loop."""
  before = self.towel_frame()
  ...
  edge, opp = self._pick_edges(before, side, across)
  carry_vec = self._carry_vec(edge, opp)
  res["carry_vec"] = tuple(map(float, carry_vec))
  two_hand_ok = side in self.TWO_HAND_SIDES
  if arms == "auto":
    if two_hand_ok:
      use = ["left", "right"]
    elif abs(edge["out"][1]) > 0.5:
      use = [("left" if edge["out"][1] > 0 else "right")]
    else:
      use = None
  else:
    use = list(arms)
  ...

def get_step_count() -> int:
  """Env steps elapsed in this rollout."""
  ...  # 3 lines

The three primitives this CaP calls. The frame labels towel edges by outward normals, so it works yawed or folded; each fold is one closed-loop call.

CaP script agent_script.py 23 of 50 lines
fr0 = _report("start")
...
size_x0, size_y0 = fr0["size_x"], fr0["size_y"]

r1 = tb.fold_edge("far")
across = r1.get("carry_vec")
fr1 = r1["after"] if r1["after"] is not None else _report("after fold1")

if fr1 is not None and fr1["size_x"] > 0.72 * size_x0 and tb.get_step_count() < 260:
  r1b = tb.fold_edge("near")
  if r1b.get("carry_vec") is not None and r1b["ok"]:
    across = r1b["carry_vec"]
  fr1 = r1b["after"] if r1b["after"] is not None else fr1

side2 = "left" if fr1["centre"][1] >= 0.0 else "right"
r2 = tb.fold_edge(side2, across=across)
fr2 = r2["after"] if r2["after"] is not None else _report("after fold2")

if fr2 is not None and fr2["size_y"] > 0.70 * size_y0 and tb.get_step_count() < 420:
  other = "right" if side2 == "left" else "left"
  r2b = tb.fold_edge(other, across=across)

_report("final")

Two fold calls. The second passes the first fold's carry direction as across. Each fold gets one retry, gated on the measured towel size and the step budget.


Real-world qualitative results

Emergent behaviors

None of the behaviors below were specified. They emerged from the probe-and-optimize harness in simulation and the agent's repairs after real trials.

real robot · 8×

Zero-shot generalization. The scanning skill generalizes to unseen objects in a zero-shot setting.

real robot · 8×

Bottle at the tote edge. The robot figured out how to pick up objects at the edge of the tote by aligning its gripper with the tote’s orientation and gently nudging the tote.

real robot · 8×

Flipping a box. When the label is on the other side, the agent learned to flip the object in the tote and scan it again.

real robot · 8×

Recovery during scan and transfer. The agent is robust to failures and can recover from them.

real robot · 8×
real robot · 8×
Choosing the grasp side. The left and right videos show the agent grasping the plate from different sides based on its distance from the tote, avoiding collisions with the tote or wall.

Real-world qualitative results

Instruction following and multi-task capabilities

Here the toolbox stays the same and only the instruction changes. We set a new task at deployment time, and the coding agent writes a fresh CaP script against the toolbox it built.

real robot · 8×

“Scan two objects and place them in the destination tote.”

real robot · 8×

“Leave one plate on the table.”

real robot · 8×

“Place the red plate in the tote.”

real robot · 8×

“Fold the towel in half.”


Simulation

Sim-to-sim behaviors

The controlled sim-to-sim experiments in the paper use an independently implemented simulator as the stand-in for the robot: Isaac Sim for barcode scanning and plate to tote, MJWarp Flex for towel folding. For each task family, the curves show success rate rising through the Stage 1 probe-and-optimize cycles in simulation and across the five Stage 2 adaptation trials on the stand-in. Below each pair of curves, we show two rollouts of the adapted toolbox on each task.