Skip to content
Pyrula

Signals

ctx.wait_for_signal(name) suspends a run until an external system sends a signal by that name. The worker is freed while it waits, and the run resumes when the signal lands.

from pyrula.workflows import workflow, Signal
@workflow(name="payment_processor")
async def payment_processor(ctx, order_id: str, amount: float) -> str:
await ctx.step(
"initiate_payment",
lambda: initiate_payment(
order_id,
amount,
idempotency_key=f"{ctx.run_id}:initiate_payment",
),
)
signal: Signal = await ctx.wait_for_signal("payment_confirmed")
if signal.payload.get("status") == "success":
await ctx.step(
"fulfill_order",
lambda: fulfill_order(order_id, idempotency_key=f"{ctx.run_id}:fulfill_order"),
)
return "fulfilled"
await ctx.step(
"cancel_order",
lambda: cancel_order(order_id, idempotency_key=f"{ctx.run_id}:cancel_order"),
)
return f"failed: {signal.payload.get('reason')}"
  1. The run hits wait_for_signal. The engine emits run:signal_waiting, sets status to signal_waiting, and frees the worker.
  2. Something delivers the signal by name with a payload.
  3. The engine emits run:signal_received and re-queues the run.
  4. On replay the wait point returns the recorded signal and execution continues.

A signal that arrives before the run reaches the wait point is buffered, so there’s no race between the sender and the waiter.

@dataclass(frozen=True)
class Signal:
name: str
payload: dict[str, Any]
sent_at: str # ISO-8601 timestamp set by the sender

Delivering a signal over HTTP belongs to the Agents serving layer. The engine only models the wait, the buffer, and the recorded result.