How to get the most out of Trigger.dev waitpoint tokens

Practical guide to waitpoint tokens — what they're for, real-world examples, why they fit AI agents, and how RobotRock uses them for human-in-the-loop approvals.

Published June 25, 2025

What waitpoint tokens are

A waitpoint token is Trigger.dev's primitive for pausing a background task until something external happens — a human clicks approve, a webhook fires, or a third-party job finishes. The task suspends at wait.forToken and Trigger.dev resumes it when the token is completed or times out.

Unlike polling in a loop or holding an HTTP request open, waitpoints are durable. Your task can wait hours or days without consuming compute, and it survives deploys and retries. On Trigger.dev Cloud, runs are checkpointed when they wait longer than a few seconds, so you are not billed for idle time while a human thinks or an external service runs.

What wait tokens are good for

Wait tokens solve a specific class of problem: your workflow needs to continue after something outside your code decides it is ready. That outside thing might be slow, unpredictable, or on a different schedule than your servers.

They shine whenever you need to:

  • Gate irreversible actions — deploy to production, send a bulk email, charge a card, delete data. The task pauses at the gate; nothing dangerous runs until someone completes the token.
  • Collect structured human input — not just yes/no, but "approve with a change ticket", "reject with a reason", or "adjust the amount to $4,200". The JSON payload you POST to complete the token becomes typed output in your task.
  • Wait on external async work — image generation, video rendering, OCR, a partner API. Pass token.url as a webhook callback instead of polling their status endpoint from a cron job.
  • Chain multi-step reviews — draft → editor → legal → publish. Each step gets its own token with its own timeout. The pipeline reads like normal async code, not a state machine you maintain in a database.
  • Bridge channels humans already use — Slack buttons, email links, an internal admin UI, or a dedicated inbox like RobotRock. Any of them can complete the same token.

Practical examples

Production deploy approval

A release pipeline builds and tests automatically, then stops before touching production. A reviewer gets a link; when they approve, the same task run continues and runs the deploy step. If nobody responds within the timeout, you can default to "blocked" instead of shipping silently.

Budget or purchase approval

An automation proposes spending $12,000 on new equipment. Finance sees the request in an inbox with context (department, requester, amount) and can approve, reject, or adjust the figure. The waiting task receives a discriminated result and branches accordingly — no separate "check approval status" job to wire up.

External webhook callback

Trigger.dev's own docs use image generation as the canonical example: create a token, pass token.url to Replicate (or any webhook-capable provider), and await the result. The provider POSTs JSON when the job finishes; that body becomes your token output.

import { task, wait } from "@trigger.dev/sdk";

export const generateImage = task({
  id: "generate-image",
  run: async ({ prompt }) => {
    const token = await wait.createToken({ timeout: "10m" });

    // Pass token.url to any service that supports webhooks
    await imageProvider.createJob({
      prompt,
      webhook: token.url,
    });

    // Task suspends here — no polling, no compute cost while waiting
    const prediction = await wait.forToken<PredictionResult>(token.id).unwrap();
    return prediction.outputUrl;
  },
});

Multi-step content review

AI drafts a blog post. An editor reviews overnight. A publisher gives final sign-off the next morning. Each human gate is a separate token with a realistic timeout — 24 hours for editorial review, one hour for publish approval.

export const contentPipeline = task({
  id: "content-pipeline",
  run: async ({ brief }) => {
    const draft = await generateDraft(brief);

    // Gate 1: editor review (hours or days is fine)
    const reviewToken = await wait.createToken({ timeout: "24h" });
    await sendForReview(draft, reviewToken.url);
    const review = await wait.forToken<ReviewResult>(reviewToken.id);

    if (!review.ok || !review.output.approved) {
      return { status: "rejected", feedback: review.output?.feedback };
    }

    // Gate 2: publish approval
    const publishToken = await wait.createToken({ timeout: "1h" });
    await sendForPublishApproval(draft, publishToken.url);
    const publish = await wait.forToken<PublishResult>(publishToken.id);

    if (!publish.ok || !publish.output.approved) {
      return { status: "not_published" };
    }

    await publishContent(draft);
    return { status: "published" };
  },
});

Slack or email approval

Send a message with a link to your approval page (or POST directly to token.url from a Slack interactive button handler). The task does not care which channel the human used — it only cares that the token was completed with a payload.

Why wait tokens are perfect for AI agents

AI agents are the hardest kind of automation to pair with humans. Models run fast; people do not. Agents call tools in the middle of a run; humans respond hours later. Serverless functions time out; polling loops waste money and lose state on retry.

