---
title: How to get the most out of Trigger.dev waitpoint tokens
description: 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
tags: Trigger.dev, Human-in-the-loop, AI agents
---

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

> **When not to use a wait token.** If you only need to delay execution for a fixed duration, use `wait.for()` or `wait.until()`. Wait tokens are for open-ended waits where you do not know when (or if) the external event will arrive.

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

```typescript
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.

```typescript
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 an "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:

```typescript
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 };
  },
});
```

> **Agents + humans, without spaghetti.** The alternative — fire-and-forget a task, store state in a database, poll for completion, reconcile on retry — works but scales poorly as you add more approval gates. Wait tokens let you write agent workflows as straight-line async code: generate → ask human → act on answer.

## Core API

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

```typescript
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.

```typescript
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`:

```typescript
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.

```typescript
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.

```typescript
// 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](https://trigger.dev/docs/wait-for-token) 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
```

> **RobotRock uses this internally.** The `robotrock/trigger` SDK tasks wrap the waitpoint pattern so you never manage token lifecycle yourself. Re-export the tasks from your `trigger/` directory and call `triggerAndWait()` from your own jobs.

```typescript
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.

```typescript
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.completeToken` call. When someone asks "who approved this deploy?" six months later, the answer is in the inbox history, not buried in Slack.

> **Better for reviewers, simpler for builders.** Wait tokens handle durability on the backend. RobotRock handles the human experience on the frontend — inbox, forms, assignment, threads, and audit — so you spend your time on agent logic, not approval UI. Your Trigger.dev task stays a single `triggerAndWait()`; your reviewers get a product-grade experience every time.

## End-to-end example

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

```typescript
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](https://docs.robotrock.io/integrations/trigger) for setup steps.
