Skip to content
Pyrula

Human-in-the-Loop

ctx.interrupt() stops a run to wait on a decision: an approval, an edit, a freeform reply. It resumes with the recorded answer. A signal tells a run something happened; an interrupt asks a question and carries a payload describing it.

from pyrula.workflows import workflow, InterruptResponse, InterruptAction
@workflow(name="expense_approval")
async def expense_approval(ctx, amount_cents: int) -> str:
response: InterruptResponse = await ctx.interrupt(
"approve_expense",
payload={"amount_cents": amount_cents},
)
if response.action == InterruptAction.ACCEPT:
await ctx.step(
"disburse",
lambda: disburse(amount_cents, idempotency_key=f"{ctx.run_id}:disburse"),
)
return "approved"
if response.action == InterruptAction.EDIT:
await ctx.step(
"disburse_edited",
lambda: disburse(
response.value["amount_cents"],
idempotency_key=f"{ctx.run_id}:disburse_edited",
),
)
return "approved with edit"
return "rejected"

ctx.interrupt(event_id, payload) writes step:pending and run:interrupted, then suspends the run (status interrupted). Something external resumes it with an InterruptResponse. The engine emits run:resumed, and the interrupt call returns that response. On replay the recorded response comes straight back, so the run isn’t interrupted twice.

@dataclass
class InterruptResponse:
action: InterruptAction | str # accept | edit | respond | ignore
value: Any = None
ActionMeaning
acceptGo ahead as proposed.
editGo ahead with value as the edited input.
respondContinue with value as a freeform reply.
ignoreSkip the proposed action.

This is the right gate for side effects that need explicit approval. Charges, irreversible writes, anything you’d want a person to sign off on before it runs. The approved effect should still be idempotent or carry an external idempotency key. See Determinism.

Delivering the resume over HTTP belongs to the Agents serving layer.