Build slash commands that respond privately
Slash commands are your bot's most discoverable featureβusers type / and see your commands in autocomplete. But commands that always reply publicly spam channels. Users get annoyed when /weather broadcasts "72Β°F in SF" to 50 people instead of just the person who asked. Ephemeral responses (only visible to the command user) keep channels clean.
Outcome
Build an /echo command with input validation and ephemeral responses.
Fast Track
- Add
/echotomanifest.jsonslash_commands - Create
server/listeners/commands/echo.tswith ack-first pattern - Run
slack runto reinstall app (manifest changes require reinstall) - Test
/echo helloand verify only you see the response
Adding a new slash command changes manifest.json. You must run slack run and approve the manifest update for the command to appear in Slack. Just restarting your server won't register the new command.
Building on Previous Lessons
- From Bolt Middleware: Use
context.correlationin logs - From Ack Semantics: Call
ack()first, thenrespond()
Ephemeral vs Public Responses
Commands can respond privately (ephemeral) or publicly (in_channel):
// Ephemeral (only command user sees - default for most commands)
await respond({
text: "Only you can see this",
response_type: "ephemeral",
});
// Public (whole channel sees)
await respond({
text: "Everyone sees this",
response_type: "in_channel",
});When to use ephemeral: Personal queries, errors, usage help
When to use public: Announcements, shared info, team updates
Hands-On Exercise 3.1
Create an /echo command that repeats user input:
Requirements:
- Add
/echotomanifest.jsonslash_commands - Create
server/listeners/commands/echo.tswith ack-first pattern - Validate input with Zodβreusing the pattern from lesson 1.3 (Boot Checks)
- Use AI to detect sentiment and append an emoji to the echo response
- Use ephemeral responses (
response_type: "ephemeral") - Register in
server/listeners/commands/index.ts - Add correlation logging (continuing lesson 2.2 pattern)
Implementation hints:
- Extract
textfrom thecommandobject for user input - Create Zod schema for input:
z.object({ text: z.string().min(1) }) - After validation, use
generateObjectwith sentiment schema:z.object({ sentiment: z.enum(["positive", "negative", "neutral"]) }) - Map sentiment to emoji: positive β π, negative β π, neutral β π
- Respond with:
You said: ${text} ${emoji} - Use
respond()for all messages afterack()(commands can't usesay()) - Add
contextparameter to access correlation fields from middleware
Manifest update (add to existing slash_commands array):
{
"command": "/echo",
"url": "https://your-app-domain.com/api/slack/events",
"description": "Echo back your text",
"should_escape": false
}Note: The URL will be automatically updated by the tunnel script when you run slack run.
Try It
-
Test AI sentiment detection:
- Type
/echo I love this! - Verify response: "You said: I love this! π" (positive sentiment)
- Type
/echo this sucks - Verify response: "You said: this sucks π" (negative sentiment)
- Type
/echo the weather is nice - Verify response: "You said: the weather is nice π" (neutral sentiment)
- Type
-
Test empty input:
- Type
/echowith no arguments - Should show usage instructions
- Type
-
Check logs for correlation:
[INFO] bolt-app { event_id: '...', command: '/echo', user: 'U09D6B53WP4' } Processing echo command
Commit
git add -A
git commit -m "feat(commands): add /echo command with input validation
- Create echo command handler with ack-first pattern
- Add Zod validation for empty input
- Use ephemeral responses to keep channels clean
- Include correlation logging from lesson 2.2
- Register command in manifest and handler index"Done-When
- Added
/echotomanifest.jsonslash_commands - Created
server/listeners/commands/echo.tswith ack-first pattern - Used Zod to validate input (show usage if empty)
- Integrated AI sentiment analysis with
generateObject - Response includes emoji based on detected sentiment
- All responses are ephemeral
- Registered in
server/listeners/commands/index.ts - Included correlation logging in handler
- Tested positive, negative, and neutral sentiment detection
Step-by-Step Solution
Step 1: Create a basic echo command
First, get the command working with just validation and echoing:
import type {
AllMiddlewareArgs,
SlackCommandMiddlewareArgs,
} from "@slack/bolt";
import { z } from "zod";
const EchoInputSchema = z.object({
text: z.string().min(1, "Command requires input"),
});
export const echoCallback = async ({
ack,
command,
respond,
logger,
context,
}: AllMiddlewareArgs & SlackCommandMiddlewareArgs) => {
try {
await ack();
logger.info({
...context.correlation,
command: command.command,
user: command.user_id,
}, "Processing echo command");
const { text, command: cmd } = command;
const validated = EchoInputSchema.safeParse({ text: text?.trim() || "" });
if (!validated.success) {
await respond({
text: `${validated.error.issues[0].message}\nUsage: ${cmd} <message>`,
response_type: "ephemeral",
});
return;
}
// Simple echo (we'll add AI next)
await respond({
text: `You said: ${validated.data.text}`,
response_type: "ephemeral",
});
} catch (error) {
logger.error({
...context.correlation,
error: error instanceof Error ? error.message : String(error),
}, "Echo command failed");
try {
await respond({
text: "Sorry, something went wrong with the echo command.",
response_type: "ephemeral",
});
} catch (respondError) {
logger.error({
...context.correlation,
error: respondError instanceof Error ? respondError.message : String(respondError),
}, "Failed to send error response");
}
}
};Step 2: Register the command
Add to server/listeners/commands/index.ts:
import type { App } from "@slack/bolt";
import { sampleCommandCallback } from "./sample-command";
import { echoCallback } from "./echo"; // Add import
const register = (app: App) => {
app.command("/sample-command", sampleCommandCallback);
app.command("/echo", echoCallback); // Add registration
};
export default { register };Step 3: Add to manifest
Add to the slash_commands array in manifest.json:
{
"command": "/echo",
"url": "https://your-app-domain.com/api/slack/events",
"description": "Echo back your text with AI sentiment",
"should_escape": false
}Note: The URL will be automatically updated by the tunnel script when you run slack run.
Test end-to-end: Run slack run (reinstalls manifest), use /echo hello world, verify "You said: hello world" appears (ephemeral).
Step 4: Add AI sentiment analysis
Now enhance with AI. Update the imports in echo.ts:
import { generateObject } from "ai"; // Add this
// Add after EchoInputSchema:
const SentimentSchema = z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
});Replace the simple respond() with AI-enhanced version:
// Replace this:
await respond({
text: `You said: ${validated.data.text}`,
response_type: "ephemeral",
});
// With this:
const analysis = await generateObject({
model: "openai/gpt-4o-mini",
schema: SentimentSchema,
prompt: `Analyze the sentiment of this text: "${validated.data.text}"`,
});
const emojiMap = {
positive: "π",
negative: "π",
neutral: "π",
};
const emoji = emojiMap[analysis.object.sentiment];
await respond({
text: `You said: ${validated.data.text} ${emoji}`,
response_type: "ephemeral",
});Test AI sentiment:
/echo I love this!β π/echo this sucksβ π/echo the weather is niceβ π
Troubleshooting
Command not appearing in autocomplete:
- Verify
manifest.jsonincludes the/echocommand - Run
slack runto reinstall the app (manifest changes require reinstall)
"Unknown command" error:
- Command name in handler registration (
app.command("/echo", ...)) must match manifest exactly - Check for typosβ
/echovs/Echoare different
Timeout errors:
- Ensure
ack()is first line in try block - No database queries or API calls before
ack()
Was this helpful?