Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-callback-url-to-buttons-and-modals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chat": minor
---

Add `callbackUrl` prop to buttons and modals. When a button is clicked or a modal is submitted, the chat SDK POSTs action data to the callback URL in addition to firing existing handlers. This enables awaitable button/modal patterns when composed with webhook-based workflow engines.
194 changes: 194 additions & 0 deletions .cursor/plans/button_modal_callbackurl_9af9ad17.plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
---
name: Button/Modal callbackUrl
overview: Add `callbackUrl` prop to `ButtonElement` and `ModalElement`. When a button click or modal submit arrives, chat POSTs action data to the callbackUrl (if present) in addition to firing all existing handlers unchanged.
todos:
- id: types
content: Add `callbackUrl` to ButtonElement, ButtonOptions, ButtonProps, ModalElement, ModalOptions, ModalProps
status: completed
- id: jsx
content: Pass `callbackUrl` through JSX runtime for Button and Modal
status: completed
- id: token-encoding
content: Implement `processCallbackUrls()` in Thread -- walk card tree, generate tokens, store in StateAdapter, encode in value
status: completed
- id: action-handler
content: In handleActionEvent(), detect token prefix, look up callbackUrl, POST payload, restore original value
status: completed
- id: modal-handler
content: Extend modal context storage with callbackUrl, POST on modal submit
status: completed
- id: tests
content: Add tests for token encoding, action handler callbackUrl resolution, and modal callbackUrl flow
status: completed
- id: changeset
content: Create changeset for chat package (minor bump)
status: completed
isProject: false
---

# Add `callbackUrl` to Buttons and Modals

## Design

`callbackUrl` is a purely additive, raw URL prop. When a button is clicked or a
modal submitted, chat POSTs a JSON payload to the URL. All existing behavior
(`onAction`, `onModalSubmit`, etc.) continues to fire as before. Chat does not
provide `createHook()` -- users bring their own URL (from workflow, a custom
endpoint, or anything else).

### Challenge: round-trip persistence

Platforms don't echo back custom button metadata. When Slack sends a
`block_actions` event, it only includes `action_id` and `value` -- not any
`callbackUrl` we attached at render time. So we need a way to recover the URL
when the click arrives.

**Approach:** Encode a short token in the button's `value` field, store the
mapping `token -> callbackUrl` in the StateAdapter cache with a TTL. All four
adapters already preserve the `value` field through their encode/decode
round-trip (Slack as `value`, Teams as `data.value`, Google Chat as
`parameters.value`, WhatsApp as `v` in the encoded JSON).

```
Render time: callbackUrl present → generate token → store token→url in StateAdapter → prepend token to value
Action time: extract token from value → look up url → POST to url → restore original value → continue normal flow
```

### Webhook payload

The POST body sent to the callbackUrl:

```typescript
// Button click
{ type: "action", actionId: string, value?: string, user: { id: string, name?: string }, threadId: string, messageId?: string }

// Modal submit
{ type: "modal_submit", callbackId: string, values: Record<string, unknown>, user: { id: string, name?: string } }
```

---

## Files to change

### 1. Types and builders -- [packages/chat/src/cards.ts](packages/chat/src/cards.ts)

- Add `callbackUrl?: string` to `ButtonElement` (line ~61) and `ButtonOptions`
(line ~352)
- Pass it through in `Button()` function (line ~374)

### 2. Types and builders -- [packages/chat/src/modals.ts](packages/chat/src/modals.ts)

- Add `callbackUrl?: string` to `ModalElement` (line ~26) and `ModalOptions`
(line ~105)
- Pass it through in `Modal()` function (line ~116)

### 3. JSX runtime -- [packages/chat/src/jsx-runtime.ts](packages/chat/src/jsx-runtime.ts)

- Add `callbackUrl?: string` to `ButtonProps` (line ~107) and `ModalProps` (line
~151)
- Pass it through in the JSX `createElement` for `Button` (line ~587) and
`Modal` (line ~657)

### 4. Token encoding in Thread.post -- [packages/chat/src/thread.ts](packages/chat/src/thread.ts)

Add a private method `processCallbackUrls(postable)` that:

- Walks the card tree (CardElement children, looking for `ActionsElement`
containing `ButtonElement`)
- For each button with `callbackUrl`: generates a short token (e.g.,
`crypto.randomUUID().slice(0,12)`), stores
`chat:callback:{token} -> callbackUrl` in StateAdapter with 30-day TTL,
prepends a sentinel to the button's `value`: `__cb:{token}|{originalValue}`,
and strips `callbackUrl` from the element
- Returns the modified card

Call this in `post()` (line ~391) and `postEphemeral()` before passing to
`adapter.postMessage()`.

