HarborHarbor
DocumentationGuidesPlugins
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.

approval.ts

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:

approval-app.ts

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
   └ return

Because 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.