Back to Videos

I Let Copilot Automate My Blog • What Could Go Wrong?

A complete breakdown of the automated workflow that turns YouTube videos into fully-written MDX blog posts using PubSubHubbub, Vercel Workflow Dev Kit, and GitHub Copilot Agents.

sashkode

This article breaks down the complete automation pipeline that generates blog articles from YouTube videos with zero manual effort. Watch the video for the full walkthrough; this article provides the implementation details, code snippets, and GitHub permalinks to explore the actual codebase.

The Goal: Supporting Material, Not Busywork

A subscriber requested easier navigation for code snippets from past videos—instead of linking to entire repositories, they wanted direct links to relevant implementations. Rather than manually writing articles for every video, the solution was to automate the entire process using AI and workflows.

The result? Publish a YouTube video → receive a pull request with a complete, structured MDX blog article → review and merge. No scripts to run, no manual writing sessions.

Architecture Overview

The automation pipeline consists of four main components:

  1. YouTube PubSubHubbub webhook — Triggers when a new video publishes
  2. Vercel Workflow Dev Kit — Orchestrates the multi-step process
  3. GitHub Copilot Agent — Writes the actual article
  4. Next.js + Fumadocs — Renders the MDX blog
YouTube Video Published

PubSubHubbub Notification

Webhook Handler (/api/webhooks/youtube)

Workflow: Fetch Metadata + Transcript

Create GitHub Issue (assigned to Copilot)

Copilot Generates MDX Article

Pull Request Ready to Merge

PubSubHubbub Webhook Integration

YouTube doesn't provide traditional webhooks, but it supports the PubSubHubbub protocol—a decentralized pub/sub standard for RSS/Atom feeds.

Setting Up the Subscription

The subscription script registers the webhook endpoint with YouTube's hub:

config/youtube/subscribe.sh
#!/bin/bash

CHANNEL_ID="${YOUTUBE_CHANNEL_ID:?'Set YOUTUBE_CHANNEL_ID environment variable'}"
CALLBACK_URL="${WEBHOOK_CALLBACK_URL:-https://your-domain.vercel.app/api/webhooks/youtube}"
HUB_URL="https://pubsubhubbub.appspot.com/subscribe"

curl -X POST "$HUB_URL" \
  -d "hub.callback=$CALLBACK_URL" \
  -d "hub.topic=https://www.youtube.com/xml/feeds/videos.xml?channel_id=$CHANNEL_ID" \
  -d "hub.verify=async" \
  -d "hub.mode=subscribe" \
  ${YOUTUBE_WEBHOOK_SECRET:+-d "hub.secret=$YOUTUBE_WEBHOOK_SECRET"}

See the complete subscription script for details.

Important: Subscriptions expire after ~10 days and need renewal. Consider setting up a cron job to run this periodically.

Webhook Handler Implementation

The webhook endpoint handles two types of requests:

  1. GET — Hub verification (returns the challenge parameter)
  2. POST — New video notifications (parses Atom XML and triggers workflow)
src/app/api/webhooks/youtube/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  const signature = request.headers.get("X-Hub-Signature");
  const body = await request.text();

  if (!signature) {
    return new Response("Missing signature", { status: 401 });
  }

  // Verify signature using HMAC-SHA1 with timing-safe comparison
  const expectedSignature = `sha1=${createHmac("sha1", env.YOUTUBE_WEBHOOK_SECRET)
    .update(body)
    .digest("hex")}`;

  const signatureBuffer = Buffer.from(signature);
  const expectedBuffer = Buffer.from(expectedSignature);

  if (
    signatureBuffer.length !== expectedBuffer.length ||
    !timingSafeEqual(signatureBuffer, expectedBuffer) 
  ) {
    return new Response("Invalid signature", { status: 401 });
  }

  // Parse Atom XML to extract video ID and title
  const videoIdMatch = body.match(/<yt:videoId>([^<]+)<\/yt:videoId>/);
  const titleMatch = body.match(/<title>([^<]+)<\/title>/);

  if (!videoIdMatch?.[1]) { 
    return new Response("Missing video ID in feed", { status: 400 }); 
  } 

  const videoId = videoIdMatch[1];
  const title = titleMatch?.[1] ?? "Untitled Video";

  // Trigger workflow asynchronously (don't block webhook response)
  startVideoToArticleWorkflow(videoId).catch((error) => {
    Logger.error(`Failed to trigger workflow for ${videoId}`, { scope: "YOUTUBE_WEBHOOK", error });
  });

  return new Response("OK", { status: 200 }); 
}

