---
title: "Shortcuts and Modals"
description: "Learn how shortcuts trigger modals from anywhere in Slack. Create a bug report shortcut that collects structured data and posts formatted messages to channels."
canonical_url: "https://vercel.com/academy/slack-agents/shortcuts-and-modals"
md_url: "https://vercel.com/academy/slack-agents/shortcuts-and-modals.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-04-09T11:08:48.950Z"
content_type: "lesson"
course: "slack-agents"
course_title: "Slack Agents on Vercel with the AI SDK"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Shortcuts and Modals

# Build shortcuts that open modals from anywhere in Slack

Users don't remember slash command syntax. Finding features through App Home sucks. Shortcuts let users trigger your bot from anywhere: Cmd+K search, message context menus, global compose. Shortcuts open modals for structured data collection without forcing users to learn command arguments.

## Outcome

Build a bug report shortcut that opens a modal, collects structured data, and posts formatted messages to channels.

## Fast Track

1. Add "Report Bug" shortcut to `manifest.json` and reinstall
2. Create shortcut handler that opens a modal with form fields
3. Create view submission handler that posts bug report to channel
4. Test end-to-end: Cmd+K → fill form → submit → message posts

## Building on Previous Lessons

- **From [Bolt Middleware](./bolt-nitro-middleware-and-logging)**: Use `context.correlation` for tracking
- **From [Ack Semantics](./acknowledgment-and-latency)**: Ack before opening modal (3-second timeout applies)
- **From [Slash Commands](./slash-commands)**: Zod validation pattern for form data

## The Shortcut → Modal → Submission Flow

```
User triggers     Bot receives      Bot opens        User submits
   Cmd+K       →   trigger_id    →    Modal      →   Form data
"Report Bug"      (3 sec TTL)      (Block Kit)      (validated)
                       ↓                                  ↓
                  ack() < 3s                      Post to channel
```

1. **User triggers shortcut** - Slack sends `trigger_id` (valid 3 seconds)
2. **Bot acks and opens modal** - `client.views.open({ trigger_id, view })`
3. **User fills form** - Modal collects structured input
4. **Bot handles submission** - Validate with Zod, post formatted message

\*\*Note: Design Blocks Faster with Block Kit Builder\*\*

Instead of hand-writing every Block Kit JSON structure, use Slack's Block Kit Builder to design your modal and copy the JSON into your code. It lets you experiment with layouts and preview exactly what users will see before you wire it up in `bug-report.ts` and `bug-report-view.ts`.

## Hands-On Exercise 3.2

Build a complete bug report workflow:

**Requirements:**

1. Add "Report Bug" global shortcut to `manifest.json`
2. Create `server/listeners/shortcuts/bug-report.ts` that opens a modal
3. Modal collects: title (input), description (multiline), channel (selector)
4. Create `server/listeners/views/bug-report-view.ts` to handle submission
5. Validate with Zod (title min 5 chars, description min 20 chars)
6. **Use AI to suggest severity (P0-P3) and category** based on bug description
7. Post formatted bug report with AI suggestions, reporter mention + timestamp
8. Handle "not\_in\_channel" error by DMing the user
9. Add correlation logging throughout

**Implementation hints:**

- Extract `trigger_id` from shortcut payload for `client.views.open()`
- Use `callback_id: "bug_report_modal"` to route submission to your view handler
- Register view handlers with `app.view()`, not `app.action()`
- Validate BEFORE calling `ack()` to show inline errors in the modal
- Use `chat.postMessage()` for posting to channels (requires `chat:write` scope)

## Try It

1. **Reinstall app:**
   ```bash
   slack run
   # Approve manifest changes when prompted
   ```

2. **Open shortcut:**
   - Press Cmd+K (Mac) or Ctrl+K (Windows/Linux)
   - Type "Report Bug"
   - Shortcut appears in results

3. **Fill modal:**
   - Title: "Login button broken on mobile"
   - Description: "Tapping login in Safari does nothing"
   - Channel: Select a channel where your bot is present

