Pause a Workflow Until a Human Clicks Approve
Some workflows can’t finish on their own. An order over a certain amount needs a human to eyeball it. A new vendor needs compliance to sign off. A refund above some threshold needs a manager. The work is half-done, the rest depends on a click that might land in two minutes or two days.
The naïve answer is to poll a database column from a cron job. The slightly less naïve answer is to split the workflow into two and wire them together with a webhook. Both are awful — the first burns CPU, the second turns one logical process into three and loses you all your local variables.
Tako’s durable workflow engine gives you a primitive that’s just better: park the run on a named event, sleep the worker, and wake up exactly where you left off when the event fires.
The setup
Imagine an order-fulfillment workflow. Charge the card, run a fraud check, wait for an admin to approve high-value orders, then ship.
// workflows/fulfill-order.ts
import { defineWorkflow } from "tako.sh";
export default defineWorkflow<{ orderId: string }>("fulfill-order", {
retries: 4,
handler: async (payload, step) => {
const order = await step.run("load-order", () => db.orders.find(payload.orderId));
await step.run("charge", () =>
stripe.charges.create({ amount: order.total, source: order.token, idempotencyKey: order.id }),
);
if (order.total > 50_000) {
const decision = await step.waitFor<{ approved: boolean; by: string }>(
`approval:order-${order.id}`,
{ timeout: 7 * 24 * 3600 * 1000 }, // 7 days
);
if (decision === null) step.bail("approval timed out — order held");
if (!decision.approved) step.bail(`rejected by ${decision.by}`);
}
await step.run("ship", () => easypost.shipments.create({ to: order.address }));
await step.run("notify", () => mailer.send(order.email, { orderId: order.id }));
},
});
The interesting line is step.waitFor. When the run hits it, the worker doesn’t sit and spin — it serializes the run state, marks the row pending in the per-app SQLite queue, inserts an event_waiters row keyed by the event name, and exits the handler. If nothing else is in flight, the worker subprocess itself shuts down. Zero CPU, zero memory, zero open connections — just a row in a file at {tako_data_dir}/apps/<app>/runs.db.
The signal
Anywhere else in your code — an HTTP handler, a webhook receiver, an admin button — fire the matching signal:
// app/admin/approve.ts
import { signal } from "tako.sh";
export default async function fetch(req: Request) {
const { orderId, approverId } = await req.json();
await signal(`approval:order-${orderId}`, {
approved: true,
by: approverId,
});
return Response.json({ ok: true });
}
The signal lands on tako-server’s internal unix socket, the matching event_waiters row is consumed, the payload is stored as the result of the waitFor step, and the run flips back to pending. The supervisor wakes the worker, the worker re-claims the run, and execution resumes — decision is now { approved: true, by: "..." } and the workflow ships the order.
Notice what doesn’t happen on resume: the load-order and charge steps don’t re-run. Their results are already in the steps table, keyed by (run_id, name), so on the next claim they return cached values instantly. That’s the step.run checkpoint contract — at-least-once for the in-flight step, exactly-once for everything before it.
What “for days” actually means
While the run is parked, your VPS isn’t holding anything open for it. The worker process is gone. tako-server can restart, the host can reboot, you can redeploy — the row stays in SQLite, the event waiter stays indexed, and signal will still find it three days from now. The 7-day timeout is just a safety valve; if it fires first, waitFor returns null and the workflow takes the cleanup path via step.bail.
The same primitive covers webhook callbacks, multi-step onboarding flows that wait on user input, payment-confirmation hops, and anything else where the next step is “the world tells us something happened.” One file, one default export, no external queue, no cron polling. Drop it in workflows/, run tako dev, and the embedded scale-to-zero worker wires up the rest.