UniteLabs
Concepts

Human in the Loop

Pause a workflow run to request a human decision or physical action before continuing.

Human in the loop (HITL) is a pattern for semi-automated workflows: the workflow engine pauses a run at a defined point, waits for an operator to confirm an action or provide input, then continues.

The SDK provides two levels of API:

  • operator_confirm() — a one-line helper for the common case: the operator just needs to confirm a physical action was taken.
  • pause_flow_run() — the underlying Prefect primitive, used directly when you need to collect structured data via a typed RunInput model.
For unitelabs-sdk< 0.10.0, import flow and task from Prefect instead. Use flow in place of both workflow and phase decorators, and task as step decorators.

Simple confirmation

The most common case: pause and wait for an operator to confirm a physical action — no structured data needed. Use operator_confirm():

workflows/qc.py
from unitelabs.sdk import operator_confirm
from unitelabs.sdk.automate import phase

@phase()
async def load_plate():
    await operator_confirm("Load sample plate into slot 1, then click Resume.")

operator_confirm() logs the message with an OPERATOR ACTION REQUIRED: prefix (easy to spot in the run log), pauses the run until the operator confirms, then logs that the run resumed. Pass timeout= (seconds, default 3600) to fail the run cleanly if no one responds.

It requires the automate extra (unitelabs-sdk[automate]); without it the call raises RuntimeError. Under the hood it pauses with a single-field confirmation model (OperatorConfirmation), so the run transitions to AWAITING_INPUT and the platform renders a confirm-and-resume control.

Typed input

When the operator needs to provide structured data — not just confirm — pass a RunInput model to pause_flow_run() directly. The platform renders a form based on the model's fields.

workflows/qc.py
from unitelabs.sdk import pause_flow_run, RunInput
from unitelabs.sdk.automate import phase

class QCInput(RunInput):
    sample_id: str
    approved: bool
    notes: str = ""

@phase()
async def quality_check():
    data = await pause_flow_run(wait_for_input=QCInput)
    if not data.approved:
        raise ValueError(f"Sample {data.sample_id} rejected at QC gate. Notes: {data.notes}")
    await log_approval(data.sample_id)

The run transitions to AWAITING_INPUT. The operator opens the run in the platform, fills in the form, and submits. Execution continues with the submitted values available as data.

The Python type annotation on each field determines which form control the platform renders:

Python typeForm control
strText input
boolToggle switch
intNumber input (whole numbers)
floatDecimal number input
Literal["a", "b", ...]Dropdown select
Enum subclassDropdown select

Fields without a default value are required. Fields with a default value are optional and pre-filled.

For constrained choices (e.g., selecting from a fixed list of protocols), use Literal or a Python Enum — both render as a dropdown. See Typed operator inputs for detailed examples.

Run state during a pause

StateProduced byOperator action
AWAITING_INPUToperator_confirm(...)Confirm, then resume
AWAITING_INPUTpause_flow_run(wait_for_input=MyRunInput)Fill form, then submit
PAUSEDpause_flow_run() (no input model)Click Resume

AWAITING_INPUT means the platform is holding a form open. operator_confirm() uses a single pre-filled confirmation field, so the form is effectively a confirm-and-resume button; a custom RunInput model renders one control per field. Bare pause_flow_run() carries no schema and the operator simply clicks Resume.

Paused runs time out after a configurable period. operator_confirm() defaults to timeout=3600 (1 hour); pass timeout=600 to either operator_confirm() or pause_flow_run() to fail the run cleanly if the operator does not respond in time — rather than leaving it suspended indefinitely.
  • Input: how workflow parameters become operator-facing fields
  • Runs: the AWAITING_INPUT and PAUSED run states and resumption

Guides