Adapter status

September 17, 2026 ยท View on GitHub

quackd never silently invents an upstream API. Every method name, socket path, topic, message type, enum or convention it relies on lives in one file per upstream, tagged VERIFIED (read from upstream source on the date given, link given) or UNVERIFIED (designed upstream but not shipped, or an assumption of ours, with what quackd does about it). A test proves UNVERIFIED names stay inside the backend that needs them. quackd doctor prints every UNVERIFIED list on your machine.

One row below has met hardware: lerobot:real, on 2026-09-15, on a LeRobot SO-101 arm. Six of the seven bodies here have still never been driven by quackd, and the row that has was driven on one bench for one afternoon. What that afternoon did and did not settle is under the table.

Important

uv pip install quackd installs the core and no robot at all. Every body below is its own distribution, and the extra that names it is what installs it.

AdapterExtraDistribution
Microduckquackd[microduck]quackd-microduck
LeRobotquackd[lerobot]quackd-lerobot
rosbridgequackd[rosbridge]quackd-rosbridge
Open Duck Mini v2quackd[open_duck]quackd-open-duck
XLeRobotquackd[xlerobot]quackd-xlerobot
AlohaMiniquackd[alohamini]quackd-alohamini
ToddlerBotquackd[toddlerbot]quackd-toddlerbot

So uv pip install "quackd[open_duck]" buys the Open Duck Mini and nothing else, and quackd[robots] buys all seven, each with the SDK its real backend needs. Two extras buy the duck and one heavy thing it can do: quackd[mujoco] is quackd-microduck[mujoco], the duck with its physics simulator, and quackd[microduck-camera] is the duck with its WebRTC camera.

An adapter that is not installed keeps its row in quackd list-adapters and in quackd doctor, marked not installed. Naming one anyway refuses with the extra to type: adapter 'lerobot' needs an extra: uv pip install 'quackd[lerobot]'. With nothing installed at all, every command that needs a body refuses and names all seven.

Adapter--robotStatusUpstream filePage
Microduckmicroduck:sim2dโœ… defaultthis page
microduck:mujocoโœ… physics simulator (MuJoCo, quackd[mujoco]): find-and-kick 10 of 10 seeds on the stand-in, and 9 or 10 of 10 on the trained gait depending on the machine and the run, seed 4 being the marginal oneadapters/microduck/src/quackd_microduck/sim3d/upstream_api.py
microduck:mockโœ…
microduck:jsonrpc๐Ÿงช experimental: every method VERIFIED, never run on a duckadapters/microduck/src/quackd_microduck/upstream_api.py
microduck:websocketโณ stub: raises with a link until upstream ships it
LeRobotlerobot:mockโœ…adapters/lerobot.md
lerobot:realโœ… run on a real arm on 2026-09-15, the only row here that has been: an SO-101 follower calibrated as arm-01 and reached as --robot lerobot:real --address COM3 with no registered name, on Windows 11, Python 3.12.12, lerobot 0.6.1, quackd 0.9.0, piloted by OpenAI gpt-6-astra. lerobot-lookout ran, once with --provider fake as well. Free-form --goal runs waved the wrist roll about plus or minus 27 degrees, reached wider with shoulder_lift -39 and elbow_flex 24 to 30, opened and closed the gripper (commanded 100, reported 98 open and 3 closed with the jaws nearly touching), and one of them mimed a duck quacking with the gripper. A USB webcam answered at opencv://1, and at opencv://2 after a replug, 640x480, with no ?backend= key needed. The arm fell at the end of every run, which is the fault the rest pose was written to fix and has not yet been tried on that arm. Every LeRobot name is still VERIFIED at a pinned commit, and still exercised with a fake arm (Python 3.12+, checklist)adapters/lerobot/src/quackd_lerobot/upstream_api.py
rosbridgerosbridge:mockโœ…adapters/rosbridge.md
rosbridge:ws๐Ÿงช every roslibpy, rosbridge and message name VERIFIED at pinned commits, exercised with fake topics and fake services, including reading the robot's own description off the bridge, never run against a bridgeadapters/rosbridge/src/quackd_rosbridge/upstream_api.py
Open Duck Mini v2open_duck:sim2dโœ… open-duck-scout 10 of 10 seedsadapters/open_duck.md
open_duck:mockโœ…
open_duck:bridge๐Ÿงช every runtime name VERIFIED at a pinned commit, the protocol exercised against the real daemon over loopback, never run on a duckadapters/open_duck/src/quackd_open_duck/upstream_api.py
XLeRobotxlerobot:mockโœ…adapters/xlerobot.md
xlerobot:zmq๐Ÿงช the whole wire format VERIFIED at a pinned commit, the client exercised against a fake host quackd wrote from that source over loopback, never run on a cartadapters/xlerobot/src/quackd_xlerobot/upstream_api.py
AlohaMinialohamini:mockโœ…adapters/alohamini.md
alohamini:sim2dโœ… alohamini-lookout 10 of 10 seeds
alohamini:zmq๐Ÿงช the whole wire format VERIFIED at a pinned commit, the client exercised against a fake host quackd wrote from that source over loopback, never run on a robot. The arm verbs additionally need quackd's own host wrapper, which nobody has run eitheradapters/alohamini/src/quackd_alohamini/upstream_api.py
ToddlerBottoddlerbot:mockโœ…adapters/toddlerbot.md
toddlerbot:sim2dโœ… toddlerbot-lookout 10 of 10 seeds
toddlerbot:bridge๐Ÿงช every upstream name VERIFIED at the commit the v2.0.0 tag points at, the protocol and the daemon's own safety machinery exercised against a fake body over loopback, never run on a robotadapters/toddlerbot/src/quackd_toddlerbot/upstream_api.py

