Opentrons Python Protocol API

SkillFiles & storage

Python API v2 for Opentrons OT-2/Flex liquid handlers: protocols as Python files with metadata and run(); control pipettes, labware, and modules (thermocycler, heater-shaker, magnetic, temperature). Simulate via opentrons_simulate then upload. Use PyLabRobot for vendor-agnostic scripts (Hamilton, Tecan).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Opentrons Python Protocol API skill

What this skill tells your AI

The instructions your AI receives, as published by jaechang-hits/sciagent-skills in skills/lab-automation/opentrons-protocol-api/SKILL.md and read by ahel’s review.

Overview

The Opentrons Protocol API v2 lets you write liquid handling protocols as plain Python files that run on OT-2 or Flex robots. Every protocol defines a metadata dictionary, an optional requirements dictionary, and a run(protocol) function. The ProtocolContext object passed to run() exposes all deck setup, pipette operations, module control, and utility methods. Protocols can be simulated on any computer with opentrons_simulate before uploading to the robot through the Opentrons App or HTTP API.

When to Use

  • Setting up PCR reactions: Distribute master mix from a tube rack into a thermocycler plate, add template DNA from individual tubes, then execute a PCR profile automatically.
  • Running serial dilutions: Programmatically step a multi-channel pipette across a 96-well plate to create 2-fold or custom dilution curves with defined diluent volumes.
  • Performing ELISA plate layouts: Add blocking buffer, primary antibody, secondary antibody, and substrate to defined wells with tip changes between each reagent.
  • Automating magnetic bead cleanups: Engage/disengage the magnetic module, aspirate supernatant, wash with ethanol, and elute — in a fully automated loop.
  • Plate reformatting and stamping: Transfer an entire 96-well plate to a destination plate with one command; reformat from tubes to plates.
  • Integrating hardware modules: Coordinate temperature control, shaking, and liquid handling steps in a single protocol with precise timing.
  • Use PyLabRobot instead when writing protocols that must run on Hamilton STAR, Tecan Freedom EVO, or other vendors without Opentrons-specific hardware; for Opentrons-only workflows the native Protocol API provides tighter integration and module support.
  • For retrieving and parsing published protocols before automation, use protocolsio-integration to search protocols.io alongside this skill.

Prerequisites

  • Python packages: opentrons
  • Robot types: OT-2 (slots 1-11, Gen2 pipettes) or Flex (slots A1-D3, Flex pipettes)
  • Environment: Python 3.10+; Opentrons App for uploading to physical robot
  • CLI tool: opentrons_simulate ships with the package for local testing
pip install opentrons
# Verify installation and simulate a protocol locally
opentrons_simulate my_protocol.py

Quick Start

A minimal protocol showing all required elements — metadata, labware, instrument, and a transfer:

from opentrons import protocol_api

metadata = {
    "protocolName": "Simple Reagent Distribution",
    "author": "Lab Automation Team",
    "apiLevel": "2.19",
}

def run(protocol: protocol_api.ProtocolContext):
    # Load labware onto deck slots
    tips    = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
    source  = protocol.load_labware("nest_12_reservoir_15ml", "2")
    plate   = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")

    # Load pipette and attach tip rack
    pipette = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])

    # Distribute 50 µL from reservoir A1 to first 12 wells using one tip
    pipette.distribute(50, source["A1"], plate.wells()[:12], new_tip="once")
    protocol.comment("Distribution complete")
# Simulate locally — no robot needed
opentrons_simulate simple_reagent_distribution.py

Core API

Module 1: Protocol Metadata and Deck Setup

Every protocol requires a metadata dict specifying at minimum apiLevel. The optional requirements dict sets the target robot type. All labware and instruments are loaded through the ProtocolContext.

from opentrons import protocol_api

# Minimum required metadata
metadata = {
    "protocolName": "My Assay Protocol",
    "author": "Jane Smith <jane@lab.org>",
    "description": "96-well assay setup with temperature control",
    "apiLevel": "2.19",
}

