Vercel Logo

Hook the driver into the flow

The kitchen pings us when the pizza's ready. The driver should do the same when they pick it up, and again when they hand it to Marge.

Same pattern, twice. Two hooks, two Route Handlers. The shape of the code is now familiar, and that's the point. Hooks are how a workflow talks to anything outside itself.

We also need a step in between so the order goes from "ready-for-pickup" to "out-for-delivery" when the driver actually grabs it. The driver UI in the starter has been listing both states this whole time. Now they'll actually flow through.

Outcome

Replace the post-dispatch sleep with two hooks: one for pickup, one for delivery. Build the two corresponding Route Handlers. The driver ops UI now drives the workflow through pickup and delivery.

Fast Track

  1. Add a markOutForDelivery step that sets status to out-for-delivery.
  2. Update processOrder to swap the post-dispatch sleep for a driver-pickup:{order.id} hook, then run markOutForDelivery, then await a driver-delivered:{order.id} hook.
  3. Build /api/driver/pickup and /api/driver/delivered to resume the matching hooks.

Hands-on exercise

1. Add the intermediate step.

Right now dispatchDelivery sets status to out-for-delivery. That's a lie. The driver hasn't picked the pizza up yet. Let's change dispatchDelivery to mean "ready for pickup" and add a separate step for "out for delivery."

Update workflows/steps/dispatch-delivery.ts:

workflows/steps/dispatch-delivery.ts
import { updateStatus } from "@/lib/orders-store";
import type { Order } from "@/lib/pizza";
 
export async function dispatchDelivery(order: Order): Promise<void> {
  "use step";
 
  await new Promise((resolve) => setTimeout(resolve, 200));
 
  updateStatus(order.id, "ready-for-pickup");
}

Create workflows/steps/mark-out-for-delivery.ts:

workflows/steps/mark-out-for-delivery.ts
import { updateStatus } from "@/lib/orders-store";
import type { Order } from "@/lib/pizza";
 
export async function markOutForDelivery(order: Order): Promise<void> {
  "use step";
 
  updateStatus(order.id, "out-for-delivery");
}

2. Wire two hooks into the workflow.

Update workflows/process-order.ts:

workflows/process-order.ts
import { createHook } from "workflow";
import type { Order } from "@/lib/pizza";
import { sendOrderConfirmation } from "./steps/send-order-confirmation";
import { acknowledgeKitchen } from "./steps/acknowledge-kitchen";
import { dispatchDelivery } from "./steps/dispatch-delivery";
import { markOutForDelivery } from "./steps/mark-out-for-delivery";
import { confirmDelivery } from "./steps/confirm-delivery";
import { sendReviewRequest } from "./steps/send-review-request";
 
export async function processOrder(order: Order): Promise<{ orderId: string }> {
  "use workflow";
 
  await sendOrderConfirmation(order);
  await acknowledgeKitchen(order);
 
  using kitchenHook = createHook<{ readyAt: string }>({
    token: `kitchen:${order.id}`,
  });
  await kitchenHook;
 
  await dispatchDelivery(order);
 
  using pickupHook = createHook<{ pickedUpAt: string }>({
    token: `driver-pickup:${order.id}`,
  });
  await pickupHook;
  await markOutForDelivery(order);
 
  using deliveryHook = createHook<{ deliveredAt: string }>({
    token: `driver-delivered:${order.id}`,
  });
  await deliveryHook;
 
  await confirmDelivery(order);
  await sendReviewRequest(order);
 
  return { orderId: order.id };
}

Two new hooks. Notice the token namespacing: driver-pickup: and driver-delivered: so they don't collide with the kitchen's hook or each other. We don't import sleep anymore; nothing in the workflow is on a fake timer.

3. Build the resume endpoints.

app/api/driver/pickup/route.ts:

app/api/driver/pickup/route.ts
import { resumeHook } from "workflow/api";
import { NextResponse } from "next/server";
 
