Crear un bot de Slack con Claude Code: triaje, incidentes y reportes diarios
Guía práctica con Bolt JS, Socket Mode, comandos slash, seguridad, pruebas y checklist de producción.
No lo dejes en un bot de notificaciones
Un Slack Bot es una app que reacciona a mensajes, comandos slash, botones y envíos de modales dentro de Slack. Bolt for JavaScript es el framework oficial de Slack para Node.js que enruta esos eventos al handler correcto. Dicho de forma sencilla: Bolt es el andamiaje que permite decir “cuando llegue este evento de Slack, ejecuta esta función”.
El error típico al usar Claude Code es quedarse con un bot que solo avisa. Un bot útil convierte el ruido del canal en trabajo estructurado: triaje de soporte, primera respuesta ante incidentes, reportes diarios, aprobaciones y revisiones antes de publicar. En los flujos de Masa, el primer bot de notificaciones fue cómodo durante unos días, pero no dejaba claro quién era responsable, qué urgencia tenía el caso ni si ya estaba cerrado.
Esta guía fue revisada con la documentación oficial de Slack el 3 de junio de 2026: Bolt for JavaScript, listeners de comandos en Bolt, Socket Mode, comandos slash, Events API, chat.postMessage, verificación de requests y tokens. Para ampliar el contexto, lee también webhooks con Claude Code, desarrollo de APIs, gestión de secretos y automatización de workflows.
Define los casos de uso antes de programar
Si pides a Claude Code “crea un bot de Slack” sin un flujo concreto, el resultado suele ser una demo superficial. Antes decide la entrada en Slack, los campos que vas a guardar, el mensaje de respuesta y qué ocurre cuando algo falla.
| Caso de uso | Entrada en Slack | Qué hace el bot | Riesgo a controlar |
|---|---|---|---|
| Triaje de soporte | /triage add, modal | Normaliza título, severidad, solicitante y notificación de canal | Usuarios pegan nombres de clientes, secretos o URLs privadas |
| Primera respuesta a incidentes | Mención @bot, botón | Devuelve checklist inicial y conserva el contexto en un hilo | El bot suena demasiado seguro en vez de escalar |
| Reporte diario | /triage list, tarea programada | Resume pendientes para standup o informe diario | Mensajes largos pierden legibilidad en Slack |
| Revisión de artículos o landing pages | Slash Command | Revisa CTA, enlaces internos, responsable y URL de publicación | Se mezclan URLs de borrador y producción |
La arquitectura es pequeña a propósito.
flowchart LR
A["Slack user"] --> B["/triage or @mention"]
B --> C["Bolt listener"]
C --> D["Triage logic"]
D --> E["chat.postMessage"]
D --> F["Modal and button"]
Un buen prompt para Claude Code sería:
Implementa un Slack Bot con Bolt for JavaScript.
El objetivo es triaje de soporte.
Incluye:
- Cambiar entre Socket Mode y Request URL con variables de entorno
- /triage add, /triage list, /triage modal
- Entrada de modal y manejo de view_submission
- Botón Mark done
- Respuesta de ayuda para app_mention
- Explicación de scopes, secrets y verificación de requests
- Tests unitarios para triage.ts
No uses APIs ficticias. Escribe TypeScript que pueda copiarse y ejecutarse.
Socket Mode o Request URL
Socket Mode recibe eventos mediante una conexión WebSocket iniciada por tu app, así que no necesitas exponer un endpoint HTTPS público durante el desarrollo local. Es buena opción para prototipos, herramientas internas y entornos detrás de un firewall. La documentación de Slack explica que debes habilitar Socket Mode y usar un app-level token que empieza por xapp-.
Request URL recibe POST HTTP de Slack en tu endpoint HTTPS. Es el patrón habitual para producción. Cuando recibes eventos por HTTP, verifica la firma con el Signing Secret. Bolt puede hacerlo si lo configuras, pero en el diseño debe quedar claro que no debes depender de verification tokens antiguos.
| Modo | Mejor para | Configuración necesaria | Trampa común |
|---|---|---|---|
| Socket Mode | Desarrollo local, PoC interno | SLACK_APP_TOKEN, connections:write | Si el proceso cae, no llegan eventos; no es ideal para Marketplace |
| Request URL | Despliegue HTTP de producción | URL HTTPS, SLACK_SIGNING_SECRET | Un ack() lento aparece como timeout en Slack |
Empieza con Socket Mode y pasa a Request URL cuando el bot toque canales de producción o usuarios externos. El código cambia con SLACK_SOCKET_MODE=true.
Manifest y scopes de Slack
Guarda el manifest en el repositorio para evitar diferencias invisibles entre dev y producción. Los permisos son mínimos: commands recibe el slash command, chat:write publica mensajes y app_mentions:read recibe menciones.
display_information:
name: Claude Triage Bot
description: Collect triage requests from Slack
background_color: "#2E2A24"
features:
bot_user:
display_name: Claude Triage
always_online: false
slash_commands:
- command: /triage
description: Add or list triage items
usage_hint: "add Fix login | list | modal"
should_escape: true
oauth_config:
scopes:
bot:
- app_mentions:read
- chat:write
- commands
settings:
event_subscriptions:
bot_events:
- app_mention
interactivity:
is_enabled: true
socket_mode_enabled: true
org_deploy_enabled: false
token_rotation_enabled: false
No añadas channels:history o groups:history solo porque parecen útiles. Añade scopes de lectura de historial únicamente cuando el bot realmente lea historial y alguien haya revisado el impacto de privacidad.
Crear el proyecto local
Usa Node.js 20 o superior.
mkdir claude-slack-triage-bot
cd claude-slack-triage-bot
npm init -y
npm install @slack/bolt @slack/types dotenv
npm install -D typescript tsx vitest @types/node
npm pkg set type=module
npm pkg set scripts.dev="tsx watch src/app.ts"
npm pkg set scripts.build="tsc"
npm pkg set scripts.start="node dist/app.js"
npm pkg set scripts.test="vitest run"
mkdir src tests
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
Crea .env.example. Los valores reales van en .env o en el gestor de secretos del hosting, nunca en Git.
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
SLACK_SOCKET_MODE=true
SLACK_APP_TOKEN=xapp-your-app-level-token
TRIAGE_CHANNEL_ID=C0123456789
PORT=3000
xoxb- es el token del bot. xapp- es el app-level token usado por Socket Mode. El Signing Secret prueba que un request HTTP vino de Slack. Claude Code no necesita esos valores reales; necesita nombres de variables, comportamiento esperado y reglas de logging.
Implementación Bolt copiable
Primero separa la lógica que no depende de Slack en src/triage.ts.
// src/triage.ts
import type { KnownBlock, View } from "@slack/types";
export type Severity = "low" | "normal" | "high";
export interface Ticket {
id: string;
channelId: string;
title: string;
createdBy: string;
severity: Severity;
status: "open" | "done";
createdAt: string;
}
const tickets = new Map<string, Ticket>();
export function resetForTest() {
tickets.clear();
}
export function parseTriageText(text: string) {
const [actionRaw, ...rest] = text.trim().split(/\s+/);
return { action: actionRaw || "help", title: rest.join(" ").trim() };
}
export function addTicket(input: {
channelId: string;
title: string;
createdBy: string;
severity?: Severity;
}) {
const ticket: Ticket = {
id: `triage_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
channelId: input.channelId,
title: input.title,
createdBy: input.createdBy,
severity: input.severity ?? "normal",
status: "open",
createdAt: new Date().toISOString(),
};
tickets.set(ticket.id, ticket);
return ticket;
}
export function completeTicket(id: string) {
const ticket = tickets.get(id);
if (!ticket) return undefined;
const updated: Ticket = { ...ticket, status: "done" };
tickets.set(id, updated);
return updated;
}
export function formatTicketList(channelId: string) {
const open = [...tickets.values()].filter((ticket) => {
return ticket.channelId === channelId && ticket.status === "open";
});
if (open.length === 0) return "No open triage items.";
return open
.map((ticket, index) => {
return `${index + 1}. [${ticket.severity}] ${ticket.title} by <@${ticket.createdBy}>`;
})
.join("\n");
}
export function ticketBlocks(ticket: Ticket): KnownBlock[] {
return [
{
type: "section",
text: {
type: "mrkdwn",
text: `*${ticket.title}*\nSeverity: ${ticket.severity}\nOwner: <@${ticket.createdBy}>`,
},
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "Mark done" },
action_id: "triage_done",
value: ticket.id,
},
],
},
];
}
export function modalView(): View {
return {
type: "modal",
callback_id: "triage_modal_submit",
title: { type: "plain_text", text: "New triage" },
submit: { type: "plain_text", text: "Create" },
close: { type: "plain_text", text: "Cancel" },
blocks: [
{
type: "input",
block_id: "title_block",
label: { type: "plain_text", text: "What needs attention?" },
element: {
type: "plain_text_input",
action_id: "title_input",
min_length: 3,
max_length: 120,
},
},
{
type: "input",
block_id: "severity_block",
label: { type: "plain_text", text: "Severity" },
element: {
type: "static_select",
action_id: "severity_input",
initial_option: {
text: { type: "plain_text", text: "Normal" },
value: "normal",
},
options: [
{ text: { type: "plain_text", text: "High" }, value: "high" },
{ text: { type: "plain_text", text: "Normal" }, value: "normal" },
{ text: { type: "plain_text", text: "Low" }, value: "low" },
],
},
},
],
};
}
Ahora conecta los listeners de Bolt.
// src/app.ts
import "dotenv/config";
import { App, LogLevel } from "@slack/bolt";
import {
addTicket,
completeTicket,
formatTicketList,
modalView,
parseTriageText,
ticketBlocks,
type Severity,
} from "./triage.js";
const socketMode = process.env.SLACK_SOCKET_MODE === "true";
const required = ["SLACK_BOT_TOKEN", socketMode ? "SLACK_APP_TOKEN" : "SLACK_SIGNING_SECRET"];
for (const key of required) {
if (!process.env[key]) throw new Error(`Missing environment variable: ${key}`);
}
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
socketMode,
appToken: process.env.SLACK_APP_TOKEN,
logLevel: LogLevel.INFO,
});
app.command("/triage", async ({ ack, command, respond, client }) => {
await ack();
const parsed = parseTriageText(command.text);
if (parsed.action === "add") {
if (!parsed.title) {
await respond("Usage: `/triage add Fix login redirect`");
return;
}
const ticket = addTicket({
channelId: command.channel_id,
title: parsed.title,
createdBy: command.user_id,
severity: "normal",
});
await respond({
response_type: "in_channel",
text: `Triage item added: ${ticket.title}`,
blocks: ticketBlocks(ticket),
});
return;
}
if (parsed.action === "list") {
await respond({ response_type: "ephemeral", text: formatTicketList(command.channel_id) });
return;
}
if (parsed.action === "modal") {
await client.views.open({ trigger_id: command.trigger_id, view: modalView() });
return;
}
await respond("Usage: `/triage add ...`, `/triage list`, or `/triage modal`");
});
app.view("triage_modal_submit", async ({ ack, view, body, client }) => {
const titleState = view.state.values.title_block.title_input;
const severityState = view.state.values.severity_block.severity_input;
const title = titleState.type === "plain_text_input" ? titleState.value?.trim() : "";
const severity =
severityState.type === "static_select"
? severityState.selected_option?.value ?? "normal"
: "normal";
if (!title) {
await ack({ response_action: "errors", errors: { title_block: "Please enter a title." } });
return;
}
await ack();
const channelId = process.env.TRIAGE_CHANNEL_ID ?? "modal-only";
const ticket = addTicket({
channelId,
title,
createdBy: body.user.id,
severity: severity as Severity,
});
if (process.env.TRIAGE_CHANNEL_ID) {
await client.chat.postMessage({
channel: process.env.TRIAGE_CHANNEL_ID,
text: `New triage item: ${ticket.title}`,
blocks: ticketBlocks(ticket),
});
}
});
app.action("triage_done", async ({ ack, action, respond }) => {
await ack();
const value = action.type === "button" ? action.value : undefined;
if (!value) return;
const ticket = completeTicket(value);
await respond(ticket ? `Closed: ${ticket.title}` : "Ticket not found.");
});
app.event("app_mention", async ({ event, say }) => {
await say({
thread_ts: event.ts,
text: "Use `/triage add ...`, `/triage list`, or `/triage modal`.",
});
});
const port = Number(process.env.PORT ?? 3000);
if (socketMode) {
await app.start();
} else {
await app.start(port);
}
app.logger.info(`Slack bot started in ${socketMode ? "Socket Mode" : `HTTP mode on ${port}`}`);
Añade pruebas unitarias que no necesitan Slack.
// tests/triage.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import {
addTicket,
completeTicket,
formatTicketList,
parseTriageText,
resetForTest,
} from "../src/triage";
describe("triage helpers", () => {
beforeEach(() => resetForTest());
it("parses slash command text", () => {
expect(parseTriageText("add Fix login")).toEqual({
action: "add",
title: "Fix login",
});
});
it("lists only open tickets", () => {
const ticket = addTicket({
channelId: "C123",
title: "Review pricing CTA",
createdBy: "U123",
severity: "high",
});
expect(formatTicketList("C123")).toContain("[high] Review pricing CTA");
completeTicket(ticket.id);
expect(formatTicketList("C123")).toBe("No open triage items.");
});
});
Ejecuta:
npm run test
npm run build
npm run dev
Con Socket Mode, deja npm run dev activo y escribe /triage add Test from Slack. Con Request URL, despliega la app y configura https://example.com/slack/events en slash commands, interactivity y event subscriptions.
Errores comunes y seguridad
Primero, llama ack() antes del trabajo lento. Un comando, botón o modal debe confirmar recepción antes de escribir en base de datos o llamar APIs externas.
Segundo, trata trigger_id como un valor de vida corta. Abre el modal primero y valida el detalle en view_submission.
Tercero, no intentes arreglar problemas de permisos solo en código. Si falta chat:write, el bot no está invitado al canal o no existe la suscripción app_mention, la solución está en Slack settings.
Cuarto, no mezcles modos. Socket Mode necesita SLACK_APP_TOKEN; Request URL necesita HTTPS y SLACK_SIGNING_SECRET. Registra el modo elegido al arrancar.
Quinto, no expongas secretos. No pegues xoxb-, xapp- ni Signing Secret en prompts de Claude Code, capturas, logs, fixtures o artículos. Si se filtran, rótalos de inmediato.
Por último, no le des demasiado juicio al bot. Para soporte e incidentes debe devolver próximos pasos y reglas de escalado, no fingir que conoce la causa raíz.
Checklist de producción
- Los scopes del manifest coinciden con las APIs usadas.
/triageno choca con otra app instalada.- Interactivity está activo para modales y botones.
- El bot está invitado al canal de destino.
SLACK_BOT_TOKEN,SLACK_APP_TOKENySLACK_SIGNING_SECRETestán en secretos.npm run testynpm run buildpasan.- Request URL usa HTTPS y verificación de firma de Slack.
- Socket Mode tiene monitoreo de proceso y reinicio.
- Los logs no incluyen tokens, datos personales sin máscara, clientes ni URLs privadas.
- El tema del canal explica a quién escalar cuando el bot no pueda responder.
Divide el trabajo de Claude Code: propuesta de manifest y scopes, lógica sin Slack, listeners de Bolt, tests unitarios y checklist de despliegue. Así no mezclas errores de configuración con bugs de código.
ClaudeCodeLab cubre estos bots internos, webhooks, APIs y secretos en formación y consultoría. Si necesitas reglas CLAUDE.md, plantillas de revisión previa a publicación y checklists de equipo, combínalo con plantillas y productos para que el bot apoye la operación y la monetización, no solo una demo.
Resultado al probarlo
La ruta más rápida no fue generar un bot enorme de una vez. Lo más estable fue fijar primero manifest y scopes, escribir luego lógica pura como triage.ts y conectar al final los listeners de Bolt con la configuración de Slack. Claude Code rinde mejor cuando también revisa permisos, secretos, pruebas y checklist de producción dentro del mismo bloque de trabajo.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.