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.
The problem
URDF is easy to read and painful to write
“…an awful lot wrong with URDF and xacro is a bandaid on top of that.”
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
Composition is copy and paste
Swapping the end effector on an arm meant a xacro full of if/else, a new file for every combination, or macro paths passed in as arguments. None of them is a function you can call.
Macros leak
Expanding the same arm macro four times for a four-arm rack overwrote variables across expansions. The workaround was to expand each arm in its own file and paste the results together.
Files multiply
A robot family turns into a tree of
.xacro, YAML and launch arguments that has to be refactored every time a variant is added. Universal Robots' description is the textbook case.No tools
No types, no autocomplete, no print statement, little syntax highlighting. Errors show up in the viewer, not the editor.
It's a tree
URDF can't say that a linkage closes. BAM's arm is a five-bar, so its closure lives outside the file, in tests.
It can't hold what you know
Actuators and gear ratios, sensors, which link is the tool tip, friction and temperature models, calibration per serial number. They end up in side files that drift from the URDF.
“The issue is xacro, not URDF. What is missing is a more ergonomic authoring software.”
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.
The format
One frozen value, in URDF's own words
You write
two_arm_cell.py
Functions, loops and imports. Plain Python, no XML.
It builds
RobotDescription
One frozen value. Unique names, one root, finite numbers, all checked.
You ship
cell.urdf · cell.xml
Inert files stamped with the value's content hash.
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()
cell.urdf
<?xml version="1.0" encoding="UTF-8"?>
<!-- generated by bam_robot_descriptions (py backend), content 763e8dddb5b7d42d -->
<robot name="cell">
<link name="world" />
<link name="left_base" />
<link name="left_upper">
<visual>
<origin xyz="0.0 0.0 0.15" rpy="0.0 0.0 0.0" />
<geometry>
<box size="0.04 0.04 0.3" />
</geometry>
</visual>
<inertial>
<origin xyz="0.0 0.0 0.15" />
<mass value="0.5" />
<inertia ixx="0.0038166666666666666" ixy="0.0" ixz="0.0" iyy="0.0038166666666666666" iyz="0.0" izz="0.00013333333333333334" />
</inertial>
</link>
<link name="left_fore">
<visual>
<origin xyz="0.0 0.0 0.125" rpy="0.0 0.0 0.0" />
<geometry>
<box size="0.04 0.04 0.25" />
</geometry>
</visual>
<inertial>
<origin xyz="0.0 0.0 0.125" />
<mass value="0.5" />
<inertia ixx="0.0026708333333333332" ixy="0.0" ixz="0.0" iyy="0.0026708333333333332" iyz="0.0" izz="0.00013333333333333334" />
</inertial>
</link>
<joint name="left_shoulder" type="revolute">
<origin xyz="0.0 0.0 0.0" rpy="0.0 0.0 0.0" />
<parent link="left_base" />
<child link="left_upper" />
<axis xyz="0.0 1.0 0.0" />
<limit effort="10.0" velocity="10.0" lower="-1.5" upper="1.5" />
<dynamics damping="0.001" friction="0.01" />
</joint>
<joint name="left_elbow" type="revolute">
<origin xyz="0.0 0.0 0.3" rpy="0.0 0.0 0.0" />
<parent link="left_upper" />
<child link="left_fore" />
<axis xyz="0.0 1.0 0.0" />
<limit effort="10.0" velocity="10.0" lower="0.0" upper="2.5" />
<dynamics damping="0.001" friction="0.01" />
</joint>
<joint name="left_mount" type="fixed">
<origin xyz="-0.2 0.0 0.0" rpy="0.0 0.0 0.0" />
<parent link="world" />
<child link="left_base" />
</joint>
<link name="right_base" />
<link name="right_upper">
<visual>
<origin xyz="0.0 0.0 0.15" rpy="0.0 0.0 0.0" />
<geometry>
<box size="0.04 0.04 0.3" />
</geometry>
</visual>
<inertial>
<origin xyz="0.0 0.0 0.15" />
<mass value="0.5" />
<inertia ixx="0.0038166666666666666" ixy="0.0" ixz="0.0" iyy="0.0038166666666666666" iyz="0.0" izz="0.00013333333333333334" />
</inertial>
</link>
<link name="right_fore">
<visual>
<origin xyz="0.0 0.0 0.125" rpy="0.0 0.0 0.0" />
<geometry>
<box size="0.04 0.04 0.25" />
</geometry>
</visual>
<inertial>
<origin xyz="0.0 0.0 0.125" />
<mass value="0.5" />
<inertia ixx="0.0026708333333333332" ixy="0.0" ixz="0.0" iyy="0.0026708333333333332" iyz="0.0" izz="0.00013333333333333334" />
</inertial>
</link>
<joint name="right_shoulder" type="revolute">
<origin xyz="0.0 0.0 0.0" rpy="0.0 0.0 0.0" />
<parent link="right_base" />
<child link="right_upper" />
<axis xyz="0.0 1.0 0.0" />
<limit effort="10.0" velocity="10.0" lower="-1.5" upper="1.5" />
<dynamics damping="0.001" friction="0.01" />
</joint>
<joint name="right_elbow" type="revolute">
<origin xyz="0.0 0.0 0.3" rpy="0.0 0.0 0.0" />
<parent link="right_upper" />
<child link="right_fore" />
<axis xyz="0.0 1.0 0.0" />
<limit effort="10.0" velocity="10.0" lower="0.0" upper="2.5" />
<dynamics damping="0.001" friction="0.01" />
</joint>
<joint name="right_mount" type="fixed">
<origin xyz="0.2 0.0 0.0" rpy="0.0 0.0 0.0" />
<parent link="world" />
<child link="right_base" />
</joint>
</robot>
cell.xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- generated by bam_robot_descriptions (mjcf backend), content 763e8dddb5b7d42d -->
<mujoco model="cell">
<compiler angle="radian" autolimits="true" inertiafromgeom="false" balanceinertia="false" />
<worldbody>
<body name="left_base" pos="-0.2 0.0 0.0">
<body name="left_upper">
<joint name="left_shoulder" type="hinge" axis="0.0 1.0 0.0" range="-1.5 1.5" damping="0.001" frictionloss="0.01" />
<inertial pos="0.0 0.0 0.15" mass="0.5" fullinertia="0.0038166666666666666 0.0038166666666666666 0.00013333333333333334 0.0 0.0 0.0" />
<geom name="left_upper_vis_0" type="box" size="0.02 0.02 0.15" pos="0.0 0.0 0.15" contype="0" conaffinity="0" group="2" />
<body name="left_fore" pos="0.0 0.0 0.3">
<joint name="left_elbow" type="hinge" axis="0.0 1.0 0.0" range="0.0 2.5" damping="0.001" frictionloss="0.01" />
<inertial pos="0.0 0.0 0.125" mass="0.5" fullinertia="0.0026708333333333332 0.0026708333333333332 0.00013333333333333334 0.0 0.0 0.0" />
<geom name="left_fore_vis_0" type="box" size="0.02 0.02 0.125" pos="0.0 0.0 0.125" contype="0" conaffinity="0" group="2" />
</body>
</body>
</body>
<body name="right_base" pos="0.2 0.0 0.0">
<body name="right_upper">
<joint name="right_shoulder" type="hinge" axis="0.0 1.0 0.0" range="-1.5 1.5" damping="0.001" frictionloss="0.01" />
<inertial pos="0.0 0.0 0.15" mass="0.5" fullinertia="0.0038166666666666666 0.0038166666666666666 0.00013333333333333334 0.0 0.0 0.0" />
<geom name="right_upper_vis_0" type="box" size="0.02 0.02 0.15" pos="0.0 0.0 0.15" contype="0" conaffinity="0" group="2" />
<body name="right_fore" pos="0.0 0.0 0.3">
<joint name="right_elbow" type="hinge" axis="0.0 1.0 0.0" range="0.0 2.5" damping="0.001" frictionloss="0.01" />
<inertial pos="0.0 0.0 0.125" mass="0.5" fullinertia="0.0026708333333333332 0.0026708333333333332 0.00013333333333333334 0.0 0.0 0.0" />
<geom name="right_fore_vis_0" type="box" size="0.02 0.02 0.125" pos="0.0 0.0 0.125" contype="0" conaffinity="0" group="2" />
</body>
</body>
</body>
</worldbody>
<contact>
<exclude body1="left_base" body2="left_upper" />
<exclude body1="left_upper" body2="left_fore" />
<exclude body1="right_base" body2="right_upper" />
<exclude body1="right_upper" body2="right_fore" />
</contact>
</mujoco>
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
| Type | URDF | MJCF | Fields |
|---|---|---|---|
RobotDescription | <robot> | <mujoco> | name, elements, interests, interfaces |
Link | <link> | <body> | name, visuals, collisions, inertial |
VisualCollision | <visual>, <collision> | <geom> | geometry, xyz, rpy, material |
Inertial | <inertial> | <inertial> | mass, xyz, rpy, ixx … izz |
BoxCylinderSphereMesh | <geometry> | geom type | size · length, radius · radius · filename, scale |
Material | <material> | rgba | name, rgba |
FixedJoint | <joint type="fixed"> | nested body | name, parent, child, xyz, rpy |
RevoluteJointContinuousJointPrismaticJoint | <joint type=…> | hinge, slide | + axis, lower, upper, effort, velocity, damping, friction |
PointOfInterestRegionOfInterest | a frame link | a body | name, parent, xyz, rpy, size or radius |
ActuatorSensor | kept beside the URDF | kept beside the MJCF | joint 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.
| Method | Returns |
|---|---|
.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. |
Principles
Twelve rules the format keeps
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, notJoint(type="revolute")) because the code reads better and the type checker can help.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.
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
ModelfromDatafor the same reason. A value can be hashed, compared, cached and sent between processes.Composition is the basic operation
prefix,combineandattachare the whole game. A four-arm rack is one arm function called four times. Adding a joint is combining with a very small description.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.
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.
Construct, don't translate
The description is built by running ordinary Python, eagerly, so loops, functions,
ifandprint()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.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.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.
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.
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
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.
Prior art
Standing on these shoulders
- pyppetDavid Dorf
A robot description format in typed Python, with pyppet_models (Franka Research 3 and Rerun demos). Its LinkedIn launch drew the Corke comment above, and RobotDescription's typed joints (
RevoluteJoint) come from it.pyppet ships the Python and names things its own way; RobotDescription ships URDF and keeps URDF's names.
- LinkForgeArouna Patouossa Mounchili
“The LLVM for Robot Descriptions”: a Python intermediate representation and linter that compiles to URDF, XACRO and SRDF, with a Blender front end (docs). Same thesis: the IR is the source code and URDF is the compiled output.
LinkForge targets URDF/XACRO/SRDF and is GPL-3.0; RobotDescription is standard-library only, frozen values, and also emits MJCF.
- odio_urdfhauptmech
Python classes shaped like URDF's own elements, started in May 2016. The proof that mirroring URDF's vocabulary works.
Same instinct; RobotDescription adds frozen values, pure composition and MJCF.
- Understanding URDFDaniella Tola, Peter Corke · IEEE CASE 2023
The 510-developer survey of how URDF is used and where it hurts. The evidence behind the problem section.
- URDF-NGROS Discourse, 2016
The 2016 next-generation URDF discussion. Most participants argued that the exchange format should stay data and that scripts, libraries or DSLs should generate it, the way servers generate HTML. The thread went quiet that September. See also URDF improvements and the working group thread.
- MuJoCo mjSpecGoogle DeepMind
MuJoCo's model-editing API: build or modify a model in code, then compile or save MJCF. The closest thing that ships today.
mjSpec is mutable and MuJoCo-only; RobotDescription is immutable and emits both URDF and MJCF.
- Amaranth & FIRRTLHardware description languages
Chip designers hit this problem first. Amaranth describes hardware by running Python; FIRRTL shows a stable IR outliving the compiler that introduced it.
- xacroROS
The XML macro language most URDFs are written in today. RobotDescription replaces it on the authoring side and keeps its output.
- robot_descriptions.py & MuJoCo MenagerieStéphane Caron et al. · Google DeepMind
Hundreds of ready-made robot models, loadable from Python, plus the curated Menagerie. Use them; they are also the test set for a future URDF importer.
Looking for robot models, specs, CAD exporters or viewers? robotdescriptions.com keeps a hand-checked list of them.
Status
Where it stands, honestly
Works today
- A reference implementation inside BAM Robotics' arm stack:
bam_robot_descriptions0.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.
Not yet
- A public package.
pip install robotdescriptiondoesn't exist yet; the code lives in BAM's private repository for now. - Reading URDF in (
from_urdf). - Closed chains as part of the format. Today a five-bar's closure is checked in tests, not described.
- Per-unit calibration: nominal values for the design, measured values for each serial number.
- Carrying foreign blocks such as
<ros2_control>and<gazebo>through untouched. - SDFormat and OpenUSD output.
Open questions
- How far should a description go past URDF? The leading answer: a CAD description (what the CAD contains) plus datasheets, meaning and calibration.
- Should generated URDF be committed beside the source, lockfile-style, or regenerated on demand?
- Which authoring styles to support beyond the flat tuple: a chained builder, a declarative class, or both.