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
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
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;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?