Skip to main content

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.

DecoratorContextLivesWhat it does
@orca.actionActionContextOne per action executionTalks to the action's device, reads labware, resolves variables, emits/waits for events.
@orca.methodMethodContextOne per method executionHands off device + labware lookups; can emit events. No wait_for.
@orca.threadThreadContextOne per thread executionEvent coordination across threads; variable reads. No device() — that lives at action scope.
@orca.workflowWorkflowContext (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.

ParameterEffect
capacityCapacityPolicy declaring slot-level capacity (how many simultaneous instances).
overflow_strategyIOverflowStrategy 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

SurfaceActionContextMethodContextThreadContextWorkflowContext
device()yes (no name)yes (by name)
device(T) typedyesyes
labware(name)yesyesctx.labware (property)
labware(name, kind) typedyes
plate/tip_rack/trough shortcutsyes
get_well_selector(name)yes
param(name)yes (async)yes (async)yes (async, submission-aware)
param(name, kind) typedyes (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_for on MethodContext. 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_for uses consumed semantics; ActionContext.wait_for is latch-then-consumed. First call inside an action sees the latest publish (latch); subsequent calls advance past what was seen. Thread wait_for advances on every call, so while True: ctx.wait_for("X") gets each new publish — not the same one.
  • WorkflowContext is 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.labware on ThreadContext is a property, not a function call. Calling ctx.labware("sample") will raise TypeError. Use ctx.labware (no parens) to get the bound instance.
  • param narrowing raises TypeError on mismatch. ctx.param("plate_count", int) against a workflow that defined plate_count: float raises immediately — handle the type at definition time, not at the call site.

Where to go next

  • Workflows — the wf builder methods in context.
  • Variables — the resolution order behind ctx.param.
  • Eventsemit and wait_for semantics, channel registry mechanics.