# Optional: target a specific robot type (Flex or OT-2)
requirements = {"robotType": "OT-2", "apiLevel": "2.19"}

def run(protocol: protocol_api.ProtocolContext):
    # OT-2: slots numbered 1-11 in a 3×4 grid
    tips_300 = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
    tips_20  = protocol.load_labware("opentrons_96_tiprack_20ul",  "4")
    source   = protocol.load_labware("nest_12_reservoir_15ml",     "2", label="Buffer Reservoir")
    plate    = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
    tube_rack = protocol.load_labware("opentrons_24_tuberack_nest_1.5ml_snapcap", "5")

    # Load both pipettes (optional: one or two mounts)
    p300 = protocol.load_instrument("p300_single_gen2", "left",  tip_racks=[tips_300])
    p20  = protocol.load_instrument("p20_single_gen2",  "right", tip_racks=[tips_20])

    print(f"Deck has {len(protocol.deck)} slots; pipettes: {[p300.name, p20.name]}")

OT-2 deck layout (3 columns × 4 rows, numbered left-to-right, bottom-to-top):

Slot map (OT-2):         Slot map (Flex, A-D rows, 1-3 cols):
 10 | 11 | Trash          D1 | D2 | D3
  7 |  8 |  9             C1 | C2 | C3
  4 |  5 |  6             B1 | B2 | B3
  1 |  2 |  3             A1 | A2 | A3

Common OT-2 pipette names: p20_single_gen2, p300_single_gen2, p1000_single_gen2, p20_multi_gen2, p300_multi_gen2. Common Flex pipette names: p50_single_flex, p1000_single_flex, p50_multi_flex, p1000_multi_flex, flex_96channel_1000.

Module 2: Pipette Operations

Low-level aspirate/dispense/blow-out operations for precise step-by-step control.

def run(protocol: protocol_api.ProtocolContext):
    tips   = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
    source = protocol.load_labware("nest_12_reservoir_15ml", "2")
    dest   = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
    p300   = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])

    p300.pick_up_tip()

    # Aspirate and dispense — basic liquid movement
    p300.aspirate(100, source["A1"])         # draw 100 µL from reservoir
    p300.dispense(100, dest["A1"])           # expel into plate well

    # Air gap to prevent dripping during transport
    p300.aspirate(80, source["A2"])
    p300.air_gap(20)                         # draw 20 µL air to cap the tip
    p300.dispense(100, dest["A2"])           # dispenses liquid + air

    # Mix in place (repetitions, volume)
    p300.mix(3, 60, dest["A1"])             # mix 60 µL × 3 times

    # Remove exterior droplets / expel residual
    p300.touch_tip(dest["A1"])              # wipe tip on well rim
    p300.blow_out(dest["A1"].top())         # expel last drop at top

    p300.drop_tip()
    protocol.comment("Low-level operations complete")
def run(protocol: protocol_api.ProtocolContext):
    tips = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
    p300 = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])

    # Adjust flow rates (µL/s) for viscous or sensitive samples
    p300.flow_rate.aspirate = 50    # slow down for viscous liquids (default ~150)
    p300.flow_rate.dispense = 150   # default dispense speed
    p300.flow_rate.blow_out = 300   # fast blow-out for complete expulsion
    print(f"Aspirate rate: {p300.flow_rate.aspirate} µL/s")

Module 3: transfer() Shortcut

transfer(), distribute(), and consolidate() handle tip management automatically and accept mix, blow-out, and air-gap options.