The first real run, and what it does not prove. One SO-101, one bench, one afternoon. The arm fell at the end of every run, because LeRobot's disconnect() disables torque by its own default and quackd kept that default: quackd robot rest-pose, and the parking at both ends of a run that goes with it, exist because of those falls. One dry run aborted with the arm did not answer: TimeoutError after a single heartbeat round trip failed, and nothing like it happened again. One dry run aborted because the pilot answered uncertain and the human said no. The camera framed the gripper and cropped the raised arm, so the model checked its own waves against joint readings rather than against the picture. And four questions came back unanswered: whether the holding band is anywhere near right, what a joint reads after ten minutes of work, whether a stall is caught when you cause one on purpose, and whether 5 degrees an action felt right in the room. They are the list at the foot of lerobot-hardware-checklist.md, and they are why that โœ… is a robot quackd has worked on rather than a robot quackd is tested on.

Pilot flocks (flock.allocation.method: pilots, --flock NAME) run one LLM pilot per body on wall-clock time, on any adapter and backend including mixed ones, and have run on mock and sim2d bodies and on no hardware. Coordinator flocks (--flock N, flock.roles) run N in-process views of one simulated world on one lockstep clock, Microducks only. The MQTT bus implements the same Bus protocol and was exercised once against a local broker (lan.md); a coordinator flock across machines also needs a clock across machines, which does not exist yet, and a pilot flock needs no such clock and has never been tried across two.

The rest of this page is the Microduck's table; the other adapters keep theirs on their own pages.

Microduck

Read: 2026-09-04, pinned at bc41fb5 (upstream main, 2026-09-03). Upstream contract: duck-ipc-proto API v23 (API_VERSION), JSON-RPC 2.0, one object per line (NDJSON), one unix socket per service. Sources: duck-ipc-proto/src/lib.rs ยท architecture.md ยท robotd-design.md ยท remote-webrtc.md ยท roadmap.md.

VERIFIED (read from upstream source)

