RobotDescription

RobotDescription · draft format · October 2026

Write the robot in Python.
Ship plain URDF.

RobotDescription is a robot described as one frozen Python value. It uses URDF's own words (Link, RevoluteJoint, Inertial), composes like ordinary code, and compiles to inert URDF and MJCF that ROS, MoveIt, Pinocchio and MuJoCo already read.

URDF is easy to read and painful to write

“…an awful lot wrong with URDF and xacro is a bandaid on top of that.”
— Peter Corke, commenting on David Dorf's pyppet announcement, LinkedIn, 2025

He would know. Corke co-wrote the survey on it: Understanding URDF: A Survey Based on User Experience (Daniella Tola and Peter Corke, IEEE CASE 2023) asked 510 robot developers how they use URDF. Of those who simulate robots, 95.5% had used it. The problems they reported: no parallel linkages or closed chains, no real standard, thin documentation and too few dynamic parameters. Most agreed URDF needs better tooling.

In the same comment he named the catch with building models in code. A model that is a program can carry malicious lines, and nobody should pull one off a server and run it the way we load XML today. That objection shaped the first principle below: code is for authoring; what you share is data.

What it looks like from the workbench

“The issue is xacro, not URDF. What is missing is a more ergonomic authoring software.”
— Zach Yamaoka, design notes, July 2026

URDF itself is a good exchange format. Every tool reads it: RViz, Foxglove, MoveIt, Pinocchio, Gazebo, Isaac Sim. So RobotDescription keeps URDF as the output and replaces the part people type.

One frozen value, in URDF's own words

  1. You write

    two_arm_cell.py

    Functions, loops and imports. Plain Python, no XML.

  2. It builds

    RobotDescription

    One frozen value. Unique names, one root, finite numbers, all checked.

  3. You ship

    cell.urdf · cell.xml

    Inert files stamped with the value's content hash.

  4. Tools read

    ROS 2 · MoveIt · Pinocchio · MuJoCo

    Nothing downstream ever runs your Python.

A RobotDescription is a name and a flat tuple of elements. Every element is a frozen dataclass named after its URDF tag, with URDF's field names. You build one by calling functions, and every method returns a new description. Here is a two-link arm, used twice:

two_arm_cell.py

from bam_robot_descriptions.urdf import (
    Box, FixedJoint, Link, RevoluteJoint, RobotDescription, Visual,
    frame_link, solid_cuboid_inertial,
)


def bar(name: str, length: float) -> Link:
    size = (0.04, 0.04, length)
    middle = (0.0, 0.0, length / 2)
    return Link(
        name,
        visuals=(Visual(Box(size), xyz=middle),),
        inertial=solid_cuboid_inertial(*size, mass=0.5, xyz=middle),
    )


def arm() -> RobotDescription:
    return RobotDescription("arm", (
        frame_link("base"),
        bar("upper", 0.30),
        bar("fore", 0.25),
        RevoluteJoint("shoulder", "base", "upper", axis=(0, 1, 0), lower=-1.5, upper=1.5),
        RevoluteJoint("elbow", "upper", "fore", xyz=(0, 0, 0.30), axis=(0, 1, 0), lower=0.0, upper=2.5),
    ))


cell = (
    RobotDescription("cell", (frame_link("world"),))
    .attach(arm().prefix("left_"), FixedJoint("left_mount", "world", "left_base", xyz=(-0.2, 0, 0)))
    .attach(arm().prefix("right_"), FixedJoint("right_mount", "world", "right_base", xyz=(0.2, 0, 0)))
)

urdf = cell.to_urdf_string()
mjcf = cell.to_mjcf_string()

Real output, not a mock-up: the URDF and MJCF tabs are exactly what the reference implementation printed for this file on 22 October 2026. Both load as a 4-joint model in Pinocchio 3.8 and MuJoCo 3.12.

Mistakes fail early

Name clashes raise the moment you combine. The whole structure is checked again before any file is written, so a broken description never reaches a viewer or simulator. Each error says what's wrong:

arm().combine(arm())
ValueError: Duplicate link name 'base' in 'arm'

arm().prefix("left_").combine(arm().prefix("right_")).to_urdf_string()
ValueError: 'arm' has 2 root links: ['left_base', 'right_base']

arm().name = "gripper"
FrozenInstanceError: cannot assign to field 'name'

Elements

TypeURDFMJCFFields
RobotDescription<robot><mujoco>name, elements, interests, interfaces
Link<link><body>name, visuals, collisions, inertial
Visual
Collision
<visual>, <collision><geom>geometry, xyz, rpy, material
Inertial<inertial><inertial>mass, xyz, rpy, ixx … izz
Box
Cylinder
Sphere
Mesh
<geometry>geom typesize · length, radius · radius · filename, scale
Material<material>rgbaname, rgba
FixedJoint<joint type="fixed">nested bodyname, parent, child, xyz, rpy
RevoluteJoint
ContinuousJoint
PrismaticJoint
<joint type=…>hinge, slide+ axis, lower, upper, effort, velocity, damping, friction
PointOfInterest
RegionOfInterest
a frame linka bodyname, parent, xyz, rpy, size or radius
Actuator
Sensor
kept beside the URDFkept beside the MJCFjoint or frame, driver, gear ratio, rate, tags

Operations