Token format: `__cb:{token}` prefix, pipe-separated from original value. Short
enough for all platforms (WhatsApp 256-char button ID limit is the tightest; ~20
chars of overhead is fine).

### 5. Action handler -- [packages/chat/src/chat.ts](packages/chat/src/chat.ts)

In `handleActionEvent()` (line ~1138), before building the full event:

```typescript
let originalValue = event.value;
let callbackUrl: string | undefined;

if (event.value?.startsWith("__cb:")) {
const pipeIdx = event.value.indexOf("|", 5);
const token =
pipeIdx === -1 ? event.value.slice(5) : event.value.slice(5, pipeIdx);
originalValue = pipeIdx === -1 ? undefined : event.value.slice(pipeIdx + 1);
callbackUrl = await this._stateAdapter.get<string>(`chat:callback:${token}`);
}

// Use originalValue as event.value for the rest of the handler
```

After handler execution (or in parallel), POST to callbackUrl if present:

```typescript
if (callbackUrl) {
fetch(callbackUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "action",
actionId: event.actionId,
value: originalValue,
user: event.user,
threadId: event.threadId,
messageId: event.messageId,
}),
}).catch((err) =>
this.logger.error("callbackUrl POST failed", { err, callbackUrl })
);
}
```

### 6. Modal submit handler -- [packages/chat/src/chat.ts](packages/chat/src/chat.ts)

Extend `StoredModalContext` to include `callbackUrl?: string`.

In `storeModalContext()` (line ~1057): accept and store `callbackUrl`.

Wire it up: when `openModal()` is called (line ~1186), if the modal has
`callbackUrl`, pass it to `storeModalContext()`.

In `processModalSubmit()` (line ~792): after retrieving modal context, if
`callbackUrl` is present, POST the modal values to it. Continue with normal
handler execution.

### 7. Exports -- [packages/chat/src/index.ts](packages/chat/src/index.ts)

No new exports needed -- `callbackUrl` is just a new optional prop on existing
types.

### 8. Adapter changes -- minimal

No adapter code changes required. The `callbackUrl` is stripped from the
ButtonElement before it reaches the adapter (step 4). The token is encoded in
the `value` field, which all adapters already preserve through their
encode/decode round-trip:

- **Slack**: `value` field on `block_actions` payload
- **Teams**: `data.value` on `Action.Submit`
- **Google Chat**: `parameters` array with key `value`
- **WhatsApp**: `v` field in encoded `chat:{json}` button ID

### 9. Tests

- Unit test for `processCallbackUrls()` -- token generation, value encoding,
StateAdapter storage
- Unit test for `handleActionEvent()` -- token extraction, callbackUrl lookup,
POST firing, original value restoration
- Unit test for modal submit -- callbackUrl stored and POSTed to on submit
- Verify existing action/modal tests still pass (no behavior change)

### 10. Changeset