Wait tokens fit agents because they turn human input into just another await in your agent code:

  • Durable tool calls— when an agent invokes a "ask human" tool, the Trigger.dev run suspends at wait.forToken. The agent does not need to restart, poll, or store intermediate state in Redis. It picks up exactly where it left off.
  • Beyond serverless timeouts — a Vercel function or short-lived worker cannot block for a business day. A Trigger.dev task with maxDuration set generously can wait as long as your token timeout allows.
  • Oversight before action — agents that send emails, modify production data, or talk to customers should pause for approval. A wait token is the checkpoint: the model proposes, a human confirms, the agent continues with the approved payload.
  • Structured decisions flow back to the model — the human's choice (approve, decline, adjust amount, add feedback) is typed JSON in result.output, ready for the next agent step.
  • Retries without duplicate approvals — if the task retries after a crash, an idempotencyKey on the token prevents spawning a second approval request for the same decision.

RobotRock's AI SDK tools support mode: "trigger" so model tool calls use wait tokens under the hood. The model calls approveByHuman or a custom sendToHuman tool; the Trigger worker suspends until someone acts in the inbox:

import { task } from "@trigger.dev/sdk";
import { generateText, stepCountIs } from "ai";
import { approveByHumanTool } from "robotrock/ai/trigger";

export const cautiousAgent = task({
  id: "cautious-agent",
  maxDuration: 3600, // agent + human can take a while
  run: async ({ prompt }) => {
    const approveByHuman = approveByHumanTool({
      mode: "trigger",
      app: "my-agent",
      defaultType: "agent-approval",
    });

    // When the model calls approveByHuman, a wait token is created
    // and the task suspends until someone acts in the inbox
    const result = await generateText({
      model: openai("gpt-4o"),
      tools: { approveByHuman },
      stopWhen: stepCountIs(8),
      system:
        "Before sending a final answer, call approveByHuman with a summary of what you plan to do.",
      prompt,
    });

    return { text: result.text, steps: result.steps.length };
  },
});

Core API

Three functions cover most use cases: create a token, wait on it, and complete it from your backend.

import { wait } from "@trigger.dev/sdk";

type ApprovalPayload = {
  approved: boolean;
  selectedOption: string;
  approvedBy: string;
};

// 1. Create a token — execution pauses at the next forToken call
const token = await wait.createToken({
  timeout: "1h",
});

// 2. Send token.id or token.url to wherever the human will respond
await notifyReviewer({ approvalUrl: token.url });

// 3. Wait until someone completes the token or it times out
const result = await wait.forToken<ApprovalPayload>(token.id);

if (result.ok) {
  console.log(result.output.approvedBy);
} else {
  console.log("Timed out:", result.error);
}

Type the payload you expect with wait.forToken<T> so the result is strongly typed when result.ok is true.

The token URL pattern

Every token exposes a token.url webhook. POST JSON to that URL and the body becomes the token output. This is the right integration point for Slack buttons, email approval links, or any service that can call back when work is done. The URL includes a pre-signed hash, so external services can complete the token without your Trigger.dev API key.

const token = await wait.createToken({ timeout: "10m" });

// token.url is a webhook — POST JSON here to complete the token
await externalService.startJob({
  callbackUrl: token.url,
});

const result = await wait.forToken<ExternalResult>(token.id);

You can also complete tokens explicitly from your own API with wait.completeToken:

import { wait } from "@trigger.dev/sdk";

// In your approval API route
export async function POST(request: Request) {
  const { tokenId, approved, option, userId } = await request.json();

  await wait.completeToken(tokenId, {
    approved,
    selectedOption: option,
    approvedBy: userId,
  });

  return Response.json({ success: true });
}

Timeouts that match reality

Humans are slower than machines. Set timeout generously — hours or days for review workflows, not minutes. Also bump maxDuration on the task so Trigger.dev does not kill the run while someone is still deciding.

const result = await wait.forToken<ApprovalPayload>(token.id);

if (result.ok) {
  return processApproval(result.output);
}

// Timed out — handle gracefully instead of throwing
await notifyTimeout();
return { status: "timeout", defaultAction: "rejected" };

Check result.ok instead of assuming a response. Timeouts are normal; provide a default action rather than crashing the pipeline.

Power features

A few optional fields make tokens easier to operate in production:

  • idempotencyKey — prevents duplicate tokens when a task retries after a partial failure.
  • tags — attach workflow or user identifiers for filtering in the Trigger.dev dashboard.
  • publicAccessToken — pass to a React UI with useWaitToken so the browser can complete the token without a server round-trip.
// Prevent duplicate tokens on task retries
const token = await wait.createToken({
  timeout: "1h",
  idempotencyKey: `review-${workflowId}`,
  tags: [`workflow:${workflowId}`, `user:${userId}`],
});

// Pass publicAccessToken to a React approval UI
return {
  tokenId: token.id,
  publicAccessToken: token.publicAccessToken,
};

See the Trigger.dev wait-for-token documentation for the full API reference.

How RobotRock uses waitpoint tokens

RobotRock's Trigger.dev integration is built entirely on waitpoint tokens. When you call sendToHumanTask or approveByHumanTask, the SDK creates a token, registers token.url as the handler webhook, sends the task to your inbox, and waits.

createToken
sendToHuman

webhook: token.url

