One CLI for the MicroDuck robot — any agent, any human, the same verbs.
flowchart LR
A["an agent<br/>(Claude, Codex, ...)"] --> C
H["a human<br/>(a terminal)"] --> C
C["<b>microduck-cli</b><br/>env · duck · policy · rules"]
C -- "JSON-RPC over<br/>~/.cache/duck-sim/duck-a.sock" --> D["<b>robotd</b><br/>API 16"]
D --> F["--fake body"]
D --> S["MuJoCo body<br/>(microduck_rl duck-body)"]
D -. "not driven yet" .-> R["a real MicroDuck"]
One unix socket, the daemon's own JSON-RPC, no robotics SDK — dependencies = []
is the whole runtime dependency list. Everything below was checked at
microduck-cli 0.9.4 against its own --help, the real daemon, and the MuJoCo
simulation on three machines.
uv tool install microduck-cli # or: uvx microduck-cli whoami
microduck --versionmicroduck and microduck-cli are the same entry point. From a checkout, prefix
with uv run — the form docs/operating-the-duck.md uses:
git clone https://github.com/agentculture/microduck-cli && cd microduck-cli
uv sync && uv run microduck whoamiPython ≥ 3.12, Linux on aarch64 — the only platform on record (see Proof).
Five noun groups. Every verb takes --json; results go to stdout, diagnostics to
stderr, never mixed. Exit 0 success, 1 your error, 2 the environment's.
| Noun | What it does | Verbs |
|---|---|---|
env |
start, check and stop the stack | overview doctor up down status hosts |
duck |
operate one duck, in robotctl's words |
overview health version monitor init relax enable do mode look stop move quack configure record |
policy |
the policy lifecycle and the microduck_rl train lane |
overview list load reset add remove search check update pad smoke train play export publish infer install |
rules |
the data-only rules layer and its 50 Hz tick engine | overview list check engine intent |
cli |
introspection — agents start at microduck learn / explain |
overview |
- It has never driven a real duck. Every verb is exercised against the MuJoCo
simulation — the real
robotd --simdaemon driving a duck that stands, holds 50 Hz and runs skills — plus a--fakebody for the unit suite. - It cannot walk yet.
duck movereaches the daemon and selects the walk network, but the joint targets come back static. Details below. - It cannot load policies on this daemon.
robot.loadPolicyand friends need API ≥ 18; the pinned build answers API 16, and those verbs say so and exit 2.
If you do have a duck: motion is gated — no --apply, no movement — and
duck relax drops torque, which makes the duck fall over.
Prerequisites. Two upstream clones at the pinned commits
(docs/upstream-pins.md) and a Rust toolchain. Without them
env doctor fails seven of its thirteen checks — that is the box, not the CLI. Point
MICRODUCK_CLONE and DUCK_SIM_RL at the clones and ask:
microduck env doctor # 13 checks: clone pins, cargo, daemons built, RL venv, port, state dirThen, in order — this numbered walkthrough is the plain-text equivalent of the
diagrams above and below, for readers whose renderer shows a mermaid fence as code:
-
Bring up a duck.
--simis the path the verification records below cover — the real daemon driving the MuJoCo body. Drop--headlessto watch it in the viewer, or use--fakefor a body that needs no simulator at all.microduck env up --sim --headless
-
Ask the robot for its own verdict.
microduck duck health
-
Stand it up, then hand it its policy. Both are gated, hence
--apply.microduck duck init --apply microduck duck enable --apply -
Run the tick engine briefly. Connect, hello, health, init, enable, armed — each step logged, then 50 ticks at 50 Hz.
microduck rules engine run --max-ticks 50 --apply
-
Inject one intent through the same admission registry a rule fires through.
microduck rules intent stop
-
Tear it down. Never kill by name —
env downis the supported path.microduck env down
docs/operating-the-duck.md walks the same six
commands with each one's exact output and what to do when a check fails; the
first-party operate-microduck skill is
the same ground for an agent, including the screenshot recipe for watching the MuJoCo
window from a headless session.
Everything in this section is copied from the verification records in
docs/verification/, not retyped. Home directories are
shortened to ~; nothing else is changed. Each record names the box, the upstream
pins, the daemon API and the CLI commit it was recorded at — and nothing re-runs
them, so a re-pin ages them silently. Check the record's date against the pins table
before trusting a number here.
| Box | Reached | Result | Caveat |
|---|---|---|---|
| DGX Spark (GB10, aarch64) | all six checks + train smoke | pass; live suite 12 passed, 0 failed | walking xfail |
| Jetson AGX Thor (JetPack 7) | all six checks, three tiers, headless | pass; 12 passed, 1 xfailed | ran on an uncommitted local torch override; the upstream fix is still open as microduck_rl#39 (issue #38), so env doctor's rl_pinned_commit fails there by design until it merges and this repo re-pins |
| Jetson AGX Orin (L4T R39) | checks 1–4 | pass | the SBSA torch wheel carries no sm_87 kernels — GPU training is not available on Orin at this pin |
A duck standing up in MuJoCo — Spark, CLI 420dc5c:
$ microduck env up --sim --headless --skip-build
waiting for duck-a to report healthy (~/.cache/duck-sim/duck-a.sock)...
microduck-cli env up: healthy (sim)
duck-a: ~/.cache/duck-sim/duck-a.sock
$ microduck duck init --apply --json
{... "summary": "init accepted: ramping to the home pose", "result": {"accepted": true}}
$ microduck duck monitor --frames 2 --json # 8 s later
{'policy': 'held', 'fallen': False, 'gravity': [-0.028, -0.00004, -0.9996], 'z': 0.0687, 'loop': {'hz': 50.03, 'missed': 0}}
The same trunk height, to four decimals, on a different box —
Orin, CLI 3c09fb0 (0.9.1):
$ microduck duck health --json
{"healthy": true, "degraded": false, "health": {"control_loop": {"target_hz": 50.0,
"achieved_hz": 49.999974411777806, "ticks": 900, "missed": 0, "last_tick_age_ms": 17}, ...}}
A rule firing, and a drop that says why — Spark, one overlay rule (fallen is
false → look, cooldown 5 s) over a 300-tick run:
$ microduck rules engine run --duck duck-a --rules /tmp/duck-rules-test.toml --apply --max-ticks 300 --json
{'ticks': 300, 'achieved_hz': 50.0, 'overruns': 0}
[SENSE stage=rule source=verify-look event=fired] look -> look-1
[SENSE stage=rule source=verify-look event=cooldown] dropped reason=cooldown: fired 0.020s ago, cooldown_s is 5.0
229 cooldown drops over the run, every one named on the microduck.sense logger —
stderr only, so JSONL on stdout stays pure. A layer whose drops are invisible is
indistinguishable from one that silently does nothing.
The live suite against a real daemon — Thor, CLI 2b00480, MuJoCo body:
$ MICRODUCK_LIVE=1 MICRODUCK_LIVE_BODY=sim MICRODUCK_LIVE_SIM=1 ... uv run pytest -m live -n0 -v tests/live
(the eleven above) PASSED
test_sim_body_stands_the_duck_up PASSED
test_sim_body_walks_forward_on_move XFAIL
======================== 12 passed, 1 xfailed in 26.65s ========================
Those twelve drive the CLI as subprocesses against the real socket. The unit suite (1101 tests at 0.9.4; the records above were taken at 998) does not — it runs against the in-process Python fake.
Stated plainly, because a record that only lists passes is a brochure:
- No physical duck. The
--fakeand MuJoCo bodies only. - Walking. Sampled at 25 Hz during
duck move --vx 0.15:policy: walk,move.applied [0.15, 0, 0],fallen: false— and the left-knee target moves between −0.09 and −0.05 rad over 97 frames while odometry goes 0.065 → 0.072 m in 4.4 s. The twist arrives, the network is selected, the joint targets are static. Ruled out: our command shape (identical to upstream'sdrive), the generated params, the keyframe, the real-time factor (1.00) and the viewer.test_sim_body_walks_forward_on_moveis kept as a non-strictxfailsentinel — an XPASS after a re-pin means walking arrived. - Upstream's own torch routing on Thor — every tier there ran on the local override, not as shipped.
- GPU training on Orin — no
sm_87kernels in the SBSA wheel. - A real Hugging Face Jobs submission — the dry run proves the command shape and the tarball, nothing was submitted or billed.
- Multi-duck, the ether, cameras and ToF in sim — upstream marks them "designed and measured but not built" on this branch.
One process owns the control socket. robotd arbitrates nothing between clients, so a second process would be two authors fighting over every channel — hence one loop, one seam, riders composed onto it:
flowchart TB
subgraph read ["1 · read — ONE snapshot per tick"]
direction LR
P["sense providers"] --> SN["Sense"]
end
subgraph decide ["2 · decide — pure, no I/O"]
direction LR
B["behaviours + rules<br/>one contribution each"] --> AR["arbitrate<br/>one owner per channel"] --> CO["compose the pose"]
end
subgraph write ["3 · write — exactly once"]
direction LR
HG{"human<br/>driving?"} -- "yes" --> WH["MOTION withheld"]
HG -- "no" --> SK["TargetSink → robotd"]
end
read --> decide --> write --> TS["4 · tick_seam riders — after the write"]
Per tick, in this order: read one Sense; ask each live behaviour once; arbitrate a
single owner per channel; compose; write through the sink exactly once; run the
tick seam after the write; expire finished lifetimes; sleep to an absolute
deadline. No wall-clock read anywhere in the loop — clock and sleep are injected,
which is what makes a 500-tick run bit-for-bit reproducible in a test. A provider
that raises degrades to None; a rider that raises is caught, counted and logged as
a named drop while its siblings still run.
CLAUDE.md has the rest: the seam rules, how to add a verb or a noun,
the error and output contracts, and the agent-first rubric CI enforces.
Nothing from these repositories is copied into this one. The CLI implements their documented commands and wire protocol and links to their docs — cite, don't import.
Upstream — the exact commits every verb is validated against are in
docs/upstream-pins.md; re-pinning is one PR that moves all
rows and re-runs the on-box verification.
| Repo | What this CLI takes |
|---|---|
pollen-robotics/microduck |
robotd, robotctl and the duck-ipc-proto JSON-RPC contract that microduck_cli/ipc/proto.py is transcribed from. |
pollen-robotics/microduck_rl |
duck-body (the MuJoCo body) and the train / play / export / publish / infer lane the policy noun drives. |
AgentCulture siblings — this CLI is composed from three of them rather than inventing a fourth architecture.
| Repo | Role here |
|---|---|
reachy-mini-cli |
The architecture: noun groups with engine logic in sibling packages, ONE tick seam for every sense, the single-SDK-owner model. |
arm101-cli |
The hardware-safety patterns: gated motion (dry-run / TTY confirm / --apply), release-on-abnormal-exit, hardware deps behind an extra. |
teken |
The agent-first rubric (teken cli doctor . --strict) that gates CI. |
devague |
The spec → plan → delivery method this repo builds by; see docs/specs/ and docs/deliveries/. |
Vendored skills under .claude/skills/ carry their provenance in
docs/skill-sources.md.
CLAUDE.md is the working agreement: CLI contracts, what to take from
each sibling, version-bump-every-PR, the cicd PR lane, worktree layout, memory
discipline.
uv sync
uv run pytest -n auto # 1101 tests
uv run teken cli doctor . --strict # the agent-first rubric gate CI runs
markdownlint-cli2 "**/*.md" "#node_modules" "#.local" "#.claude/skills" "#.teken"Apache 2.0 — see LICENSE.