ThingValueUsed for
API version23hello handshake; mismatch โ†’ we refuse rather than guess
FramingNDJSON: one JSON-RPC 2.0 object per linewire
Runtime direnv DUCK_RUNTIME_DIR overrides /runsocket path
Sockets/run/robotd.sock, /run/configd.sock, /run/updaterd.sock, /run/padd/pad.sock (pad.input only), /run/tofd/tof.sock (tof.stream only)addresses
helloparams {api_version} โ†’ {api_version, daemon_version?, revision?}connect
robot.movenotification {vx, vy, vyaw} m/s, rad/s, trunk frame, x forward, y left, +vyaw leftmove, go_to, search_scan (re-sent every 100 ms)
robot.stoprequest; zero velocity, not limpstop, every run's final stop
robot.headnotification {neck_pitch, head_pitch, head_yaw, head_roll}(not used; robot.look preferred)
robot.lookrequest {x, y, z, neck_pitch} โ†’ {head, clamped}gaze, re-centering before steering
robot.dorequest {skill} โ†’ {accepted, reason?}, answered on accept/refuse rather than on completion. Skill is now String โ€” "a name, not an enumeration" โ€” and `ground_pickkick_left
robot.posenotification {z, roll, pitch, active}pose intent (no verb yet)
robot.enablerequest {on, toggle?} (toggle is #[serde(default)], so {on} alone is valid). Policy execution, not a flag: upstream says it "can bring a limp robot up as a side effect of being asked to drive", so treat it as motionstand_up
robot.init / robot.relaxpower the joints + ramp to home pose (moves every joint) / torque off (collapse)never sent by quackd
robot.soundrequest {tag, hold?}; tags `alarmgreet
robot.subscribe โ†’ robot.staterequest {hz?}, then notifications {t, move{requested,applied,limited_by}, head[4], policy, safety{fallen,limp,gravity,gain?}, loop{hz,missed}, joints, targets, odom, theremin?, chorale?}state
robot.state is not pushed until robot.subscribethe loop publishes into a bounded broadcast and never waits on a subscriber, so a slow client gets a gap rather than backpressurewhy the transport subscribes inside connect()
robot.subscribe -> SubscribeResult.skillsthe answer carries {accepted, walk?, stand?, unavailable?, sitstand?, ground_pick?, skills[]} โ€” what is constant for the processlearning the robot's real skill list instead of assuming five
safety.fallen gates nothing upstream"computed every tick โ€ฆ debounced 0.2 s", and "a report, not a rule"refusing to walk a fallen duck is quackd's own rule, so quackd must read the frame
robot.healthrequest โ†’ {healthy, degraded?, reason?, battery{volts,percent}?, motors?}heartbeat every 500 ms; battery abort
robot.mode / robot.setMode`{mode: walkroller}`
tof.stream โ†’ tof.frame8ร—8 depth on tofd's socket(not used yet)
pad.inputgamepad raw tap; the pad is the authoritydocumented, not used
robotd intent deadmanvelocity zeroes when intents stop; "stop is not limp"why move re-sends

UNVERIFIED (designed, assumed, or missing upstream) โ€” and what we do

