The .duck file

September 17, 2026 · View on GitHub

A .duck file is a task for an LLM-piloted robot, or for a flock of them. It is deliberately SKILL.md-shaped: YAML frontmatter between --- fences, then a Markdown body. The frontmatter is a contract the executor enforces; the body is the prompt. The LLM is never trusted to self-police. .duck is the format name the way Dockerfile is: a task for a LeRobot arm is a .duck too. duck: 1 (quackd 0.4) adds what a multi-robot task needs, and duck: 2 adds what a task says about the body: a correction to the robot's own datasheet, and what a flock role physically needs (ADR-0019, ADR-0032). duck: 0 files parse and run unchanged.

Machine-readable schema: ../quackd/duckfile/schema.json (generated from quackd/duckfile/schema.py; a test keeps them in sync).

File shape

# optional comment lines above the first fence are allowed
---
<YAML mapping>
---
<Markdown body — must be non-empty>

Encoding UTF-8. The first non-blank, non-comment line must be ---.

Frontmatter fields

FieldTypeRequiredEnforced byMeaning
duck0, 1 or 2yesparserSpec version. 1 unlocks requires, robots, flock.roles, flock.frame_hints and flock.allocation.method: pilots; 2 unlocks datasheet and flock.roles.<role>.needs. Using a key under too low a version is an error that names the fix.
nameslug ^[a-z0-9][a-z0-9-]{0,63}$yesparserIdentifier; run directories and the fake pilot's strategies key on it.
descriptionstringyesOne human-facing line. Shown in the system prompt.
authorstringnoCredit.
verbs.allowlist of verb names, ≥ 1, uniqueyesexecutorThe only verbs the LLM may call. stop is always allowed. Unknown names fail quackd validate.
verbs.confirmlist ⊆ allowno (default [])executorVerbs that prompt a human y/N before running (--yes auto-accepts; MCP refuses unless --yes).
budgets.max_stepsint 1–1000 (default 40)noexecutorMaximum verb executions.
budgets.max_minutesnumber > 0 ≤ 180 (default 5)noloopRobot-clock cap (sim time in both simulators, sim2d and mujoco, wall-clock on hardware). Checked before each model call and again the moment the model answers, so a provider that replies late cannot spend the overrun. A verb already running is not interrupted, so a run can overshoot by that verb's own timeout.
budgets.max_llm_callsint 1–2000 (default 40)noloopMaximum provider calls (re-prompts count).
successlist of strings, ≥ 1yesLLM (+ ground truth in sim tests)Criteria the model judges itself against via declare_success(reason).
abort_whenlist of stringsnoexecutor for two phrasings; LLM otherwiseSee below.
personastringnoTone. Inserted verbatim into the system prompt.
providerslist of stringsnoTested-with, not a restriction.
learned_verbslist of {name, policy, description?, metadata?}novalidate rejects non-emptyReserved for v2 (learned-verbs.md).
flockmapping, see belownocoordinator or pilotsCooperating robots. Absent means a single robot, unless the run names a stored flock (--flock NAME), which makes it a pilot flock.
requireslist of verb names ⊆ allow (v1)no (default [])validate --robotThe verbs the task needs. Checked against each robot's manifest. For a v0 file every allowed verb is required.
datasheet (v2)mapping, see belownoloop and MCP sessionCorrections and additions to the robot's own datasheet, for the build in front of you. Rendered in the prompt as coming from the task file.
robots<adapter>[:<backend>], or a mapping member → spec (v1)noCLIThe default robot(s), so quackd run <duck> needs no --robot. Flags win over the file.

requires and robots (v1)

requires is the honest minimum: a robot that lacks one of these verbs cannot do the task, and quackd validate <duck> --robot <adapter>:<backend> says so with a field-level line such as requires kick, but arm-01 (lerobot-so101) does not provide it (exit 1). Verbs in allow that are not required are advisory: a robot may lack them and still qualify, and validate reports them as a weaker verbs.allow line. For a solo task every listed robot must provide every required verb; for a flock, the flock as a whole must (see the roles below). Aliases count: a robot that provides observe satisfies get_frame.

robots names the default robot for a solo task (robots: microduck:sim2d) or one per flock member (robots: {duck-01: microduck:sim2d, duck-02: microduck:sim2d}).

flock — cooperating robots