def run(protocol: protocol_api.ProtocolContext):
    tips   = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
    source = protocol.load_labware("corning_96_wellplate_360ul_flat", "2")
    dest   = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
    p300   = protocol.load_instrument("p300_single_gen2", "left", tip_racks=[tips])

    # transfer(): one source → one destination, with optional per-well tip changes
    p300.transfer(
        100,
        source["A1"],
        dest["A1"],
        new_tip="always",       # options: "always", "once", "never"
        mix_after=(3, 50),      # mix 50 µL × 3 reps after each dispense
        blow_out=True,
        touch_tip=True,
    )

    # transfer() with lists: pairwise source-destination mapping
    sources = source.wells()[:8]
    dests   = dest.wells()[:8]
    p300.transfer(75, sources, dests, new_tip="always")

    # distribute(): one source → many destinations (single tip, multi-dispense)
    p300.distribute(
        50,
        source["A1"],
        dest.wells()[:12],
        new_tip="once",         # use one tip for all destinations
        disposal_volume=10,     # extra volume drawn to ensure accuracy
    )

    # consolidate(): many sources → one destination (collect, then dispense)
    p300.consolidate(
        50,
        source.wells()[:8],
        dest["A1"],
        mix_after=(3, 100),
    )
    print("Compound transfer operations complete")

Module 4: Labware, Liquids, and Well Access

Load labware from the library, navigate wells by name/row/column, and define liquids for visual tracking in the Opentrons App.

def run(protocol: protocol_api.ProtocolContext):
    plate  = protocol.load_labware("corning_96_wellplate_360ul_flat", "1")
    p300   = protocol.load_instrument("p300_single_gen2", "left",
                                      tip_racks=[protocol.load_labware("opentrons_96_tiprack_300ul", "2")])

    # Access wells by alphanumeric name
    well_a1 = plate["A1"]

    # Access all wells (column-major order: A1, B1, C1, ..., H1, A2, ...)
    all_wells = plate.wells()
    print(f"Total wells: {len(all_wells)}")   # 96

    # Access by row (8 rows, A-H; each row has 12 wells)
    row_a = plate.rows()[0]    # [A1, A2, ..., A12]
    row_b = plate.rows()[1]    # [B1, B2, ..., B12]

    # Access by column (12 columns, 1-12; each column has 8 wells)
    col_1 = plate.columns()[0]   # [A1, B1, C1, D1, E1, F1, G1, H1]

    # Vertical position control within a well
    p300.pick_up_tip()
    p300.aspirate(80, well_a1.bottom(z=1))   # 1 mm above well bottom
    p300.dispense(80, well_a1.top(z=-2))     # 2 mm below well top
    p300.aspirate(80, well_a1.center())      # geometric center
    p300.drop_tip()
def run(protocol: protocol_api.ProtocolContext):
    reservoir = protocol.load_labware("nest_12_reservoir_15ml", "1")
    plate     = protocol.load_labware("corning_96_wellplate_360ul_flat", "2")

    # Define liquids for visual tracking in Opentrons App
    pbs     = protocol.define_liquid(name="1× PBS",    description="Phosphate buffered saline",    display_color="#0077BB")
    sample  = protocol.define_liquid(name="Sample",    description="Cell lysate, 1 mg/mL protein", display_color="#EE7733")

    # Assign liquids to wells with known starting volumes (µL)
    reservoir["A1"].load_liquid(liquid=pbs,    volume=10000)
    reservoir["A2"].load_liquid(liquid=sample, volume=5000)

    # Mark destination wells as empty
    for well in plate.wells():
        well.load_empty()

    print("Liquids defined and assigned")

Module 5: Hardware Modules

Control temperature, magnetic, thermocycler, and heater-shaker modules. Each module is loaded by its model name string and occupies specific deck slots.

def run(protocol: protocol_api.ProtocolContext):
    # --- Temperature Module (Gen2) ---
    temp_mod = protocol.load_module("temperature module gen2", "3")
    temp_plate = temp_mod.load_labware("corning_96_wellplate_360ul_flat")
    temp_mod.set_temperature(celsius=4)       # blocks until target reached
    print(f"Temp module: {temp_mod.temperature}°C")
    # temp_mod.deactivate()                   # turn off at end

    # --- Magnetic Module (Gen2) ---
    mag_mod  = protocol.load_module("magnetic module gen2", "6")
    mag_plate = mag_mod.load_labware("nest_96_wellplate_100ul_pcr_full_skirt")
    mag_mod.engage(height_from_base=10)       # raise magnets 10 mm from plate base
    protocol.delay(seconds=300)               # hold beads for 5 min
    mag_mod.disengage()

    # --- Heater-Shaker Module ---
    hs_mod = protocol.load_module("heaterShakerModuleV1", "1")
    hs_plate = hs_mod.load_labware("corning_96_wellplate_360ul_flat")
    hs_mod.close_labware_latch()
    hs_mod.set_target_temperature(celsius=37)
    hs_mod.wait_for_temperature()
    hs_mod.set_and_wait_for_shake_speed(rpm=500)
    protocol.delay(minutes=30)
    hs_mod.deactivate_shaker()
    hs_mod.deactivate_heater()
    hs_mod.open_labware_latch()
    print("Heater-shaker cycle complete")
