---
title: "Human-in-the-loop AI agents: durable approval workflows in TypeScript"
description: "How to pause an AI agent for human approval and resume it on Trigger.dev: waitpoint tokens, chat.agent tool approvals, Slack and Linear Approve buttons, and webhook waiters, with timeouts, escalation and a comparison with Temporal, LangGraph and Microsoft Agent Framework."
date: 2026-10-23
---

**We generate the images for our changelog with an AI task. It posts a few options to Slack, and then waits up to 24 hours for one of us to approve one (or reject them all).**

That's human in the loop: do some work, then let a human reject, suggest changes, or approve. On Trigger.dev the waiting part is free. Tasks checkpoint during waits, so an approval step that waits a week uses no resources and isn't billed.

When we built that flow, the Slack side took a 225-line route that verified Slack's signature and completed the token by hand. As of [Trigger.dev 4.8.0](https://trigger.dev/changelog/v4-8-0), Slack and Linear approvals are built in, so you don't have to write that part anymore.

This page covers the four ways to ask a human on Trigger.dev, how the wait actually works in the [open source](https://github.com/triggerdotdev/trigger.dev) run engine, and how Temporal, LangGraph and Microsoft Agent Framework do it. Everything was checked against the docs and the code on October 23, 2026.

## TL;DR

- Use a [waitpoint token](https://trigger.dev/docs/wait-for-token) in any task. Waitpoint tokens can pause a run until you complete the token (or it times out).
- Use `needsApproval` on a tool in [`chat.agent`](https://trigger.dev/docs/ai-chat/patterns/human-in-the-loop) for approvals in your own chat UI.
- Use the [Slack](https://trigger.dev/docs/webhooks/slack) or [Linear](https://trigger.dev/docs/webhooks/linear) agents and approvals are Approve and Deny buttons in the thread.
- Use a [webhook waiter](https://trigger.dev/docs/webhooks/waiters) when the approval happens in another tool, like a GitHub review.
- Checking who's allowed to approve, and keeping an audit record, are up to you. The Slack and Linear buttons don't check who clicked.

## When an agent should ask

Before anything that's hard to undo. OWASP lists "Excessive Agency" in its [Top 10 for LLM applications](https://genai.owasp.org/llmrisk/llm062025-excessive-agency/), and one of its fixes is to "require a human to approve high-impact actions before they are taken". OpenAI's [practical guide to building agents](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf) gives the same examples we'd give: "canceling user orders, authorizing large refunds, or making payments".

Anthropic's [framework for safe agents](https://www.anthropic.com/news/our-framework-for-developing-safe-and-trustworthy-agents) puts it as humans keeping control "before high-stakes decisions are made". That's why Claude Code asks before it edits your files.

The question is what your agent does while it waits for the answer. The AI SDK hands you an approval request and stops there (its `generateText` and `streamText` don't pause anything). Something has to keep the agent's state for the hour, or the week, until a person answers. That's the part Trigger.dev does.

And you don't run any servers to do it. Compute is included: you deploy the code, nothing runs while the agent waits, and when the answer comes in we restore the run on a machine we run. It's all open source too ([Apache 2.0](https://github.com/triggerdotdev/trigger.dev)), and you can [self-host](https://trigger.dev/docs/self-hosting/overview) it (though self-hosted waits hold the machine, since checkpointing is cloud-only).

(If you're thinking of serverless functions for this, they time out. AWS Lambda [stops at 15 minutes](https://docs.aws.amazon.com/lambda/latest/dg/configuration-timeout.html), so a three-day approval has to be saved somewhere and picked back up by something else.)

[Tierly](https://trigger.dev/customers/tierly-customer-story) runs pricing analyses that coordinate "10+ AI models, dozens of data extraction tasks, and human-in-the-loop review gates". In their words: "We use Trigger.dev's wait tokens to pause execution until approval". Their frontend shows a review modal, the user edits the data, and when they approve, the workflow carries on with their changes.

## Waitpoint tokens

This is the one to use for a task or workflow. Waitpoints shipped with [Trigger.dev v4](https://trigger.dev/blog/v4-beta-launch) and went GA in [August 2025](https://trigger.dev/changelog/waitpoints). You create a token, send its ID to whoever needs to decide, and wait on it.

Here's a refund agent that asks a support lead, goes to a manager if nobody answers in 24 hours, and gives up after another 24:

```ts
import { task, wait } from "@trigger.dev/sdk";
import { notifyReviewer, issueRefund, recordDecision } from "@/lib/refunds";

type Decision = { approved: boolean; reviewerId: string; note?: string };

export const refundAgent = task({
  id: "refund-agent",
  run: async (payload: { refundId: string; amount: number; reason: string }) => {
    const tiers = [
      { role: "support-lead", timeout: "24h" },
      { role: "support-manager", timeout: "24h" },
    ];

    for (const tier of tiers) {
      const token = await wait.createToken({
        timeout: tier.timeout,
        // A retried attempt gets the same token back instead of asking twice.
        idempotencyKey: `refund-${payload.refundId}-${tier.role}`,
        tags: [`refund:${payload.refundId}`],
      });

      // On a retry the token comes back cached, and the reviewer already has the request.
      if (!token.isCached) {
        await notifyReviewer({ role: tier.role, tokenId: token.id, ...payload });
      }

      const result = await wait.forToken<Decision>(token);
      if (!result.ok) continue; // timed out: escalate to the next tier

      await recordDecision(payload.refundId, result.output);

      if (!result.output.approved) {
        return { status: "rejected", by: result.output.reviewerId };
      }

      await issueRefund(payload.refundId, { idempotencyKey: payload.refundId });
      return { status: "refunded", by: result.output.reviewerId };
    }

    return { status: "expired" };
  },
});
```

The only error `wait.forToken()` can return is a timeout, so `ok: false` means nobody answered in time. That's your escalation branch. (Tokens have no timeout by default. Set one, or a forgotten approval waits forever.)

When the reviewer clicks, your backend checks they're allowed to and completes the token:

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

export async function decideRefund(req: Request, tokenId: string, approved: boolean) {
  const reviewer = await getSessionUser(req); // from your auth, never from the request body
  const request = await loadPendingApproval(tokenId); // from your own approvals table
  if (request.role !== reviewer.role) throw new Error("Not allowed to decide this refund");

  await wait.completeToken(tokenId, { approved, reviewerId: reviewer.id });
}
```

You can also complete a token from the browser with the [`useWaitToken`](https://trigger.dev/docs/realtime/react-hooks/use-wait-token) hook, or have another service POST to the token's callback URL. The callback URL is an [HMAC of the token ID](https://github.com/triggerdotdev/trigger.dev/blob/bd2a14e6ad83fbe056a473b0082ff4110747fa38/apps/webapp/app/services/httpCallback.server.ts#L13-L30) and needs no API key, so anyone who has it can approve. Keep it between servers.

There are two working examples in our docs: an [article summary workflow](https://trigger.dev/docs/guides/example-projects/human-in-the-loop-workflow) with a review step, and a [meme generator](https://trigger.dev/docs/guides/example-projects/meme-generator-human-in-the-loop) that asks for approval with Slack buttons.

## What happens while it waits

All of this is in the open source [run engine](https://github.com/triggerdotdev/trigger.dev/tree/bd2a14e6ad83fbe056a473b0082ff4110747fa38/internal-packages/run-engine), so you don't have to take our word for it.

A token is a row in the [`Waitpoint` table](https://github.com/triggerdotdev/trigger.dev/blob/bd2a14e6ad83fbe056a473b0082ff4110747fa38/internal-packages/database/prisma/schema.prisma#L1614-L1687) with a status, an optional timeout and an output. Calling `wait.forToken()` [links your run to that row](https://github.com/triggerdotdev/trigger.dev/blob/bd2a14e6ad83fbe056a473b0082ff4110747fa38/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts#L351-L469) and tells the worker it can suspend. The worker checkpoints the process, frees the machine, and [releases the run's concurrency slot](https://github.com/triggerdotdev/trigger.dev/blob/bd2a14e6ad83fbe056a473b0082ff4110747fa38/internal-packages/run-engine/src/engine/systems/checkpointSystem.ts#L241-L276). So 500 runs waiting on approvals don't stop new runs starting.

The timeout isn't a cron job polling for old tokens. Creating a token with a timeout [schedules a delayed job](https://github.com/triggerdotdev/trigger.dev/blob/bd2a14e6ad83fbe056a473b0082ff4110747fa38/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts#L317-L328) for that moment, which completes the token with a timeout error.

Completing a token is one guarded write. It only matches a token that's still pending ([`PostgresRunStore.ts`](https://github.com/triggerdotdev/trigger.dev/blob/bd2a14e6ad83fbe056a473b0082ff4110747fa38/internal-packages/run-store/src/PostgresRunStore.ts#L2631-L2653)):

```ts
this.prisma.waitpoint.updateMany({
  where: { id: waitpointId, status: "PENDING" },
  data: {
    status: "COMPLETED",
    completedAt: completion.completedAt ?? new Date(),
    output: completion.output?.value,
    outputType: completion.output?.type,
    outputIsError: completion.output?.isError,
  },
})
```

The first answer wins. A double click, a retried request, or a timeout racing a late approval matches nothing the second time, and the API still returns success. (A timeout is stored as a completed token with `outputIsError: true`, which is how it reaches you as `ok: false`.)

Then the engine [puts your run back in the queue](https://github.com/triggerdotdev/trigger.dev/blob/bd2a14e6ad83fbe056a473b0082ff4110747fa38/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts#L780-L840) with its original timestamp, so a run coming back from an approval goes ahead of new runs. It restores from the checkpoint and `wait.forToken()` returns.

## Approvals in your own chat UI

[`chat.agent()`](https://trigger.dev/docs/ai-chat/overview) runs each conversation as a durable session. For approvals it uses the AI SDK's `needsApproval`, which [shipped in v4.5.0](https://trigger.dev/changelog/v4-5-0). Here's the example from the [docs](https://trigger.dev/docs/ai-chat/patterns/human-in-the-loop):

```ts
import { tool } from "ai";
import { z } from "zod";
import { sendEmail } from "@/lib/email";

export const sendEmailTool = tool({
  description: "Send an email after the user approves.",
  inputSchema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
  needsApproval: true,
  execute: async (input) => sendEmail(input),
});
```

When the model calls the tool, your frontend gets an approval request and answers it:

```tsx
const { messages, sendMessage, addToolApprovalResponse, status } = useChat({
  id: chatId,
  transport,
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});

// Inside your message renderer:
if (isToolUIPart(part) && part.state === "approval-requested") {
  return (
    <div key={i}>
      <p>Tool "{part.type}" wants to run with input:</p>
      <pre>{JSON.stringify(part.input, null, 2)}</pre>
      <button onClick={() => addToolApprovalResponse({ id: part.approval.id, approved: true })}>Approve</button>
      <button onClick={() => addToolApprovalResponse({ id: part.approval.id, approved: false, reason: "User denied" })}>Deny</button>
    </div>
  );
}
```

Approve and the tool runs. Deny and it's skipped.

`needsApproval` can also be a function, so you can only ask when it matters (refunds over $500, say). These examples use AI SDK 6. `chat.agent` also works with [AI SDK 7](https://trigger.dev/docs/ai-chat/changelog), where `needsApproval` is deprecated in favor of a `toolApproval` option.

While it waits, the run stays warm for 30 seconds, then suspends. If nobody answers within the turn timeout (1 hour by default) the run ends, and a later answer starts a new run with the conversation restored. Our [chat.agent launch](https://trigger.dev/changelog/chat-agent) put it this way: "an approval can sit overnight or over a weekend."

## Approvals in Slack

`@trigger.dev/slack` turns a `chat.agent` into a native Slack agent. Each DM or channel thread is a durable session, the reply streams into the thread, and approvals are Approve and Deny buttons.

A tool with no `execute` is the approval. When the model calls it, the turn pauses until someone clicks:

```ts
import { webhooks } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
import { appMentions, slack, webhookSource } from "@trigger.dev/slack";
import { tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const slackEvents = webhooks.endpoint.define({ id: "slack", source: webhookSource() });

// No execute: calling this pauses the turn for a human decision.
const requestApproval = tool({
  description:
    "Request human approval before a sensitive or irreversible action. " +
    "Call this and stop; you will receive { approved: boolean }, then proceed or decline.",
  inputSchema: z.object({
    action: z.string().describe("A short description of the action needing approval"),
  }),
});

export const supportAgent = chat.agent({
  id: "support-agent",
  channels: [
    slack({
      id: "support-slack",
      endpoint: slackEvents,
      token: process.env.SLACK_BOT_TOKEN!,
      startOn: appMentions(),
      ownWorkspaceOnly: true,
    }),
  ],
  tools: { requestApproval },
  run: async ({ messages, tools, streamText }) =>
    streamText({
      model: anthropic("claude-sonnet-5-5"),
      messages,
      tools,
      system:
        "Before any sensitive or irreversible action (refunds, cancellations, deletions), " +
        "call requestApproval and wait. If approved, confirm it is done; if denied, decline.",
    }),
});
```

The bot posts "Approval needed" with the two buttons. Click Approve and the buttons collapse to "Approved by @you" and the agent carries on in the thread. Slack's stop button cancels it instead, so a late click does nothing.

Under the hood each button [carries the tool call ID](https://github.com/triggerdotdev/trigger.dev/blob/bd2a14e6ad83fbe056a473b0082ff4110747fa38/packages/slack/src/index.ts#L712-L718), so a click resolves exactly the call it was posted for, and a second click on a call that's already decided is ignored. The click comes back to the same endpoint as Slack's events, as a second webhook that wakes the agent.

The run is suspended the whole time, so someone can take days to decide without it costing compute or hitting `maxDuration`.

Signature verification, Slack's URL handshake, retries and streaming are all handled for you. That's the 225 lines we wrote by hand. You create the Slack app from the [manifest in the docs](https://trigger.dev/docs/webhooks/slack#setup), paste in its signing secret, and pass the bot token.

You can change the buttons with the `approval` option, or give one tool its own buttons with `interactions` (pick one of three options, say).

### Who can click Approve

Anyone who can see the buttons. Every click is verified as coming from Slack, but the [click handler](https://github.com/triggerdotdev/trigger.dev/blob/bd2a14e6ad83fbe056a473b0082ff4110747fa38/packages/slack/src/index.ts#L770-L775) doesn't check who the clicker is. Three ways to tighten it:

- Turn on `ownWorkspaceOnly: true` (it's in the example above). It drops clicks from Slack Connect guests and other workspaces.
- Run approval agents in a channel only the approvers are in.
- For a real allowlist, connect Slack through [`chat.channels.custom`](https://trigger.dev/docs/webhooks/channels), where you write the `onInteraction` that turns a click into an answer, and return `null` for anyone who isn't allowed.

The approval card also shows the tool's input, which the model wrote. Treat it like any other model output when someone's reading it to decide.

## Approvals in Linear

[`@trigger.dev/linear`](https://trigger.dev/docs/webhooks/linear) works the same way. Assign the agent an issue or @mention it, and a tool with no `execute` shows Approve and Deny in Linear's agent panel (it's Linear's [elicitation](https://linear.app/developers/agent-interaction) activity). The decision is recorded on the issue ("Approved by Jane.") and the run carries on. Like Slack, it doesn't check who chose.

It's a good fit when the approval belongs with the work, like "should I close these 14 duplicates?"

## Other ways to use webhooks

The Slack and Linear agents run on [durable webhooks](https://trigger.dev/docs/webhooks/overview), which shipped in the same release. We host the URL, verify every request against the provider's signature, dedupe retries, and send each event to the tasks and agents subscribed to it. That gives you a few more ways to put a human in the loop.

### Wait for an approval in another tool

A [waiter](https://trigger.dev/docs/webhooks/waiters) pauses a run until a matching webhook arrives. Here an agent opens a pull request and waits for someone to approve it on GitHub:

```ts
import { task, webhooks } from "@trigger.dev/sdk";
import { openPullRequest, requestReview, mergePullRequest } from "@/lib/github";

// The fields this example reads from GitHub's pull_request_review event.
type PullRequestReviewEvent = {
  repository: { full_name: string };
  pull_request: { number: number };
  review: { state: string; user: { login: string } };
};

const github = webhooks.endpoint.define({
  id: "github",
  source: webhooks.github<PullRequestReviewEvent>(),
});

export const codeChangeAgent = task({
  id: "code-change-agent",
  // repo is "owner/name"
  run: async (payload: { repo: string; branch: string; title: string }) => {
    const pr = await openPullRequest({ ...payload, draft: true });

    const waiter = await webhooks.waiter.create(github, {
      match: {
        "header.x-github-event": "pull_request_review",
        "event.repository.full_name": payload.repo,
        "event.pull_request.number": pr.number,
        "event.review.state": "approved",
      },
      timeout: "7d",
      idempotencyKey: `review-${payload.repo}-${pr.number}`,
    });

    // Only ask for the review once something is waiting for it.
    await requestReview(payload.repo, pr.number);

    const result = await webhooks.waiter.wait(waiter);
    if (!result.ok) return { status: "no-approval", pr: pr.number };

    const reviewer = result.output.event.review.user.login;
    await mergePullRequest(pr.number);
    return { status: "merged", pr: pr.number, approvedBy: reviewer };
  },
});
```

A waiter is a waitpoint token underneath, so everything in the section above applies. You just don't have to write the receiver that completes it.

Waiters time out after 24 hours by default and can wait up to 90 days. They only match webhooks that arrive after they're created, so the example opens the pull request as a draft and only requests the review once the waiter exists. Match on the repository as well as the PR number, or an approval in another repo with the same number would count. The `match` paths are hashed into a lookup key, so finding the right waiter doesn't mean scanning them all, and [one delivery can resume up to 10,000 waiting runs](https://github.com/triggerdotdev/trigger.dev/blob/bd2a14e6ad83fbe056a473b0082ff4110747fa38/internal-packages/webhook-engine/src/engine/index.ts#L94-L102).

The same thing works for a contract signed in DocuSign, a form in Typeform or a ticket moved in Jira. There are [46 named sources](https://trigger.dev/docs/webhooks/sources), and `webhooks.custom<T>()` for anything else.

### Your own chat tool

[`chat.channels.custom`](https://trigger.dev/docs/webhooks/channels) connects any chat surface to an agent. You write four small hooks: post the buttons, turn a click into the tool's answer, remove the buttons, and clean up on stop. Because you see the raw click, it's where you check who clicked.

### Events into a conversation

[Session routing](https://trigger.dev/docs/webhooks/session-routing) sends a webhook to a running agent session by a key, like an order ID. If that session is a Slack thread, the agent can answer there, so a "payment failed" event lands in the thread where someone's already talking to the agent.

### Replays

Every delivery has its own page with the runs it started or resumed, and you can [replay](https://trigger.dev/docs/webhooks/deliveries) it. A replay never resumes a waiting run, so it can't approve something twice.

## Timeouts

| | Default | When it runs out |
| --- | --- | --- |
| Waitpoint token | None | `wait.forToken()` returns `ok: false` |
| Webhook waiter | 24 hours (90 days max) | The wait fails with `WaitpointTimeoutError` |
| `chat.agent` approval | 1 hour turn timeout | The run ends; a later answer starts a new run with the conversation restored |
| Slack or Linear approval | Same as `chat.agent` | Same, and Slack's stop button cancels it |

To escalate, loop over reviewers like the refund example. Give each tier its own token and timeout, and put the tier in the idempotency key so a retry doesn't skip ahead.

Put the approval before the side effect. Stopping can't undo a tool that already ran.

## Things we've learned

These come up a lot in our support channels.

- **Close connections before a long wait.** The process is restored exactly, but the database on the other end won't have kept your socket open for three days. Disconnect in a [`tasks.onWait`](https://trigger.dev/docs/database-connections) handler and reconnect on resume.
- **Keep secrets out of token outputs.** Whatever you complete a token with is stored and shows up in the run. Pass an ID and look the details up after.
- **Waiting runs don't hold concurrency.** A run blocked on an approval gives its slot back, so you don't need to size your queues for people who are on holiday.

## What you still build

Trigger.dev keeps the run, its state and its trace. Two things are yours:

- **Who can approve.** Check the reviewer before you complete the token, or in `onInteraction` for a custom channel. Our docs say to keep "authorization and idempotency checks" in your own function.
- **The audit record.** The run trace and the chat transcript show the tool call and its result, but for compliance write the decision, the reviewer and the action to your own table. Key it on the token or tool call ID so retries don't write it twice.

And because retries run a task from the top, give anything that moves money or sends messages an idempotency key.

## How Temporal, LangGraph and Microsoft Agent Framework do it

All three can pause an agent for a human. Checked against their docs on October 23, 2026.

**Temporal** is the most mature here. A workflow waits on a [Signal or Update](https://docs.temporal.io/develop/typescript/message-passing) with `condition(fn, timeout)`, which returns `false` on timeout, and Temporal says a workflow can run "for seconds or years". Updates give the caller an acknowledgement, which is nice for "approve and tell me it worked". Temporal Cloud runs the service, but [Workers run in your environment](https://docs.temporal.io/cloud/introduction), so you deploy and scale the workers that pick a workflow back up when the approval arrives. You write deterministic workflow code with side effects in Activities, and Temporal Cloud bills each Signal, Update and timer as an [Action](https://docs.temporal.io/cloud/actions). Their [human-in-the-loop recipe](https://docs.temporal.io/ai/cookbook/human-in-the-loop-python) is in Python and approves from a CLI script.

**LangGraph** has the nicest primitive in code. Call [`interrupt()`](https://docs.langchain.com/oss/javascript/langgraph/interrupts) anywhere in a node and resume with `new Command({ resume })`. You need a checkpointer (Postgres in production) or LangSmith Deployment to keep the state. On resume the node runs again from the top, so anything before `interrupt()` has to be safe to repeat. There's no built-in timeout.

**Microsoft Agent Framework** went 1.0 for .NET and Python in [April 2026](https://devblogs.microsoft.com/agent-framework/microsoft-agent-framework-version-1-0/). Wrap a function in `ApprovalRequiredAIFunction` (C#) or use `approval_mode="always_require"` (Python), and the run comes back with an approval request for your app to answer. An approval only counts if it matches a pending request in the same session, which stops forged or replayed approvals. [Workflows](https://learn.microsoft.com/en-us/agent-framework/workflows/human-in-the-loop) save pending requests in checkpoints. Timeouts and hosting are up to you.

| | Trigger.dev | Temporal | LangGraph | Microsoft Agent Framework |
| --- | --- | --- | --- | --- |
| Pause | `wait.forToken`, tool approvals, webhook waiters | `condition()` on a Signal or Update | `interrupt()` | Approval-required tools, `RequestPort` / `request_info` |
| State while waiting | Run checkpointed, concurrency released | Event history in Temporal | Your checkpointer, or LangSmith Deployment | Agent session, workflow checkpoints |
| Built-in timeout | Yes | Yes | No | No |
| Built-in approval buttons | Your chat UI (via AI SDK), Slack, Linear | No | No | No |
| Approver check | Yours | Yours | Yours | Yours (responses bound to the session) |
| Code rules | Plain async TypeScript | Deterministic workflow code | Code before `interrupt()` runs again | Stable IDs to restore checkpoints |
| Languages | TypeScript | Many | Python, TypeScript | .NET, Python (Go in preview) |
| Who runs your code | We do, compute included | You run the Workers | You, or LangSmith Deployment | You, or Foundry Hosted Agents |
| Open source | Yes (Apache 2.0) | Yes (MIT) | Yes (MIT) | Yes (MIT) |
| Cost while waiting | Not billed | Actions per Signal, Update and timer, plus storage | Your infrastructure, or metered deployment | Your infrastructure |

Use Temporal if you already run it or need more than TypeScript. Use LangGraph if your agent is a graph and you want to pause from anywhere in it. Use Microsoft Agent Framework if you're on .NET and Azure. Use Trigger.dev if you're writing the agent in TypeScript and want the wait, the timeout and the Slack or Linear buttons without building them, or running workers for them. It already runs [hundreds of millions of agents a month](https://trigger.dev/blog/series-a).

## FAQs

- **What does human in the loop mean for AI agents?** The agent does some work, then pauses so a human can reject, suggest changes, or approve, and carries on with their answer. It's mostly used before things that are hard to undo: refunds, deploys, deletions, emails.

- **Does a waiting agent cost anything on Trigger.dev?** No compute. Tasks checkpoint during waits, so an approval step that waits a week uses no resources and isn't billed. The run gives back its concurrency slot too. A `chat.agent` stays warm for 30 seconds before it suspends, and those 30 seconds do count.

- **How long can an agent wait for approval?** A waitpoint token waits until you complete it, unless you set a timeout. A webhook waiter can wait up to 90 days. A `chat.agent` run ends after its turn timeout (1 hour by default), but a later answer starts a new run with the conversation restored.

- **Can I approve AI agent actions from Slack?** Yes. With `@trigger.dev/slack`, a tool with no `execute` posts Approve and Deny buttons in the thread, and the click resumes the run. `@trigger.dev/linear` does the same in Linear, and `chat.channels.custom` works for any other chat tool.

- **What if two people answer, or the answer comes after the timeout?** The first one wins. Completing a token only updates it while it's still pending, so the second answer changes nothing, and a timed-out token can't be approved later.

- **Can I limit who approves in Slack?** Not with the built-in buttons, which accept a click from anyone who can see them. Use `ownWorkspaceOnly`, a channel only approvers are in, or `chat.channels.custom` with your own check in `onInteraction`.
