Opentrons importer — user reference¶
End-user documentation for the Opentrons Protocol API (.py) importer. Contributor / architecture notes live in opentrons-architecture.md.
Quickstart — try it in 30 seconds¶
Save the following as trivial.py (a working 4-step Opentrons protocol):
metadata = {
'protocolName': 'Trivial single-aspirate-and-dispense',
'apiLevel': '2.13',
}
def run(protocol):
tips = protocol.load_labware('opentrons_96_tiprack_300ul', 1)
plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 2)
pipette = protocol.load_instrument('p300_single_gen2', 'left',
tip_racks=[tips])
pipette.pick_up_tip()
pipette.aspirate(100, plate['A1'])
pipette.dispense(100, plate['B1'])
pipette.drop_tip()
Three ways to import it — pick whichever fits:
1. Web app (no install)¶
- Go to kitchen.rhylthyme.com.
- In the left sidebar, expand Upload Program.
- Drag your
trivial.pyonto the drop zone (or click the zone and pick the file)..pyfiles are listed alongside.json,.yaml,.pptx, and.cookas accepted formats. - The visualization appears in the main pane: one Left: P300 single-channel track with four steps —
Pickup Tip → Aspirate → Dispense → Drop Tip.
2. CLI¶
pip install rhylthyme
rhylthyme import trivial.py -o trivial.json # or: rhylthyme-import-opentrons trivial.py | jq .
Prints the same program JSON the web upload renders.
3. MCP (Claude Desktop and other agents)¶
Once the Rhylthyme MCP connector is wired into your client, call the import_from_source tool with:
Pass a public .py URL via "query": "<url>" instead of "text" if you'd rather not paste the source.
Larger example protocols live in the worked examples section below — those .py files double as the importer's golden-fixture test inputs, so they stay in sync with the code.
Supported features¶
The importer parses a protocol's run(protocol) body and emits one Rhylthyme step per recognised method call. Anything the simulator can't execute falls through to a static AST parser, which is more conservative (no helper expansion, no mount/module awareness) but lets you import protocols that reference labware definitions the importer doesn't ship.
Status: ✅ supported · 🚧 partial (AST-only or unexpanded) · ⏳ planned.
Pipettes & mounts¶
| Hardware | Status | Notes |
|---|---|---|
| OT-2 single-channel (P20/P50/P300/P1000) | ✅ | track label = <Side>: P<vol> single-channel |
OT-2 8-channel (*_multi*) | ✅ | track label = <Side>: P<vol> 8-channel |
Flex 1-channel (flex_1channel_*) | ✅ | track label = <Side>: Flex 1-channel <vol> |
Flex 8-channel (flex_8channel_*) | ✅ | track label = <Side>: Flex 8-channel <vol> |
Flex 96-channel (flex_96channel_1000) | ✅ | dedicated Flex 96-channel track; emits a shared gantry resource constraint (maxConcurrent: 1 over all pipetting tasks) so it cannot run concurrently with another mount pipette |
Flex gripper (protocol.move_labware(..., use_gripper=True)) | ✅ | dedicated Flex Gripper track + a measurement-task constraint with maxConcurrent: 1. Manual move_labware() without use_gripper=True renders as a pause prompt on the Protocol track. |
| Two-mount protocols | ✅ | two parallel tracks, each with maxConcurrent: 1 |
Pipette commands¶
Each row maps to one CommandEvent per call.
| Method | Status | Notes |
|---|---|---|
pick_up_tip / pickup_tip | ✅ | normalised to pickup_tip |
drop_tip | ✅ | |
return_tip | ✅ | rendered as drop_tip |
aspirate | ✅ | |
dispense | ✅ | |
mix | ✅ | duration scales by repetitions |
move_to | ✅ | |
air_gap | ✅ | |
blow_out | ✅ | |
touch_tip | ✅ | |
home (pipette) | ✅ | |
transfer | ✅ | expanded to pickup_tip → (aspirate → dispense)* → drop_tip; honours new_tip="always"/"once"/"never"; broadcasts across list args |
distribute | ✅ | expanded to pickup_tip → aspirate → dispense* → drop_tip |
consolidate | ✅ | expanded to pickup_tip → aspirate* → dispense → drop_tip |
Protocol-level¶
| Method | Status | Notes |
|---|---|---|
protocol.load_labware(...) | ✅ | setup, emits no step |
protocol.load_adapter(...) | ✅ | setup, emits no step |
protocol.load_instrument(...) | ✅ | setup, emits no step |
protocol.load_module(...) | ✅ | setup, emits no step (module commands run on the module's own track — see below) |
protocol.delay(seconds=N, minutes=M, msg=...) | ✅ | renders as a fixed-duration step on the Protocol track; task: delay |
protocol.pause(msg=...) | ✅ | renders as an indefinite-duration step on the Protocol track |
protocol.home() | ✅ | renders on the Protocol track |
protocol.comment(...) / protocol.set_rail_lights(...) etc. | 🚧 | silently no-op (not work) |
Modules¶
Each loaded module gets its own track + a maxConcurrent: 1 resource constraint. Module commands map to the same task vocabulary as the protocols.io importer (heating, incubation, mixing, measurement).
| Module | Status | Recognised commands |
|---|---|---|
Heater-shaker (heaterShakerModuleV1) | ✅ | set_and_wait_for_shake_speed, set_target_temperature, wait_for_temperature, deactivate_shaker, deactivate_heater, open_labware_latch, close_labware_latch |
Magnetic module v1 / v2 / block (magneticModuleV2, magneticBlockV1, etc.) | ✅ | engage, disengage |
Temperature module (temperatureModuleV1 / V2) | ✅ | set_temperature, await_temperature, deactivate |
Thermocycler (thermocyclerModuleV1 / V2) | ✅ | open_lid, close_lid, set_block_temperature, set_lid_temperature, execute_profile, deactivate |
Absorbance module (Flex, absorbanceReaderV1) | ✅ | initialize, read, open_lid, close_lid |
Background timing model. Module commands run on their own track so the pipette track can continue in parallel — the visualiser shows the robot doing other work during a shake or thermocycler profile. The duration of thermocycler.execute_profile(steps=..., repetitions=N) is computed exactly: sum(step.hold_time_seconds) * N. Set-and-wait / deactivate commands fall back to static estimates; later phases may extend the model with volume / temperature awareness.
Entry points¶
| Surface | Status | Notes |
|---|---|---|
CLI: rhylthyme-import-opentrons <file.py> | ✅ | reads file or stdin (-), writes Rhylthyme program JSON to stdout |
Web upload modal accepts .py | ✅ | /api/upload detects .py extension and routes through OpentronsImporter; structured error in modal UI on parse failure |
MCP import_from_source opentrons variant | ✅ | Python local + Node remote servers both accept source="opentrons"; pass query=<URL> for a hosted .py, OR text=<source> for inline source. |
Worked examples¶
The four files below are both the golden-fixture inputs for the test suite and the worked-example reference protocols for these docs — single source of truth. CI fails when one exists without the other.
Each fixture lives at rhylthyme-importers/tests/fixtures/opentrons/<name>.py. Run the CLI on any of them to see exactly what an import produces:
| Fixture | Phase | What it exercises |
|---|---|---|
trivial | 1 | Single-mount OT-2 pipette with the four core commands: pickup_tip → aspirate → dispense → drop_tip. |
pcr_setup | 2 | OT-2 single-channel pipette, mix(repetitions=3), protocol.delay(seconds=30), protocol.pause("..."), and the Protocol track for protocol-level steps. |
serial_dilution | 2 | Two-mount protocol: 8-channel distribute + per-column transfers on the left, single-channel transfer on the right. Demonstrates the simulator's helper-method expansion. |
elisa | 3 | Four module families on one program: heater-shaker, magnetic, temperature, absorbance. Each gets its own track and a maxConcurrent: 1 resource constraint. |
cell_culture_passage | 4 | Flex 96-channel pipette (own track + shared gantry constraint), Flex 1-channel on the right mount, and gripper.move_labware(..., use_gripper=True) driving a dedicated Flex Gripper track. |
Troubleshooting (error catalog)¶
Every error string the importer can emit, with the most likely cause and how to fix it.
| Error | Cause | Fix |
|---|---|---|
source does not define run(); not an Opentrons protocol | The uploaded .py is a plain script or library file — Opentrons protocols must define a top-level def run(protocol): function. | Wrap the body in def run(protocol): ..., or upload a file that actually is a protocol. |
syntax error at line N: <msg> | The protocol's Python source has a SyntaxError so neither parser can compile it. | Open the file in your editor / run python -c "import ast; ast.parse(open('foo.py').read())" to find the bad line. |
protocol module import failed: <reason> | The simulator could compile the module but executing its top level raised (typically a missing import the stub doesn't fake). | Remove the offending top-level statement, or wrap it in if __name__ == '__main__': so it only runs when called as a script — the importer only needs run(). |
protocol has no callable run(protocol) function | A run symbol exists but isn't callable (e.g. run = "something"). | Make sure the protocol defines def run(protocol):. |
protocol run() raised <Type>: <msg> | The simulator caught an exception inside run(). The AST fallback should fire and emit a WARNING instead — if it didn't, the importer surfaces this error so the user knows what went wrong. | Fix the underlying issue (often unsupported labware that the stub couldn't fake), or rely on the AST fallback by ensuring at least one pipette.method(...) call is statically visible. |
no such file: <path> | import_from_url(path) was given a path that doesn't exist on disk. | Pass a correct path, or use import_from_source(text) with the file contents inline. |
Invalid file type. Use .json, .yaml, .yml, .pptx, .cook, or .py | /api/upload got a file with an unsupported extension. | Rename your file to .py (for Opentrons protocols) or use one of the listed extensions. |
Either url/query or text required | /api/import was called for source='opentrons' without either query=<URL> or text=<source>. | Pass one of the two. |
Any of the above errors are importer-side — the importer never gets to the program builder when they fire. Once the import succeeds you might still see a soft warning surfaced in the web UI:
⚠️ This protocol was parsed statically; some steps may be missing.
That means the simulator hit something it couldn't execute and the AST fallback ran. The visualization is real but conservative — helper methods (transfer / distribute / consolidate) appear as single steps instead of expanded into low-level pipette calls. To get full fidelity, run the protocol against the simulator locally (rhylthyme-import-opentrons <file> will print the simulator error on stderr) and fix the issue.
Limitations¶
- API version: Protocol API v2 only. v1 (legacy) and Protocol Designer JSON exports are out of scope. See the PRD for rationale.
- Duration estimates are static lookups, not volume- or distance-aware. Expect ±20% vs. wall-clock for now; the model lives behind a one-function seam, so a more accurate model can be swapped in without touching the parser or builder.
- AST fallback is conservative: helper methods (
transfer,distribute,consolidate) appear as single events (not expanded), and the parser can't tell which mount a pipette is on. Any AST-parsed result carries aWARNINGevent the web UI surfaces as a banner. - Dynamic control flow that depends on runtime values (e.g. conditionals reading a CSV during
run()) is handled best-effort by the simulator's stubbed labware (which returns sensible defaults so the protocol doesn't crash). When that's not enough, the AST fallback kicks in.
Duration model reference (auto-generated)¶
Regenerate with make docs after editing DURATION_SECONDS in opentrons/duration_model.py.
| Command type | Seconds | Notes |
|---|---|---|
absorbance.close_lid | 6 | |
absorbance.initialize | 30 | |
absorbance.open_lid | 6 | |
absorbance.read | 45 | |
air_gap | 3 | Pull a small air buffer into the tip. |
aspirate | 4 | Single-well aspirate; ignores volume in the static model. |
blow_out | 3 | Force remaining liquid out of the tip. |
consolidate | 22 | Helper-method fallback (AST path only). |
delay | 0 | Reads event.args["seconds"] from the protocol. |
dispense | 4 | Single-well dispense. |
distribute | 22 | Helper-method fallback (AST path only). |
drop_tip | 4 | Drop tip into trash. |
gripper.move_labware | 15 | One labware traverse on the Flex deck. |
heater_shaker.close_labware_latch | 6 | |
heater_shaker.deactivate_heater | 8 | |
heater_shaker.deactivate_shaker | 8 | |
heater_shaker.open_labware_latch | 6 | |
heater_shaker.set_target_temperature | 8 | |
heater_shaker.shake | 60 | Default placeholder; protocols typically gate shake duration with a follow-up call. |
heater_shaker.wait_for_temperature | 60 | |
home | 12 | Send pipette / gantry to home. |
magnetic.disengage | 6 | |
magnetic.engage | 30 | Settle-time estimate; tune for your bead chemistry. |
mix | 6 | Per repetition; total = base × repetitions. |
move_to | 8 | Move the pipette to a named location. |
pause | 0 | Renders as indefinite duration / manual trigger. |
pickup_tip | 6 | Tip pickup (any pipette). |
temperature.await_temperature | 120 | Block-warm-up estimate; varies by setpoint. |
temperature.deactivate | 6 | |
temperature.set_temperature | 6 | |
thermocycler.close_lid | 30 | |
thermocycler.deactivate | 6 | |
thermocycler.execute_profile | 0 | Computed at parse time: sum(step.hold_time_seconds) * repetitions. |
thermocycler.open_lid | 30 | |
thermocycler.set_block_temperature | 60 | |
thermocycler.set_lid_temperature | 60 | |
touch_tip | 3 | Brush the tip against the well rim. |
transfer | 18 | Helper-method fallback (AST path only). |