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
- Add "Report Bug" shortcut to
manifest.jsonand reinstall - Create shortcut handler that opens a modal with form fields
- Create view submission handler that posts bug report to channel
- Test end-to-end: Cmd+K → fill form → submit → message posts
Building on Previous Lessons
- From Bolt Middleware: Use
context.correlationfor tracking - From Ack Semantics: Ack before opening modal (3-second timeout applies)
- From 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
- User triggers shortcut - Slack sends
trigger_id(valid 3 seconds) - Bot acks and opens modal -
client.views.open({ trigger_id, view }) - User fills form - Modal collects structured input
- Bot handles submission - Validate with Zod, post formatted message
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:
- Add "Report Bug" global shortcut to
manifest.json - Create
server/listeners/shortcuts/bug-report.tsthat opens a modal - Modal collects: title (input), description (multiline), channel (selector)
- Create
server/listeners/views/bug-report-view.tsto handle submission - Validate with Zod (title min 5 chars, description min 20 chars)
- Use AI to suggest severity (P0-P3) and category based on bug description
- Post formatted bug report with AI suggestions, reporter mention + timestamp
- Handle "not_in_channel" error by DMing the user
- Add correlation logging throughout
Implementation hints:
- Extract
trigger_idfrom shortcut payload forclient.views.open() - Use
callback_id: "bug_report_modal"to route submission to your view handler - Register view handlers with
app.view(), notapp.action() - Validate BEFORE calling
ack()to show inline errors in the modal - Use
chat.postMessage()for posting to channels (requireschat:writescope)
Try It
-
Reinstall app:
slack run # Approve manifest changes when prompted -
Open shortcut:
- Press Cmd+K (Mac) or Ctrl+K (Windows/Linux)
- Type "Report Bug"
- Shortcut appears in results
-
Fill modal:
- Title: "Login button broken on mobile"
- Description: "Tapping login in Safari does nothing"
- Channel: Select a channel where your bot is present
-
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.tswith modal - Created
server/listeners/views/bug-report-view.tsfor 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.jsonincludes the shortcut infeatures.shortcuts - Run
slack runand approve manifest changes (reinstall required) - Check
server/listeners/shortcuts/index.tsregistered the handler
Modal doesn't open:
- Extract
trigger_idfrombody.trigger_idin your shortcut handler - Call
ack()beforeclient.views.open()(both must happen within 3 seconds of trigger) - trigger_id expires after 3 seconds - if you see
expired_trigger_iderror, the user must trigger the shortcut again (no retry mechanism) - Check logs for Block Kit validation errors (typos in property names like
plain_textvsplaintextwill 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_idfrom modal (e.g.,bug_title, nottitle) - Use
safeParse()to catch validation issues before submission
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:
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:
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:
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;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.
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:
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().
Was this helpful?