Tutorial: Approval Workflow (Signals)
Build a human-in-the-loop expense approval workflow using signals. The workflow submits an expense, parks until a manager approves or the timeout expires, then processes the result.
The Signal Pattern
Section titled “The Signal Pattern”- Workflow reaches
wait_for_signal()and parks — no workers held - External system sends a signal (API call, webhook, CLI)
- Workflow resumes with the signal payload as input to the next step
- Optional timeout triggers escalation if no response
from datetime import timedeltafrom sayiir import task, Flow, run_durable_workflow, send_signal, resume_workflow, InMemoryBackend
@taskdef submit_expense(expense: dict) -> dict: return {**expense, "status": "pending_approval"}
@taskdef process_approved(approval: dict) -> str: return f"Expense approved by {approval['approver']} — processing reimbursement"
workflow = ( Flow("expense-approval") .then(submit_expense) .wait_for_signal("manager_approval", timeout=timedelta(hours=48)) .then(process_approved) .build())
backend = InMemoryBackend()
# Submit — parks at signalstatus = run_durable_workflow(workflow, "exp-001", {"employee": "Alice", "amount": 250.00}, backend=backend)
# Manager approves (could be minutes or days later, from any process)send_signal("exp-001", "manager_approval", {"approver": "Bob", "decision": "approved"}, backend=backend)
# Resume — processes approvalstatus = resume_workflow(workflow, "exp-001", backend=backend)print(status.output)import { task, flow, runDurableWorkflow, sendSignal, resumeWorkflow, InMemoryBackend } from "sayiir";
const submitExpense = task("submit-expense", (expense: Record<string, unknown>) => { return { ...expense, status: "pending_approval" };});
const processApproved = task("process-approved", (approval: Record<string, unknown>) => { return `Expense approved by ${approval.approver} — processing reimbursement`;});
const workflow = flow<Record<string, unknown>>("expense-approval") .then(submitExpense) .waitForSignal<Record<string, unknown>>("approval-wait", "manager_approval", { timeout: "48h" }) .then(processApproved) .build();
const backend = new InMemoryBackend();
// Submit — parks at signalconst status1 = runDurableWorkflow(workflow, "exp-001", { employee: "Alice", amount: 250 }, backend);
// Manager approvessendSignal("exp-001", "manager_approval", { approver: "Bob", decision: "approved" }, backend);
// Resume — processes approvalconst status2 = resumeWorkflow(workflow, "exp-001", backend);if (status2.status === "completed") console.log(status2.output);In production, the submit and approve steps happen in separate processes — the workflow durably parks in PostgreSQL until the signal arrives.
Full example: Approval Workflow (Python) Complete source code with signal sender script and production mode walkthrough.
Next Steps
Section titled “Next Steps”- Signals Guide — Advanced signal patterns and best practices
- Retries & Timeouts — Handle timeouts gracefully