Key implementation details:

  • Timing-safe comparison prevents timing attacks on signature verification
  • Top-level regex patterns for performance (defined once, reused)
  • Asynchronous workflow trigger returns 200 OK immediately (webhooks must respond quickly)

See the full webhook implementation.

Durable Workflow with Vercel Workflow Dev Kit

The Vercel Workflow Dev Kit provides durable execution for long-running tasks. This is critical because YouTube's auto-generated captions may not be available immediately after publishing.

Workflow Steps

The workflow consists of three main steps:

src/features/videos/server/video-to-article.workflow.ts
export async function videoToArticleWorkflow(videoId: string) {
  "use workflow";

  // Step 1: Fetch video metadata from YouTube Data API
  const metadata = await fetchVideoMetadata(videoId); 

  if (!metadata) {
    throw new Error(`Failed to fetch metadata for video ${videoId}`);
  }

  // Step 2: Fetch transcript (with retry logic)
  let transcript = await fetchTranscript(videoId);

  if (!transcript) {
    // Wait 2 hours for YouTube to process auto-captions
    await sleep("2h"); 
    transcript = await fetchTranscript(videoId);
  }

  if (!transcript) {
    throw new Error(`Captions unavailable for video ${videoId}`);
  }

  // Step 3: Create GitHub issue assigned to Copilot
  const issueUrl = await createCopilotIssue({ videoId, metadata, transcript });

  return { success: true, issueUrl };
}

The sleep("2h") call is the killer feature here—the workflow pauses for 2 hours, then resumes execution automatically. This would be complex to implement manually with traditional job queues.

Starting the Workflow

The workflow is triggered from the webhook handler:

src/features/videos/server/video-to-article.workflow.ts
import { start } from "workflow/api";

export async function startVideoToArticleWorkflow(videoId: string) {
  const run = await start(videoToArticleWorkflow, [videoId]);
  return { runId: run.runId };
}

See the complete workflow implementation.

Fetching Video Metadata and Transcripts

The workflow fetches data from YouTube using two different APIs:

Video Metadata (YouTube Data API)

Simple REST API call with API key authentication:

src/features/videos/server/captions.ts
export async function getVideoMetadata(videoId: string): Promise<VideoMetadata | null> {
  const url = new URL(`${YOUTUBE_API_BASE}/videos`);
  url.searchParams.set("part", "snippet");
  url.searchParams.set("id", videoId);
  url.searchParams.set("key", env.YOUTUBE_API_KEY); 

  const response = await fetch(url.toString());
  const data = (await response.json()) as YouTubeVideoResponse;
  const video = data.items?.[0];

  if (!video) {
    return null;
  }

  const thumbnails = video.snippet.thumbnails;
  const thumbnailUrl = thumbnails.maxres?.url ?? thumbnails.high?.url ?? thumbnails.default?.url ?? "";

  return {
    videoId: video.id,
    title: video.snippet.title,
    description: video.snippet.description,
    publishedAt: video.snippet.publishedAt,
    channelTitle: video.snippet.channelTitle,
    thumbnailUrl,
  };
}

Transcript Fetching (OAuth Required)

The captions API requires OAuth2 authentication with a refresh token:

src/features/videos/server/captions.ts
// Cache for OAuth access token
let cachedAccessToken: { token: string; expiresAt: number } | null = null;

