Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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