Specification · section 4

The device package

A device is described the way an Agent Skill is: a folder whose contents load in three levels, each only when the agent needs it. This is the same progressive disclosure that lets a harness hold hundreds of skills at ~100 tokens each, applied to hardware.

thermocycler-01/
├── DEVICE.md          Level 1: YAML frontmatter, the card        (~100 tokens, always cheap)
│                      Level 2: Markdown body, operating instructions (loaded when the device is chosen)
├── descriptor.yaml    Level 3: full machine-readable spec: limits, params, examples
├── driver.py          Level 3: code, a Driver subclass or an adapter's DEVICE object
├── references/        Level 3: manual excerpts, SOPs, calibration tables
├── scripts/           Level 3: ready-made orchestration scripts for mhp_run
└── methods/           Level 3, optional: the parameter sets this instrument runs, per project (§4.5)
Level Content Served by Loaded when
1 frontmatter: id, class, make, model, location, tags, description device/describe {detail: "card"}, directory/search search results; ~40 tokens per device
2 DEVICE.md body: how to operate it, what to check, what never to do device/describe {detail: "summary"} (with a slim capability table) the agent picks this device; under 1k tokens
3 descriptor.yaml items, references, scripts, driver device/describe {select}, resources/list, resources/read {path} the agent needs that item; none until asked

4.1 DEVICE.md

The frontmatter is the card. description is what a directory ranks and what an agent matches its task against, so it must say both what the device is and when to pick it, in under 1,024 characters. The body is written for the agent that has just chosen the device: an operating procedure, what to watch, and pointers to the resources it may need.

---
mhp: "2026-09-12"
id: thermocycler-01
class: thermocycler
make: SimBio
model: TC-96
location: bay 3, bench 3
tags: [pcr, heating, 96-well]
description: 96-well PCR thermocycler with heated lid, 4 to 105 degC block. Use for PCR,
  denaturation, ligation holds and any timed temperature program on a 96-well SBS plate.
  Not for tubes, not for cooling below 4 degC.
driver: driver.py:SimThermocycler
---

# Thermocycler-01

Sits on bench 3 in bay 3, left of the liquid handler; the plate arm reaches it at `thermocycler`.
The block ramps about 4 °C/s. Turn `lid_heater` on ~40 s before loading to avoid condensation.

## Operating procedure
1. Confirm `lid_closed` is true; `run_protocol` refuses otherwise.
2. Set `lid_heater` true; wait for `lid_temperature` above 100 °C.
3. Invoke `run_protocol`; poll the job. 30 cycles of three steps take ~75 minutes.
4. Write `target_temperature` 4 to hold. Open the lid only below 60 °C (needs a human).

## Resources
- `references/protocols.md`: standard programs per polymerase.
- `scripts/pcr.py`: complete run through `mhp_run`, including the 4 °C hold.

4.2 descriptor.yaml

The machine-readable part. Identity fields live in the frontmatter and are merged in; everything below is enforced or served by the driver.

device:
  notes: >
    96-well block on bench 3, left of the liquid handler. Lid must be closed
    before any run. Block ramps ~4 °C/s; lid heater takes ~40 s to reach 105 °C.

physical:                        # anything the agent cannot infer from code
  mass_kg: 12.5
  footprint_mm: [330, 460, 250]
  power_w: 850
  notes: Bench-mounted, do not relocate while running. Hot lid surface up to 110 °C.

signals:                         # READ
  - name: block_temperature
    type: number
    unit: degC
    notes: Measured at block centre; edge wells lag by ~0.5 °C.
  - name: lid_closed
    type: boolean
    notes: True when the lid latch is engaged.

settings:                        # WRITE
  - name: target_temperature
    type: number
    unit: degC
    limits: {min: 4, max: 105}   # enforced by the driver
    approval: auto               # auto | confirm | forbid
    notes: Below 4 °C condensation forms; above 105 °C the seal fails.

actions:                         # INVOKE -> job
  - name: run_protocol
    duration: long               # short | long
    approval: auto
    interlocks: [lid_closed]     # boolean signals that must read exactly true
    params:                      # undeclared parameters are refused
      steps: array of {temp: degC, hold_s: number}
      cycles: integer
    required: [steps, cycles]
    limits: {cycles: [1, 100]}   # enforced by the driver, element-wise for lists
    pausable: true               # the driver checkpoints between steps
    notes: Runs a cycling program; steps are repeated `cycles` times.
    examples:                    # what normal usage looks like (§3.5)
      - {steps: [{temp: 95, hold_s: 30}, {temp: 58, hold_s: 30}, {temp: 72, hold_s: 45}], cycles: 30}
  - name: open_lid
    duration: short
    approval: confirm            # a human must confirm each invocation
    notes: Lid may be hot; a human should be present.

safety:
  estop: true                    # driver implements safety/estop
  interlocks: [lid_closed]
  watchdog_s: 10                 # while a session holds the lease: fail safe if it goes silent this long (§6)
  notes: E-stop cuts block and lid heaters; the block cools passively.

locations:                       # optional, device-class specific extensions
  thermocycler: [-410, 90, 120]

4.3 Resources

resources/list returns the relative paths of every file in the package except DEVICE.md. resources/read {path} returns a text file's contents (capped; binary files return size only) and MUST refuse paths outside the package. Scripts under scripts/ are written for mhp_run and see lab; an agent reads one, adapts the constants, and runs it, so the device owner's tested procedure is what executes.

