Recipes
OAuth Approval Workflow
step.waitForEvent — park a run until an approver clicks a button. (Beta)
Beta
Uses step.waitForEvent which is part of the Functions/Workflows beta surface.
A run that initiates an expensive action, then parks until an approver clicks an Approve / Reject button. The classic example: a Linear issue auto-routed to a triager, who decides whether to create a follow-up ticket.
defineJob({
name: "approve-and-act",
description: "Park until an approver decides.",
handler: async (ctx, input: { context: string }) => {
await ctx.plugins.slackMcp.postMessage({
channel: "#approvals",
text: [
`*Approval needed*`,
`Context: ${input.context}`,
`Approve: https://approvals.acme.tryharbor.ai/run/{{ run.id }}/approve`,
`Reject: https://approvals.acme.tryharbor.ai/run/{{ run.id }}/reject`,
].join("\n"),
});
// Park the run for up to 24 hours waiting for one of these events.
const decision = await step.waitForEvent("decision", {
timeout: "24h",
expected: ["approved", "rejected"],
});
if (decision.kind === "approved") {
await ctx.plugins.linearMcp.createIssue({
teamId: input.teamId,
title: `Follow-up: ${input.context}`,
});
return { acted: true };
}
return { acted: false, reason: decision.kind };
},
});The companion App route resumes the parked run:
deployApp({
name: "approvals",
jobs: {
approve: "deliver-event-approved",
reject: "deliver-event-rejected",
},
routes: {
"GET /run/:runId/approve": { auth: "workspace_member", job: "approve" },
"GET /run/:runId/reject": { auth: "workspace_member", job: "reject" },
},
});Each helper Function calls POST /runs/events:
defineJob({
name: "deliver-event-approved",
handler: async (_ctx, input: { runId: string }) => {
await fetch(\`https://api.tryharbor.ai/runs/\${input.runId}/events\`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "decision", payload: { kind: "approved" } }),
});
return { delivered: true };
},
});What's happening
Run approve-and-act from the dashboard or an SDK client after the approval record is ready.
│
├ post Slack message
│
├ step.waitForEvent("decision", 24h)
│ run parks (no CPU burn, no isolate cost) for up to 24h
│
◄ approver clicks the button
│ App route delivers POST /runs/<id>/events
│
├ run resumes with decision.kind = "approved"
├ creates Linear ticket
└ returnBecause the durable lane uses Cloudflare Workflows under the hood, the parked run survives Worker restarts, scaling events, region failovers — it picks up exactly where it left off.
Where to go next
Multi-step with retries covers
the other major Workflow atom: step.sleep for time-based backoff.