Building Developer slack tutorial typescript integration

Building a Slack bot with agent skills

A step-by-step walkthrough of building a Slack bot that can search your docs, answer questions, and take actions, using agent skills and the Slack API.

5 min read
On this page

Most Slack bots are dumb. They respond to exact commands with canned answers. /ticket create gives you a form. /status dumps a JSON blob. Useful, but not interesting.

We’re going to build one that actually thinks. When someone asks “what’s our policy on expense reports?” in a Slack channel, the bot searches your company docs, finds the relevant section, and answers in plain language. When someone says “create a ticket for the broken login page,” the bot extracts the details and creates a structured ticket. No slash commands, no forms. Just conversation.

The architecture is simple: Slack sends events to your server, your server routes messages to an agent with skills, and the agent’s response goes back to the channel.

What you’ll need

Before we start writing code, set up the Slack side:

  1. Go to api.slack.com/apps and create a new app from scratch
  2. Under “OAuth & Permissions,” add these bot token scopes: app_mentions:read, chat:write, channels:history
  3. Under “Event Subscriptions,” enable events and subscribe to app_mention (we’ll add the request URL later)
  4. Install the app to your workspace and copy the bot token and signing secret

You’ll also need Node.js 18+ and a way to expose your local server to the internet (ngrok works fine for development, or use Slack’s socket mode, which we’ll cover at the end).

The server

Let’s start with the entry point. This handles incoming Slack events, verifies they’re legitimate, and routes them to the right place.

import express from "express";
import crypto from "crypto";
import { handleMention } from "./agent";

const app = express();

// Capture the raw body for signature verification, then parse JSON.
// JSON.stringify(req.body) can produce a different string than what
// Slack sent, which breaks the HMAC check.
app.use(
  express.json({
    verify: (req: express.Request, _res, buf) => {
      (req as any).rawBody = buf.toString("utf-8");
    },
  }),
);

const SLACK_SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET!;
const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN!;

// In-memory deduplication for single-process bots.
// Use Redis if you're running multiple instances.
const processedEvents = new Set<string>();

function verifySlackSignature(
  req: express.Request,
  res: express.Response,
  next: express.NextFunction,
) {
  const timestamp = req.headers["x-slack-request-timestamp"] as string;
  const signature = req.headers["x-slack-signature"] as string;

  if (!timestamp || !signature) {
    return res.status(401).send("Missing signature headers");
  }

  // Reject requests older than 5 minutes to prevent replay attacks
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return res.status(401).send("Request too old");
  }

  // Use the raw body captured by the verify callback above.
  // This ensures the HMAC is computed over the exact bytes Slack sent.
  const rawBody = (req as any).rawBody as string;
  const basestring = `v0:${timestamp}:${rawBody}`;
  const hash =
    "v0=" +
    crypto
      .createHmac("sha256", SLACK_SIGNING_SECRET)
      .update(basestring)
      .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature))) {
    return res.status(401).send("Invalid signature");
  }

  next();
}

app.post("/slack/events", verifySlackSignature, async (req, res) => {
  const { type, event } = req.body;

  // Slack sends a challenge request when you first set up the URL
  if (type === "url_verification") {
    return res.json({ challenge: req.body.challenge });
  }

  if (type === "event_callback" && event.type === "app_mention") {
    // Deduplicate: Slack may resend events if our response was slow
    const eventId = req.body.event_id;
    if (processedEvents.has(eventId)) {
      return res.status(200).send();
    }
    processedEvents.add(eventId);

    // Acknowledge immediately so Slack doesn't retry
    res.status(200).send();

    // Process asynchronously
    handleMention(event, SLACK_BOT_TOKEN).catch((err) =>
      console.error("Failed to handle mention:", err),
    );
    return;
  }

  res.status(200).send();
});

app.listen(3000, () => console.log("Listening on port 3000"));

A few things worth noting. We verify every request with Slack’s signing secret. This matters because your endpoint is public, and without verification anyone could send fake events. We also respond with 200 immediately before processing the message. Slack expects a response within 3 seconds, and if you don’t send one, it’ll retry the event up to three times. You’ll end up with triple responses, which is confusing for everyone.

For more on why verification and fast acknowledgment matter, see our error handling guide.

Defining the tools

This is where it gets interesting. Our bot has two tools: searching docs and creating tickets. Each tool definition tells the agent what it can do and what parameters it needs. If you’re new to this pattern, our tool use guide covers the fundamentals.

import Anthropic from "@anthropic-ai/sdk";

const anthropic = new Anthropic();

interface SlackEvent {
  text: string;
  channel: string;
  user: string;
  ts: string;
  thread_ts?: string;
}