4.4 Field rules

4.5 Methods: the methods/ folder

Some instruments do not run actions so much as methods: an HPLC, GC, MS or PXRD runs one named parameter set per injection or scan, and that set changes from project to project and compound to compound. A balance or a laser probe has no such thing. MHP treats methods as a primitive that every device answers (§5.8), and as Level 3 content of the package for the devices that have them. The rules below are the whole game; the reference implementation enforces each one.

M1. Declaring. An action that runs methods carries methods: true in descriptor.yaml. A device with at least one such action is method-capable and reports capabilities.methods: true in initialize. A device with none reports false: its methods/list and methods/find answer empty, and methods/save is refused with NotSupported. No device may be method-capable without declaring the action, and no method may target an action that is not declared method-driven.

M2. Where they live. Methods are files in the package, under methods/, one folder per project:

methods/
├── README.md                       optional: what a method means on this instrument
├── _shared/                        methods owned by no project: vendor defaults, system suitability
│   └── <method>.yaml
└── <project>/                      one folder per project that has run on this instrument
    ├── <method>.yaml               the current version
    ├── <method>.history.jsonl      every earlier version, append-only, never rewritten
    └── <method>.runs.jsonl         every run of it on this instrument, append-only

Project ids and method ids match ^[a-z0-9][a-z0-9._-]{0,63}$. _shared is the one reserved project id. A method's full id is <project>/<method>; wherever a request names a method, name MAY be the full id, or a bare method id together with project (default _shared). Nothing else in the package may be named methods. A package without method-driven actions MUST NOT have a methods/ folder; the validator refuses either inconsistency. The folder is written by the device itself, so it is not part of the package's enforced configuration: a host that fingerprints a package to detect tampering (§9.1, reload) MUST leave methods/ out of the fingerprint, and a method file is never code.

M3. The record. A method file is YAML with exactly these top-level keys:

Key Required Meaning
name, project yes must equal the file name and folder name
action yes the method-driven action that receives params
params yes an object: exactly what actions/invoke sends; nothing else is interpreted
compounds yes one or more lowercase names; what the method is for; methods/find matches on them
version yes integer ≥ 1, assigned by the device, never chosen by the caller
hash yes first 12 hex of sha256 over canonical JSON {action, params}; assigned by the device
created, updated yes Unix seconds, assigned by the device
validated yes {at, hash} when the device's own parameter gate passed for this version; null if the file was authored by hand and never checked
status yes active or retired; retired methods are kept, listed only on request, never found by methods/find
author, notes no free text: who and why
source no {kind: vendor_file \| manual \| person \| agent \| derived, ref}: where the parameters came from
derived_from no {project, name, version}: the method this one was copied or tuned from

A file with any other key, a missing required key, or a name/project that disagrees with its path is invalid and the device MUST NOT serve it. History entries repeat the record as it was; run entries are {ts, job, session, state, overrides, project, result_digest}.

M4. Versions. methods/save on a name that exists compares hash and action. Unchanged: the record's metadata is updated and version stays. Changed: the previous record is appended to the history file and version increments. Versions are never renumbered or removed. methods/delete sets status: retired; it does not remove the file. A retired method can be saved again, which reactivates it as a new version.

M5. Validation on save. Before writing, the device runs the target action's parameter gate on params: types, required parameters, undeclared parameters, limits, and the whole-request validate_params hook. State, interlocks, leases and busy are run-time conditions and are not checked. A refusal is returned unchanged and nothing is written. The stored validated.hash MUST equal the record's hash.

M6. Running one. actions/invoke accepts method: {name, project?, version?, overrides?} in place of, or in addition to, params. The parameters sent to the action are the method's, then overrides, then explicit params, later keys winning. The job carries method: {project, name, version, hash, overrides?} and project, in every status answer and every job notification, so a result is traceable to the exact parameters that produced it. On completion the device appends the run to the method's runs file. Overrides are for adapting between runs; they are never saved implicitly.

M7. Asking. A method-driven action invoked with neither method nor params is refused with MethodRequired (-32015). data carries the answer of methods/find for the request's compound and project (choice, method or candidates, ask), so the caller knows exactly what to ask a person. A method-driven action invoked with explicit params under a project that has no method for the given compound is accepted, and the response carries methodHint, telling the caller to save the parameters under that project if they worked. This is how a new project's methods come to exist on an instrument.

M8. Finding. methods/find {compound, project?, action?} answers with choice:

choice When The caller's duty
one exactly one active method for the compound in the project; or none in the project and exactly one anywhere (then newProject names the project) use it
several more than one candidate at the level that matched ask the person which one; an autonomous loop with a declared project takes the project's own, never one from another project
none no active method for the compound ask the person for the parameters, run once with explicit params, save them under the project

Project methods take precedence over _shared; _shared over other projects' methods. ask is a sentence the caller can put to a person as is.

M9. Provenance. The host's run log records method and project with every invoke; the device's runs file is the instrument-side record; the history file is the parameter-side record. Together they answer, for any result, which parameters produced it, on which instrument, in which project, and what they were derived from.

M10. Plans. In plan mode methods/list, get, find and diff are real; save and delete are recorded, not executed; a run of a method is a dry-run invoke with the resolved parameters, so the plan shows the method's name and version next to the step.