async function getAccessToken(): Promise<string> {
  // Check if we have a valid cached token (with 5 min buffer)
  if (cachedAccessToken && Date.now() < cachedAccessToken.expiresAt - 5 * 60 * 1000) {
    return cachedAccessToken.token;
  }

  // Refresh the token
  const response = await fetch(GOOGLE_OAUTH_TOKEN_URL, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      client_id: env.YOUTUBE_CLIENT_ID,
      client_secret: env.YOUTUBE_CLIENT_SECRET,
      refresh_token: env.YOUTUBE_REFRESH_TOKEN,
      grant_type: "refresh_token",
    }),
  });

  const data = (await response.json()) as { access_token: string; expires_in: number };

  cachedAccessToken = {
    token: data.access_token,
    expiresAt: Date.now() + data.expires_in * 1000,
  };

  return cachedAccessToken.token;
}

The OAuth flow must be completed manually once to obtain the refresh token, which is then stored in environment variables.

Creating the GitHub Issue

The workflow creates a GitHub issue using the GraphQL API and assigns it to the Copilot agent:

src/features/videos/server/github-issue.ts
export async function createGitHubIssue(data: {
  videoId: string;
  metadata: VideoMetadata;
  transcript: string;
}): Promise<string> {
  const { videoId, metadata } = data;

  // Idempotency check: see if an issue already exists
  const existingIssue = await findExistingIssue(videoId);
  if (existingIssue) {
    return existingIssue.url;
  }

  // Get repository ID
  const repositoryId = await getRepositoryId();

  // Generate issue content
  const title = `📝 Write Article: ${metadata.title}`;
  const body = generateIssueBody(data); 

  // Create the issue
  const issue = await createIssue(repositoryId, title, body);

  // Assign to Copilot agent
  const copilotAgentId = await getCopilotAgentId();

  if (copilotAgentId) {
    await assignIssue(issue.id, copilotAgentId); 
  }

  return issue.url;
}

Issue Template Structure

The issue body includes everything Copilot needs:

src/features/videos/server/github-issue.ts
function generateIssueBody(data: {
  videoId: string;
  metadata: VideoMetadata;
  transcript: string;
}): string {
  const { videoId, metadata, transcript } = data;
  const publishDate = new Date(metadata.publishedAt).toISOString().split("T")[0];
  const slug = toKebabCase(metadata.title); 

  return `## 🎬 Write Blog Article for YouTube Video

**Video ID:** ${videoId}
**Title:** ${metadata.title}
**Published:** ${publishDate}

### Agent Instructions

Follow the instructions in \`.github/copilot/agents/article-writer.md\` for complete guidance.

**Key principles:**
- Create **supporting material**, not a transcript
- **Explore the repository** for related code
- Use **GitHub permalinks** to \`trunk\` branch

### Output

Create a blog article at \`content/videos/${slug}.mdx\`

### Video Description

\`\`\`
${metadata.description}
\`\`\`

### Full Transcript

\`\`\`
${transcript}
\`\`\`
`;
}

The toKebabCase utility converts the video title into a URL-friendly slug:

src/utils/shared/kebab-case.ts
export function toKebabCase(input: string): string {
  return input
    .replace(/([a-z\d])([A-Z])/g, "$1-$2") // Insert hyphen before uppercase
    .replace(/[_\s]+/g, "-") // Replace underscores and spaces
    .replace(/[^a-zA-Z0-9-]/g, "") // Remove non-alphanumeric
    .toLowerCase()
    .replace(/-+/g, "-") // Remove consecutive hyphens
    .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
}

See the GitHub issue creation implementation and kebab-case utility.

Finding the Copilot Agent

The implementation searches for Copilot in the repository's suggested actors:

src/features/videos/server/github-issue.ts
async function getCopilotAgentId(): Promise<string | null> {
  const query = `
    query GetSuggestedActors($owner: String!, $name: String!) {
      repository(owner: $owner, name: $name) {
        suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) { // [!code highlight]
          nodes {
            login
            ... on User { id }
            ... on Bot { id }
          }
        }
      }
    }
  `;

  const data = await graphql<SuggestedActorsResponse>(query, {
    owner: REPO_OWNER,
    name: REPO_NAME,
  });

  // Find the Copilot agent (case-insensitive search)
  const copilotAgent = data.repository.suggestedActors.nodes.find((actor) => {
    const login = actor.login.toLowerCase();
    return actor.id && (login.includes("copilot") || login === "copilot-swe-agent");
  });

  return copilotAgent?.id ?? null;
}