ThingStatus upstreamWhat quackd does
robot.state.policy == 'sit' means sittingassumption: the state frame names the policy that drove the tick, and we assume a sitting robot's is named something containing sit. Upstream notes two gaits can "both report walk", so the name is a policy and not a posturejsonrpc infers posture from it and lists the assumption in extras.assumptions. sit/stand read posture first and refuse when it is unknown, because upstream has one sit_toggle rather than a sit and a stand: firing it unaimed is a coin flip whose losing side sits a standing duck down
WebSocket agent gatewayarchitecture.md ยง5.3 designs "open a WebSocket, poll a frame, send intents"; roadmap M5 in progress, not shipped--robot microduck:websocket is a stub that raises with the links
get_frameยง5.3: "JPEG on demand, or 1โ€“2 fps push"; not in duck-ipc-protonot called anywhere; the stub will use it when it exists
camera snapshot over a unix sockettoday the camera reaches clients only through mediad's WebRTC track; no socket-level frame method, and robotctl/duckctl have no camera subcommand eitherjsonrpc.get_frame() returns None unless --camera-url names a source: an HTTP snapshot you provide, or mediad's WebRTC track (below). A snapshot is pulled on a 5 fps timer and served from memory, so observe costs no round trip and a failed fetch is reported by camera_health() rather than raised. Without one the manifest drops camera and the four verbs that need eyes, instead of advertising sight the robot has not got
mediad media.detections notificationsbuilt, not merely designed: mediad/src/detect.rs emits {width, height, took_ms, boxes[{x0,y0,x1,y1,score}]} at ~2 Hz (RKNN on the NPU, ONNX on CPU) โ€” and it detects ducks, not balls. UNVERIFIED because it is broadcast to WebRTC signalling clients while remote-webrtc.md still says perception consumes pixels locally: source and design doc disagreeunreachable from robotd's socket either way, so our Detector protocol is still the stand-in
stand_upno such RPC; robotd recovers from falls itself (limp โ†’ settle โ†’ ramp โ†’ standing policy)stand_up sends robot.enable {on: true} and checks safety.fallen afterwards โ€” and fails rather than claiming "upright" when nothing is reporting falls

What this robot is, as numbers

Its datasheet, which the pilot is shown and told to judge a task against before anything moves (manifest-spec.md):

Mass0.8 kg (official: the Pollen Robotics README)
Height0.25 m (official: the Pollen Robotics README)
Actuated joints15 (official: the Pollen Robotics README; XL330 class servos, which is an estimate)
Not publishedpayload, reach, endurance

And what it cannot do whatever the task says, which is the half a refusal usually turns on, in the words the pilot is shown:

  • carry, hold or push anything: the beak scoops at the floor and nothing else
  • climb or descend a step
  • hold a heading for long without a landmark: the IMU has no magnetometer, so heading drifts

A figure nobody published is listed as not published, and the pilot is told to decline whatever hinges on it rather than guess. A .duck file can correct any of it for the build in front of you (duck-spec.md).

What we do not touch

robot.init (moves every joint), robot.relax (the robot collapses), system.*, net.*, update.*. The gamepad (padd) keeps authority on hardware; quackd does not arbitrate. The same principle holds on every adapter: disable_torque is never sent to an arm, and a base over rosbridge gets a zero Twist, not silence. On an Open Duck the guarantee is stronger than a promise: the bridge protocol has no word that reaches torque, so going limp is unreachable rather than merely forbidden.

Getting a picture off a real Microduck

There is no camera method in duck-ipc-proto, no snapshot or MJPEG route in mediad (its HTTP port serves one page), and no camera subcommand in robotctl or duckctl. The camera reaches clients as an H.264 WebRTC track and nowhere else. Two ways to point quackd at it:

--camera-url webrtc://<duck>:8443--camera-url http://<host>:9872/snapshot.jpg
Where it runsyour machinewherever the snapshot server is
Touches the robotnothinga snapshot server has to hold /dev/video0, so mediad must be stopped first โ€” it holds the device for the life of its process
Needsquackd[microduck-camera] (aiortc, av, websockets)nothing
Costsone media session, so it competes with the browser consolemediad, and therefore the console and the WebRTC path, while it runs

The WebRTC route is the one to reach for on a robot you do not own. Signalling is gst-plugins-rs net/webrtc, read from mediad/webclient/index.html at the pin: the producer offers and quackd answers. There is no authentication โ€” upstream's own note is that a pairing PIN which is 000000 on every robot "authenticates nobody" โ€” so tunnel it (ssh -L 8443:127.0.0.1:8443 radxa@<duck>) rather than trusting the network.

mediad opens a control datachannel at every peer whether it wants one or not. quackd reads media.detections off it and writes nothing: motion goes over robotd's socket, where the allowlist, the confirm gates and the deadman feed already are, and two ways to move one robot would mean two places to be sure about.

Neither route has been run against a Microduck.

How to help

