diff --git a/.changeset/add-callback-url-to-buttons-and-modals.md b/.changeset/add-callback-url-to-buttons-and-modals.md new file mode 100644 index 00000000..3f9ed700 --- /dev/null +++ b/.changeset/add-callback-url-to-buttons-and-modals.md @@ -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. diff --git a/.cursor/plans/button_modal_callbackurl_9af9ad17.plan.md b/.cursor/plans/button_modal_callbackurl_9af9ad17.plan.md new file mode 100644 index 00000000..c0a8604e --- /dev/null +++ b/.cursor/plans/button_modal_callbackurl_9af9ad17.plan.md @@ -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, 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(`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" diff --git a/apps/docs/content/docs/actions.mdx b/apps/docs/content/docs/actions.mdx index 855b4b74..b1f678cb 100644 --- a/apps/docs/content/docs/actions.mdx +++ b/apps/docs/content/docs/actions.mdx @@ -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` | 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` | Open a modal dialog | +| `raw` | `unknown` | Platform-specific event payload | ## Pass data with buttons @@ -94,5 +94,58 @@ bot.onAction("feedback", async (event) => { ``` -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. + +## 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( + + + + + + + ); + + 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). diff --git a/apps/docs/content/docs/api/cards.mdx b/apps/docs/content/docs/api/cards.mdx index 8f909630..35b350a0 100644 --- a/apps/docs/content/docs/api/cards.mdx +++ b/apps/docs/content/docs/api/cards.mdx @@ -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', + }, }} /> diff --git a/apps/docs/content/docs/api/modals.mdx b/apps/docs/content/docs/api/modals.mdx index b176a1f0..11159b62 100644 --- a/apps/docs/content/docs/api/modals.mdx +++ b/apps/docs/content/docs/api/modals.mdx @@ -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', diff --git a/apps/docs/content/docs/cards.mdx b/apps/docs/content/docs/cards.mdx index e5381310..0627f363 100644 --- a/apps/docs/content/docs/cards.mdx +++ b/apps/docs/content/docs/cards.mdx @@ -109,6 +109,12 @@ The `id` maps to your `onAction` handler. Optional `value` passes extra data: ``` +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" + +``` + ### CardLink Inline hyperlink rendered as text. Unlike `LinkButton` (which must be inside `Actions`), `CardLink` can be placed directly in a card alongside other content. diff --git a/apps/docs/content/docs/modals.mdx b/apps/docs/content/docs/modals.mdx index b5c0f639..82c9c065 100644 --- a/apps/docs/content/docs/modals.mdx +++ b/apps/docs/content/docs/modals.mdx @@ -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 @@ -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( + + + +); + +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: diff --git a/examples/nextjs-chat/next.config.ts b/examples/nextjs-chat/next.config.ts index b5029a63..d2045035 100644 --- a/examples/nextjs-chat/next.config.ts +++ b/examples/nextjs-chat/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { withWorkflow } from "workflow/next"; const nextConfig: NextConfig = { transpilePackages: [ @@ -24,4 +25,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withWorkflow(nextConfig); diff --git a/examples/nextjs-chat/package.json b/examples/nextjs-chat/package.json index bc81e7ac..e3022e06 100644 --- a/examples/nextjs-chat/package.json +++ b/examples/nextjs-chat/package.json @@ -18,15 +18,16 @@ "@chat-adapter/slack": "workspace:*", "@chat-adapter/state-memory": "workspace:*", "@chat-adapter/state-redis": "workspace:*", - "@chat-adapter/telegram": "workspace:*", "@chat-adapter/teams": "workspace:*", + "@chat-adapter/telegram": "workspace:*", "@chat-adapter/whatsapp": "workspace:*", "ai": "^6.0.5", "chat": "workspace:*", "next": "^16.1.7", "react": "^19.0.0", "react-dom": "^19.0.0", - "redis": "^5.11.0" + "redis": "^5.11.0", + "workflow": "4.2.0-beta.71" }, "devDependencies": { "@types/node": "^25.3.2", diff --git a/examples/nextjs-chat/src/app/page.tsx b/examples/nextjs-chat/src/app/page.tsx index dc732444..5dbd8cd3 100644 --- a/examples/nextjs-chat/src/app/page.tsx +++ b/examples/nextjs-chat/src/app/page.tsx @@ -45,6 +45,10 @@ export default function Home() { DM Support - Say "DM me" to get a direct message +
  • + Callback URLs - Buttons can POST action data to + external workflow URLs when clicked (see Deploy example) +
  • Configuration

    diff --git a/examples/nextjs-chat/src/lib/bot.tsx b/examples/nextjs-chat/src/lib/bot.tsx index 58179248..02b670d7 100644 --- a/examples/nextjs-chat/src/lib/bot.tsx +++ b/examples/nextjs-chat/src/lib/bot.tsx @@ -24,6 +24,7 @@ import { TextInput, toAiMessages, } from "chat"; +import { createWebhook } from "workflow"; import { buildAdapters } from "./adapters"; const AI_MENTION_REGEX = /\bAI\b/i; @@ -63,6 +64,7 @@ const agent = new ToolLoopAgent({ // Handle new @mentions of the bot bot.onNewMention(async (thread, message) => { + 'use workflow' await thread.subscribe(); // Check if user wants to enable AI mode (mention contains "AI") @@ -87,6 +89,9 @@ bot.onNewMention(async (thread, message) => { // Default welcome card await thread.startTyping(); + + using workflowCallbackHook = createWebhook(); + await thread.post( { + + Open Link