Vercel Logo

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

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
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:

    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

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

  • Added "Report Bug" shortcut to manifest.json
  • Created server/listeners/shortcuts/bug-report.ts with modal
  • Created server/listeners/views/bug-report-view.ts for submission
  • Registered both handlers in their respective index files
  • Modal validates input with Zod before posting
  • Bug report posts to selected channel with formatting
  • Handles "not_in_channel" error by DMing user
  • 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
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:

/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:

{
  "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:

/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:

/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;
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.

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:

/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().