def run(protocol: protocol_api.ProtocolContext):
    # --- Thermocycler Module (Gen2) ---
    # Auto-occupies slots 7-11 on OT-2; no slot argument needed
    tc_mod  = protocol.load_module("thermocyclerModuleV2")
    tc_plate = tc_mod.load_labware("nest_96_wellplate_100ul_pcr_full_skirt")

    tc_mod.open_lid()
    tc_mod.set_lid_temperature(celsius=105)   # pre-heat lid to prevent condensation

    # Initial denaturation
    tc_mod.set_block_temperature(95, hold_time_seconds=180)

    # PCR cycling profile
    profile = [
        {"temperature": 95, "hold_time_seconds": 15},   # denaturation
        {"temperature": 60, "hold_time_seconds": 30},   # annealing
        {"temperature": 72, "hold_time_seconds": 30},   # extension
    ]
    tc_mod.execute_profile(steps=profile, repetitions=35, block_max_volume=25)

    # Final extension and hold
    tc_mod.set_block_temperature(72, hold_time_minutes=5)
    tc_mod.set_block_temperature(4)           # hold at 4°C indefinitely
    tc_mod.deactivate_lid()
    tc_mod.open_lid()
    print("PCR complete; plate held at 4°C")

Module 6: Advanced Protocol Features

Pause for user interaction, log comments visible in the app, control rail lights, and detect simulation mode.

def run(protocol: protocol_api.ProtocolContext):
    # Pause and prompt the user (robot stops, app shows message)
    protocol.pause(msg="Add 10 µL of enzyme to tube A1, then resume")

    # Timed delay (robot waits without user action)
    protocol.delay(seconds=30, msg="Waiting 30s for reaction incubation")
    protocol.delay(minutes=5)

    # Log a comment visible in Opentrons App run log
    protocol.comment("Starting serial dilution — columns 1 to 11")

    # Rail lights for visual status indication
    protocol.set_rail_lights(True)    # lights on
    protocol.set_rail_lights(False)   # lights off

    # Home all axes (useful after an error or before finishing)
    protocol.home()

    # Detect simulation vs. physical run — skip slow waits in simulation
    if protocol.is_simulating():
        protocol.comment("Running in simulation mode — skipping 10-min incubation")
    else:
        protocol.delay(minutes=10)

    # Load waste bin (Flex only — OT-2 uses fixed trash)
    # trash = protocol.load_trash_bin("A3")

    print("Protocol control features demonstrated")

Common Workflows

Workflow 1: PCR Setup with Thermocycler

Goal: Transfer master mix from a tube rack into a PCR plate on the thermocycler, add template DNA from individual samples, then run a complete PCR cycling program.

from opentrons import protocol_api

metadata = {
    "protocolName": "PCR Setup and Run",
    "author": "Lab Automation",
    "apiLevel": "2.19",
}