Built an Open Duck Mini v2? That is the row most likely to flip this year, because it is one of three bodies here you can build from scratch, and one of three whose robot side quackd ships and already exercises. open-duck-hardware-checklist.md is the order to try it in, and there is an issue template waiting for the result.

The physics backend's upstreams

microduck:mujoco runs the robot Pollen trains, on the policy Pollen trained. Two upstreams, both pinned, both fetched at run time into ~/.quackd/cache and checked against a recorded sha256, and neither shipped: the 3D model files are CC BY-NC-SA (licenses.md). Every name quackd relies on lives in adapters/microduck/src/quackd_microduck/sim3d/upstream_api.py, and ADR-0030 is the reasoning.

What that โœ… rests on. Two find-and-kick sweeps over the same ten seeds: one on the kinematic stand-in, which CI's physics job runs on every push against a software rasteriser, and one on the trained gait, which needs upstream's model and so runs nightly, fetched the way a first run fetches it. The real duck's other tests โ€” that it walks, turns, stays upright, refuses to sit and stands itself up โ€” go with the second. The gait numbers below are still one machine's word; the sweeps are not.

Read: 2026-09-07, pinned at 2b25a48 (develop, 2026-09-06) and policies at 088524a.

WhatStatusWhy it matters
robot_walk.xml and its 38 STL meshesVERIFIEDthe body: one free joint, 14 hinges, position actuators, an IMU and a head camera
alpha_walking.onnx, alpha_stand.onnxVERIFIEDobs[1,61] โ†’ actions[1,14], the normaliser baked in, Apache-2.0 on the Hub
observation layout, 13-value command, ctrl = default_pose + action, 50 HzVERIFIEDread from scripts/infer_policy.py; the same layout appears in the daemon and in Pollen's own browser simulator
projected gravity is the world's -z in the trunk frameVERIFIEDget its sign wrong and the duck braces and stands still for every command, silently
the gait floor: no gait below vx 0.23 m/s or wz 1.0 rad/s; above it about 0.38x the commanded speedUNVERIFIEDmeasured here on one machine with the XML's own actuators, and re-measured on 2026-09-17 when MuJoCo 3.13 moved it up from 0.22: the floor belongs to the physics build as much as to the policy. Upstream trains and deploys with a different actuator model, so a real duck may track commands directly
a positive head_pitch in the command vector tilts the camera downUNVERIFIEDmeasured by driving the command and watching the rendered head camera, not read anywhere, so quackd negates its own pitch to make looking up positive. neck_pitch and head_roll are left at zero: quackd's gaze has one pitch and no roll, so nothing has exercised them
the four stand-ins, in place of ball_kick_left.onnx, ball_kick_right.onnx, alpha_ground_pick.onnx, alpha_sitstand.onnxUNVERIFIEDupstream's episodic policies did nothing from a standing pose when tried and the sit-stand one toppled the model, so these four are quackd's stand-ins and say so in extras.assumptions

The head camera is the one place quackd deliberately does not do what the file says: upstream's <camera> quaternion is not MuJoCo's viewing convention, so rendering through it looks backwards into the duck's own shell. quackd renders from the camera's position along the head body's forward axis instead.

Got your hands on a Microduck? microduck-hardware-checklist.md is the order to try it in, and there is an issue template waiting for the result. Nothing in it installs anything on the robot or needs sudo, because the first Microduck most people touch will belong to somebody else. microduck-lookout is the task to point at it first: nothing in its allowlist moves a leg.

Ran --robot open_duck:bridge against a duck you built, toddlerbot:bridge against a ToddlerBot on its stand, xlerobot:zmq against a cart, alohamini:zmq against an AlohaMini, microduck:jsonrpc against a real duck, or rosbridge:ws against a bridge? Open an issue with quackd doctor output and the first lines of transcript.jsonl. Every row above that flips from ๐Ÿงช/โณ to โœ… is one line in an upstream_api.py and one row here. lerobot:real is the one that has already flipped, on one arm on one bench, so a second run against an SO-101 is still worth an issue: it either widens that row or contradicts it, and the one that contradicts it is worth more.