skills/opentrons-integration/references/protocol_authoring.md
Use this guide to turn a wet-lab method into a reviewable Opentrons Python protocol. It targets Protocol API v2 and assumes the protocol will be imported into the Opentrons App.
Create a protocol specification before writing code.
Record every assumption that has not been confirmed by the operator.
The API level controls both available methods and behavior. It is not merely a documentation label.
At the 2026-07-23 baseline:
Do not declare an unsupported API level just because a newer local Python package can simulate it.
A protocol file normally contains imports, metadata, requirements, optional
parameter definitions, helper functions, and run().
from opentrons import protocol_api
metadata = {
"protocolName": "Assay setup",
"author": "Automation Team",
"description": "Prepare an eight-sample assay plate.",
}
requirements = {"robotType": "Flex", "apiLevel": "2.29"}
def add_parameters(parameters: protocol_api.ParameterContext) -> None:
parameters.add_int(
variable_name="sample_count",
display_name="Sample count",
default=8,
minimum=1,
maximum=24,
)
def run(protocol: protocol_api.ProtocolContext) -> None:
sample_count = protocol.params.sample_count
protocol.comment(f"Preparing {sample_count} samples")
Avoid top-level network calls, package installation, local machine paths, or other side effects. Protocol analysis evaluates the protocol in a controlled environment, and the robot may not have workstation packages or network access.
Do not use apiLevel in both metadata and requirements.
Create a simple manifest before calling load methods:
| Slot | Item | Adapter/module | Notes |
|---|---|---|---|
| A3 | Flex trash bin | — | Required for dropped tips |
| C1 | 50 µL tip rack | Flex tip-rack adapter if required | Left pipette |
| C2 | Sample rack | — | Tubes 1–8 |
| D1 | Temperature Module GEN2 | Aluminum block | Master mix |
| Thermocycler | PCR plate | Thermocycler Module GEN2 | Destination |
Then check:
Use named variables and labels:
sample_rack = protocol.load_labware(
"opentrons_24_tuberack_nest_1.5ml_snapcap",
"C2",
label="Samples 1-8",
)
Labels appear in setup and run views and are more useful than generic names
such as plate1.
The API load name identifies geometry, not merely a product category.
Loading on an adapter should reflect the physical stack:
adapter = protocol.load_adapter(
"opentrons_96_well_aluminum_block",
"D1",
)
plate = adapter.load_labware(
"opentrons_96_wellplate_200ul_pcr_full_skirt"
)
For modules, call the module or module adapter's load_labware() method. Do not
repeat a deck slot already supplied to load_module().
Liquid definitions improve setup visualization but do not move or measure liquid.
master_mix = protocol.define_liquid(
name="Master mix",
description="2x PCR master mix",
display_color="#E377C2",
)
reagent_rack.load_liquid(
wells=["A1"],
volume=500,
liquid=master_mix,
)
For varying volumes:
sample_rack.load_liquid_by_well(
volumes={"A1": 30, "A2": 40, "A3": 50},
liquid=sample,
)
In API 2.22+ protocols, prefer labware-level methods over deprecated
Well.load_liquid().
Declared volumes support visualization and meniscus calculations. They do not replace physical setup checks or a source-volume budget.
Use runtime parameters for values an operator should set without editing code:
Keep hardware geometry and safety-critical invariants in code unless every allowed choice is separately validated.
def add_parameters(parameters: protocol_api.ParameterContext) -> None:
parameters.add_float(
variable_name="transfer_volume",
display_name="Transfer volume",
description="Volume delivered to each destination.",
default=25.0,
minimum=10.0,
maximum=40.0,
unit="µL",
)
parameters.add_bool(
variable_name="dry_run",
display_name="Dry run",
description="Shorten delays and use test-safe behavior.",
default=False,
)
Constraints:
Use defaults that simulate successfully. Do not make a hazardous setting the default.
Separate configuration from repetitive actions:
def transfer_samples(
pipette: protocol_api.InstrumentContext,
sources: list[protocol_api.Well],
destinations: list[protocol_api.Well],
volume: float,
) -> None:
for source, destination in zip(sources, destinations, strict=True):
pipette.transfer(
volume,
source,
destination,
new_tip="always",
mix_after=(3, min(volume, 20)),
)
Guidelines:
zip(..., strict=True) only if the robot's Python version supports it;
the current baseline uses Python 3.10 and does.volume_ul and
temperature_c.run() or helpers called from run().protocol.pause() for required operator intervention.protocol.comment() text is computed during analysis. Do not rely on it to
report a live sensor value that only exists during physical execution.
Define a policy before choosing new_tip:
"always": isolate samples or source-destination pairs."once": one tip for an entire complex command; safe only when all contacts
share a contamination domain."never": caller must already hold a tip and must handle disposal.Examples of unsafe optimization:
Count multi-channel tips as tip sets. A full 8-channel pickup consumes eight tips, and a full 96-channel pickup consumes an entire rack.
For each source:
required source volume =
delivered volume
+ disposal volume
+ mixing and pre-wet loss
+ geometry-dependent dead volume
+ validated reserve
For each destination, calculate the maximum intermediate volume, not just the final expected volume. Include mix strokes, carryover splits, and any material left before a subsequent transfer.
Do not use a pipette below its supported range. For example, 100 nL is below the range of every current Opentrons pipette and requires a different dispensing technology or a redesigned dilution scheme.
opentrons package used for simulation, but remember that the
robot executes its installed software stack.