From cec13a4b5cbb9d8fd40680ce0a7e8ed7df30ad43 Mon Sep 17 00:00:00 2001 From: v0 Date: Mon, 16 Feb 2026 00:16:28 +0000 Subject: [PATCH 1/2] RFC: Workflow-based modal actions with 'use step' Proposes replacing the current bot.onAction/onModalSubmit/onModalClose string-matcher pattern with an inline, awaitable modal API powered by Workflow DevKit. Modals become a single awaitable expression inside a 'use workflow' function that suspends via createWebhook() and resumes with form values on submit. Key proposals: - openModal() returns Promise inside workflows - Inline onAction={} prop on + +bot.onAction("approve", async (event) => { /* ... */ }); + +// Proposed pattern -- inline binding + +``` + +**How it works:** + +1. When the JSX is rendered, `onAction` closures are registered in a per-render handler map keyed by an auto-generated action ID. +2. The `id` prop is auto-generated (e.g., `action_`) and embedded in the platform payload. +3. When the platform sends back the action event, the Chat class looks up the closure by auto-generated ID and invokes it. +4. Since the closure is a `"use workflow"` function, it becomes a durable workflow run that can suspend/resume. + +The existing `id` + `bot.onAction()` pattern continues to work -- `onAction` is purely additive. + +### Type-Safe Modal Results + +The `openModal` return type encodes the form field IDs and types from the modal definition: + +```ts +interface ModalResult = Record> { + action: "submit"; + values: TValues; + user: Author; + viewId: string; + raw: unknown; +} +``` + +With generics on the Modal component, we can infer the shape: + +```tsx +const result = await event.openModal( + title="Feedback"> + + + , +); + +result.values.message; // string -- type-safe +result.values.category; // string -- type-safe +result.values.typo; // TypeScript error +``` + +### Validation Loop + +Server-side validation that sends error messages back to the modal (Slack's `response_action: "errors"` pattern) becomes a simple loop: + +```tsx +bot.onAction("report", async (event) => { + "use workflow"; + + let result: ModalResult; + let errors: Record | null = null; + + do { + result = await event.openModal( + + + + + , + ); + + errors = null; + if (result.values.title.length < 3) { + errors = { title: "Title must be at least 3 characters" }; + } + } while (errors); + + await event.thread.post(`Bug filed: ${result.values.title} (${result.values.severity})`); +}); +``` + +Internally, when `errors` is set, the next `openModal` call returns a `response_action: "errors"` response to the platform before suspending again for the next submission. + +### Cancellation via Try/Catch + +When a user closes a modal (clicks Cancel or the X button), the webhook resolves with a `close` event. The `openModal` implementation throws a `ModalClosedError`: + +```tsx +bot.onAction("feedback", async (event) => { + "use workflow"; + + try { + const result = await event.openModal( + + + , + ); + await event.thread.post(`Thanks for the feedback: ${result.values.message}`); + } catch (err) { + if (err instanceof ModalClosedError) { + console.log(`${err.user.userName} cancelled the feedback form`); + // Optionally notify the user + } + } +}); +``` + +This replaces `bot.onModalClose()` entirely for workflows. The error is caught in the same scope where the modal was opened, with full access to the surrounding closure. + +### Timeout Pattern + +Using Workflow DevKit's `sleep()` and `Promise.race`: + +```tsx +bot.onAction("approval", async (event) => { + "use workflow"; + + const modalPromise = event.openModal( + + + , + ); + + const result = await Promise.race([ + modalPromise, + sleep("1 hour").then(() => "timeout" as const), + ]); + + if (result === "timeout") { + await event.thread.post("Approval request expired after 1 hour."); + return; + } + + await event.thread.post(`Approved: ${result.values.reason}`); +}); +``` + +No compute resources are consumed during the sleep or while waiting for the modal -- the workflow is fully suspended. + +### Multi-Step Wizard + +Sequential modals that would currently require chaining multiple `onModalSubmit` handlers with `privateMetadata` become a simple linear flow: + +```tsx +bot.onAction("onboarding", async (event) => { + "use workflow"; + + // Step 1: Basic info + const step1 = await event.openModal( + + + + , + ); + + // Step 2: Preferences (has access to step1 values in scope!) + const step2 = await event.openModal( + + + + , + ); + + // Step 3: Confirmation + const step3 = await event.openModal( + + + , + ); + + // All values available in one scope -- no privateMetadata gymnastics + await event.thread.post( + `Onboarded ${step1.values.name} (${step1.values.email}) to ${step2.values.team} in ${step2.values.timezone}`, + ); +}); +``` + +### Parallel Modal Collection + +Using `Promise.all` with webhooks to collect responses from multiple users: + +```tsx +async function collectVotes(thread: Thread, voters: string[]) { + "use workflow"; + + const results = await Promise.all( + voters.map(async (userId) => { + const dmThread = await bot.openDM(userId); + await dmThread.post( + + Please submit your vote. + + + + , + ); + }), + ); + + return results; +} +``` + +## Implementation + +### Architecture + +``` + ┌──────────────────────────────────────┐ + │ Workflow Runtime │ + │ │ + User clicks │ bot.onAction("feedback", async () { │ + [Feedback] button │ "use workflow"; │ + │ │ │ + ▼ │ // Step 1: open modal │ + ┌─────────┐ processAction() │ const webhook = createWebhook() │ + │ Platform ├─────────────────────►│ adapter.openModal(triggerId, │ + │ (Slack) │ │ modal, webhook.url) │ + └─────────┘ │ │ + │ │ ──── workflow suspends ──── │ + │ User fills form │ (no compute) │ + │ and clicks Submit │ │ + ▼ │ ──── webhook fires ──── │ + ┌─────────┐ POST webhook.url │ │ + │ Platform ├─────────────────────►│ const result = await webhook │ + │ (Slack) │ │ // { values, user, viewId } │ + └─────────┘ │ │ + │ // Step 2: handle result │ + │ await thread.post(...) │ + │ }); │ + └──────────────────────────────────────┘ +``` + +### Key Implementation Details + +#### 1. Webhook-Based Resumption + +The core mechanism uses `createWebhook()` from Workflow DevKit. When `openModal()` is called inside a `"use workflow"` function: + +1. A webhook is created via `createWebhook()` +2. The webhook URL is passed to the adapter's `openModal()` method (new `webhookUrl` parameter) +3. The adapter stores the webhook URL alongside the modal's platform-specific metadata +4. When the platform sends a submission/close event, the adapter POSTs to the webhook URL instead of calling `processModalSubmit()` +5. The workflow resumes with the payload + +#### 2. Adapter Changes + +The `Adapter.openModal()` signature gains an optional `webhookUrl` parameter: + +```ts +interface Adapter { + openModal?( + triggerId: string, + modal: ModalElement, + contextId?: string, + options?: { webhookUrl?: string }, + ): Promise<{ viewId: string }>; +} +``` + +When `webhookUrl` is present, the adapter stores it in the modal metadata (e.g., Slack's `private_metadata`). On submission/close, if a webhook URL is found in the metadata, the adapter POSTs to it instead of calling `processModalSubmit()` / `processModalClose()`. + +#### 3. Serialization + +chat-sdk already has full `@workflow/serde` integration: + +- `ThreadImpl` has `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` static methods +- `Message` has the same +- `chat.registerSingleton()` enables lazy adapter resolution after deserialization + +The `ActionEvent` and `ModalResult` types will need similar serde support so they can cross the workflow suspension boundary. + +#### 4. `onAction` Prop Handler Registry + +For inline `onAction` props on `