allocation.method chooses the kind. auction (the default) is the 0.3 coordinator: 2 to 4 Microducks in one sim2d arena on a lockstep clock, members are state machines, at most one model call for the whole run, and every other key in this block is read. An auction duck with a non-empty verbs.confirm fails quackd validate, because an auction member has no pilot and no terminal to prompt on. pilots (duck: 1, 0.9) is the other kind: 2 to 8 bodies on any backend, one LLM pilot each, on wall-clock time, splitting the work by talking. A pilot flock reads only members, takes verbs.confirm with --yes, and names no roles. Full semantics: flock.md.

FieldTypeDefaultEnforced byMeaning
flock.membersint, or a list of unique slugs. 2–4 for an auction, 2–8 for pilots3coordinator or pilotsMember count (named duck-0…) or explicit names. --flock N overrides the count for an auction; --flock NAME supplies the members from the registry.
flock.allocation.methodauction · pilots (v1)auctionparserWhich kind of flock. auction: Contract Net, one referee, sim2d Microducks. pilots: one LLM per body, any backend, and every other allocation, safety, search and roles key below is ignored (ADR-0034).
flock.allocation.bidball_distanceball_distancecoordinatorLower camera-estimated distance wins.
flock.allocation.tie_breakduck_idduck_idcoordinatorLexicographic member name.
flock.allocation.hysteresis_pct0–10020coordinatorA challenger must bid this much lower to unseat the current claimant.
flock.allocation.claim_lease_s> 0 ≤ 606coordinatorLongest a claim may be held before re-auction (sim clock). A fixed fuse from the grant, not a progress timer.
flock.safety.min_separation_m0.1–2.00.4coordinatorNon-kickers keep at least this far from the action.
flock.safety.one_claimantbooltruecoordinatorAt most one robot approaches the ball. Always enforced, false is rejected at validation.
flock.safety.per_duck_heartbeat_s> 0 ≤ 101.0coordinatorBus heartbeat period; the watchdog presumes a duck dead after 3× this, or this plus 2.5 s, whichever is longer.
flock.search.partitionheadingheadingcoordinatorEach duck owns a heading sector.
flock.search.restart_s> 0 ≤ 1208memberRe-scan the sector when nothing was found for this long.
flock.roles (v1)mapping {spotter: {requires: [...]}, kicker: {requires: [...]}}absentcoordinatorHeterogeneous roles, auction only. A robot bids only for a role whose requires its manifest satisfies. quackd knows exactly these two roles (both must be given), one robot each; members must then be a named list. Each role's requiresallow.
flock.roles.<role>.needs (v2)mapping in the datasheet vocabulary{}coordinatorWhat the body must be able to do, not only what it must know. See the rules below. Checked by validate --robots, by the member before it bids, and by the coordinator from what the bid carried (flock.md).
flock.frame_hints (v1)auto · on · offautorunnerShare arena-frame target hints between robots. auto is on only when every member runs in sim2d; there is no shared frame on hardware (flock.md).

Unknown keys anywhere are errors (extra="forbid").

needs — the datasheet vocabulary (v2)

A role's needs is checked against a robot's datasheet, and each key is checked its own way. The same vocabulary is what a pilot names in assess_task, so a refusal and a role are worded alike (manifest-spec.md).

KeyHow it is checked
payload_kg, reach_m, armsminimums. The body must publish at least this much. A minimum of 0 asks for nothing and is always met.
endurance_mina minimum, except on a mains-powered body (tethered: true), which has nothing to run down and passes.
work_height_mnot a minimum: a height the hands must be able to reach, so it must fall inside the body's workspace_height_m band. Asking for 0.4 m fails a body that reaches 0.5 to 1.25 m, because that is below it.
manipulator, mobilitymust match the body's own word, or be any, which accepts anything except none.
terraina floor, not a match: indoor_flat < indoor < outdoor, and a body rated for more than the task asks passes. A body that publishes no terrain meets indoor_flat and nothing above it, because that is what the prompt tells such a body to assume about itself, and only where the prompt says so: a body with no datasheet at all, or one that does not move, meets none of them.

A figure the maker never published counts as not met, because a robot that cannot say what it carries is not the one to ask to carry something. The two exceptions are in the table above, and both exist so that an honest answer is not refused: a minimum of zero, and the floor a body with no published terrain is already told to assume. The words are manipulator: beak · gripper · arms · any, mobility: legged · wheeled · any and terrain: indoor_flat · indoor · outdoor. Of those, terrain is the one to be careful with: five shipped bodies are rated indoor_flat and two publish nothing, so a role asking for indoor or outdoor can be filled by no robot quackd ships today (manifest-spec.md).

