Context API reference
Every Orca decorator gives your function a context object. There are four shapes — one per decorator — each tuned to what makes sense at that level.
| Decorator | Context | Lives | What it does |
|---|---|---|---|
@orca.action | ActionContext | One per action execution | Talks to the action's device, reads labware, resolves variables, emits/waits for events. |
@orca.method | MethodContext | One per method execution | Hands off device + labware lookups; can emit events. No wait_for. |
@orca.thread | ThreadContext | One per thread execution | Event coordination across threads; variable reads. No device() — that lives at action scope. |
@orca.workflow | WorkflowContext (wf) | One per workflow registration (build time) | Builder, not an execution context. Declares threads, handlers, variables. |
ActionContext
@orca.action(device=shaker, inputs=[sample_plate])
async def shake(ctx: ActionContext):
await ctx.device().shake(duration=30, speed=500)
The richest of the four. Created once per action execution.
Devices
ctx.device() # DeviceHandle
ctx.device(IShaker) # typed handle (cast for IDE autocomplete)
No name argument — each action targets a single device declared on @orca.action(device=...).
Labware
plate = ctx.labware("sample") # LabwareInstance
plate = ctx.labware("sample", PlateInstance) # narrowed; raises TypeError on mismatch
Lookup is by the labware-template variable name from the inputs=[...] declaration.
PLR-object shortcuts skip the wrapper:
plr_plate = ctx.plate("sample") # IPlate (PLR object directly)
plr_tip_rack = ctx.tip_rack("tips") # ITipRack
plr_trough = ctx.trough("reservoir") # ITrough
These are equivalent to ctx.labware("sample", PlateInstance).plate etc.
Well selectors
selector = ctx.get_well_selector("sample") # WellSelector
Returns the WellSelector declared on the action via well_selectors={...}. Falls back to all_wells() if none was specified.
Pooling
quadrant = ctx.pool_index("read_plate") # 0-based index of this contribution
When several threads contribute into one shared receiver (e.g. four 96-well plates pooled into one 384-well read plate), ctx.pool_index(receiver_name) returns the 0-based index of this action's contribution to the named receiver's current instance. Use it to route each contributor into a distinct region (quadrant, column block, well range). Raises ValueError if this action doesn't contribute to that receiver. ActionContext only.
Variables
factor = await ctx.param("dilution_factor") # OptionValue (any)
factor = await ctx.param("dilution_factor", float) # narrowed; raises TypeError on mismatch
ctx.param is async; await it. Resolves through the variable store with the execution's scope. See Variables.
Events
await ctx.emit("PLATE.READY", value=plate.barcode, data={"step": "sealed"})
value, data = await ctx.wait_for("PLATE.READY", timeout=60.0)
Both methods are async. emit is fire-and-forget; wait_for blocks until the named event arrives (or times out).
Manual operator step
await ctx.manual_step(
"Please refill the reservoir on deck slot A1 and confirm.",
timeout_hours=2,
)
Logs the instruction to the orca.operator logger, emits OPERATOR.INSTRUCTION, and blocks until a targeted OPERATOR.CONFIRM.{step_id} event fires. Each call gets its own step id so concurrent manual steps don't interfere. timeout_hours=0 (default) means wait indefinitely.
MethodContext
@orca.method
async def warm_and_shake(ctx: MethodContext):
yield warm
yield shake
Created once per method execution. Smaller surface than ActionContext because most work in a method happens inside the yielded actions, not the method body.
Devices
ctx.device("shaker_1") # DeviceHandle by name
ctx.device("shaker_1", IShaker) # typed handle
Methods can span devices, so you pass the name. (Compare to ActionContext.device() which takes no name — actions are device-bound.)
Labware
plate = ctx.labware("sample") # LabwareInstance
No kind narrowing overload here — use ActionContext.labware(name, kind) inside an action if you need it.
Variables
factor = await ctx.param("dilution_factor") # OptionValue
Async. No kind narrowing. The typed form is action-level only.
Events
await ctx.emit("STEP.DONE", value="warm_and_shake")
emit only. No wait_for on MethodContext — wait at the action level (inside an @orca.action) or the thread level.
ThreadContext
@orca.thread(labware=sample_plate, start="stacker", end="waste")
async def sample_journey(ctx: ThreadContext):
yield shake_step
yield seal_step
Created once per thread execution. The thread body is the orchestration layer between methods.
No device()
Thread-level code doesn't issue device commands directly — that's what yielded methods are for. There's no ctx.device() on ThreadContext. If you find yourself wanting one, move the work into an @orca.action.
Labware
plate = ctx.labware # property, not a call
The labware instance this thread is tracking, derived from the @orca.thread(labware=...) declaration. Raises RuntimeError if accessed in a context that has no bound labware (rare — typically only in synthetic event-driven threads).
Variables
factor = await ctx.param("dilution_factor")
Async, and same as the others but submission-aware — if the thread is tagged with a submission_id, the per-submission variable partition is consulted before the execution-wide partition. See Variables for the resolution order.
Events (consumed semantics)
await ctx.emit("RESULT.READY", value=plate.barcode)
value, data = await ctx.wait_for("RESULT.READY", timeout=120)
wait_for uses consumed semantics: each call advances past the last-seen publish counter. Looping while True: value = await ctx.wait_for("RESULT.READY") sees each NEW publish, not the same one. (This differs from the static @orca.on(...) decorator which uses latch semantics.)
Join receivers
while ctx.has_more_work():
yield orca.join(other_thread)
ctx.has_more_work() returns True while the thread's receiver slot may still get contributions from other threads — useful for replacing hard-coded for i in range(N) loops with submission-driven termination.
Returns True unconditionally if no slot context is attached (a thread with no receiver slot, e.g. a fixed for i in range(N) loop drives termination instead).
Partner constraints (auto-spawn targeting)
ctx.set_partner_constraint("tips_template", {"barcode": "RACK-007"})
yield use_tips
When the next action triggers auto-spawn for a labware matching tips_template, the constraints are passed to the labware finder to select the right partner (e.g. by barcode). Raises RuntimeError if the thread context doesn't have a partner-constraint setter wired (only some threads do).
WorkflowContext (wf)
@orca.workflow(name="smc_assay")
def smc_assay(wf: WorkflowContext):
wf.start(sample_journey)
wf.thread(tips_journey)
wf.on("RESULT.READY", MyHandler())
wf.variable("plate_count", VariableDefinition(type="int", default=1))
Builder, not execution context. Called once at workflow registration. Use it to declare structure; don't put runtime logic here.
wf.start(thread)
Register an entry thread that starts when the workflow starts.
wf.thread(thread, capacity=None, overflow_strategy=None)
Register a contributor thread for auto-spawning. When an action needs labware owned by this thread, the engine spawns a fresh instance. The thread's generator should yield orca.join(...) at the point where it participates in the shared action.
| Parameter | Effect |
|---|---|
capacity | CapacityPolicy declaring slot-level capacity (how many simultaneous instances). |
overflow_strategy | IOverflowStrategy for custom overflow handling (priority queues, parallel receivers, etc.). |
wf.on(event_name, handler)
Subscribe an event handler to a workflow-level event. Handlers receive every matching event during this workflow's executions.
wf.variable(name, definition)
Declare a workflow-scoped variable. See Variables for the VariableDefinition fields.
Quick reference
| Surface | ActionContext | MethodContext | ThreadContext | WorkflowContext |
|---|---|---|---|---|
device() | yes (no name) | yes (by name) | — | — |
device(T) typed | yes | yes | — | — |
labware(name) | yes | yes | ctx.labware (property) | — |
labware(name, kind) typed | yes | — | — | — |
plate/tip_rack/trough shortcuts | yes | — | — | — |
get_well_selector(name) | yes | — | — | — |
param(name) | yes (async) | yes (async) | yes (async, submission-aware) | — |
param(name, kind) typed | yes (async) | — | — | — |
emit(name, value, data) | yes (async) | yes (async) | yes (async) | — |
wait_for(name, timeout) | yes (latch-then-consumed) | — | yes (consumed) | — |
manual_step(instruction, timeout_hours) | yes | — | — | — |
has_more_work() | — | — | yes | — |
set_partner_constraint(...) | — | — | yes | — |
start(thread) | — | — | — | yes |
thread(thread, ...) | — | — | — | yes |
on(event_name, handler) | — | — | — | yes |
variable(name, definition) | — | — | — | yes |
Pitfalls
ActionContext.device()takes NO name. The device comes from the action's@orca.action(device=...)declaration. Methods are different —MethodContext.device("shaker_1")takes a name because methods can span devices.- No
wait_foronMethodContext. Events flow at action scope (during a single device interaction) or thread scope (between methods). Methods themselves don't wait — their actions do. ThreadContext.wait_foruses consumed semantics;ActionContext.wait_foris latch-then-consumed. First call inside an action sees the latest publish (latch); subsequent calls advance past what was seen. Threadwait_foradvances on every call, sowhile True: ctx.wait_for("X")gets each new publish — not the same one.WorkflowContextis a builder. Code inside@orca.workflow def my_assay(wf):runs ONCE at registration time, not per execution. Don't put per-run logic there.ctx.labwareonThreadContextis a property, not a function call. Callingctx.labware("sample")will raiseTypeError. Usectx.labware(no parens) to get the bound instance.paramnarrowing raisesTypeErroron mismatch.ctx.param("plate_count", int)against a workflow that definedplate_count: floatraises immediately — handle the type at definition time, not at the call site.