Create a changeset for `chat` package with a `minor` bump: "Add callbackUrl
support to buttons and modals"
79 changes: 66 additions & 13 deletions apps/docs/content/docs/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,18 @@ bot.onAction(async (event) => {

The `event` object passed to action handlers:

| Property | Type | Description |
|----------|------|-------------|
| `actionId` | `string` | The `id` from the Button or Select component |
| `value` | `string` (optional) | The `value` from the Button or selected option |
| `user` | `Author` | The user who clicked |
| `thread` | `Thread \| null` | The thread containing the card (null for view-based actions like home tab buttons) |
| `messageId` | `string` | The message containing the card |
| `threadId` | `string` | Thread ID |
| `adapter` | `Adapter` | The platform adapter |
| `triggerId` | `string` (optional) | Platform trigger ID (used for opening modals) |
| `openModal` | `(modal) => Promise<void>` | Open a modal dialog |
| `raw` | `unknown` | Platform-specific event payload |
| Property | Type | Description |
| ----------- | -------------------------- | ---------------------------------------------------------------------------------- |
| `actionId` | `string` | The `id` from the Button or Select component |
| `value` | `string` (optional) | The `value` from the Button or selected option |
| `user` | `Author` | The user who clicked |
| `thread` | `Thread \| null` | The thread containing the card (null for view-based actions like home tab buttons) |
| `messageId` | `string` | The message containing the card |
| `threadId` | `string` | Thread ID |
| `adapter` | `Adapter` | The platform adapter |
| `triggerId` | `string` (optional) | Platform trigger ID (used for opening modals) |
| `openModal` | `(modal) => Promise<void>` | Open a modal dialog |
| `raw` | `unknown` | Platform-specific event payload |

## Pass data with buttons

Expand Down Expand Up @@ -94,5 +94,58 @@ bot.onAction("feedback", async (event) => {
```

<Callout type="info">
Modals are currently supported on Slack. Other platforms will receive a no-op or fallback behavior.
Modals are currently supported on Slack. Other platforms will receive a no-op
or fallback behavior.
</Callout>

## Callback URLs

Buttons accept a `callbackUrl` prop. When clicked, the action data is POSTed to that URL in addition to firing any `onAction` handler. This pairs naturally with [Workflow](https://useworkflow.dev) webhooks to build approval flows without any `onAction` handler at all:

```tsx title="lib/bot.tsx" lineNumbers
import { createWebhook } from "workflow";

bot.onNewMention(async (thread) => {
const approve = createWebhook();
const deny = createWebhook();

await thread.post(
<Card title="Deploy v2.4.1?">
<Actions>
<Button callbackUrl={approve.url} id="approve" style="primary">
Approve
</Button>
<Button callbackUrl={deny.url} id="deny" style="danger">
Deny
</Button>
</Actions>
</Card>
);

const accepted = await Promise.race([
approve.then(() => true),
deny.then(() => false),
]);

await thread.post(accepted ? "Deploying!" : "Cancelled.");
});
```

The workflow suspends at `Promise.race` until someone clicks a button. No `onAction` registration needed -- the webhook URL handles it.

### Callback payload

The POST body sent to the `callbackUrl`:

```json
{
"type": "action",
"actionId": "approve",
"value": "v2.4.1",
"user": { "id": "U123", "name": "alice" },
"threadId": "slack:C123:1234567890.123",
"messageId": "1234567890.456"
}
```

For modals, see [callbackUrl on modals](/docs/modals#callback-urls).
4 changes: 4 additions & 0 deletions apps/docs/content/docs/api/cards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ Button({ id: "delete", label: "Delete", style: "danger", value: "item-123" })
description: 'Optional payload sent with the action callback.',
type: 'string',
},
callbackUrl: {
description: 'URL to POST action data to when this button is clicked.',
type: 'string',
},
}}
/>

Expand Down
4 changes: 4 additions & 0 deletions apps/docs/content/docs/api/modals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ bot.onAction("open-form", async (event) => {
type: 'boolean',
default: 'false',
},
callbackUrl: {
description: 'URL to POST form values to when the modal is submitted.',
type: 'string',
},
privateMetadata: {
description: 'Arbitrary string passed through the modal lifecycle (e.g., JSON context).',
type: 'string',
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/content/docs/cards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ The `id` maps to your `onAction` handler. Optional `value` passes extra data:
<Button id="report" value="bug">Report Bug</Button>
```

Optional `callbackUrl` causes the action data to be POSTed to a URL when clicked. See [Callback URLs](/docs/actions#callback-urls) for details.

```tsx title="lib/bot.tsx"
<Button callbackUrl={webhook.url} id="approve" style="primary">Approve</Button>
```

### CardLink

Inline hyperlink rendered as text. Unlike `LinkButton` (which must be inside `Actions`), `CardLink` can be placed directly in a card alongside other content.
Expand Down
32 changes: 32 additions & 0 deletions apps/docs/content/docs/modals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ The top-level container for the form.
| `submitLabel` | `string` (optional) | Submit button text (defaults to "Submit") |
| `closeLabel` | `string` (optional) | Cancel button text (defaults to "Cancel") |
| `notifyOnClose` | `boolean` (optional) | Fire `onModalClose` when user cancels |
| `callbackUrl` | `string` (optional) | URL to POST form values to on submit |
| `privateMetadata` | `string` (optional) | Custom context passed through to handlers |

### TextInput
Expand Down Expand Up @@ -176,6 +177,37 @@ bot.onModalClose("feedback_form", async (event) => {
});
```

## Callback URLs

Like buttons, modals accept a `callbackUrl`. When the modal is submitted, the form values are POSTed to the URL:

```tsx title="lib/bot.tsx" lineNumbers
import { createWebhook } from "workflow";

const webhook = createWebhook();

await event.openModal(
<Modal callbackUrl={webhook.url} callbackId="intake" title="Request Access" submitLabel="Submit">
<TextInput id="reason" label="Reason" multiline />
</Modal>
);

const request = await webhook;
const body = await request.json();
// body.values.reason contains the submitted text
```

The POST body for modal submissions:

```json
{
"type": "modal_submit",
"callbackId": "intake",
"values": { "reason": "Need access to production logs" },
"user": { "id": "U123", "name": "alice" }
}
```

## Pass context with privateMetadata

Use `privateMetadata` to carry context from the button click through to the submit handler:
Expand Down
3 changes: 2 additions & 1 deletion examples/nextjs-chat/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";

const nextConfig: NextConfig = {
transpilePackages: [
Expand All @@ -24,4 +25,4 @@ const nextConfig: NextConfig = {
},
};

export default nextConfig;
export default withWorkflow(nextConfig);
Loading
Loading