4. **Submit and verify:**
   Expected message in channel:
   ```
   🐛 Bug Report
   Title: Login button broken on mobile
   Reported by: @yourname

   Description:
   Tapping login in Safari does nothing

   Reported on Nov 19, 2024 at 2:30 PM
   ```

## Commit

```bash
git add -A
git commit -m "feat(shortcuts): add bug report modal workflow

- Add Report Bug global shortcut to manifest
- Create shortcut handler that opens modal with form
- Create view submission handler with Zod validation
- Post formatted bug reports to selected channels
- Handle not_in_channel error with DM fallback
- Include correlation logging throughout"
```

## Done-When

- [x] Added "Report Bug" shortcut to `manifest.json`
- [x] Created `server/listeners/shortcuts/bug-report.ts` with modal
- [x] Created `server/listeners/views/bug-report-view.ts` for submission
- [x] Registered both handlers in their respective index files
- [x] Modal validates input with Zod before posting
- [x] Bug report posts to selected channel with formatting
- [x] Handles "not\_in\_channel" error by DMing user
- [x] All handlers include correlation logging

## Troubleshooting

**Shortcut doesn't appear in Cmd+K:**

- Verify `manifest.json` includes the shortcut in `features.shortcuts`
- Run `slack run` and approve manifest changes (reinstall required)
- Check `server/listeners/shortcuts/index.ts` registered the handler

**Modal doesn't open:**

- Extract `trigger_id` from `body.trigger_id` in your shortcut handler
- Call `ack()` before `client.views.open()` (both must happen within 3 seconds of trigger)
- **trigger\_id expires after 3 seconds** - if you see `expired_trigger_id` error, the user must trigger the shortcut again (no retry mechanism)
- Check logs for Block Kit validation errors (typos in property names like `plain_text` vs `plaintext` will break)

**"not\_in\_channel" error:**

- Bot must be invited to the channel before it can post: `/invite @botname`
- Solution handles this by DMing the user with instructions

**Validation errors don't show in modal:**

- Return validation errors BEFORE calling `ack()`: `await ack({ response_action: "errors", errors: {...} })`
- Error object keys must match `block_id` from modal (e.g., `bug_title`, not `title`)
- Use `safeParse()` to catch validation issues before submission

\*\*Warning: The block\_id/action\_id/callback\_id Triplet\*\*

Three string IDs must align correctly or nothing works:

- **callback\_id:** Set in modal definition, used in `app.view("callback_id", handler)`
- **block\_id:** Set per input block, used as key in error responses
- **action\_id:** Set per input element, used to extract values from `view.state.values`

Mismatch any of these and you get silent failures or cryptic errors. Keep them consistent and descriptive (e.g., `bug_title` for all three).

## Step-by-Step Solution

### Step 1: Create the shortcut handler

Create `/slack-agent/server/listeners/shortcuts/bug-report.ts`:

