Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Hardware-agnostic Python liquid-handler library: portable scripts run on Hamilton STAR, Tecan Freedom EVO, Opentrons OT-2, or a simulator without vendor lock-in. For protocol automation, method dev, plate reformatting, serial dilutions, and Python lab workflows.
.claude/skills/jaechang-hits-pylabrobot/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 113% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 138% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 82% | 0% |
| case-19 | ✗→✓ | ▲ Improved | 243% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 242% | 0% |
PyLabRobot is an open-source Python library that abstracts liquid handling robot hardware behind a unified API. Write a protocol once and run it on any supported robot — Hamilton STAR, Tecan Freedom EVO, Opentrons OT-2, or a simulated backend — without changing the protocol code. PyLabRobot handles deck layout, resource management, and aspirate/dispense operations through a clean, async-first interface.
opentrons Python SDK instead; for multi-vendor portability use PyLabRobot.pylabrobotpylabrobot[hamilton] for Hamilton STAR, pylabrobot[opentrons] for OT-2bashpip install pylabrobot pip install "pylabrobot[hamilton]" # add Hamilton USB driver pip install "pylabrobot[opentrons]" # add Opentrons REST driver
pythonimport asyncio from pylabrobot.liquid_handling import LiquidHandler from pylabrobot.liquid_handling.backends import SimulatorBackend from pylabrobot.resources import Deck, Cos_96_Rd, HTF_L async def main(): backend = SimulatorBackend(open_browser=False) lh = LiquidHandler(backend=backend, deck=Deck()) await lh.setup() plate = Cos_96_Rd(name="plate") tips = HTF_L(name="tips") lh.deck.assign_child_resource(plate, rails=2) lh.deck.assign_child_resource(tips, rails=5) await lh.pick_up_tips(tips["A1"]) await lh.aspirate(plate["A1"], vols=50) await lh.dispense(plate["B1"], vols=50) await lh.drop_tips(tips["A1"]) await lh.stop() print("Transfer complete: 50 uL from A1 -> B1") asyncio.run(main())
The LiquidHandler class is the central controller. It wraps a backend and a Deck.
pythonimport asyncio from pylabrobot.liquid_handling import LiquidHandler from pylabrobot.liquid_handling.backends import SimulatorBackend from pylabrobot.resources import Deck async def main(): backend = SimulatorBackend(open_browser=False) lh = LiquidHandler(backend=backend, deck=Deck()) await lh.setup() # connect to hardware / start simulator print("LiquidHandler ready:", lh) await lh.stop() # disconnect cleanly asyncio.run(main())
python# Connecting to a real Hamilton STAR from pylabrobot.liquid_handling.backends.hamilton import STAR async def main(): backend = STAR() lh = LiquidHandler(backend=backend, deck=Deck()) await lh.setup() # lh is now connected to physical hardware await lh.stop()
Resources (plates, tip racks, reservoirs) are placed on the deck by rail position.
pythonfrom pylabrobot.resources import ( Deck, Cos_96_Rd, # Corning 96-well round-bottom plate Cos_384_Sq, # Corning 384-well plate HTF_L, # Hamilton tip rack (filtered, large) Trough_1_Row_1_Col_4, # 4-channel reservoir ) deck = Deck() plate_96 = Cos_96_Rd(name="sample_plate") plate_384 = Cos_384_Sq(name="assay_plate") tips = HTF_L(name="tip_rack") reservoir = Trough_1_Row_1_Col_4(name="buffer") deck.assign_child_resource(plate_96, rails=1) deck.assign_child_resource(plate_384, rails=4) deck.assign_child_resource(tips, rails=8) deck.assign_child_resource(reservoir, rails=11) print("Deck resources:", [r.name for r in deck.children])
Pick up and drop tips before and after liquid operations.
python# Pick up tips from the first column of the tip rack await lh.pick_up_tips(tips["A1:H1"]) # all 8 tips in column 1 # After liquid operations, drop tips back await lh.drop_tips(tips["A1:H1"]) # Single tip await lh.pick_up_tips(tips["A1"]) await lh.drop_tips(tips["A1"]) print("Tip operations complete")
Aspirate liquid from wells. Accepts single wells, ranges, or lists.
python# Aspirate 100 uL from a single well await lh.aspirate(plate["A1"], vols=100) # Aspirate different volumes from multiple wells simultaneously await lh.aspirate( plate["A1:A4"], vols=[50, 75, 100, 125], ) print("Aspiration complete")
pythonfrom pylabrobot.resources import Coordinate # Aspirate with flow rate and liquid height control await lh.aspirate( plate["A1"], vols=50, flow_rates=100, # uL/s offsets=Coordinate(0, 0, 1), # 1 mm above well bottom )
Dispense liquid into target wells.
python# Dispense 100 uL into a single well await lh.dispense(plate["B1"], vols=100) # Multi-well dispense with different volumes await lh.dispense( plate["B1:B4"], vols=[50, 75, 100, 125], ) print("Dispense complete")
transfer combines aspirate and dispense for simple source-to-destination moves.
python# Transfer 50 uL from A1 -> B1 await lh.transfer(plate["A1"], plate["B1"], transfer_volume=50) # Multi-well pairwise transfer sources = plate["A1:A8"] destinations = plate["B1:B8"] await lh.transfer(sources, destinations, transfer_volume=75) print("Transfer complete")
The SimulatorBackend runs a browser-based visualizer for protocol debugging.
pythonfrom pylabrobot.liquid_handling.backends import SimulatorBackend # With visual browser (default — opens http://localhost:2121) backend = SimulatorBackend(open_browser=True) # Headless simulation (CI/testing) backend = SimulatorBackend(open_browser=False) # After setup(), liquid movements are visualized in real time await lh.setup() # Check browser for visual confirmation before running on real hardware print("Simulator running at http://localhost:2121")
All robot operations (setup, aspirate, dispense, transfer) are Python async coroutines. Run them inside an async def function using asyncio.run() or Jupyter's top-level await syntax.
pythonimport asyncio async def run_protocol(lh, plate, tips): await lh.pick_up_tips(tips["A1"]) await lh.aspirate(plate["A1"], vols=50) await lh.dispense(plate["B1"], vols=50) await lh.drop_tips(tips["A1"]) print("Protocol complete") asyncio.run(run_protocol(lh, plate, tips))
Wells are addressed by alphanumeric position ("A1") or slice notation ("A1:H1" for a column, "A1:A12" for a row).
pythonwell = plate["A1"] # single well col1 = plate["A1:H1"] # 8 wells in column 1 row_a = plate["A1:A12"] # 12 wells in row A print(f"Single: {well.name}") print(f"Column: {len(col1)} wells") print(f"Row: {len(row_a)} wells")
Goal: Perform a 2-fold serial dilution across a 96-well plate.
pythonimport asyncio from pylabrobot.liquid_handling import LiquidHandler from pylabrobot.liquid_handling.backends import SimulatorBackend from pylabrobot.resources import Deck, Cos_96_Rd, HTF_L, Trough_1_Row_1_Col_4 async def serial_dilution(): backend = SimulatorBackend(open_browser=False) lh = LiquidHandler(backend=backend, deck=Deck()) await lh.setup() plate = Cos_96_Rd(name="plate") tips = HTF_L(name="tips") diluent = Trough_1_Row_1_Col_4(name="diluent") lh.deck.assign_child_resource(plate, rails=1) lh.deck.assign_child_resource(tips, rails=5) lh.deck.assign_child_resource(diluent, rails=9) # Add 100 uL diluent to columns 2-12 for col in range(2, 13): col_label = f"A{col}:H{col}" await lh.pick_up_tips(tips[f"A{col}:H{col}"]) await lh.aspirate(diluent["A1:H1"], vols=100) await lh.dispense(plate[col_label], vols=100) await lh.drop_tips(tips[f"A{col}:H{col}"]) # Serial transfer: col 1 -> 2 -> ... -> 11 for col in range(1, 12): src = f"A{col}:H{col}" dst = f"A{col+1}:H{col+1}" await lh.pick_up_tips(tips[f"A{col}:H{col}"]) await lh.aspirate(plate[src], vols=100) await lh.dispense(plate[dst], vols=100) await lh.drop_tips(tips[f"A{col}:H{col}"]) print("Serial dilution complete: 12 columns, 2-fold steps") await lh.stop() asyncio.run(serial_dilution())
Goal: Transfer compounds from specified source wells to a destination plate based on a CSV hit list.
pythonimport asyncio import pandas as pd from pylabrobot.liquid_handling import LiquidHandler from pylabrobot.liquid_handling.backends import SimulatorBackend from pylabrobot.resources import Deck, Cos_96_Rd, HTF_L async def cherry_pick(hit_list_csv: str, volume: float = 50.0): # CSV must have columns: source_well, dest_well hits = pd.read_csv(hit_list_csv) print(f"Cherry-picking {len(hits)} hits at {volume} uL each") backend = SimulatorBackend(open_browser=False) lh = LiquidHandler(backend=backend, deck=Deck()) await lh.setup() src = Cos_96_Rd(name="source") dst = Cos_96_Rd(name="destination") tips = HTF_L(name="tips") lh.deck.assign_child_resource(src, rails=1) lh.deck.assign_child_resource(dst, rails=4) lh.deck.assign_child_resource(tips, rails=8) # Get all well names from tip rack tip_wells = [w.name for w in tips.wells] for i, row in hits.iterrows(): await lh.pick_up_tips(tips[tip_wells[i]]) await lh.transfer(src[row["source_well"]], dst[row["dest_well"]], transfer_volume=volume) await lh.drop_tips(tips[tip_wells[i]]) print(f"Cherry-pick complete: {len(hits)} transfers done") await lh.stop() # asyncio.run(cherry_pick("hits.csv", volume=50))
| Parameter | Module | Default | Range / Options | Effect | |-----------|--------|---------|-----------------|--------| | vols | aspirate / dispense | required | 0 – robot max (µL) | Volume to aspirate or dispense per well | | flow_rates | aspirate / dispense | backend default | 10 – 1000 µL/s | Speed of liquid movement | | blow_out_air_volume | dispense | 0 | 0 – 30 µL | Air volume blown after dispense to empty tip | | offsets | aspirate / dispense | Coordinate(0,0,0) | Any Coordinate | Positional offset from well center (x, y, z mm) | | open_browser | SimulatorBackend | True | True, False | Open browser-based visual simulator on setup | | rails | deck assignment | required | 1 – max deck rails | Physical slot on the deck for a resource | | transfer_volume | transfer | required | 0 – robot max (µL) | Volume for high-level aspirate+dispense transfer |
SimulatorBackend(open_browser=True) before connecting to physical hardware. The browser visualizer shows deck layout and liquid movements in real time.await lh.stop() to release hardware connections.python try: await run_my_protocol(lh) finally: await lh.stop()
When to use: Reagent addition to cell culture wells requiring homogeneous mixing.
pythonasync def dispense_and_mix(lh, src, dst, tips, volume=50, mix_vol=40, mix_reps=3): await lh.pick_up_tips(tips["A1"]) await lh.aspirate(src["A1"], vols=volume) await lh.dispense(dst["A1"], vols=volume) for _ in range(mix_reps): await lh.aspirate(dst["A1"], vols=mix_vol) await lh.dispense(dst["A1"], vols=mix_vol) await lh.drop_tips(tips["A1"]) print(f"Dispensed {volume} uL and mixed {mix_reps}x")
When to use: Replicate an entire 96-well plate to a second plate.
pythonasync def stamp_plate(lh, src_plate, dst_plate, tips, volume=100): for col in range(1, 13): col_label = f"A{col}:H{col}" await lh.pick_up_tips(tips[col_label]) await lh.aspirate(src_plate[col_label], vols=volume) await lh.dispense(dst_plate[col_label], vols=volume) await lh.drop_tips(tips[col_label]) print(f"Full plate stamped: {volume} uL per well, 12 columns")
http://localhost:2121 shows animated deck with per-well volume trackingpandas / CSV logging in wrapper code as needed| Problem | Cause | Solution | |---------|-------|----------| | RuntimeError: No backend connected | lh.setup() not awaited before operations | Ensure await lh.setup() completes before any liquid handling call | | ResourceNotFoundError | Resource name not assigned to deck | Call deck.assign_child_resource(resource, rails=N) before referencing wells | | asyncio.InvalidStateError | Coroutine called outside async context | Wrap top-level calls in async def main() and use asyncio.run(main()) | | Well address KeyError | Incorrect well label format | Use uppercase letter + integer: "A1", "H12", not "a1" or "A01" | | VolumeError: exceeds tip capacity | Requested volume larger than tip max | Use appropriate tip type; HTF_L holds up to 1000 µL | | Simulator shows no movement | open_browser=False with no viewer | Set open_browser=True or open http://localhost:2121 manually | | ImportError: pylabrobot.hamilton | Backend extras not installed | pip install "pylabrobot[hamilton]" |
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 25,466 | 43,345 | +70% | 1 | 1 | 0% | 3,451 | 7,336 | +113% | 0 | 0 | — |
case-02 | fail→pass | 17,301 | 13,096 | -24% | 1 | 1 | 0% | 3,030 | 7,221 | +138% | 0 | 0 | — |
case-03 | fail→pass | 17,993 | 7,953 | -56% | 1 | 1 | 0% | 3,470 | 6,330 | +82% | 0 | 0 | — |
case-04 | pass→pass | 11,933 | 4,530 | -62% | 1 | 1 | 0% | 1,914 | 5,396 | +182% | 0 | 0 | — |
case-05 | pass→pass | 9,573 | 7,816 | -18% | 1 | 1 | 0% | 1,398 | 5,962 | +326% | 0 | 0 | — |
case-19 | fail→pass | 9,022 | 4,114 | -54% | 1 | 1 | 0% | 1,579 | 5,414 | +243% | 0 | 0 | — |
case-06 | pass→pass | 9,988 | 6,504 | -35% | 1 | 1 | 0% | 1,513 | 5,784 | +282% | 0 | 0 | — |
case-07 | pass→pass | 37,824 | 7,097 | -81% | 1 | 1 | 0% | 2,011 | 6,025 | +200% | 0 | 0 | — |
case-08 | fail→pass | 9,448 | 5,913 | -37% | 1 | 1 | 0% | 1,691 | 5,785 | +242% | 0 | 0 | — |
case-09 | fail→pass | 15,395 | 3,917 | -75% | 1 | 1 | 0% | 3,023 | 5,517 | +83% | 0 | 0 | — |
case-10 | pass→pass | 13,187 | 7,014 | -47% | 1 | 1 | 0% | 2,482 | 6,189 | +149% | 0 | 0 | — |
case-11 | fail→pass | 12,887 | 6,329 | -51% | 1 | 1 | 0% | 2,631 | 6,065 | +131% | 0 | 0 | — |
case-12 | pass→pass | 11,666 | 8,104 | -31% | 1 | 1 | 0% | 2,245 | 6,349 | +183% | 0 | 0 | — |
case-13 | pass→pass | 9,499 | 6,163 | -35% | 1 | 1 | 0% | 1,665 | 6,054 | +264% | 0 | 0 | — |
case-14 | fail→pass | 16,475 | 13,459 | -18% | 1 | 1 | 0% | 3,106 | 7,415 | +139% | 0 | 0 | — |
case-15 | pass→pass | 15,198 | 16,402 | +8% | 1 | 1 | 0% | 2,881 | 5,829 | +102% | 0 | 0 | — |
case-16 | pass→pass | 10,165 | 7,237 | -29% | 1 | 1 | 0% | 1,776 | 6,058 | +241% | 0 | 0 | — |
case-17 | pass→pass | 10,214 | 10,207 | -0% | 1 | 1 | 0% | 2,034 | 6,803 | +234% | 0 | 0 | — |
case-18 | fail→pass | 8,063 | 1,730 | -79% | 1 | 1 | 0% | 1,420 | 4,938 | +248% | 0 | 0 | — |
case-20 | pass→pass | 6,998 | 3,234 | -54% | 1 | 1 | 0% | 1,160 | 5,217 | +350% | 0 | 0 | — |
case-21 | pass→pass | 27,459 | 2,860 | -90% | 1 | 1 | 0% | 2,271 | 5,225 | +130% | 0 | 0 | — |
case-22 | fail→pass | 14,234 | 18,656 | +31% | 1 | 1 | 0% | 2,666 | 6,298 | +136% | 0 | 0 | — |
case-23 | fail→pass | 20,371 | 2,285 | -89% | 1 | 1 | 0% | 3,664 | 5,115 | +40% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +48 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.