agentleFS
OpentronsOpentrons/opentrons/.cursor/skills/protocol-authoring/SKILL.md

protocol-authoring

Skill525 starsChanged 2 months agoApache-2.0View on GitHub

Create valid Opentrons Python API protocols for OT-2 and Flex robots. Use when creating, writing, editing, or helping with protocol files, liquid handling automation, or Opentrons protocol development. Also use when debugging protocol errors to trace into API source code.

We split this skill into the rules it gives an agent

7 rules. About 0 are common, 5 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈5

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Keep protocols minimal: Use the fewest steps needed to demonstrate the requested behavior.

    line 13
  2. Very specific:

    Flex only: must load trash bin or waste chute before any drop_tip

    line 99
  3. Distinctive:

    All local dev artifacts live in these gitignored directories:

    line 255
  4. Distinctive:

    Required changes: when deriving from an existing definition:

    line 285
  5. Distinctive:

    No special import needed — the CLI handles loading the definition at run time.

    line 300
  6. Distinctive:

    How to update: use the Write or StrReplace tools on the relevant skill file.

    line 389
  7. Very specific:

    Not accounting for 8-channel tip math: one pick_up_tip() = 8 tips.

    line 406

≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Opentrons Protocol Authoring

Behavior Defaults — READ FIRST

This skill is primarily used by developers, SDETs, and QA who need protocols for testing and development. Follow these defaults unless the user explicitly says otherwise:

  1. Don't ask unnecessary questions. Pick reasonable labware, volumes, and pipettes. Just produce a valid, working protocol.
  2. Keep protocols minimal. Use the fewest steps needed to demonstrate the requested behavior. Don't generate dozens of repeated transfers — 2–4 operations are enough to validate a feature.
  3. Always define liquids with protocol.define_liquid() and well.load_liquid() for all source wells.
  4. Default to liquid class functions (transfer_with_liquid_class, distribute_with_liquid_class, consolidate_with_liquid_class) on Flex with API >= 2.24. Fall back to plain transfer/distribute/consolidate only for OT-2 or when the user explicitly asks.
  5. Default to Flex unless the user specifies OT-2.
  6. Use the latest API version unless the user specifies otherwise. Look up MAX_SUPPORTED_VERSION in api/src/opentrons/protocols/api_support/definitions.py to get the current value.
  7. Default liquid class: water. Use glycerol_50 or ethanol_80 if the protocol context calls for viscous or volatile liquids.
  8. Reasonable defaults: flex_1channel_1000 pipette, opentrons_flex_96_tiprack_1000ul tip rack, nest_96_wellplate_2ml_deep plate, 100 µL transfer volume.

Quick Start — Flex Protocol (Default)

from opentrons import protocol_api

metadata = {
    "protocolName": "Liquid Class Transfer Demo",
    "author": "Opentrons",
    "description": "Minimal transfer using liquid classes",
}

requirements = {"robotType": "Flex", "apiLevel": "<MAX_SUPPORTED_VERSION>"}
# ^^^ Replace <MAX_SUPPORTED_VERSION> with the value from
# api/src/opentrons/protocols/api_support/definitions.py

def run(protocol: protocol_api.ProtocolContext) -> None:
    trash = protocol.load_trash_bin("A3")

    tiprack = protocol.load_labware("opentrons_flex_96_tiprack_1000ul", "D2")
    source_plate = protocol.load_labware("nest_96_wellplate_2ml_deep", "D1")
    dest_plate = protocol.load_labware("nest_96_wellplate_2ml_deep", "C1")

    pipette = protocol.load_instrument(
        "flex_1channel_1000", mount="left", tip_racks=[tiprack]
    )

    # Define and load liquids
    sample = protocol.define_liquid(
        name="Sample", description="Aqueous sample", display_color="#0088FF"
    )
    source_plate["A1"].load_liquid(liquid=sample, volume=500)
    source_plate["A2"].load_liquid(liquid=sample, volume=500)

    # Use liquid class transfer (default: water)
    water = protocol.get_liquid_class(name="water")
    pipette.transfer_with_liquid_class(
        liquid_class=water,
        volume=100,
        source=[source_plate["A1"], source_plate["A2"]],
        dest=[dest_plate["A1"], dest_plate["A2"]],
        new_tip="always",
    )

Quick Start — OT-2 Protocol

from opentrons import protocol_api

metadata = {
    "protocolName": "OT-2 Transfer Demo",
    "author": "Opentrons",
    "description": "Minimal transfer for OT-2",
}

