Your First Protocol
This tutorial walks the full physical-layer dependency chain in one runnable script. By the end you will have executed a pipette transfer against the Hamilton simulator and, more importantly, you will understand why the order of the chain matters. Every protocol you write from now on will follow the same shape.
Why this matters
The Operate overview sets out the dependency chain every protocol has to satisfy: labware → deck → device → commands. Writing protocols feels difficult when people jump straight to the last stage without building the first three. This tutorial wires all four together in the simplest possible end-to-end example so the chain becomes muscle memory.
You will use the Hamilton simulator throughout, so no hardware is required.
Prerequisites
- Python 3.10 or newer
uvinstalled- The UniteLabs SDK and liquid handling packages (installed below)
- Familiarity with the simulator is helpful but not required
Set up the project
Create a new directory and initialize the project:
mkdir first-protocol && cd first-protocol
uv init --no-workspace
uv add unitelabs-labware unitelabs-liquid-handling
Create a single script file:
touch protocol.py
The rest of the tutorial lives in protocol.py. Build it up step by step; each step below adds to the same file.
Step 1 — Define your labware
Before anything moves, the SDK needs to know what physical objects exist. In this step you create Python objects for every piece of labware the protocol will touch. You are not placing them anywhere yet; you are just defining them.
Open protocol.py and add:
from unitelabs.labware import PredefinedLiquids, Standard96Plate, StandardTrough
from unitelabs.labware.hamilton import (
PLT_CAR_L5MD_A00,
TIP_CAR_480_A00,
HamiltonTip_300,
HamiltonTipRack_300,
)
# A 96-well destination plate
plate = Standard96Plate(identifier="destination_plate")
# A reservoir of water as the source
trough = StandardTrough(identifier="water_source")
trough.containers[0].add_liquid(PredefinedLiquids.WATER, 150_000) # 150 mL
# A rack of filter tips
tip_rack = HamiltonTipRack_300(filled_with=HamiltonTip_300)
# Two carriers — one for tips, one for the plate + trough
tip_carrier = TIP_CAR_480_A00(identifier="tip_carrier")
plate_carrier = PLT_CAR_L5MD_A00(identifier="plate_carrier")
A few things worth noticing:
- Every labware gets a stable
identifier=. This is what you will reference later when you save or reload a deck. Auto-generated identifiers change every run and make saved layouts non-reproducible. - The
troughhas aContainerthat tracks liquid volume. You can add, remove, and inspect volumes on anyFillablelabware; this is what lets the simulator catch overfill and underflow errors before they reach real samples. - Tips are "in" a tip rack, plates are "on" a carrier. The tree structure (carrier → slot → plate → well) is how the SDK computes absolute positions for every well.
For the full model see Labware and Standard Labware.
Step 2 — Start the simulator and place labware on its deck
The deck is an attribute of the liquid handler, not a standalone object. You bring up the simulator (which gives you a device context with its own deck), then place your carriers on that deck at specific tracks.
Add to protocol.py:
import asyncio
from unitelabs.liquid_handling.testing import MicrolabSTARMock
async def run():
lh = MicrolabSTARMock()
await lh.configure()
await lh.initialize()
# Wire labware into carriers
tip_carrier[0] = tip_rack
plate_carrier[0] = plate
plate_carrier[1] = trough
# Place the carriers on the deck at physical tracks
lh.deck.add(tip_carrier, track=7)
lh.deck.add(plate_carrier, track=1)
print(lh.deck.summary())
Run the script so far to confirm the deck prints cleanly:
uv run python -c "import asyncio; from protocol import run; asyncio.run(run())"
You should see a summary showing the plate carrier on tracks 1–6 (plate in slot 0, trough in slot 1) and the tip carrier on tracks 7–12 (tip rack in slot 0).
If a resource collides with another, or if you place something outside the deck bounds, the simulator raises immediately. That is the point of this step. Deck problems are cheap to fix before any liquid moves.
For advanced patterns like saving the deck to JSON and reloading it across runs, see Building a Deck.
Step 3 — Run your first command
Now that the SDK knows what exists and where it is, you can issue a command. The dependency chain is satisfied: plate["A1"] has a resolvable absolute position because the plate is in a carrier which is on track 1 of a known deck.
Extend run():
from unitelabs.labware.hamilton import LiquidClass
liquid_class = LiquidClass.HamiltonTip_300_Water_DispenseJet_Empty()
# Pick up 8 tips from column 1 of the tip rack
await lh.pipettes.pick_up_tips_from(channels=range(8), rack=tip_rack)
# Aspirate 100 µL of water from the trough
await lh.pipettes.aspirate(
source=trough,
channels=range(8),
volume=100,
liquid_class=liquid_class,
)
# Dispense into column 1 of the destination plate
await lh.pipettes.dispense(
target=plate["A1":"H1"],
channels=range(8),
volume=100,
liquid_class=liquid_class,
)
# Return the tips
await lh.pipettes.discard_tips(channels=range(8))
# Inspect the result
print("Trough volume:", trough.containers[0].volume)
print("Well A1 volume:", plate["A1"].container.volume)
Run the full script again. The simulator's liquid model tracks volumes through every operation, so you can verify your protocol logic before touching real samples: the trough should be 800 µL lighter (8 channels × 100 µL), and each well in column 1 should now contain 100 µL.
This is the hello-world of liquid handling. Every real protocol is a longer version of this shape: pick up tips, aspirate from some source, dispense into some destination, discard tips. What changes between protocols is the labware, the volumes, the liquid classes, and the loops.
Step 4 — Handle errors
A protocol that works only on the happy path is not a protocol; it is a demo. On real hardware, timeouts happen, tips fall off, and samples get left in unexpected states. The goal of error handling in lab automation is not graceful failure; it is leaving every instrument in a safe, known state before the script exits or retries.
Add logging to your imports at the top of protocol.py:
import logging
logging.basicConfig(level=logging.INFO)
Then wrap the commands in try / except / finally:
async def run():
lh = MicrolabSTARMock()
await lh.configure()
await lh.initialize()
tip_carrier[0] = tip_rack
plate_carrier[0] = plate
plate_carrier[1] = trough
lh.deck.add(tip_carrier, track=7)
lh.deck.add(plate_carrier, track=1)
try:
await lh.pipettes.pick_up_tips_from(channels=range(8), rack=tip_rack)
await lh.pipettes.aspirate(
source=trough,
channels=range(8),
volume=100,
liquid_class=liquid_class,
)
await lh.pipettes.dispense(
target=plate["A1":"H1"],
channels=range(8),
volume=100,
liquid_class=liquid_class,
)
except Exception:
logging.exception("Protocol step failed — attempting safe state")
raise
finally:
# Always return tips, whether or not the protocol succeeded
await lh.pipettes.discard_tips(channels=range(8))
The finally block runs regardless of success or failure. Here it guarantees that the channels never end up holding tips. A real run that crashes mid-protocol should not leave the pipette armed and blocking the next run.
The pattern generalizes. For a full protocol, every step that changes an instrument's physical state (door open, plate picked up, tips held) should have a corresponding safe-state action in finally or in a wider except block. See Error Handling for common failure patterns (timeout, unexpected state, partial completion, connection loss) and a safe-state checklist.
Where to go next
You have walked the full chain: labware → deck → device → command → error. Everything from here is a variation on this shape.
- Basic Pipetting: same pattern against Hamilton or Bravo, with finer control over liquid classes and tip handling.
- Labware and Standard Labware: the model behind the Python objects you instantiated in step 1.
- Building a Deck: save this deck layout to JSON and reload it across runs so team members and CI share the same physical setup.
- Your First Workflow: wrap this protocol in a Prefect flow so it can be scheduled, retried, and run unattended from the platform.