Repository navigation
v0.16.0 the SO-101's pilot hands the arm to a learned policy, one short segment at a time
The SO-101's pilot can hand the arm to a learned policy now. The model plans the subtasks and
judges each from a fresh look at the arm, and between those looks a policy drives the arm one
short segment at a time, from a server of its own, while quackd keeps the bus. No policy has
driven the real arm yet. A trained ACT has driven its twin on the simulator, and that is as far
as any checkpoint has gone.
The policy executes and the model plans
manipulate(instruction) hands the arm to its policy for one segment, and the instruction tells
the policy the subtask in a few words. It is confirm gated, with pick's preconditions, torque
on and nothing hot. It runs 10 s, or what the task file's policy.segment_s says, unless it
ends sooner, when the policy's chunks have all played or when the arm has stopped moving under
it for a second. That last one holds the arm where it stopped in case something is in its way.
The verb is ok on those three endings and on nothing else. Its summary says why it ended, how
long it ran, the chunks, the clipped goals and the ticks a second it achieved, and never that
the task is done. A starved policy, a guard, an error or a stop ends it failed, with the arm
held. A pilot whose verbs include manipulate gets a Your executor section in its prompt. It
tells the pilot to give one short subtask per call and to look again after each. The verb's ok
says only that the segment ran, so the pilot is told never to declare success on that alone.
The loop the segment runs in is quackd's. It paces the ticks on the arm's clock at the policy's
own rate, which has to be a finite number between 1 and 60 Hz or the segment is refused, and
each tick reads the arm and judges that reading before anything is sent. Any one of these stops
the policy and holds the arm where it is: a hot joint, torque off, a camera that gave no frame,
an action that is not a finite number or names no motor of this arm, a goal held past the travel
for a second, three sends in a row that did not reach the arm, or a read the arm did not answer.
A goal past the travel is clipped and counted, as
ADR-0036
decided, and a joint reading outside its travel is left out of every action. The policy's calls
have a thread of their own, so a policy that stops answering holds up no read, stop or
heartbeat.
The step cap is the verbs' speed at the policy's rate. A verb moves a joint at most one step
every 0.1 s, 5 degrees unless QUACKD_LEROBOT_MAX_STEP_DEG says otherwise, which is 50 degrees
a second. The same step sent thirty times a second would be three times that speed, so a
policy's cap per send is the verbs' speed over its rate, and never more than one verb step: at
the default, a policy at 30 Hz gets 1.7 degrees a send. The one setting governs both. The cap is
written on the follower as the segment starts, and the verbs' step goes back as it ends, however
it ends.
pick runs its policy through the same loop now, and stops it the moment something is held,
judged on the loop's own reads each tick. It used to return and leave the policy driving the arm
through the pilot's thinking, so the next arm verb was refused once as pick is running. A stop
from elsewhere ends a pick as stopped: with the stop named, and a LeRobot policy is reset at
the start of every pick, so a second pick no longer replays the first one's queued actions.
0.15.0 said nothing in quackd handed pick a policy yet, and --policy-url does now
(ADR-0048).
Where a policy runs
A LeRobot checkpoint's processors can import any code their JSON names, so no quackd command
runs a policy in the process that owns the arm's serial bus. It runs in quackd policy serve, a
server you start yourself, on port 9875 on loopback, and the arm's side reaches it over HTTP
with a client that needs no torch and no LeRobot. The server loads an ACT, a SmolVLA or a pi05
checkpoint named as OWNER/NAME@REVISION, at --fps or the fps of the dataset its
train_config.json names, and serves two scripted policies that need no torch, scripted:hold
and scripted:sweep. quackd policy check asks a server what it serves, or serves --policy
itself for the check, and --bench streams synthetic observations through the real client at
the policy's rate and says the --latency-s to serve with.
docs/policies.md
says to bench twice, the second time served with that latency, and what a good second bench
looks like. On the laptop that is a Python 3.12 environment with the server's extra, a check of
the checkpoint, the server in a second terminal and the run in the first:
quackd policy check --policy OWNER/NAME@REVISION --bench
quackd policy serve --policy OWNER/NAME@REVISION
quackd run task.duck --robot arm-01-sim --policy-url http://127.0.0.1:9875 --llm openaiSmolVLA and pi05 want a GPU. On a rented one, a Linux box with an NVIDIA GPU and Python 3.12,
the server binds that machine's own loopback and is served with --jpeg-quality 90, since a
tunnel looks like loopback to both ends and raw frames cost far more bytes over a network. The
laptop reaches it through a tunnel in a terminal of its own:
ssh -L 9875:127.0.0.1:9875 you@gpu-hostThe token. The server always wants one, and with no --token-file it writes one to
~/.quackd/policy.token the first time and reads it from there after, which is where a client
on the same machine finds it. On a rented GPU the laptop's client looks in its own file, so hand
it the server's in QUACKD_POLICY_TOKEN, or give --policy-token on one command:
export QUACKD_POLICY_TOKEN="$(ssh you@gpu-host cat .quackd/policy.token)"It travels in a header, never a URL, and a run's record keeps *** where it would be. The
server binds only 127.0.0.1 or ::1 unless --behind-tls says a TLS proxy stands in front of
it, and the client refuses localhost, so write 127.0.0.1
(docs/policies.md).
A checkpoint is code. The server reads its config.json and both processor files at the
revision named, before a byte of weights is fetched, and refuses any step in them that a class
key names or that none of ACT's, SmolVLA's and pi05's processors use. A model named inside the
checkpoint is refused too, unless --pin REPO@REVISION fixes it at a commit or a tag. None of
that makes a checkpoint safe to serve because somebody sent it to you, so read the revision
before you serve it, and serve it from a Hub cache you control on a machine that holds nothing
you would mind it reading
(docs/policies.md).
How a run reaches it
--policy-url points quackd run, quackd preflight and quackd serve-mcp at a server, with
--policy-token beside it. Only the flag names the address: no variable and no registered robot
does, so a policy drives the arm only on a command that says so. lerobot:real and
lerobot:mujoco take it. The mock runs its own script and refuses one, and so do a flock and
every other body. Over MCP, pick and manipulate both need --yes. The server is asked what
it serves before anything connects, and the run header names it, redacted, with its checkpoint.
Before its motors are energised, the connect checks that the policy fits this arm and refuses it
with the arm untouched if not. One part of that check wants the 1st and 99th percentiles of the
state the policy learned from to lie inside this arm's calibrated travel. On the lab arm's
calibration it refused 55 of the 68 servable SO-100 and SO-101 ACT checkpoints on the Hub, every
one on shoulder_lift: on the lab arm the travel recorded for that joint stops short of the
arm's fold, and on the arms those checkpoints learned on it reached theirs.
--accept-other-frame lets such a policy connect. Every goal it answers is still clipped to
this arm's travel, so the flag changes what drives the arm and never where the arm may go, and
summary.json records that it was given.
A duck: 3 task file has a policy section. It lists the instructions manipulate may be
told, which the executor enforces, and says how long each segment may run and how long they may
run in all. A run with no section, a --goal run or an MCP session with no task gets 10 s
segments and 120 s of them
(duck-spec.md).
A --goal run given --policy-url allows manipulate behind a confirm, so a person says yes
to each segment unless --yes, or an answer piped in on stdin, answers for them. A --goal run
without --policy-url allows what it always did. A decision LLM is shown manipulate and never
takes it: an answer that clears every other gate ends on gate: shadow_only, the model takes
the turn, and a decision_shadow record sets the two answers side by side. Letting the decision
LLM take the turn would need an agreement rate measured from those records, and a decision of
its own.
--controller vla and the person who judges. It flies the arm with --policy-url and no
model at all. Its verdict is always uncertain, so a person decides whether the arm should try.
It hands the policy each of the task file's policy.instructions in order, or the --goal as
the only one, one manipulate apiece through the same confirm and budget as any pilot's, and
then asks Did the arm do it?. Only a yes typed by the person at the terminal, in answer to
that question, is a success. A no, a pipe on stdin or nobody there is a failure, and the time
the person takes is not charged to max_minutes. It reads the verb's result and the answer and
never the simulator's truth, and it costs nothing. It is refused before anything connects
without a policy server, with --yes, with no terminal to ask on, with --dry-run or beside a
decision LLM, and serve-mcp refuses --controller because over MCP the client is the pilot
(the arm's page).
A run's record keeps what its policy did. run_start names the server and the checkpoint, and
summary.json has a policy block with the segments, their seconds, the ticks, the late and
starved ones, the chunks, the clipped goals, the rate the ticks achieved and the round trip. On
the simulator the block says clock: sim, because the ticks were paced on the simulator's
clock.
Fixes
A stop during pick could refuse its own hold. A stop cancels the policy loop first, and a
loop cancelled in the middle of a bus call leaves that call on the wire, which quackd files as a
wedge and refuses every call behind until it comes back. The hold the stop was about to write
was the first call refused, so the arm kept the policy's last goal. A rest move, a release or a
close that landed there was refused the same way, and a heartbeat that landed there aborted the
run over an arm that was answering. Each now waits for that one call to come back, and no longer
than its own deadline, which is judged by the bus's stamps as every call's budget has been since
0.15.0. It was found in the test suite, where every stop that landed mid call was refused.
A pick cancelled while it waited on an earlier segment could still run. An abort or a
dropped MCP call that cancelled the pick during that wait was passed on to the segment it waited
for and lost there, so the pick's policy drove the arm for up to its whole max_s. The
cancellation ends it now.
Then the small ones. quackd serve-mcp says a refused connect as quackd run says it and
exits 1, where it used to bury the sentence in a traceback and hand the client a closed
connection. A plain quackd validate checks a task file against every body installed rather
than the Microduck's verbs, so an arm's task that allows move_joints no longer fails with
unknown verbs. On Windows, input from NUL no longer counts as a person at a terminal.
quackd doctor's header no longer ends in a bare duck-ipc-proto API v on a machine without
the Microduck's adapter. time.monotonic ticks every 15.6 ms on Windows before Python 3.13, and
the event loop's timer woke the arm's sleeps up to one of those ticks early, so the arm's wall
clock is perf_counter now and every sleep finishes its last millisecond on it. And the docs
said a policy's goal past the travel is refused, when it is clipped and counted.
What ran, and what did not
No policy has driven the real arm. Every segment, pick's and manipulate's, has run against
the test suite's fake arm or on the arm's simulator. CI's policy job builds a tiny random ACT
on Python 3.12 and serves it offline through the real server to the real client, checking every
action of a chunk against what LeRobot's own select_action plays from the same files.
One trained checkpoint has driven anything: an ACT from the Hub, natsuki0000/act-so101-bluecap
at commit 82f75fe40a311026b4f7cacdea7bf14cadc44ccd, served on the laptop's CPU, an Intel Core
i5-10210U with no GPU. On 2026-09-28 it drove a twin of the lab's arm on the simulator through
two segments over quackd serve-mcp. On 2026-09-29 OpenAI's gpt-6-sol flew that twin with
quackd run --goal and --policy-url, handed the same ACT two manipulate segments and
declared failure on seeing no blue cap, since the simulator's table holds a red cube and a pen.
That proves the plumbing and nothing about the task. What a policy does on the simulator says
nothing about the arm either: its frames are renders of quackd's own scene, and nothing has
compared the simulator against the arm.
lerobot/smolvla_base loaded on the same laptop and took 169 to 188 s a chunk, timed in a
script outside the server. 474 of its 500 parameter tensors are bfloat16, which that CPU has no
native arithmetic for, and cast to float32 it still took about 18 s, past the 10 s the client
waits for a step, so it never answered one through the client. It wants a GPU. pi05 has not run.
FLUX 3 Action has not run anywhere, and the server refuses its policy type. --controller vla,
its judge prompt and --decision-mode shadow beside a policy have run in the test suite and
never with a trained checkpoint. How fast the policy loop runs on the real bus, with torch
inferring on the laptop that drives the arm, is unmeasured, since the simulator's clock is
lockstep and nothing timed on it is a rate.
The lab arm's twin cannot start a segment from the pose it starts in. Its fold lies past
shoulder_lift's travel, so its first manipulate is refused until a pilot moves that joint in
with move_joints, and --controller vla, which only calls manipulate, cannot. Calibrating
the arm again with the shoulder folded all the way back would put the fold inside its travel.
That is the last of the seven bench steps 0.14.0 owes.
The arm has not run quackd since 2026-09-23, so those seven are still owed, and so are 0.15.0's.
Step 18 of
docs/lerobot-hardware-checklist.md,
which hands the arm to a policy, comes only after every step above it, and it is where the
loop's rate on the real bus gets measured.
Licences
No policy's weights ship with quackd. quackd policy serve downloads the checkpoint you name,
at the revision you name, into your own Hugging Face cache, so its weights are your download
under their own licence, and .gitignore keeps *.safetensors out of a commit. LeRobot, which
the server runs them in, is Apache-2.0. This is what each one's own page said on 2026-09-28, and
it is not legal advice:
- An ACT you trained on your own data is yours.
- SmolVLA's base,
lerobot/smolvla_base, is Apache-2.0 on its card, and so is its backbone,
HuggingFaceTB/SmolVLM2-500M-Video-Instruct. - The
lerobot/pi05_basecard saysgemma, and no licence file is shipped with it, while
LeRobot's pi05 page says the model follows the Apache 2.0 licence of OpenPI, so the two
disagree. Its tokenizer,google/paligemma-3b-pt-224, is gated behind a licence you accept on
the Hub. - FLUX 3 Action, which quackd does not serve, is under the FLUX Kommunity License v1.0. It calls
any physical AI application a Robotics Use, control signals for any physical device included,
and grants Robotics Uses for purposes that are not commercial (Section 2.a). A Qualifying
User, whose gross annualised revenue with its affiliates is under US$5,000,000, may also use
the model's Outputs commercially on the conditions of Section 2.e. The licence does not say
that reaches a Robotics Use, and anything it does not expressly authorise needs a licence from
Black Forest Labs, so ask them before any commercial use on an arm.
The table in
docs/policies.md has
each, with the page it was read on.
Install
quackd[lerobot-vla] is the policy server's extra, and it goes wherever the policy runs, on the
laptop beside the arm or on the rented GPU. It is quackd-lerobot[vla], LeRobot's [smolvla]
extra on Python 3.12. That brings LeRobot, torch and transformers and leaves out [feetech],
since the server never opens a serial port. This is the README's line for it:
uv pip install "quackd[lerobot-vla]"The arm's own process keeps the extra it had, quackd[lerobot] for the arm on Python 3.12 or
quackd[lerobot-sim] for its simulator on 3.11 or newer, and the client it reaches the server
with needs no torch and no LeRobot. pi05 also wants LeRobot's [pi] extra on the machine that
serves it, and a Hugging Face token that has been granted google/paligemma-3b-pt-224.
quackd doctor has a row for the server's extra.