const tools: Anthropic.Tool[] = [
  {
    name: "search_docs",
    description: `Search company documentation for information relevant to a question.
Use this when someone asks about policies, procedures, how-tos, or any
question that might be answered in our internal docs. Returns the most
relevant passages with their source document names.`,
    input_schema: {
      type: "object" as const,
      properties: {
        query: {
          type: "string",
          description:
            "The search query. Use natural language, not keywords. For example: 'What is the PTO policy for part-time employees?'",
        },
      },
      required: ["query"],
    },
  },
  {
    name: "create_ticket",
    description: `Create a task or ticket from a Slack conversation. Use this when someone
asks to file a bug, create a task, or track an issue. Extract the title
and description from the conversation context.`,
    input_schema: {
      type: "object" as const,
      properties: {
        title: {
          type: "string",
          description: "A short, descriptive title for the ticket",
        },
        description: {
          type: "string",
          description:
            "Detailed description including any relevant context from the conversation",
        },
        priority: {
          type: "string",
          enum: ["low", "medium", "high"],
          description:
            "Priority level based on urgency described in the message",
        },
      },
      required: ["title", "description"],
    },
  },
];

The descriptions are written for the agent, not for humans. Notice how search_docs explains when to use it (“when someone asks about policies, procedures, how-tos”) and the parameter description says “use natural language, not keywords.” These cues help the agent pick the right tool and call it correctly. For deeper thinking on this, see our skill composition guide.

Implementing the tools

In a real system, search_docs would query a vector database or search index. For this tutorial, we’ll simulate it with a simple file-based search. The pattern is the same either way.

import { readdir, readFile } from "fs/promises";
import { join } from "path";

const DOCS_DIR = process.env.DOCS_DIR || "./docs";

async function searchDocs(
  query: string,
): Promise<{ results: Array<{ file: string; excerpt: string }> }> {
  const files = await readdir(DOCS_DIR);
  const results: Array<{ file: string; excerpt: string }> = [];

  // Simple keyword matching. Replace this with vector search in production.
  const queryWords = query.toLowerCase().split(/\s+/);

  for (const file of files) {
    if (!file.endsWith(".md") && !file.endsWith(".txt")) continue;

    const content = await readFile(join(DOCS_DIR, file), "utf-8");
    const lower = content.toLowerCase();
    const matchCount = queryWords.filter((w) => lower.includes(w)).length;

    if (matchCount >= queryWords.length * 0.4) {
      // Find the most relevant paragraph
      const paragraphs = content.split(/\n\n+/);
      const best = paragraphs.reduce((a, b) => {
        const scoreA = queryWords.filter((w) =>
          a.toLowerCase().includes(w),
        ).length;
        const scoreB = queryWords.filter((w) =>
          b.toLowerCase().includes(w),
        ).length;
        return scoreB > scoreA ? b : a;
      });

      results.push({
        file: file.replace(/\.(md|txt)$/, ""),
        excerpt: best.slice(0, 500),
      });
    }
  }

  return {
    results:
      results.length > 0
        ? results.slice(0, 3)
        : [{ file: "none", excerpt: "No matching documents found." }],
  };
}

interface Ticket {
  id: string;
  title: string;
  description: string;
  priority: string;
  createdAt: string;
}

async function createTicket(
  title: string,
  description: string,
  priority: string = "medium",
): Promise<Ticket> {
  // In production, this would call Jira, Linear, GitHub Issues, etc.
  const ticket: Ticket = {
    id: `TICKET-${Date.now().toString(36).toUpperCase()}`,
    title,
    description,
    priority,
    createdAt: new Date().toISOString(),
  };

  console.log("Created ticket:", ticket);
  return ticket;
}

The doc search here is naive on purpose. It reads files from a directory and does keyword matching. Swap searchDocs with a call to Pinecone, Weaviate, or even a full-text search engine like Typesense and the rest of the code stays the same. That’s the nice thing about the tool pattern: the interface stays stable while the implementation changes.

The agent loop

This is the core function that ties everything together. It takes a Slack event, sends the message to the agent with available tools, handles tool calls in a loop, and posts the final response back to the channel.

async function handleMention(
  event: SlackEvent,
  botToken: string,
): Promise<void> {
  // Strip the bot mention from the message text
  const userMessage = event.text.replace(/<@[A-Z0-9]+>/g, "").trim();

  if (!userMessage) {
    await postToSlack(
      event.channel,
      "You mentioned me but didn't say anything. What can I help with?",
      botToken,
      event.thread_ts || event.ts,
    );
    return;
  }

  const messages: Anthropic.MessageParam[] = [
    { role: "user", content: userMessage },
  ];

  const systemPrompt = `You are a helpful assistant in a company Slack workspace.
You can search internal documentation and create tickets.
Be concise in your responses since this is a chat context, not an email.
If you find relevant docs, summarize the answer rather than pasting the
whole document. If someone wants a ticket created, confirm what you created.
If you genuinely don't know something and can't find it in the docs, say so.`;

  // Agent loop: keep going until the model stops calling tools
  // Check docs.anthropic.com for current model IDs
  let response = await anthropic.messages.create({
    model: "claude-sonnet-4-20250514",
    max_tokens: 1024,
    system: systemPrompt,
    tools,
    messages,
  });

  while (response.stop_reason === "tool_use") {
    const toolUseBlocks = response.content.filter(
      (block): block is Anthropic.ContentBlockParam & { type: "tool_use" } =>
        block.type === "tool_use",
    );

    const toolResults: Anthropic.ToolResultBlockParam[] = [];

    for (const toolUse of toolUseBlocks) {
      let result: unknown;

      try {
        switch (toolUse.name) {
          case "search_docs":
            result = await searchDocs(
              (toolUse.input as { query: string }).query,
            );
            break;
          case "create_ticket":
            const input = toolUse.input as {
              title: string;
              description: string;
              priority?: string;
            };
            result = await createTicket(
              input.title,
              input.description,
              input.priority,
            );
            break;
          default:
            result = { error: `Unknown tool: ${toolUse.name}` };
        }
      } catch (err) {
        result = {
          error: `Tool execution failed: ${err instanceof Error ? err.message : "unknown error"}`,
        };
      }

      toolResults.push({
        type: "tool_result",
        tool_use_id: toolUse.id,
        content: JSON.stringify(result),
      });
    }

    messages.push({ role: "assistant", content: response.content });
    messages.push({ role: "user", content: toolResults });

    // Check docs.anthropic.com for current model IDs
    response = await anthropic.messages.create({
      model: "claude-sonnet-4-20250514",
      max_tokens: 1024,
      system: systemPrompt,
      tools,
      messages,
    });
  }

  // Extract the final text response
  const textBlock = response.content.find((block) => block.type === "text");
  const reply =
    textBlock && "text" in textBlock
      ? textBlock.text
      : "I processed your request but couldn't generate a response.";

  await postToSlack(
    event.channel,
    reply,
    botToken,
    event.thread_ts || event.ts,
  );
}