Human acts in inbox
RobotRock POSTs to token.url
forToken resolves
task continues
const token = await wait.createToken({ timeout });

const client = createClient({
  apiKey: config.apiKey,
  webhook: { url: token.url },
});

await client.sendToHuman({ ...taskInput, validUntil });

const outcome = await wait.forToken<RobotRockHandlerWebhookPayload>(token.id);

if (!outcome.ok) {
  throw new Error("Human response timed out");
}

return toDiscriminatedApprovalResult(payload.actions, outcome.output);

The validUntil field on your task input is converted to a matching token timeout automatically, so inbox expiry and Trigger.dev suspension stay aligned.

Why RobotRock makes waitpoints better

A bare wait token gives you a durable pause and a webhook URL. That solves the engineering problem — but someone still has to experience the approval. Roll your own UI for every workflow and you end up with scattered Slack threads, one-off admin pages, and email links that expire without context. RobotRock sits on top of the token and gives reviewers a consistent, purpose-built inbox.

One inbox for every workflow

Deploy gates, budget requests, and agent approvals all land in the same place. Reviewers filter by app, type, or assignment instead of hunting through notifications. When an agent fires five approval requests in a week, finance does not need five different URLs — they open their inbox.

Context humans can actually read

Raw JSON is fine for machines; it is terrible for decisions. RobotRock renders task context with a widget system — currency fields show as formatted amounts, arrays of objects become tables, long text renders as markdown, diffs can use compare widgets. You send structured context.data from your task; the inbox picks the right presentation via context.ui.

Actions that collect the right input

Approve and reject are not enough for real workflows. Each action can carry a JSON Schema form — require a rejection reason, capture an adjusted amount, ask for a change ticket. The reviewer picks an action card, fills in only the fields that action needs, and submits. That structured payload completes the wait token and flows back to your task as typed output.

await sendToHumanTask.triggerAndWait({
  type: "budget-approval",
  name: "Budget Request: $12,000",
  description: "New server hardware for Q3 capacity planning",
  assignTo: { emails: ["finance@company.com"] },
  context: {
    data: {
      amount: 12000,
      currency: "USD",
      department: "Engineering",
      requestedBy: "alex@company.com",
      lineItems: [
        { item: "GPU nodes", qty: 4, unitCost: 2500 },
        { item: "NVMe storage", qty: 2, unitCost: 1000 },
      ],
    },
    ui: {
      amount: { "ui:widget": "currency", "ui:options": { currency: "USD" } },
      lineItems: { "ui:widget": "table" },
    },
  },
  actions: [
    { id: "approve", title: "Approve" },
    {
      id: "reject",
      title: "Reject",
      schema: {
        type: "object",
        required: ["reason"],
        properties: { reason: { type: "string", title: "Rejection reason" } },
      },
      ui: { reason: { "ui:widget": "textarea" } },
    },
    {
      id: "adjust",
      title: "Adjust amount",
      schema: {
        type: "object",
        required: ["adjustedAmount"],
        properties: {
          adjustedAmount: { type: "number", title: "Adjusted amount" },
        },
      },
    },
  ],
});

Route to the right person

Not every approval belongs in a shared channel. Use assignTo to narrow a task to specific people or groups — finance sees budget requests, on-call sees deploy gates. Reviewers only see what is meant for them, which cuts noise and speeds up response times.

Progress while they wait

A silent wait feels broken. RobotRock threads let your agent or pipeline post live updates — "tests passed", "waiting for legal", "build failed" — on the same task the human is reviewing. The status bar and update history give reviewers confidence that something is still happening behind the wait token, not that the automation died overnight.

Share links for people outside your team

Not every approver has a RobotRock account. Generate a share link or use token-based public access so an external reviewer can open the task, see the context, and complete the action — RobotRock POSTs to token.url on their behalf. No custom approval page to build and maintain for each integration.

Audit trail by default

Every handled task records who acted, when, and what they submitted. That is compliance-ready without you wiring logging around each wait.completeTokencall. When someone asks "who approved this deploy?" six months later, the answer is in the inbox history, not buried in Slack.

End-to-end example

In practice, your code stays short. Export RobotRock's SDK tasks once, then call triggerAndWait() inside any Trigger.dev job:

import { task } from "@trigger.dev/sdk";
import { sendToHumanTask } from "robotrock/trigger";

export const deployTask = task({
  id: "deploy",
  maxDuration: 3600,
  run: async () => {
    const result = await sendToHumanTask.triggerAndWait({
      type: "deploy-approval",
      name: "Deploy v2.4.0 to production",
      actions: [
        { id: "approve", title: "Deploy now" },
        { id: "reject", title: "Block deploy" },
      ],
    });

    if (!result.ok) throw new Error("Human gate failed");
    return result.output;
  },
});

When a reviewer acts in RobotRock, Trigger.dev wakes the task with the handler payload so your pipeline can continue. Read the Trigger.dev integration guide for setup steps.