Human in the Loop
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 typedRunInputmodel.
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():
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.
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 type | Form control |
|---|---|
str | Text input |
bool | Toggle switch |
int | Number input (whole numbers) |
float | Decimal number input |
Literal["a", "b", ...] | Dropdown select |
Enum subclass | Dropdown 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
| State | Produced by | Operator action |
|---|---|---|
AWAITING_INPUT | operator_confirm(...) | Confirm, then resume |
AWAITING_INPUT | pause_flow_run(wait_for_input=MyRunInput) | Fill form, then submit |
PAUSED | pause_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.
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.Related concepts
- Input: how workflow parameters become operator-facing fields
- Runs: the
AWAITING_INPUTandPAUSEDrun states and resumption