The while (response.stop_reason === "tool_use") loop is the heart of the agent pattern. The model might need to call one tool, or multiple tools in sequence. Maybe it searches the docs, doesn’t find what it needs, and tries a different query. The loop lets that happen naturally.

Notice the error handling inside the tool execution. If a tool throws, we catch it and send an error message back to the agent as a tool result. The agent can then tell the user something went wrong instead of just crashing silently. This is a pattern I’ve seen trip up a lot of first-time bot builders: if you don’t handle tool errors, the whole conversation just dies.

Posting back to Slack

The last piece: sending the agent’s response back to the channel.

async function postToSlack(
  channel: string,
  text: string,
  botToken: string,
  threadTs: string,
): Promise<void> {
  const response = await fetch("https://slack.com/api/chat.postMessage", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${botToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      channel,
      text,
      thread_ts: threadTs,
    }),
  });

  const data = (await response.json()) as { ok: boolean; error?: string };
  if (!data.ok) {
    console.error("Slack API error:", data.error);
  }
}

We always reply in a thread by passing thread_ts. This keeps the channel clean. If someone mentions the bot in an existing thread, it replies in that thread. If they mention it in a top-level message, it starts a new thread under that message.

Testing locally with socket mode

You don’t want to set up ngrok every time you test a change. Slack’s socket mode lets your bot connect over a WebSocket instead of requiring a public URL.

Install the Slack Bolt library, which handles socket mode for you:

npm install @slack/bolt

Then create a separate entry point for local development:

import { App } from "@slack/bolt";
import { handleMention } from "./agent";

const app = new App({
  token: process.env.SLACK_BOT_TOKEN!,
  appToken: process.env.SLACK_APP_TOKEN!,
  socketMode: true,
});

app.event("app_mention", async ({ event, say }) => {
  // The Bolt framework handles verification and acknowledgment for us
  await handleMention(event as any, process.env.SLACK_BOT_TOKEN!);
});

(async () => {
  await app.start();
  console.log("Bot is running in socket mode");
})();

To enable socket mode, go to your app settings on api.slack.com, click “Socket Mode,” toggle it on, and generate an app-level token with the connections:write scope. Set that as SLACK_APP_TOKEN.

Now you can run the bot locally, mention it in a channel, and see it respond. No tunneling required.

Things that will bite you

A few lessons from building bots like this.

Duplicate events. Slack sometimes sends the same event twice, especially if your server was slow to respond. Keep a set of processed event timestamps and skip duplicates. A simple Set<string> works for single-process bots. Use Redis if you’re running multiple instances.

Long responses. Slack messages have a 4,000-character limit for the text field. If your agent writes a long answer (which it will, when summarizing documents), you need to truncate or split the message. I usually ask the agent to keep responses under 2,000 characters in the system prompt, but sometimes it ignores that.

Rate limits. The Slack API has rate limits on chat.postMessage (roughly 1 message per second per channel). If your bot is popular, you’ll need a queue. The Bolt framework handles some of this for you.

Thread context. Our current implementation doesn’t send previous thread messages to the agent. It treats each mention as independent. For a smarter bot, fetch the thread history using conversations.replies and include it in the messages array. This lets the bot have multi-turn conversations within a thread.

Where to go from here

The bot we built has two tools. A production bot might have ten or twenty: searching docs, creating tickets, looking up customer info, checking deploy status, querying dashboards. The pattern scales well because each tool is independent. You add a new tool definition, implement the function, add a case to the switch statement, and the agent figures out when to use it.

The hard part isn’t the code. It’s writing good tool descriptions and handling the edge cases where the agent picks the wrong tool or hallucinates an answer. That’s where the real iteration happens, and it’s worth spending time on. The skill design principles article has more on getting that right.