requirements = {"robotType": "OT-2", "apiLevel": "<MAX_SUPPORTED_VERSION>"}

def run(protocol: protocol_api.ProtocolContext) -> None:
    tiprack = protocol.load_labware("opentrons_96_tiprack_300ul", "1")
    source_plate = protocol.load_labware("nest_96_wellplate_2ml_deep", "2")
    dest_plate = protocol.load_labware("nest_96_wellplate_2ml_deep", "3")

    pipette = protocol.load_instrument(
        "p300_single_gen2", mount="left", tip_racks=[tiprack]
    )

    sample = protocol.define_liquid(
        name="Sample", description="Aqueous sample", display_color="#0088FF"
    )
    source_plate["A1"].load_liquid(liquid=sample, volume=500)

    pipette.transfer(100, source_plate["A1"], dest_plate["A1"])

Required Elements

  1. requirements dict — robotType ("Flex" or "OT-2") and apiLevel
  2. def run(protocol): — entry point receiving ProtocolContext
  3. Flex only: must load trash bin or waste chute before any drop_tip

metadata dict is optional but recommended. apiLevel goes in metadata OR requirements, not both.

Look up the current max API version from MAX_SUPPORTED_VERSION in api/src/opentrons/protocols/api_support/definitions.py. Flex requires >= 2.15.

Liquid Classes (Default for Flex)

Available liquid classes (Flex, API >= 2.24):

NameTypeWhen to Use
waterAqueousDefault for most protocols
glycerol_50ViscousViscous samples, glycerol solutions
ethanol_80VolatileEthanol, volatile solvents
water = protocol.get_liquid_class(name="water")

# Transfer (1-to-1)
pipette.transfer_with_liquid_class(
    liquid_class=water, volume=100,
    source=[plate["A1"]], dest=[plate["B1"]],
    new_tip="always",
)

# Distribute (1-to-many)
pipette.distribute_with_liquid_class(
    liquid_class=water, volume=50,
    source=reservoir["A1"], dest=plate.rows()[0][:4],
    new_tip="once",
)

# Consolidate (many-to-1)
pipette.consolidate_with_liquid_class(
    liquid_class=water, volume=50,
    source=plate.rows()[0][:4], dest=reservoir["A1"],
    new_tip="once",
)

Defining Liquids (Always Do This)

sample = protocol.define_liquid(
    name="Sample", description="Aqueous sample", display_color="#0088FF"
)
buffer = protocol.define_liquid(
    name="Buffer", description="Wash buffer", display_color="#00CC66"
)
reagent = protocol.define_liquid(
    name="Reagent", description="Reaction reagent", display_color="#FF4444"
)

source_plate["A1"].load_liquid(liquid=sample, volume=500)
reservoir["A1"].load_liquid(liquid=buffer, volume=10000)

Common display colors: #0088FF (blue/sample), #00CC66 (green/buffer), #FF4444 (red/reagent), #FFB800 (yellow/media), #9933FF (purple/enzyme), #FF6B35 (orange/beads).

OT-2 vs Flex Key Differences

FeatureOT-2Flex
Deck slots1–11 (numeric)A1–D4 (alphanumeric)
TrashFixed (slot 12)Must call load_trash_bin()
Liquid classesNot supportedget_liquid_class() (API 2.24+)
GripperN/Amove_labware(lw, dest, use_gripper=True)
96-channelN/Aflex_96channel_1000

Flex Deck Layout

     1        2        3        4 (staging)
A  [ A1 ]  [ A2 ]  [ A3 ]  [ A4 ]
B  [ B1 ]  [ B2 ]  [ B3 ]  [ B4 ]
C  [ C1 ]  [ C2 ]  [ C3 ]  [ C4 ]
D  [ D1 ]  [ D2 ]  [ D3 ]  [ D4 ]

OT-2 Deck Layout

 10    11    12(trash)
  7     8     9
  4     5     6
  1     2     3

Pipettes

Flex

NameChannelsRange
flex_1channel_5011–50 µL
flex_1channel_20011–200 µL
flex_1channel_100015–1000 µL
flex_8channel_5081–50 µL
flex_8channel_20081–200 µL
flex_8channel_100085–1000 µL
flex_96channel_200961–200 µL
flex_96channel_1000965–1000 µL

OT-2

