---
title: Approvals
description: Pause a durable workflow until a human approves it in chat.
type: guide
prerequisites:
  - /docs/cards
  - /docs/actions
related:
  - /docs/ai/ai-sdk-tools
---

# Approvals



`requestApproval` from `chat/workflow` posts a card with Approve/Deny buttons and suspends a [Workflow SDK](https://workflow-sdk.dev) workflow until a user clicks. The wait can last seconds or days and survives deploys and restarts. You don't need an approvals table, an `onAction` handler, or a polling loop.

```typescript title="workflows/deploy.ts" lineNumbers
import { requestApproval } from "chat/workflow";
import type { Thread } from "chat";

export async function deployApproval(opts: { thread: Thread; version: string }) {
  "use workflow";

  const { approved, user, timedOut } = await requestApproval(opts.thread, {
    title: `Deploy ${opts.version}?`,
    fields: { Version: opts.version },
    timeout: "24h",
  });

  if (approved) {
    await deploy(opts.version);
  }
}

async function deploy(version: string) {
  "use step";
  // Trigger your deploy here.
}
```

Start the workflow from any handler. `Thread` instances serialize across the workflow boundary automatically:

```typescript title="lib/bot.ts" lineNumbers
import { start } from "workflow/api";
import { deployApproval } from "@/workflows/deploy";

bot.onNewMention(async (thread, message) => {
  await start(deployApproval, [{ thread, version: "v1.2.3" }]);
});
```

## Setup

Install the optional `workflow` peer dependency and configure your framework for [Workflow SDK](https://workflow-sdk.dev/docs/getting-started). Then register your `Chat` instance as a singleton at startup so threads can be revived on the other side of the workflow boundary:

```typescript title="lib/bot.ts"
chat.registerSingleton();
```

## Options

| Option                       | Type                                                   | Description                                                                   |
| ---------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- |
| `title`                      | `string`                                               | Card title (required)                                                         |
| `subtitle`                   | `string`                                               | Rendered under the title                                                      |
| `description`                | `string`                                               | Body text (markdown) above the fields                                         |
| `fields`                     | `Record<string, string>`                               | Key/value pairs rendered as a bold-label list                                 |
| `approveLabel` / `denyLabel` | `string`                                               | Button labels. Default: "Approve" / "Deny"                                    |
| `timeout`                    | `` number \| `${number}${"ms"\|"s"\|"m"\|"h"\|"d"}` `` | Give up after this long. Omit to wait indefinitely                            |
| `approvers`                  | `string[]`                                             | User IDs allowed to decide. Clicks from others post a notice and keep waiting |

## Result

| Property   | Type                            | Description                                  |
| ---------- | ------------------------------- | -------------------------------------------- |
| `approved` | `boolean`                       | True when the approve button was clicked     |
| `timedOut` | `boolean`                       | True when `timeout` elapsed with no decision |
| `user`     | `{ id: string; name?: string }` | Who decided. Absent on timeout               |

Once a decision lands (or the timeout elapses), the card is edited in place: the buttons are removed and replaced with an outcome line, leaving an audit trail in the thread and preventing stale clicks.

## Who can approve

Anyone who can see the card can decide the approval. The click itself is trustworthy: Chat SDK verifies the platform's signature on the incoming event, and the button's callback URL never reaches the client. So the `user.id` on the result really is the person who clicked.

If only certain people should decide, pass `approvers` with their user IDs. When anyone else clicks, the bot posts a notice in the thread and the workflow keeps waiting:

```typescript
await requestApproval(thread, {
  title: `Deploy ${version}?`,
  approvers: ["U_ALICE", "U_BOB"],
});
```

Set `approvers` for anything consequential.

## Custom cards

The card builders are exported for cases the wrapper doesn't cover, such as posting an approval card whose buttons target a webhook you manage yourself:

```typescript
import { buildApprovalCard, buildResolvedCard } from "chat/workflow";

const card = buildApprovalCard(
  { title: "Deploy?", fields: { Version: "v1.2.3" } },
  webhookUrl
);
```

<Callout type="info">
  The [Human-in-the-Loop guide](https://vercel.com/kb/guide/human-in-the-loop-with-chat-sdk-and-workflow-sdk)
  walks through the underlying pattern, including multi-stage approvals and
  validating approvers with `createHook()`.
</Callout>


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)