Every method is pure: it returns a new description and leaves its inputs alone, so any intermediate can be reused.

MethodReturns
.add(*elements)A new description with more elements.
.prefix(p)Renames every link, joint and frame. Materials stay shared.
.combine(*others, name=)Flat merge. Identical materials merge; clashing names raise.
.attach(other, joint)Combine, plus the joint that mounts it. Reads like the sentence.
.validate()One root, unique names, every reference resolves, every number finite.
.content_hash()A short SHA-256 of the value. Equal descriptions, equal hash.
.to_urdf_string()Inert URDF, stamped with the content hash.
.to_mjcf_string()The same robot as MuJoCo MJCF, same stamp.

Twelve rules the format keeps

  1. Author in Python, ship data

    The .py file is the source, the way source code is. URDF and MJCF are compiled artifacts, stamped with a content hash so you can tell which source made them. People share and load the artifacts, never the program, so a downloaded model can't run code. Most of the 2016 URDF-NG discussion argued for the same split: keep the exchange format as data, and generate it with whatever language you like.

  2. Speak URDF

    Same names, same fields: Link, Inertial, xyz, rpy. Every engineer, tool and language model already knows them, so nothing needs translating. Joint types are classes (RevoluteJoint, not Joint(type="revolute")) because the code reads better and the type checker can help.

  3. URDF is the floor, not the ceiling

    A description must always compile to valid URDF and carry at least what URDF carries. It may carry more: actuators, sensors, points and regions of interest. Where a target format can't hold something, it lowers to plain frames or stays beside the file. It is never silently dropped.

  4. A description is a value

    Frozen dataclasses with no hidden state. Joint positions, live readings and connections are runtime state, and they live elsewhere. Pinocchio splits Model from Data for the same reason. A value can be hashed, compared, cached and sent between processes.

  5. Composition is the basic operation

    prefix, combine and attach are the whole game. A four-arm rack is one arm function called four times. Adding a joint is combining with a very small description.

  6. Explicit over magic

    Nothing renames, positions or fills itself in behind your back. What you read in the source is what is in the robot.

  7. Joints name their links

    A joint stores its parent and child as names, the way URDF does, not as Python objects. Object references break quietly when a description is copied or prefixed; names survive any tree operation.

  8. Construct, don't translate

    The description is built by running ordinary Python, eagerly, so loops, functions, if and print() all just work. Nothing parses your code. Hardware description languages learned this: Amaranth builds circuits by running Python and has no “supported subset”; MyHDL parsed it and hit one.

  9. Zero runtime dependencies

    The core is the Python standard library. A full four-arm rack compiles to URDF in a bare python -I -S. Viewers, solvers and simulators are consumers, installed as extras.

  10. Fail at build time

    Name clashes raise as soon as you combine, and the whole structure is checked again before any file is written. A broken description never reaches a viewer or simulator, and every error names the link or joint at fault.

  11. CAD owns the bodies

    Shapes, masses and inertias come from CAD. The description adds what CAD doesn't know: which parts move, how, what drives them and what they mean to the software. A GUI for it is for composition and configuration, not for creating geometry.

  12. The format outlives the code

    Keep the format small and stable, and treat any one implementation as replaceable. FIRRTL, the IR behind the Chisel hardware language, is the precedent: the repository of its original Scala compiler is archived, and the format lives on in LLVM's CIRCT.

Why I'm building this

I build robot arms for sorting waste at BAM Robotics. Before you can simulate a robot, plan its motion or check it against the real one, you need a digital description of the machine. As my design notes put it: “We just simply need a way to describe a physical machine digitally.”

The first time I saw robot data in Python was pymoveit2's ur.py. There was no URDF to find and parse: you imported the robot, asked it for base_link, and swapping arms was a one-line change.

Then I tried to build what BAM actually needs: four arms on one rack, with hands that change. In xacro that meant generated files, leaking macros and a recursive mix of URDF and Python. Every workaround felt like running up a steep cliff. When I wrote it down, the design doc said: “Just explaining it now makes it feel complicated.”

So the description moved into Python, first as dataclasses that fed xacro (October 2025), then, from July 2026, as frozen values that write URDF themselves. BAM's five-bar arms, their bench rig and the four-arm rack now compile from Python. A test holds every one equal to the old xacro output, and another checks the joints against Pinocchio.

I have written this many, many times. What's different now is that the format is pinned to URDF's own vocabulary, so the code can change underneath it without the format changing. The idea of a URDF is central to robotics. It deserves better authoring.

Standing on these shoulders

Looking for robot models, specs, CAD exporters or viewers? robotdescriptions.com keeps a hand-checked list of them.

Where it stands, honestly

Works today

  • A reference implementation inside BAM Robotics' arm stack: bam_robot_descriptions 0.0.1.
  • 216 tests pass. Every description equals its old xacro build, the comparison is shown to fail when it should, and joints are checked against Pinocchio.
  • URDF and MJCF output, both stamped with the same content hash.
  • Composition at real scale: BAM's five-bar arms, a bench test rig and a four-arm rack.

Open questions

  1. How far should a description go past URDF? The leading answer: a CAD description (what the CAD contains) plus datasheets, meaning and calibration.
  2. Should generated URDF be committed beside the source, lockfile-style, or regenerated on demand?
  3. Which authoring styles to support beyond the flat tuple: a chained builder, a declarative class, or both.