export async function POST(request: Request) {
  const { orderId } = (await request.json()) as { orderId: string };
 
  await resumeHook(`driver-pickup:${orderId}`, {
    pickedUpAt: new Date().toISOString(),
  });
 
  return NextResponse.json({ ok: true });
}

app/api/driver/delivered/route.ts:

app/api/driver/delivered/route.ts
import { resumeHook } from "workflow/api";
import { NextResponse } from "next/server";
 
export async function POST(request: Request) {
  const { orderId } = (await request.json()) as { orderId: string };
 
  await resumeHook(`driver-delivered:${orderId}`, {
    deliveredAt: new Date().toISOString(),
  });
 
  return NextResponse.json({ ok: true });
}

If you're squinting and thinking "these are almost identical," you're right. We could DRY them up. We won't. Two endpoints with three lines each that say what they do is clearer than one clever endpoint with a state param.

Try It

Place an order. Walk the order through all four humans now:

  1. /kitchen → click Mark ready. Status moves to "Ready for pickup."
  2. /driver → first list shows the order. Click Pick up. Status moves to "Out for delivery."
  3. /driver → second list shows the order. Click Mark delivered. Status moves to "Delivered."
  4. Inbox → the review email arrives a moment later.

The customer's /orders/[runId] page polls and updates as you click. So does the kitchen and driver ops UIs.

In the dashboard timeline:

processOrder                completed
  ├─ sendOrderConfirmation    completed
  ├─ acknowledgeKitchen       completed
  ├─ kitchen hook             resumed
  ├─ dispatchDelivery         completed
  ├─ driver-pickup hook       resumed
  ├─ markOutForDelivery       completed
  ├─ driver-delivered hook    resumed
  ├─ confirmDelivery          completed
  └─ sendReviewRequest        completed

Every step. Every hook. Every resume. Every payload. All of it persisted by Vercel.

A workflow is glue

We've now written six steps and three hooks. The workflow itself is almost entirely await statements. That's the right amount of code for a workflow to have. The actual work lives in steps. The actual events live outside. The workflow's job is to sequence them.

Commit

feat(workflow): drive pickup and delivery with hooks

Done-When

  • dispatchDelivery now sets status to ready-for-pickup
  • A new markOutForDelivery step sets status to out-for-delivery
  • processOrder awaits the pickup hook, runs markOutForDelivery, then awaits the delivery hook
  • /api/driver/pickup and /api/driver/delivered exist and use resumeHook
  • The driver ops UI walks an order from ready-for-pickup to delivered

Solution

The new step files appear in the hands-on section above. The complete workflow:

workflows/process-order.ts
import { createHook } from "workflow";
import type { Order } from "@/lib/pizza";
import { sendOrderConfirmation } from "./steps/send-order-confirmation";
import { acknowledgeKitchen } from "./steps/acknowledge-kitchen";
import { dispatchDelivery } from "./steps/dispatch-delivery";
import { markOutForDelivery } from "./steps/mark-out-for-delivery";
import { confirmDelivery } from "./steps/confirm-delivery";
import { sendReviewRequest } from "./steps/send-review-request";
 
export async function processOrder(order: Order): Promise<{ orderId: string }> {
  "use workflow";
 
  await sendOrderConfirmation(order);
  await acknowledgeKitchen(order);
 
  using kitchenHook = createHook<{ readyAt: string }>({
    token: `kitchen:${order.id}`,
  });
  await kitchenHook;
 
  await dispatchDelivery(order);
 
  using pickupHook = createHook<{ pickedUpAt: string }>({
    token: `driver-pickup:${order.id}`,
  });
  await pickupHook;
  await markOutForDelivery(order);
 
  using deliveryHook = createHook<{ deliveredAt: string }>({
    token: `driver-delivered:${order.id}`,
  });
  await deliveryHook;
 
  await confirmDelivery(order);
  await sendReviewRequest(order);
 
  return { orderId: order.id };
}

Everything in this workflow is now event-driven. There's just one problem: if the kitchen ghosts us, the workflow waits forever. That's 3.3.

Was this helpful?

supported.