Assignment Using replaceActorsForAssignable

Using replaceActorsForAssignable mutation is more reliable for triggering the Copilot coding agent:

src/features/videos/server/github-issue.ts
async function assignIssue(issueId: string, assigneeId: string): Promise<void> {
  const mutation = `
    mutation ReplaceActors($issueId: ID!, $actorIds: [ID!]!) {
      replaceActorsForAssignable(input: { // [!code highlight]
        assignableId: $issueId
        actorIds: $actorIds
      }) {
        assignable {
          ... on Issue {
            id
            assignees(first: 10) {
              nodes { login }
            }
          }
        }
      }
    }
  `;

  await graphql<ReplaceActorsResponse>(mutation, {
    issueId,
    actorIds: [assigneeId],
  });
}

GitHub Copilot Agent Instructions

The agent receives detailed instructions from .github/copilot/agents/article-writer.md:

Philosophy:

  • Create supporting material, not transcription
  • Link to actual implementation using GitHub permalinks
  • Explore beyond the transcript for related utilities and types
  • Emphasize code snippets with explanations

Code Block Features:

  • Add title="path/to/file.ts" for file context
  • Use // [!code ++] for additions, // [!code --] for removals
  • Use // [!code highlight] for emphasis
  • Use tab="filename.tsx" for multi-file examples

See the complete agent instructions.

Rendering MDX with Fumadocs

The blog uses Fumadocs—an open-source MDX documentation framework with excellent code highlighting and navigation features.

Fumadocs Source Configuration

The source config defines the content schema and MDX processing options:

config/fumadocs/source.config.ts
import { remarkMdxFiles } from "fumadocs-core/mdx-plugins";
import { applyMdxPreset, defineCollections, frontmatterSchema } from "fumadocs-mdx/config";
import { z } from "zod";

import { auraTheme } from "../../src/features/videos/shared/aura-theme";

export const videos = defineCollections({
  type: "doc",
  dir: "content/videos",
  schema: frontmatterSchema.extend({
    author: z.string(),
    date: z.string().date().or(z.date()),
    youtubeVideoId: z.string(),
  }),
  mdxOptions: applyMdxPreset({
    preset: "fumadocs",
    remarkPlugins: [remarkMdxFiles],
    rehypeCodeOptions: {
      themes: {
        light: auraTheme,
        dark: auraTheme,
      },
    },
  }),
});

See the Fumadocs configuration.

Video Detail Page

The page component renders the MDX with embedded YouTube player:

src/video/server/video-detail-page.tsx
export const VideoDetailPage = Page.create({
  path: "/videos/[slug]",
  name: "video",
}).page(async ({ getPathParams }) => {
  const { slug } = await getPathParams();
  const page = videos.getPage([slug]); 

  if (!page) {
    notFound();
  }

  const MDX = page.data.body; 
  const components = getMDXComponents({});
  const toc = page.data.toc;

  return (
    <div className="flex min-h-screen justify-center">
      <div className="flex w-full max-w-6xl gap-8">
        <article className="flex-1">
          <header>
            <h1>{page.data.title}</h1>
            
            {/* Embedded YouTube player */} {}
            <div className="aspect-video rounded-xl overflow-hidden">
              <iframe
                src={`https://www.youtube.com/embed/${page.data.youtubeVideoId}`} {}
                title={page.data.title}
                allowFullScreen
              />
            </div>
          </header>

          {/* Render MDX content */} {}
          <div className="prose dark:prose-invert">
            <MDX components={components} />
          </div>
        </article>

        <TableOfContents items={toc} hasVideo={true} />
      </div>
    </div>
  );
});

See the video detail page implementation.

Explore the complete implementation:

Further Reading

I Let Copilot Automate My Blog • What Could Go Wrong? | Videos | sashkode