Architecture
This page covers how Orca Framework is put together: the layers a workflow is built from, how the runtime schedules those layers onto real hardware, and the rules it follows when two pieces of labware want the same spot. Read it if you are evaluating Orca, extending it, or explaining behaviour you did not expect. If you only want to write a workflow and run it, Actions and Threads cover that without any of this.
The first two sections are the whole model. Everything after them is detail.
The layers
Orca keeps two descriptions of your lab apart.
- A topology is the physical lab: which devices exist, where they sit, which arm moves things. You define it once.
- A workflow is what should happen to a piece of labware. It is defined separately, and the same workflow runs on a sim lab, a dev lab or a production lab without changes.
That separation is what makes a workflow portable. You develop and test against a simulated topology on your laptop, then run the same workflow against a topology that points at real hardware.
Inside a workflow, work nests five layers deep. A workflow holds one or more labware threads. A labware thread is the path one piece of labware takes through the lab, written with the @orca.thread decorator. A thread runs methods in order. A method is an ordered sequence of actions. An action runs at one device and issues one or more operations, the individual device calls. An operation is the smallest unit of work.
Workflow
└── Labware thread (one piece of labware; @orca.thread in code)
└── Method (an ordered sequence of actions)
└── Action (one device, one reservation)
└── Operation (one device call: aspirate, shake, seal)
Two things the diagram does not say. Threads run in parallel: a workflow with three threads has three pieces of labware moving through the lab at once. And the action is the layer Orca schedules and reserves for, so it is the layer to think in when two threads compete.
Above all of it sits the runtime, a long-running service that accepts workflow submissions, schedules them, moves labware, fires events and recovers from incidents. Everything the runtime can do is reachable from the Python API, the localhost daemon and the orca CLI, so external systems can drive the lab programmatically.
How work gets scheduled
When you submit a workflow:
- The runtime creates an execution, an instance of the workflow, and stamps it with the run mode.
- The starting thread spawns. Secondary threads spawn when their triggers fire: a join opportunity, or a contributing thread completing.
- For each thread, methods run in sequence. Before each method:
- The runtime reserves the method's device and the sites it owns, waiting if another thread holds it.
- The transporter moves the labware to the device.
- All actions in the method run.
- Threads can pause, branch, or park along the way. The runtime waits for events or operator input.
- When all methods complete, the transporter moves labware to the thread's end location.
- When all threads finish, the execution completes.
Two threads can want the same location, so claims on a location are ranked in three tiers:
- Labware standing on the spot. Nothing is ever granted a position a plate is on.
- A thread whose real plate is on its way there.
- A LIVE manual place wait, holding the spot open for a plate nobody has placed yet.
Tier 2 beats tier 3. The claim it beats is displaced, not dropped, and re-taken afterwards. That order is the one that finishes: the operator removes the plate that arrived, then places the next one. The reverse order finishes only if the operator happens to place first, and nothing tells them to. Every other reservation is unranked and waits its turn.
Reservations are taken as threads go, not pre-scheduled, so a cycle is possible. The runtime detects one by finding a knot in the wait-for structure and picking a thread to yield, moving its labware out of the way. The deadlock detector asks the same ranking question the reservation gate asks, so it never reports a thread as waiting on a claim it would in fact be granted over. What it cannot resolve, a stall detector catches: it pauses the execution and hands the operator something to act on instead of hanging.
The pieces in detail
Topology
Defined as a Topology(locations, transporters, pools):
- Locations: named positions that can hold labware. Each location holds either a device (a
Sealer,Shaker,LiquidHandler,Storage, and so on) or a passive resource (aPlatePad). - Transporters: robotic arms and translators that move labware between locations. Each has a teachpoint store mapping position names to physical coordinates.
- Resource pools: groups of interchangeable devices treated as one addressable target. An action bound to a pool runs at whichever member the runtime can reserve.
A topology is just data. It is defined separately from any workflow, so the same physical lab can run many workflows.
Underneath, a location that holds a device is not one node. Every device owns its labware sites, named <location>/<site>, and those sites are the nodes labware actually routes to. A single-nest shaker owns one (shaker/slot). A liquid handler owns one per deck position. The device name itself never enters the routing graph. It is the key the reservation system locks.
See Topology.
Transporters
Every mechanism that moves a plate is a transporter, including a liquid handler's own on-deck gripper. The gripper has no teachpoints: the build wires it as edges between that handler's deck sites, so a deck-to-deck hop is an ordinary routed move rather than a hidden side effect of the action.
A Translator is a shuttle: one carriage on a rail, so its taught positions hold at most one labware between them. Boarding it is granted only together with the reservations for the rest of the crossing, through to the first position where a plate can safely rest. Both or nothing. A plate can therefore never stop on the carriage and cut the bridge for every other thread.
See Transporters.
Actions
A step that runs at one device, and the unit Orca schedules and reserves for:
@orca.action(device=shaker, inputs=[plate])
async def shake(ctx: ActionContext):
await ctx.device().shake(duration=30, speed=500)
An action declares which device it runs on and which labware it consumes and produces. The body talks to the device through ctx.device(), a typed handle, and can call any method the device exposes. Each driver call the body makes is an operation, and one action can issue several.
The reservation an action takes covers the device and the sites that device owns. Another thread cannot slide labware into a free deck site of a device someone else is running an action on, unless that labware is one of the action's own declared inputs.
Actions can also emit events (ctx.emit(...)), wait for events (ctx.wait_for(...)), read workflow variables (await ctx.param(...)), and stop for a person (await ctx.manual_step("Refill the buffer trough")), which pauses the thread until an operator confirms.
See Actions.
Methods
A named, reusable sequence of actions:
@orca.method
async def shake_step(ctx: MethodContext):
yield shake
yield read_pressure
Each action inside the method is bound to its own device (or resource pool). The method itself just composes them. When a method yields an action, the transporter brings labware to that action's device, possibly a different device than the previous action. A method can set a failure_policy of PAUSE (the default, which stops the thread for an operator) or ABORT.
See Methods.
Labware threads
A labware thread is the path one piece of labware takes:
@orca.thread(labware=sample_plate, start=("stacker", DISPENSE), end="waste")
async def sample_thread(ctx: ThreadContext):
yield shake_step
yield seal_step
The decorator declares the labware template, where the labware starts, where it ends, and the body yields methods in order. start and end also say how the labware arrives and leaves: an operator places and removes it by default, DISPENSE has a stacker feed it, and REUSE_EXISTING / LEAVE_IN_PLACE keep deck-resident labware alive across runs.
Threads support more flow primitives: orca.join() to synchronize with other threads on a shared method, orca.branch() for conditional paths based on events, orca.park() to hold labware at a declared parking spot (or at whichever of several is granted first), and orca.on() to wait for a named event.
See Threads.
Workflows
A workflow ties threads together:
@orca.workflow(name="my_assay")
def my_assay(wf: WorkflowContext):
wf.start(sample_thread) # primary thread, kicks off the workflow
wf.thread(tips_thread) # secondary thread, spawned when needed
wf.on("ACTION.COMPLETED", MyHandler()) # event handler
wf.variable("dilution_factor", VariableDefinition(...))
Workflows are the unit you submit to the runtime. A single workflow can have many threads, branches, joins, and event handlers. The workflow function is a regular def: it declares structure and runs nothing.
See Workflows.
The runtime
The SystemRuntime is a long-lived service. You stand it up once at process start, and it stays up accepting submissions until the process exits.
The runtime owns:
- Workflow templates: the registered workflows that can be submitted.
- Stores: pluggable persistence for labware (
ILabwareStore), incidents (IIncidentStore), and the service-backed teachpoint, access-config, deck-layout, grip-profile, move-defaults, execution-record and ops-history stores. Orca ships in-memory and embedded SQLite implementations. A hosted deployment swaps in database-backed ones without touching workflow code. - The event bus: fires on every status change. Sinks subscribe for monitoring, persistence, or external integrations.
- Active executions: workflow runs in flight at any moment.
Operator-facing runtime operations (submit, pause_execution, resume_execution, stop_execution, recover_thread, blockers, unsettled_state, and the device, labware, teachpoint and calibration facades) are reachable from all three control surfaces that ship in the box:
| Surface | When to reach for it |
|---|---|
Python SystemRuntime API | In-process integration. Your own service code, embedded testing, in-process orchestration loops. |
| Localhost daemon REST + SSE | Out-of-process control on the same host. The orca.daemon FastAPI service binds 127.0.0.1 only, exposes the runtime over REST, and streams events over SSE. |
orca CLI | Operator workflow. Talks to the daemon over its local REST API. |
For remote or network access, including authenticated REST and MCP for external clients, wrap the daemon in your own service layer or use Orca AI, which is purpose-built for that role.
See Runtime for instantiating SystemRuntime and submitting workflows to it.
What the runtime knows about the lab
Scheduling is only half of it. The runtime also keeps a record of physical fact, and that record is what recovery and every operator surface read.
One labware instance, one identity. Each plate or rack the runtime creates gets a unique name, <template>-<8 hex chars>. That same name is used for the PyLabRobot object and for the entry on the driver's own deck, so two plates of the same template on one deck are never confused, and a tip count folds onto the rack it belongs to.
One owner per physical fact. Where a labware is, what a well holds, and what a pipetting head is carrying each have exactly one place they are written, and a CI guard fails the build if a second copy appears. A projection built from that record (a driver's tip tracker, a deck world) may not refuse an operation on state the runtime itself gave it.
Positions and volumes survive a restart. Completed moves and well volumes are persisted, so a runtime that comes back up restores where labware is and what is in it rather than starting blind.
Deck state is reconciled against the device. A liquid handler reports what it believes is on its deck. When that disagrees with the record, the difference is raised as an incident rather than silently overwritten, and reconnecting an agent re-seeds its deck.
Anything the record cannot answer goes on one worklist. unsettled_state() returns every subject whose contents or head state is unknown or contradicted, each with the verb that settles it. See State.
Move and pipetting parameters are layered. Grip height, approach clearance, jaw opening and the rest resolve from a default, then the labware, then the site, then how the labware is being carried right now. Each layer sets only the fields it needs. An operator can edit a layer without touching workflow source. See Move parameters.
When something goes wrong
A failing action pauses the thread and records an incident. Recovery is not one verb:
- The operator answers with
RETRY(run the action again from the top),RETRY_OP(re-run only the failed device call, with the action still suspended),CONTINUE(the situation is dealt with, carry on),ABORT_ACTION,ABORT_METHODorABORT_THREAD. - The place a thread stopped decides which of those it accepts.
RETRY_OPneeds a suspended device call.ABORT_ACTIONneeds a bound action. The runtime publishes the verbs the pause site will honour, so a surface offers only what will work rather than sending one to find out. - A driver error says what it left the instrument in. A refusal the driver made on its own state ("I will not park the gantry while the head holds tips") moved nothing, so it does not fault the device. A command that ran and stopped part-way does fault it, and the engine will not drive that instrument until an operator clears it.
- One list says everything that is stopping the run.
blockers()derives, on every read, every condition that will not clear by itself: a device fault, a device an operator has taken, an error-paused thread, a paused execution, a thread waiting on labware someone parked, a plate waiting to be placed or removed by hand, a manual step nobody has confirmed, a runtime that was never built. Each blocker carries the remedies that clear it. Clearing a device fault no longer leaves an operator guessing why the arm still has not moved.
An operator can also take a device mid-run and drive it by hand. The runtime holds the device for the duration so the engine cannot schedule it in between, and records every command against the run, the same trail a workflow's own commands leave. The one class it refuses is a write to the driver's own picture of where labware is (add_deck_labware, reset_world and six siblings). Those move the driver and not the ledger, so the refusal names the operator verb that writes both.
See also
- Runtime: standing up a
SystemRuntimeand submitting workflows to it. - Workflows: multi-thread workflows, joins, branches, parks.
- Devices: what is wired up out of the box.
- Submissions: groups, batching, and joining in-flight executions.
- Glossary: every term on this page, in one place.