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