```typescript title="/slack-agent/server/listeners/shortcuts/bug-report.ts"
import type { AllMiddlewareArgs, SlackShortcutMiddlewareArgs } from "@slack/bolt";

const bugReportShortcut = async ({
  ack,
  client,
  body,
  logger,
  context,
}: AllMiddlewareArgs & SlackShortcutMiddlewareArgs) => {
  try {
    logger.info({
      ...context.correlation,
      user: body.user.id,
    }, "Processing bug report shortcut");
    
    await ack();

  const modal = {
    type: "modal",
    callback_id: "bug_report_modal",
    title: {
      type: "plain_text",
      text: "Report a Bug"
    },
    submit: {
      type: "plain_text",
      text: "Submit Report"
    },
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: "Help us track and fix bugs faster with detailed reports."
        }
      },
      {
        type: "input",
        block_id: "bug_title",
        label: {
          type: "plain_text",
          text: "Bug Title"
        },
        element: {
          type: "plain_text_input",
          action_id: "title_input",
          placeholder: {
            type: "plain_text",
            text: "Brief description of the issue"
          }
        }
      },
      {
        type: "input",
        block_id: "bug_description",
        label: {
          type: "plain_text",
          text: "Description"
        },
        element: {
          type: "plain_text_input",
          action_id: "description_input",
          multiline: true,
          placeholder: {
            type: "plain_text",
            text: "Steps to reproduce, expected vs actual behavior"
          }
        }
      },
      {
        type: "input",
        block_id: "channel_select",
        label: {
          type: "plain_text",
          text: "Report to Channel"
        },
        element: {
          type: "channels_select",
          action_id: "channel_input",
          placeholder: {
            type: "plain_text",
            text: "Select channel"
          }
        }
      }
    ]
  };

    await client.views.open({
      trigger_id: body.trigger_id,
      view: modal,
    });
  } catch (error) {
    logger.error({
      ...context.correlation,
      error: error instanceof Error ? error.message : String(error),
    }, "Failed to open bug report modal");
  }
};

export default bugReportShortcut;
```

### Step 2: Add to manifest

Add to the `shortcuts` array in `manifest.json`:

```json
{
  "name": "Report Bug",
  "type": "global",
  "callback_id": "bug_report_shortcut",
  "description": "Open a bug report form"
}
```

### Step 3: Register the shortcut

Add to `server/listeners/shortcuts/index.ts`:

```typescript title="/slack-agent/server/listeners/shortcuts/index.ts"
import type { App } from "@slack/bolt";
import sampleShortcutCallback from "./sample-shortcut"; // default import
import bugReportShortcut from "./bug-report"; // Add import

const register = (app: App) => {
  app.shortcut("sample_shortcut_id", sampleShortcutCallback);
  app.shortcut("bug_report_shortcut", bugReportShortcut); // Add registration
};

export default { register };
```

### Step 4: Create the view submission handler

Create `/slack-agent/server/listeners/views/bug-report-view.ts`:

```typescript title="/slack-agent/server/listeners/views/bug-report-view.ts"
import type { AllMiddlewareArgs, SlackViewMiddlewareArgs } from "@slack/bolt";
import { z } from "zod";

// Validate form data (same Zod pattern as lesson 1.3 and 3.1)
const BugReportSchema = z.object({
  title: z.string().min(5, "Title must be at least 5 characters"),
  description: z.string().min(20, "Description must be at least 20 characters"),
  channel: z.string().regex(/^C[A-Z0-9]+$/, "Invalid channel ID"),
});

const bugReportSubmit = async ({
  ack,
  body,
  view,
  client,
  logger,
  context,
}: AllMiddlewareArgs & SlackViewMiddlewareArgs) => {
  logger.info({
    ...context.correlation,
    user: body.user.id,
  }, "Processing bug report submission");
  

  // Extract form data from modal state
  // Access pattern: view.state.values[block_id][action_id][value_or_selected_*]
  const values = view.state.values;
  const rawData = {
    title: values.bug_title.title_input.value,
    description: values.bug_description.description_input.value,
    channel: values.channel_select.channel_input.selected_channel,
  };

  // Validate with Zod
  const validated = BugReportSchema.safeParse(rawData);
  if (!validated.success) {
    // For modals, you validate BEFORE ack() (opposite of commands/shortcuts)
    // Return errors to show them inline in the modal
    return await ack({
      response_action: "errors",
      errors: {
        // Map Zod field names to block_ids manually
        bug_title: validated.error.issues.find((i) => i.path[0] === "title")?.message,
        bug_description: validated.error.issues.find((i) => i.path[0] === "description")?.message,
        channel_select: validated.error.issues.find((i) => i.path[0] === "channel")?.message,
      },
    });
  }

  // Validation passed - acknowledge and close modal
  await ack();

  // Use validated.data (type-safe, no casting needed)
  const { title, description, channel } = validated.data;
  
  // Use AI to suggest severity and category (automated triage)
  let aiSuggestion = "";
  try {
    const analysis = await generateObject({
      model: "openai/gpt-4o-mini",
      schema: z.object({
        severity: z.enum(["P0", "P1", "P2", "P3"]),
        category: z.enum(["auth", "ui", "performance", "data", "other"]),
      }),
      prompt: `Analyze this bug and suggest severity (P0=critical, P3=minor) and category:\nTitle: ${title}\nDescription: ${description}`,
    });
    aiSuggestion = `\n*AI Suggests:* ${analysis.object.severity} | ${analysis.object.category}`;
    
    logger.info({
      ...context.correlation,
      severity: analysis.object.severity,
      category: analysis.object.category,
    }, "AI triage suggestion generated");
  } catch (error) {
    logger.error({
      ...context.correlation,
      error: error instanceof Error ? error.message : String(error),
    }, "AI analysis failed, posting without suggestion");
  }

  const bugReport = {
    channel,
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `🐛 *Bug Report*\n*Title:* ${title}\n*Reported by:* <@${body.user.id}>${aiSuggestion}`,
        }
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `*Description:*\n${description}`
        }
      },
      {
        type: "context",
        elements: [
          {
            type: "mrkdwn",
            // Slack date formatting: <!date^unix_timestamp^format|fallback_text>
            text: `Reported on <!date^${Math.floor(Date.now() / 1000)}^{date_short} at {time}|just now>`,
          }
        ]
      }
    ]
  };

  // Post formatted bug report
  try {
    await client.chat.postMessage(bugReport);
    logger.info({
      ...context.correlation,
      channel,
      title,
    }, "Bug report posted successfully");
  } catch (error) {
    logger.error({
      ...context.correlation,
      error: error instanceof Error ? error.message : String(error),
      channel,
    }, "Failed to post bug report");

    // If bot not in channel, DM the user with instructions
    // Type cast needed because Bolt doesn't expose Slack API error structure
    if ((error as any).data?.error === "not_in_channel") {
      await client.chat.postMessage({
        channel: body.user.id,
        text: `⚠️ I couldn't post to <#${channel}>. Please invite me with \`/invite @botname\` or select a different channel.`,
      });
    }
  }
};

export default bugReportSubmit;
```

\*\*Note: Modal State Access Pattern\*\*

Slack's modal state is deeply nested: `view.state.values[block_id][action_id][property]`. The property name varies by input type:

- Text inputs: `.value`
- Channel selectors: `.selected_channel`
- User selectors: `.selected_user`
- Date pickers: `.selected_date`

The `block_id` and `action_id` come from your modal definition. Match them exactly or value extraction fails. TypeScript can't help you here—one typo and you get `undefined` at runtime.

\*\*Warning: Modal Validation: ack() Order is Different\*\*

**Commands/shortcuts:** Always `ack()` first, then validate\
**Modals:** Validate first, then conditionally `ack()` with errors or success

Why the flip? Modals support inline validation errors via `response_action: "errors"`. Commands don't—once you `ack()` a command, you can only `respond()` with error messages. Modals let you validate, return errors to the form, and the user can fix and resubmit without retriggering the whole flow.

### Step 5: Register the view handler

Add to `server/listeners/views/index.ts`:

```typescript title="/slack-agent/server/listeners/views/index.ts"
import type { App } from "@slack/bolt";
import { sampleViewCallback } from "./sample-view";
import bugReportSubmit from "./bug-report-view"; // Add import

const register = (app: App) => {
  app.view("sample_view_id", sampleViewCallback);
  app.view("bug_report_modal", bugReportSubmit); // Add registration
};

export default { register };
```

**Important:** View handlers use `app.view()` (for modal submissions), not `app.action()` (for button clicks). The `callback_id` from your modal (`bug_report_modal`) must match the string in `app.view()`.

\*\*Side Quest: AI-Powered Message Improvement Shortcut\*\*


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
