Build a Slack Bot That Summarizes YouTube Videos
Zied · 8/1/2026 · 8 min read
Build a Slack Bot That Summarizes YouTube Videos
Someone drops a 45-minute conference talk in your #engineering channel and asks what you think. With a working /summarize slash command, you type the URL and get a clean, three-paragraph summary in seconds. This tutorial walks through every layer of that bot, from the Slack webhook handler to the YouTube transcript API call to the LLM prompt, including the parts that trip people up.

What You Will Build
A Slack slash command: /summarize https://youtube.com/watch?v=VIDEO_ID
When a user runs it, your backend:
- Parses the video ID from the URL
- Fetches the full transcript as JSON from the YouTube Transcriber API
- Sends the transcript text to an LLM with a summarization prompt
- Posts a formatted summary back to the Slack channel
The whole flow runs in under three seconds for most videos, which is the hard timeout Slack enforces before marking a command as failed. For longer videos, you will use Slack's delayed-response pattern to work around that limit.
Setting Up the Slack App
Go to api.slack.com/apps and create a new app from scratch. Give it a name like "YouTube Summarizer" and pick your workspace.
Enable the slash command:
- Under "Features," click "Slash Commands" and add
/summarize. - Set the Request URL to your server endpoint. During development, use ngrok to expose localhost.
- Set the description to "Paste a YouTube URL to get an AI summary."
Enable incoming webhooks so the bot can post messages to channels.
Under "OAuth & Permissions," add these bot token scopes:
commands(for slash commands)chat:write(to post messages)incoming-webhook
Install the app to your workspace and save the Bot User OAuth Token. You will need it later.
Verify request signatures. Slack sends an x-slack-signature HMAC header with every request. Always validate it before processing:
const crypto = require("crypto");
function verifySlackRequest(req, signingSecret) {
const timestamp = req.headers["x-slack-request-timestamp"];
const body = req.rawBody; // must be the raw string, not parsed
const sig = req.headers["x-slack-signature"];
const base = `v0:${timestamp}:${body}`;
const expected = "v0=" + crypto
.createHmac("sha256", signingSecret)
.update(base)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
Skip this and you are open to anyone triggering your bot with forged payloads.
Parsing the Video ID and Fetching the Transcript
Slack sends slash command payloads as application/x-www-form-urlencoded. The user's message lives in the text field.
app.post("/slack/summarize", express.urlencoded({ extended: true }), async (req, res) => {
if (!verifySlackRequest(req, process.env.SLACK_SIGNING_SECRET)) {
return res.status(401).send("Unauthorized");
}
const { text, response_url } = req.body;
const videoUrl = text.trim();
// Acknowledge immediately so Slack does not time out
res.json({ response_type: "ephemeral", text: "Fetching transcript and summarizing..." });
// Now do the slow work asynchronously
processSummary(videoUrl, response_url);
});
Returning a 200 with a short message buys you time. The real work happens in processSummary, which posts back via response_url.
Fetch the transcript from YouTube Transcriber:
const fetch = require("node-fetch");
async function fetchTranscript(videoUrl) {
const params = new URLSearchParams({
video_url: videoUrl,
format: "json",
include_timestamp: "false",
send_metadata: "true",
});
const response = await fetch(
`https://getyoutubetranscriber.com/api/v2/transcript?${params}`,
{
headers: {
Authorization: `Bearer ${process.env.YT_TRANSCRIBER_KEY}`,
},
}
);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw Object.assign(new Error("Transcript fetch failed"), {
status: response.status,
detail: err,
});
}
return response.json();
}
The response shape looks like this:
{
"video_id": "dQw4w9WgXcQ",
"language": "en",
"metadata": {
"title": "Rick Astley - Never Gonna Give You Up",
"author_name": "Rick Astley",
"thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"
},
"transcript": [
{ "text": "We're no strangers to love", "start": 18640, "duration": 3240 },
{ "text": "You know the rules and so do I", "start": 21880, "duration": 3000 }
]
}
Join the text fields to get a single string for the LLM:
function transcriptToText(data) {
return data.transcript.map((seg) => seg.text).join(" ");
}
For a deep dive into what this API returns and how to handle the JSON cleanly, the post Feed YouTube Transcripts to GPT and Claude covers the LLM integration patterns in detail.
Summarizing with an LLM and Formatting the Slack Message
Pass the transcript text to your preferred LLM. Here is the call using the OpenAI SDK, but the same pattern works with Claude, Gemini, or any instruction-following model:
const OpenAI = require("openai");
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function summarize(title, transcriptText) {
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content:
"You summarize YouTube video transcripts. Write 3-4 sentences. Cover the main argument, key points, and any concrete takeaways. Plain prose only, no bullet lists.",
},
{
role: "user",
content: `Title: ${title}\n\nTranscript:\n${transcriptText.slice(0, 12000)}`,
},
],
max_tokens: 300,
});
return response.choices[0].message.content.trim();
}
Capping the transcript at 12,000 characters covers most videos up to 20-30 minutes without hitting context limits. For longer videos, see the section below on chunking.
Format and post to Slack:
async function processSummary(videoUrl, responseUrl) {
try {
const data = await fetchTranscript(videoUrl);
const transcriptText = transcriptToText(data);
const summary = await summarize(data.metadata.title, transcriptText);
const message = {
response_type: "in_channel",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*<${videoUrl}|${data.metadata.title}>*\n${summary}`,
},
accessory: {
type: "image",
image_url: data.metadata.thumbnail_url,
alt_text: data.metadata.title,
},
},
],
};
await fetch(responseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
});
} catch (err) {
await fetch(responseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
response_type: "ephemeral",
text: friendlyError(err),
}),
});
}
}
Using Slack's Block Kit here gives you the thumbnail next to the summary, which makes the output feel polished without extra work.

Handling Errors, Long Videos, and Rate Limits
Error mapping. Not every video has a transcript, and not every URL is valid. Map API status codes to messages your users will understand:
function friendlyError(err) {
switch (err.status) {
case 400:
return "That does not look like a valid YouTube URL. Try again with a full youtube.com or youtu.be link.";
case 404:
return "No transcript found for that video. It may have captions disabled or be a live stream.";
case 402:
return "The summarizer ran out of transcript credits. Contact your workspace admin.";
case 429:
return "Too many requests right now. Try again in a minute.";
default:
return `Something went wrong (${err.status || "unknown"}). Try again shortly.`;
}
}
For a full treatment of what to do when captions are missing or disabled, see Handle Missing YouTube Transcripts Gracefully.
Long videos. If a video is two hours long, the transcript can exceed 50,000 characters. Slicing it at 12,000 characters works for most use cases, but if you want better coverage, chunk the transcript and summarize each chunk, then combine:
function chunkTranscript(text, maxChars = 10000) {
const chunks = [];
for (let i = 0; i < text.length; i += maxChars) {
chunks.push(text.slice(i, i + maxChars));
}
return chunks;
}
async function summarizeLong(title, transcriptText) {
const chunks = chunkTranscript(transcriptText);
const chunkSummaries = await Promise.all(
chunks.map((chunk) => summarize(title, chunk))
);
// Combine chunk summaries into a final pass
return summarize(title, chunkSummaries.join("\n\n"));
}
This costs more in LLM tokens but gives much better results on technical talks and long-form content.
Rate limits. YouTube Transcriber enforces per-minute rate limits depending on your plan. The free tier allows 30 requests per minute, and paid plans go up to 250-350. The API returns a 429 with a Retry-After header when you exceed the limit.
For a bot that sees sporadic traffic, rate limits are rarely an issue. If your team shares a lot of videos in bursts, add a simple in-memory cache keyed by video ID:
const cache = new Map();
async function fetchTranscriptCached(videoUrl) {
const id = extractVideoId(videoUrl);
if (cache.has(id)) return cache.get(id);
const data = await fetchTranscript(videoUrl);
cache.set(id, data);
// Expire after 24 hours
setTimeout(() => cache.delete(id), 24 * 60 * 60 * 1000);
return data;
}
For production, swap the in-memory map for Redis or another persistent store. The post Caching YouTube Transcripts to Cut API Costs covers the caching patterns in full.
Deploying the Bot Serverlessly
A Slack bot for a team of 20-50 people handles maybe a few dozen requests per day. A serverless function on Vercel, AWS Lambda, or Cloudflare Workers is more than sufficient and usually free.
Deploy to Vercel in three steps:
- Create a
api/summarize.jsfile (Vercel automatically routes/api/summarizeto it). - Add your environment variables in the Vercel dashboard:
SLACK_SIGNING_SECRET,SLACK_BOT_TOKEN,YT_TRANSCRIBER_KEY,OPENAI_API_KEY. - Run
vercel deploy.
// api/summarize.js
export default async function handler(req, res) {
if (req.method !== "POST") return res.status(405).end();
// rawBody must be set before express.urlencoded parses the body
// Use vercel's raw body support
const rawBody = await getRawBody(req);
req.rawBody = rawBody.toString();
if (!verifySlackRequest(req, process.env.SLACK_SIGNING_SECRET)) {
return res.status(401).send("Unauthorized");
}
const params = new URLSearchParams(req.rawBody);
const videoUrl = params.get("text").trim();
const responseUrl = params.get("response_url");
res.json({ response_type: "ephemeral", text: "Summarizing..." });
// Vercel allows background work after res.end()
processSummary(videoUrl, responseUrl);
}
One gotcha: Vercel's hobby plan kills execution after the response is sent. To reliably fire the background processSummary call, use Vercel's waitUntil API or switch to a plan that supports background functions. AWS Lambda with an async invocation is a simpler option if you need guaranteed execution.
Update your Slack slash command's Request URL to your deployed Vercel endpoint and you are live.
What to Add Next
The slash command above is a solid starting point. A few natural extensions:
- Support youtu.be short links. Extend
extractVideoIdto handle bothyoutube.com/watch?v=andyoutu.be/formats. - Add a language option. The YouTube Transcriber API accepts a
langparameter. Let users type/summarize URL lang:esto get a Spanish transcript before summarizing. - Post to a dedicated channel. Instead of replying ephemerally, post summaries to a
#video-summarieschannel so the whole team benefits. - Persist summaries. Save video ID and summary to a database so repeated requests return instantly without a second API call.
YouTube Transcriber provides 100 free credits with no credit card required, which is enough to test this entire bot and summarize hundreds of typical videos. Visit the docs to grab a key and run the first transcript fetch before you write a single line of Slack code.