def run(protocol: protocol_api.ProtocolContext):
    # Hardware setup
    tc_mod    = protocol.load_module("thermocyclerModuleV2")
    tc_plate  = tc_mod.load_labware("nest_96_wellplate_100ul_pcr_full_skirt")
    tips_300  = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
    tips_20   = protocol.load_labware("opentrons_96_tiprack_20ul",  "4")
    reagents  = protocol.load_labware("opentrons_24_tuberack_nest_1.5ml_snapcap", "2")
    p300      = protocol.load_instrument("p300_single_gen2", "left",  tip_racks=[tips_300])
    p20       = protocol.load_instrument("p20_single_gen2",  "right", tip_racks=[tips_20])

    # Define liquids
    master_mix = protocol.define_liquid("Master Mix", "2× PCR master mix", "#33BBEE")
    template   = protocol.define_liquid("Template",   "gDNA 10 ng/µL",     "#EE3377")
    reagents["A1"].load_liquid(master_mix, volume=500)
    for i in range(8):
        reagents.wells()[i + 1].load_liquid(template, volume=50)

    # Step 1: Open lid and distribute master mix (20 µL per well, 8 wells)
    tc_mod.open_lid()
    protocol.comment("Distributing master mix")
    p300.distribute(
        20,
        reagents["A1"],
        tc_plate.wells()[:8],
        new_tip="once",
        blow_out=True,
        blowout_location="source well",
    )

    # Step 2: Add template DNA (5 µL per well, fresh tip each time)
    protocol.comment("Adding template DNA")
    for i in range(8):
        p20.transfer(
            5,
            reagents.wells()[i + 1],
            tc_plate.wells()[i],
            new_tip="always",
            mix_after=(2, 10),
        )

    # Step 3: Run PCR
    tc_mod.close_lid()
    tc_mod.set_lid_temperature(105)
    tc_mod.set_block_temperature(95, hold_time_seconds=180)  # initial denaturation

    profile = [
        {"temperature": 95, "hold_time_seconds": 15},
        {"temperature": 60, "hold_time_seconds": 30},
        {"temperature": 72, "hold_time_seconds": 30},
    ]
    tc_mod.execute_profile(steps=profile, repetitions=35, block_max_volume=25)
    tc_mod.set_block_temperature(72, hold_time_minutes=5)    # final extension
    tc_mod.set_block_temperature(4)                          # hold
    tc_mod.deactivate_lid()
    tc_mod.open_lid()
    protocol.comment("PCR complete — 8 reactions in wells A1:H1")

Workflow 2: ELISA Serial Dilution with Multi-Channel Pipette

Goal: Use a multi-channel pipette to add diluent to columns 2-12, perform 2-fold serial dilutions across the plate, and add detection reagent to all wells in a single pass.

from opentrons import protocol_api

metadata = {
    "protocolName": "ELISA Serial Dilution",
    "author": "Lab Automation",
    "apiLevel": "2.19",
}

def run(protocol: protocol_api.ProtocolContext):
    # Deck layout
    tips_300  = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
    tips_300b = protocol.load_labware("opentrons_96_tiprack_300ul", "4")  # extra rack
    reservoir = protocol.load_labware("nest_12_reservoir_15ml", "2")
    plate     = protocol.load_labware("corning_96_wellplate_360ul_flat", "3")
    multi     = protocol.load_instrument("p300_multi_gen2", "left",
                                          tip_racks=[tips_300, tips_300b])

    # Define liquids
    diluent = protocol.define_liquid("Diluent", "PBS + 1% BSA", "#0077BB")
    sample  = protocol.define_liquid("Sample",  "Serum 1:10",   "#EE7733")
    reservoir["A1"].load_liquid(diluent, volume=50000)
    reservoir["A2"].load_liquid(sample,  volume=5000)

    # Step 1: Load column 1 with undiluted sample (all 8 rows at once)
    protocol.comment("Loading undiluted sample into column 1")
    multi.transfer(100, reservoir["A2"], plate.columns()[0], new_tip="once")

    # Step 2: Add diluent to columns 2-12
    protocol.comment("Adding diluent to columns 2-12")
    multi.distribute(
        100,
        reservoir["A1"],
        [col[0] for col in plate.columns()[1:]],  # A2 through A12 (multi-channel reads full column)
        new_tip="once",
        disposal_volume=10,
    )

    # Step 3: Serial dilution — transfer 100 µL from each column to the next, mix
    protocol.comment("Performing 2-fold serial dilution across columns 1→11")
    multi.transfer(
        100,
        [col[0] for col in plate.columns()[:11]],   # cols 1-11 as source
        [col[0] for col in plate.columns()[1:]],    # cols 2-12 as destination
        mix_after=(5, 80),                           # mix 80 µL × 5 reps after each dispense
        new_tip="always",                            # fresh tip per column to avoid carry-over
    )

    # Step 4: Remove 100 µL from column 12 to equalize volumes
    multi.pick_up_tip()
    multi.aspirate(100, plate.columns()[11][0])
    multi.drop_tip()

    protocol.comment("ELISA serial dilution complete — 11 dilution steps, 12 columns")
    print("Protocol complete: 2-fold dilution series across 96-well plate")

