Vercel Logo

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

  1. Add /echo to manifest.json slash_commands
  2. Create server/listeners/commands/echo.ts with ack-first pattern
  3. Run slack run to reinstall app (manifest changes require reinstall)
  4. Test /echo hello and verify only you see the response
Manifest Changes Require Reinstall

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

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:

  1. Add /echo to manifest.json slash_commands
  2. Create server/listeners/commands/echo.ts with ack-first pattern
  3. Validate input with Zodβ€”reusing the pattern from lesson 1.3 (Boot Checks)
  4. Use AI to detect sentiment and append an emoji to the echo response
  5. Use ephemeral responses (response_type: "ephemeral")
  6. Register in server/listeners/commands/index.ts
  7. Add correlation logging (continuing lesson 2.2 pattern)

Implementation hints:

  • Extract text from the command object for user input
  • Create Zod schema for input: z.object({ text: z.string().min(1) })
  • After validation, use generateObject with 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 after ack() (commands can't use say())
  • Add context parameter 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

  1. 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)
  2. Test empty input:

    • Type /echo with no arguments
    • Should show usage instructions
  3. 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 /echo to manifest.json slash_commands
  • Created server/listeners/commands/echo.ts with 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:

/slack-agent/server/listeners/commands/echo.ts
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.json includes the /echo command
  • Run slack run to 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β€”/echo vs /Echo are different

Timeout errors:

  • Ensure ack() is first line in try block
  • No database queries or API calls before ack()