NameChannelsRange
p20_single_gen211–20 µL
p300_single_gen2120–300 µL
p1000_single_gen21100–1000 µL
p20_multi_gen281–20 µL
p300_multi_gen2820–300 µL

Common Labware

Flex tip racks: opentrons_flex_96_tiprack_50ul, opentrons_flex_96_tiprack_200ul, opentrons_flex_96_tiprack_1000ul

OT-2 tip racks: opentrons_96_tiprack_20ul, opentrons_96_tiprack_300ul, opentrons_96_tiprack_1000ul

Plates: nest_96_wellplate_2ml_deep, corning_96_wellplate_360ul_flat, opentrons_96_wellplate_200ul_pcr_full_skirt, nest_96_wellplate_200ul_flat

Reservoirs: nest_12_reservoir_15ml, nest_1_reservoir_195ml, nest_1_reservoir_290ml

Tube racks: opentrons_24_tuberack_nest_1.5ml_snapcap, opentrons_6_tuberack_nest_50ml_conical

Modules Quick Reference

temp_mod = protocol.load_module("temperature module gen2", "D1")
tc = protocol.load_module("thermocycler module gen2")        # A1+B1 on Flex
hs = protocol.load_module("heaterShakerModuleV1", "C1")
mag_block = protocol.load_module("magneticBlockV1", "C1")    # Flex only
mag_mod = protocol.load_module("magnetic module gen2", "1")   # OT-2 only
apr = protocol.load_module("absorbanceReaderV1", "B3")        # Flex, API 2.21+
stacker = protocol.load_module("flexStackerModuleV1", "D4")   # Flex, API 2.25+

For detailed module operations, see reference-modules.md.

Runtime Parameters (API 2.18+)

def add_parameters(parameters: protocol_api.Parameters) -> None:
    parameters.add_int(variable_name="sample_count", display_name="Samples",
                       default=8, minimum=1, maximum=96)
    parameters.add_bool(variable_name="dry_run", display_name="Dry Run", default=False)

def run(protocol: protocol_api.ProtocolContext) -> None:
    count = protocol.params.sample_count

For complete RTP guide, see reference-rtp.md.

Working Directories (Monorepo Root)

All local dev artifacts live in these gitignored directories:

DirectoryPurpose
tmp-protocols/Protocol .py files
tmp-custom-labware/Custom labware .json definitions
tmp-csv/CSV files for RTP inputs

Custom Labware

Custom labware JSON files go in tmp-custom-labware/. The parameters.loadName in the JSON is the string passed to load_labware().

Creating a Custom Labware Definition

The easiest starting point is copying an existing definition from shared-data/labware/definitions/2/<name>/<version>.json and modifying the key fields:

{
  "namespace": "custom",
  "version": 1,
  "parameters": {
    "loadName": "my_custom_plate"
  },
  "metadata": {
    "displayName": "My Custom Plate"
  }
  ...
}

Required changes when deriving from an existing definition:

  • parameters.loadName → your unique load name (no spaces, underscores OK)
  • namespace → "custom" (must not be "opentrons")
  • version → 1
  • metadata.displayName → human-readable name

Save as tmp-custom-labware/<loadName>.json (file name convention matches loadName).

Using Custom Labware in a Protocol

plate = protocol.load_labware("my_custom_plate", "D1")

No special import needed — the CLI handles loading the definition at run time.

For a proper custom labware definition from scratch, use the Opentrons Labware Creator

CSV Runtime Parameters

CSV files go in tmp-csv/. They are used exclusively via the add_csv_file RTP type (API 2.20+).

Defining a CSV Parameter

def add_parameters(parameters: protocol_api.Parameters) -> None:
    parameters.add_csv_file(
        variable_name="transfer_map",
        display_name="Transfer Map",
        description="CSV with columns: source_well, dest_well, volume_ul",
    )

Using the CSV in run()

rows = protocol.params.transfer_map.parse_as_csv()
# rows is a list of lists; rows[0] is the header row
for row in rows[1:]:
    src, dst, vol = row[0].strip(), row[1].strip(), float(row[2].strip())
    pipette.transfer(vol, source[src], dest[dst])

Example CSV (tmp-csv/transfer_map.csv)

source_well,dest_well,volume_ul
A1,A1,100
A2,A2,150
A3,A3,75

Note: opentrons_simulate cannot accept RTP files. Protocols with CSV RTPs must be verified with opentrons analyze. See the protocol-verification skill.

Additional References

Skill Reference Files (in this directory)

