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
device.idMUST be unique within a host's set of devices.classis an open string; §13 lists the initial vocabulary.device.description(frontmatter) SHOULD state what the device is and when to pick it, ≤ 1,024 characters. It is the ranking text for directories and the card text for agents.- Every object (device, physical, signal, setting, action, safety) MAY carry a
notesstring. Notes are natural language for the agent. They are the mechanism by which tacit knowledge enters the protocol; drivers MUST pass them through unchanged. typeis one ofnumber | integer | boolean | string | object | array.unitSHOULD be a UCUM code or a common spelled-out unit (degC,mm,percent,N,rpm). Values are checked against the declared type before any limit or vendor code runs: numbers must be finite, integers and booleans are not interchangeable,nullis refused, and strings may not contain control characters.limitson a setting:{min, max}for numbers,{enum: [...]}for strings. The driver MUST refuse writes outside limits withLimitViolation.limitson an action: a map from parameter name to[min, max],{min, max}or{enum: [...]}, applied element-wise to lists. The driver MUST enforce them, for dry runs and real runs alike, together withrequiredand, whenparamsis declared, the rule that undeclared parameters are refused. Checks that span a whole request (every step of a program) belong in the driver's side-effect-freevalidate_paramshook, which also runs for dry runs, so no step starts before the whole request is known to be valid.approval:auto(agent may act),confirm(each call needsapproved: true, which only the host may set, and only after a person confirmed that exact request; §7.2),forbid(never agent-operable; listed so the agent knows the capability exists and why it is off-limits).interlocks: names of boolean signals that MUST read exactlytrueat the moment of the write or invoke; false, unreadable or stale values count as open (InterlockOpen).during_jobon a setting (default false) says it may change while a job runs; otherwise the write is refused as busy.concurrent,pausableandcancellableon an action declare what the driver can actually do; pause and cancel requests for actions that do not support them are refused withNotSupported.- A factory-built driver (adapter) generates its capabilities from bindings; descriptor.yaml then refines named items and MUST NOT name a capability the driver has no binding for.
duration:shortactions are expected to finish within seconds and hosts MAY block on them;longactions run as jobs and the host SHOULD poll or subscribe.tagsondeviceSHOULD be set: they are what a directory filters on, along withlocation. A package meant to be shared cannot know where the instrument will stand, solocationis recorded by the lab that installs it (mhp_lab op='location',mhp lab location <id> "bay 3") and the fleet record is the authority; the frontmatter value is a default for a device installed in one place only.exampleson an action is an array of parameter objects.- Unknown top-level keys (for example
locations) are permitted and MUST be preserved by adapters and bridges. Device classes define their own conventions.
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.