Key Parameters

ParameterModule / FunctionDefaultRange / OptionsEffect
new_tiptransfer, distribute, consolidate"always""always", "once", "never"Controls tip change strategy; use "always" to prevent cross-contamination
mix_aftertransferNone(repetitions, volume) tupleAspirate/dispense in destination well after each dispense to homogenize
mix_beforetransferNone(repetitions, volume) tupleAspirate/dispense in source well before each aspirate
blow_outtransferFalseTrue, FalseExpel residual volume after dispense; set blowout_location to control where
air_gaptransfer00–pipette max µLInsert air gap after aspirate to prevent dripping during robot moves
disposal_volumedistribute00–pipette max µLExtra volume drawn at start to improve dispense accuracy; discarded to trash
flow_rate.aspiratepipette propertyvaries by model11000 µL/sAspirate speed; lower for viscous samples (glycerol, proteins > 5 mg/mL)
flow_rate.dispensepipette propertyvaries by model11000 µL/sDispense speed; lower for foaming or delicate cell suspensions
height_from_basemag_mod.engage()020 mmHeight of magnet tips above plate base; depends on bead/plate geometry
repetitionstc_mod.execute_profile()199Number of PCR thermal cycles

Best Practices

  1. Always simulate before running on hardware: Use opentrons_simulate protocol.py to catch labware name errors, tip shortages, volume overflows, and slot conflicts without consuming consumables or robot time.

    opentrons_simulate my_pcr_setup.py
    # Output shows all commands; errors printed with line numbers
    
  2. Prefer compound operations over manual pick-up/aspirate/dispense/drop sequences: transfer(), distribute(), and consolidate() handle tip management, air gaps, and blow-out automatically. Reserve low-level calls for operations not supported by compound methods.

  3. Count tips before running: Calculate total tip consumptions (each new_tip="always" transfer costs one tip per well pair). If tips exceed rack capacity, add additional racks to tip_racks=[].

    n_transfers = len(source_wells)   # one tip per transfer
    tips_per_rack = 96
    racks_needed = -(-n_transfers // tips_per_rack)   # ceiling division
    print(f"Need {racks_needed} tip rack(s) for {n_transfers} transfers")
    
  4. Use define_liquid() and load_liquid() for setup validation: Liquid tracking in the Opentrons App displays color-coded wells with volumes, making it easy to verify correct reagent placement before pressing Run.

  5. Distinguish OT-2 slots from Flex slots in protocol files: OT-2 uses numeric strings ("1" through "11") while Flex uses grid coordinates ("A1" through "D3"). Set requirements = {"robotType": "Flex"} or "OT-2" to catch slot mismatches during simulation.

  6. Adjust flow rates for difficult liquids: Viscous solutions (≥20% glycerol, PEG, protein > 5 mg/mL) require lower aspirate rates (25-50 µL/s). Reduce dispense speed for foaming samples to avoid bubble formation.

  7. Use protocol.pause() for manual steps, not protocol.delay(): pause() stops the robot and notifies the operator; the run resumes on demand. delay() is for timed waits (incubations, module equilibration) where no human action is needed.

Common Recipes

Recipe: Plate Replication (96-Well to 96-Well)

When to use: Duplicate an entire source plate into a destination plate with fresh tips per well.

from opentrons import protocol_api

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
362
Forks
36
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
opentrons-protocol-api
Source
github.com/jaechang-hits/sciagent-skills