Build
Add an instrument
How to put a new instrument or machine under OpenMHP so that any AI agent harness can find it, understand it, and operate it within the limits you set. One folder, a few files, no changes to the agent.
An MHP device is a device package: a folder shaped like an Agent Skill. What you write loads into the agent in three levels, each only when needed.
devices/hotplate-01/
├── DEVICE.md Level 1: YAML frontmatter, the card (what agents see in search results)
│ Level 2: Markdown body, operating instructions (loaded when an agent picks it)
├── descriptor.yaml Level 3: signals, settings with limits, actions, interlocks, safety
├── driver.py Level 3: the code that talks to the hardware
├── references/ Level 3: SOPs, manual excerpts, calibration tables (optional)
└── scripts/ Level 3: tested end-to-end runs for mhp_run (optional)
The easy way: let your agent do it
After npx openmhp setup, three sentences cover most instruments:
- "Find the instruments on my network." The agent runs
mhp_lab scan. Anything already serving MHP (a bench PC runningmhp serve, a vendor device with MHP built in, a SiLA 2 or OPC UA adapter) shows up with its card. - "Add the thermocycler."
mhp_lab add. It is in your lab and searchable from now on. - "Onboard my hotplate." For an instrument with no MHP server yet, the
openmhp-onboard-deviceskill interviews you with the questions in step 1 below, then the agent writes the package withmhp_lab newandwrite, validates it, and adds it. You review DEVICE.md at the end. The package lives in~/.openmhp/devices/and is hosted by the bridge itself, so nothing else needs to run.
The rest of this page is what the agent does, so you can do it by hand, check its work, or serve the result from a bench PC for the whole lab.
By hand
-
Gather what only the owner knows
Before writing anything, answer these. They become the package.
- What is it, and when should an agent pick it over the alternatives? What is it not for?
- Where does it sit? Which tags would someone search for?
- How do you run it, step by step? What do you check first, what do you watch, how do you leave it safe?
- What can be read (each value with its unit)? What can be set, and the range you would never leave?
- What must be true before it runs: lid closed, door closed, homed, nobody in the cell?
- What should always need a human? What should an agent never do?
- How does it stop in an emergency? How long may an agent go silent before it should fail safe?
-
Write DEVICE.md
The frontmatter is the card.
descriptionmust say both what the device is and when to pick it, in under 1,024 characters; it is the text a directory ranks against the agent's request. The body is for the agent that has just chosen the device.--- mhp: "2026-09-09" id: hotplate-01 class: hotplate make: IKA model: C-MAG HS 7 location: fume hood 2 tags: [heating, stirring, chemistry] description: Magnetic stirrer hotplate, 20 to 300 degC plate, 0 to 1500 rpm. Use for heating and stirring capped flasks up to 500 mL in fume hood 2. Not for open solvents above 60 degC and not for anything needing temperature feedback inside the vessel. driver: driver.py:Hotplate --- # Hotplate-01 Sits in fume hood 2 on the left. The plate surface reaches 500 °C even when the setpoint is 300 °C, because the setpoint is the plate probe, not the surface. Keep solvents capped. ## Operating procedure 1. Read `sash_closed`; heating refuses while the sash is up. 2. Write `stir_rpm` before `target_temperature` so the bar is moving as heat arrives. 3. Write `target_temperature`. Ramp is about 2 °C/s; read `plate_temperature` to confirm. 4. When done, write `target_temperature` 20 and `stir_rpm` 0, then invoke `shutdown`. ## What to watch - The stir bar rattles above 800 rpm with 50 mL flasks; lower the speed if `stir_rpm` reads unstable. - `shutdown` cuts the heater relay; it needs a human to confirm because the plate stays hot. ## Resources - `references/sop.md`: the lab's SOP for reflux setups. - `scripts/reflux.py`: heat to setpoint, hold, cool, shut down. -
Write descriptor.yaml
The machine-readable half. Identity comes from the frontmatter, so this file holds only what the driver enforces or serves. Every numeric setting needs
limits. Every action that heats or moves needsinterlocksorapproval: confirm.physical: mass_kg: 3.2 notes: Plate surface up to 500 degC. Keep solvents capped. signals: - name: plate_temperature type: number unit: degC - name: sash_closed type: boolean notes: Fume hood sash interlock. settings: - name: target_temperature type: number unit: degC limits: {min: 20, max: 300} # the driver refuses anything outside interlocks: [sash_closed] approval: auto - name: stir_rpm type: number unit: rpm limits: {min: 0, max: 1500} actions: - name: shutdown duration: short approval: confirm # a human must say yes to each call notes: Cuts the heater relay. Plate stays hot for minutes. safety: estop: true interlocks: [sash_closed] watchdog_s: 10 notes: E-stop opens the heater relay and stops the stirrer.Approval levels:
auto(the agent may act),confirm(each call needs a human's yes, passed back asapproved: true),forbid(listed so the agent knows it exists, never agent-operable). Full field rules are in the specification. -
Write driver.py
Pick the route that matches your hardware. All three give you every safety gate, the three describe levels, jobs, notifications and e-stop from the base class. You never re-implement limit checks.
Route A: native driver (vendor API, serial, SDK)
from openmhp.driver import Driver, Job import serial class Hotplate(Driver): def setup(self): self.dev = serial.Serial("/dev/ttyUSB0", 9600, timeout=1) def on_read(self, name): cmd = {"plate_temperature": "IN_PV_1", "sash_closed": "IN_SASH"}[name] return float(self._query(cmd)) def on_write(self, name, value): # limits already checked by the base class cmd = {"target_temperature": "OUT_SP_1", "stir_rpm": "OUT_SP_4"}[name] self._query(f"{cmd} {value}") def on_invoke(self, job: Job): # runs in a worker thread; report progress, honour cancel if job.action == "shutdown": self._query("STOP_1"); self._query("STOP_4") return {"heater": "off"} def on_estop(self): self._query("STOP_1"); self._query("STOP_4") def _query(self, cmd): self.dev.write((cmd + "\\r\\n").encode()); return self.dev.readline().decode().strip()Route B: bindings (you already have Python callables)
from openmhp.adapters import BoundDriver, Signal, Setting, Action DEVICE = BoundDriver(device={}, # identity is merged in from DEVICE.md signals=[Signal("plate_temperature", read=plc.read_pv, unit="degC"), Signal("sash_closed", read=plc.sash, type="boolean")], settings=[Setting("target_temperature", write=plc.set_sp, limits={"min": 20, "max": 300}, interlocks=["sash_closed"])], actions=[Action("shutdown", run=lambda job, p: plc.off(), approval="confirm")], estop=plc.off)Route C: an adapter (SiLA 2, PyLabRobot, MADSci, OPC UA, ROS 2)
from openmhp.adapters.opcua import opcua_device DEVICE = opcua_device("opc.tcp://hood2-plc:4840", device={}, signals={"plate_temperature": ("ns=2;s=HP.PV", "degC"), "sash_closed": ("ns=2;s=Hood.Sash", "boolean")}, settings={"target_temperature": ("ns=2;s=HP.SP", {"min": 20, "max": 300}, "degC")}, actions={"shutdown": ("ns=2;s=HP", "ns=2;s=HP.Off", {"approval": "confirm"})}, estop=("ns=2;s=HP", "ns=2;s=HP.Off"))For routes B and C, omit the
driver:line in the frontmatter; the loader findsDEVICE. See Adapters for each ecosystem's binding shape. -
Add references and a tested script
Drop the SOP or manual pages into
references/as Markdown or text. Put one end-to-end run you have actually tested intoscripts/, written formhp_run: it seeslaband should print one summary line. Mention both in the DEVICE.md "Resources" list. Agents read these only when they need them, so there is no cost to including a lot.# scripts/reflux.py hp = lab["hotplate-01"] with hp: hp.write("stir_rpm", 400) hp.write("target_temperature", 80) while hp.read("plate_temperature") < 78: time.sleep(5) time.sleep(3600) hp.write("target_temperature", 20); hp.write("stir_rpm", 0) print("reflux done; plate at", hp.read("plate_temperature"), "degC") -
Validate and serve
python skills/openmhp-onboard-device/scripts/validate_package.py devices/hotplate-01 mhp serve pkg:devices/hotplate-01 --http 18923Then prove the gates from another terminal:
mhp http://localhost:18923 describe card # level 1 looks right? mhp http://localhost:18923 describe summary # instructions present? mhp http://localhost:18923 write target_temperature 350 # must be refused, code -32010 mhp http://localhost:18923 invoke shutdown --dry-run # must say ApprovalRequired mhp http://localhost:18923 estop && mhp http://localhost:18923 reset -
Register it in the directory
Add the new server to the manifest and restart the directory. From then on any agent can find it by describing what it needs.
python skills/openmhp-adapt-fleet/scripts/build_manifest.py fleet.json \ hotplate-01=http://hood2-pc:18923 thermocycler-01=http://bench3:18921 arm-01=http://bench3:18922 mhp serve-directory fleet.json --http 18900 mhp http://localhost:18900 find heat and stir a flask in the fume hoodNothing changes on the harness side. The bridge still exposes the same eight tools; the new instrument simply appears in
mhp_findresults. -
Verify from the agent
In your harness, ask for something the instrument does and something it must refuse. Confirm that the agent read the operating procedure (it should mention the sash), that the out-of-range request came back to you, and that the
confirmaction asked for your yes. Then hand DEVICE.md to the instrument owner for review, and commit the folder: changing a limit is a code review, not a runtime action.
Checklist
- frontmatter complete; description says what it is and when to pick it
- DEVICE.md has an operating procedure, what to watch, and a resources list
- every numeric setting has limits; every energetic action has interlocks or needs confirmation
- validator passes; out-of-range write refused; e-stop wired
- directory finds it from a plain-language query; the owner has reviewed DEVICE.md