abort_when — what is enforced

Two phrasings are recognised (case-insensitive) and enforced by the executor:

  • Battery below N% (also under, <) — before every verb, if the robot reports battery_percent < N, the run aborts. A body whose manifest has no battery sensor reports None and this rule can never fire on it, so it is silently unenforceable there rather than an error. An AlohaMini is the shipped example (adapters/alohamini.md).
  • Same verb fails N times in a row — N consecutive failed results of one verb abort the run.

Every other entry is handed to the LLM under "Abort conditions you must respect yourself". The spec says this plainly rather than pretending prose is policy.

Verb names

Anything the robot's manifest provides (quackd list-verbs, or list-verbs --robot): the core verbs observe report_state stop say move go_to search_scan approach_and on any robot that meets their requirements, a robot's own extensions (Microduck: sit stand stand_up kick grab gaze quack), plus any registered learned verb. The 0.3 names get_frame, walk_to and walk are permanent aliases of observe, go_to and move; a file may use either spelling but not both. stop may never appear in confirm. Params and ranges come from the registry, not the .duck file (ADR-0018).

Body

Free Markdown, non-empty, placed verbatim at the end of the system prompt under "Task file: <name><description>". Conventions the starters follow:

  • # Task — one or two sentences of intent.
  • ## Strategy — a numbered plan naming verbs in backticks.
  • ## Notes — failure modes and what to do about them (verify-and-retry, when to give up).

The body cannot widen the contract: a verb mentioned in the body but absent from allow is refused at runtime and the LLM is told so.

Runtime semantics

  • The loop ends with one of success, failure (the LLM's declaration), infeasible (the pilot judged the task beyond this body, so nothing moved and quackd run exits 3), budget, aborted (heartbeat, kill switch, enforced abort_when), or error (a provider or transport that failed, or a bug). The robot is stopped in every case and its adapter closed.
  • --max-steps on the CLI overrides budgets.max_steps for one run.
  • --dry-run executes the verbs their adapter declared read_only (observe, alias get_frame, report_state, the rosbridge base's introspect) and logs everything else without sending an intent.

Validation

quackd validate <files or globs or bundled names> prints a table and exits 1 on any failure, with a path and a field-level reason. Checks: parse, schema, unknown verbs, learned_verbs empty, no confirm in an auction flock. With --robot <adapter>:<backend> (one or more) or --robots name=spec,..., the file is also checked against those robots' manifests: requires (or, for v0, allow) per robot, and every flock role fillable by at least one robot. Without a flag, the duck's own robots: default is used, then the Microduck.

Resolution

quackd run x tries x as a path, then x / x.duck among the bundled starters (ducks/ in a checkout, quackd/ducks/ inside the wheel).

Versioning

duck: 0 is the 0.1 to 0.3 contract (ADR-0005); duck: 1 adds requires, robots, flock.roles, flock.frame_hints and, since 0.9, flock.allocation.method: pilots (ADR-0019, ADR-0034); duck: 2 adds datasheet and flock.roles.<role>.needs (ADR-0032). Older files keep parsing because the version is explicit and the parser is strict; the only new rejections a v0 file can hit are two contradictions no shipped file contains (a verb listed next to its alias, stop in confirm). Older quackd versions refuse newer files, which is the correct failure.

datasheet (v2)

Every robot publishes what it weighs, can carry and can reach, each number with how sure quackd is of it and who says so (manifest-spec.md). A task file can correct that for the build in front of it: a printed gripper that holds 300 g rather than the 500 g a vendor lists, a reach somebody measured with a tape.

duck: 2
datasheet:
  payload_kg: {value: 0.3, confidence: measured, source: weighed with the printed gripper}
  reach_m: 0.35
  cannot: [lift anything wider than the printed gripper's 60 mm opening]

A figure given here replaces the robot's own and is rendered as coming from the task file, so the pilot can see which numbers are the maker's and which are yours. A bare number is shorthand for {value: n}, and its confidence defaults to estimate. The words can be corrected too, not only the figures: manipulator, arms, tethered and terrain. The sentence lists (cannot, notes, not_rated) extend the robot's: a task file can add something a body cannot do, and can never delete one. A sentence must start with a word and stay under 300 characters, so the leading -, ` or * that a list invites is refused at parse time: the prompt bullets these itself, and a backtick is how it spells a verb. A correction the body contradicts, a payload on a robot with nothing to hold with, is refused by validate before the run starts. A flock duck cannot carry one, because it describes one body.