Human-in-the-loop AI agents: durable approval workflows in TypeScript

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, 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 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 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 for approvals in your own chat UI.
  • Use the Slack or Linear agents and approvals are Approve and Deny buttons in the thread.
  • Use a webhook waiter 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, 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 gives the same examples we'd give: "canceling user orders, authorizing large refunds, or making payments".

Anthropic's framework for safe 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), and you can self-host 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, so a three-day approval has to be saved somewhere and picked back up by something else.)

Tierly 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 and went GA in August 2025. 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:

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:

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 hook, or have another service POST to the token's callback URL. The callback URL is an HMAC of the token ID 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 with a review step, and a meme generator that asks for approval with Slack buttons.

What happens while it waits

All of this is in the open source run engine, so you don't have to take our word for it.

A token is a row in the Waitpoint table with a status, an optional timeout and an output. Calling wait.forToken() links your run to that row and tells the worker it can suspend. The worker checkpoints the process, frees the machine, and releases the run's concurrency slot. 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 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):

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 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() runs each conversation as a durable session. For approvals it uses the AI SDK's needsApproval, which shipped in v4.5.0. Here's the example from the docs:

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:

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, 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 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:

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, 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, 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 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, 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 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 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, 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 pauses a run until a matching webhook arrives. Here an agent opens a pull request and waits for someone to approve it on GitHub:

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.

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, and webhooks.custom<T>() for anything else.

Your own chat tool

chat.channels.custom 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 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 it. A replay never resumes a waiting run, so it can't approve something twice.

Timeouts

DefaultWhen it runs out
Waitpoint tokenNonewait.forToken() returns ok: false
Webhook waiter24 hours (90 days max)The wait fails with WaitpointTimeoutError
chat.agent approval1 hour turn timeoutThe run ends; a later answer starts a new run with the conversation restored
Slack or Linear approvalSame as chat.agentSame, 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 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 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, 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. Their human-in-the-loop recipe is in Python and approves from a CLI script.

LangGraph has the nicest primitive in code. Call interrupt() 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. 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 save pending requests in checkpoints. Timeouts and hosting are up to you.

Trigger.devTemporalLangGraphMicrosoft Agent Framework
Pausewait.forToken, tool approvals, webhook waiterscondition() on a Signal or Updateinterrupt()Approval-required tools, RequestPort / request_info
State while waitingRun checkpointed, concurrency releasedEvent history in TemporalYour checkpointer, or LangSmith DeploymentAgent session, workflow checkpoints
Built-in timeoutYesYesNoNo
Built-in approval buttonsYour chat UI (via AI SDK), Slack, LinearNoNoNo
Approver checkYoursYoursYoursYours (responses bound to the session)
Code rulesPlain async TypeScriptDeterministic workflow codeCode before interrupt() runs againStable IDs to restore checkpoints
LanguagesTypeScriptManyPython, TypeScript.NET, Python (Go in preview)
Who runs your codeWe do, compute includedYou run the WorkersYou, or LangSmith DeploymentYou, or Foundry Hosted Agents
Open sourceYes (Apache 2.0)Yes (MIT)Yes (MIT)Yes (MIT)
Cost while waitingNot billedActions per Signal, Update and timer, plus storageYour infrastructure, or metered deploymentYour 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.

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.