FileWhen to use
reference-liquid-handling.mdDetailed liquid handling patterns, tip math, transfer anti-patterns
reference-modules.mdModule load names, operations, Flex Stacker, APR
reference-rtp.mdRuntime parameters — all types, CSV RTPs
reference-source-map.mdSource code navigation for debugging
reference-labware-deck.mdCommon labware load names, deck layout rules (Flex + OT-2), OT-2→Flex migration
reference-96channel.md96-channel pipette constraints, nozzle configs, tip adapter rules
reference-examples-index.mdIndex of AI server example docs — what each covers and when to read it

AI Server Source Docs (read on demand)

Located in opentrons-ai-server/api/storage/docs/. Use reference-examples-index.md to decide which file to read. Do not read all of them — they total ~10,000 lines. Read only what the current task needs.

FileContents
full-examples.mdComplete production protocols (PCR, reagent transfer, HS)
casual_examples.mdCasual NL → protocol mappings, pooling, triplicates
serial_dilution_examples.mdSerial dilution patterns (single/multi-channel, row/column-wise)
pcr_protocols_with_csv.mdPCR + CSV RTP well mapping, thermocycler profiles
transfer_function_notes.mdtransfer() deep dive — loops, tip behavior, modules
out_of_tips_error_219.mdTip math, multi-channel capacity, index error prevention
commands-v0.0.1.mdCommon command patterns and pitfalls
standard-loadname-info.mdFull labware catalog (86 items)
96-channel-pipette.mdFull 96-channel guide (see reference-96channel.md for summary)
deck_layout.mdFull deck rules (see reference-labware-deck.md for summary)
OT2ToFlex.mdFull migration guide (see reference-labware-deck.md for summary)
transfer_with_liquid_class.mdLiquid class transfer differences and custom properties
flex_stacker_usage.mdFlex Stacker patterns (see reference-modules.md for summary)
runtime_parameters.mdRTP examples (see reference-rtp.md for summary)

Keeping This Skill Current

Update this skill whenever you discover something new. These files are the team's shared knowledge base — stale information hurts everyone.

TriggerWhat to update
A new API method, parameter, or behavior is usedAdd it to the relevant section in SKILL.md or the appropriate reference-*.md
A bug or constraint is found via source inspectionAdd it to reference-source-map.md under the relevant debugging section
MAX_SUPPORTED_VERSION changesCheck api/src/opentrons/protocols/api_support/definitions.py and add any new API-version-gated features to the skill
A new labware load name is usedAdd it to the Common Labware list
A new liquid class becomes available in shared-data/liquid-class/definitions/Add it to the Liquid Classes table
Actual behavior differs from what this skill saysCorrect the skill, not just the protocol
A new module is supportedAdd it to the Modules section and reference-modules.md

How to update: use the Write or StrReplace tools on the relevant skill file. Keep edits focused — fix only what changed. Don't rewrite sections that are still accurate.

Common Mistakes

  • Forgetting load_trash_bin() on Flex
  • Using OT-2 pipette names on Flex or vice versa
  • Putting apiLevel in both metadata and requirements
  • Using numeric slots on Flex or alpha on OT-2
  • Exceeding pipette volume range
  • Using transfer with new_tip="never" without calling pick_up_tip() first
  • Forgetting to define_liquid / load_liquid for source wells
  • Using plain transfer on Flex when transfer_with_liquid_class is available
  • Calling apr.initialize() without apr.close_lid() first (APR lid must be closed before init)
  • Passing only the top well (e.g. plate["A1"]) to 8-channel *_with_liquid_class — must pass full column or set group_wells=False
  • Using f-strings or variable references in metadata dict — the parser requires static literals only (no f"...", no {var}, no function calls)
  • Wrapping transfer() in a for loop over wells — transfer() handles iteration internally; pass lists instead
  • Using 8-channel pipette with wells() instead of columns() — 8-channel picks up an entire column at once
  • Not accounting for 8-channel tip math: one pick_up_tip() = 8 tips; a single 96-well rack supports only 12 column operations
  • Loading a 96-channel pipette without adapter="opentrons_flex_96_tiprack_adapter" for full (ALL) tip pickup
  • Using start="A1" for 96-channel COLUMN mode — always use start="A12" to avoid deck edge collision
  • Placing a tube rack in a staging area slot (A4–D4) — gripper cannot safely move tube racks
  • Loading the Heater-Shaker or Temperature Module in column 2 slots (A2, B2, C2, D2) — forbidden on Flex

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.