Skip to content
Docs

Build a daily digest bot with Chat SDK and Workflow SDK

Build a daily digest bot that posts a daily digest of GitHub stats to Slack. Learn to use Vercel Connect to set up Slack and GitHub app securely in your project.

13 min read
Last updated June 17, 2026

Build a daily digest bot that gathers GitHub activity, summarizes it with a model, and posts to Slack on a schedule without running it inline in a cron handler, where a single slow source or a failing channel can take down the whole run. A cron route starts a single durable workflow that fans out a step per channel, so each fetches, summarizes, and posts on its own, and retries independently.

This guide builds the bot with Chat SDK and Workflow SDK. Every credential is brokered at runtime: Slack and GitHub authenticate through Vercel Connect, and the model authenticates with your project's OpenID Connect (OIDC) token. There are no API keys or client secrets to set.

To get started, clone this template’s GitHub repository. You can also deploy it to Vercel with one click:

GitHub daily digest Slack bot

A Slack bot that reads your GitHub activties and compiles an AI generated daily disgest for you every day.

Deploy Template

If you are working with an AI coding agent, hand it the project and this prompt:

AI Prompt
I want to deploy and customize the daily digest bot template.
Clone the repo at https://github.com/vercel-labs/daily-digest-bot, then read AGENTS.md and ARCHITECTURE.md to understand how it's built and the conventions to follow.
Install dependencies with pnpm, then help me run it locally with `pnpm dev` and deploy with `vercel deploy`. When searching for information, check for applicable skill(s) first and then Vercel documentation.
I want to deploy and customize the durable web research agent template.
Clone the repo at https://github.com/vercel-labs/durable-web-research-agent, then read AGENTS.md and ARCHITECTURE.md to understand how it's built and the conventions to follow.
Install dependencies with pnpm, then help me run it locally with `pnpm dev` and deploy with `vercel deploy`. When searching for information, check for applicable skill(s) first and then Vercel documentation.

Turn your agent into a Vercel expert with this plugin. It gives your coding agent current knowledge of the Vercel products this template uses, including Vercel Connect, Vercel Workflows, Vercel Cron, AI Gateway, and Chat SDK. The plugin is optional; it is not required to use this template or for this guide.

Terminal

Terminal
npx plugins add vercel/vercel-plugin

Before you begin, make sure you have:

  • Node.js 20+
  • pnpm (or npm/yarn)
  • A Vercel team and project with Vercel Connect enabled, plus permission to create connectors and link them to projects
  • The Vercel CLI is installed
  • A Slack workspace where you can install apps
  • A GitHub account where you can install apps

Create a new Next.js app with create-next-app :

Terminal
pnpm create next-app@latest scheduled-digest-bot --yes
cd scheduled-digest-bot

Then install the Chat SDK, Vercel Connect, and Workflow SDK packages:

Terminal
pnpm add chat @chat-adapter/slack @chat-adapter/state-redis ai workflow @vercel/connect zod

Wrap your Next.js config in withWorkflow:

next.config.ts
import { withWorkflow } from "workflow/next";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: [
"@chat-adapter/slack",
"@chat-adapter/state-redis",
"@redis/client",
"@slack/socket-mode",
"@slack/web-api",
"redis",
],
};
export default withWorkflow(nextConfig);

Your agent uses Redis for thread subscriptions and distributed locking. Provision Upstash Redis and connect it to your project with the Vercel CLI:

Terminal
vercel link
vercel integration add upstash

vercel integration add installs the Upstash integration if it isn’t already, provisions a database, connects it to your project, and pulls its connection environment variables into .env.local. Follow the prompts to pick the Redis product and a plan.

Use the Vercel CLI to link the project and pull environment variables:

Terminal
vercel env pull

AI SDK uses VERCEL_OIDC_TOKEN to authenticate with the Vercel AI Gateway with OIDC authentication.

.env.local
VERCEL_OIDC_TOKEN=...

Create the Slack connector in Vercel Connect before you wire the bot locally. Vercel Connect creates and manages the Slack app, so you do not need to create a Slack app at api.slack.com or copy a long-lived Slack bot token.

  1. Open the Connect page in your Vercel team dashboard.
  2. Choose Create Connector.
  3. Select Slack as the provider.
  4. Select the Slack workspace and name the connector, for example digest-bot.
  5. Keep triggers enabled if this project should receive Slack events.
  6. Keep the default scopes selected.
  7. Create the connector and install it in the Slack workspace.
  8. In the connector settings, link it to the Vercel project and select the environments where it should be available.

Copy the Slack connector id and store it in .env.local file as CONNECTOR_SLACK, for example:

.env.local
CONNECTOR_SLACK=slack/digest-bot

Also, add the following environment variables:

.env.local
CRON_SECRET="replace-with-a-long-random-string" # e.g. "openssl rand --base64 32"
DIGEST_CHANNEL_ID="slack:SLACK_CHANNEL_ID"

Create the GitHub connector in Vercel Connect and install it on the repositories you want included in the digest.

  1. Open the Connect page in your Vercel team dashboard.
  2. Choose Create Connector.
  3. Select GitHub as the provider.
  4. Select the GitHub account or organization to connect.
  5. Install the connector on all repositories the digest should read, or select a smaller repository allowlist.
  6. Create the connector.
  7. In the connector settings, link it to the Vercel project and select the environments where it should be available.

Copy the GitHub connector id and store it in .env.local file as CONNECTOR_GITHUB, for example:

.env.local
CONNECTOR_GITHUB=github/digest-github

The digest requests an app-scoped token from Vercel Connect:

lib/digest/sources.ts
import { getToken } from "@vercel/connect";
export async function getGitHubToken() {
const connector = process.env.CONNECTOR_GITHUB;
if (!connector) {
throw new Error("CONNECTOR_GITHUB is required.");
}
return getToken(connector, { subject: { type: "app" } });
}

The connector installation determines which repositories the token can access. If a repository is missing from the digest, check the connector installation and project/environment link before changing code.

lib/bot.ts centralizes the Chat SDK instance. It requests short-lived Slack tokens from Vercel Connect and configures Redis state so proactive posts and webhook handling share the same bot.

lib/bot.ts
import { createSlackAdapter } from "@chat-adapter/slack";
import { createRedisState } from "@chat-adapter/state-redis";
import { getToken } from "@vercel/connect";
import { Chat } from "chat";
let bot: Chat | null = null;
function getSlackBotToken() {
const connector = process.env.CONNECTOR_SLACK;
if (!connector) {
throw new Error(
"CONNECTOR_SLACK is required. Use the Vercel Connect connector id, such as slack/acme-slack.",
);
}
return getToken(connector, { subject: { type: "app" } });
}
function verifyConnectForwardedSlackRequest() {
return true;
}
export function getBot() {
if (!bot) {
bot = new Chat({
userName: process.env.BOT_USER_NAME ?? "digest-bot",
adapters: {
slack: createSlackAdapter({
botToken: getSlackBotToken,
webhookVerifier: verifyConnectForwardedSlackRequest,
}),
},
state: createRedisState(),
dedupeTtlMs: 600_000,
}).registerSingleton();
}
return bot;
}

lib/digest/types.ts keeps the workflow input, source contract, and model output schema in one place. The same schema validates the cron input and constrains the AI SDK response.

lib/digest/types.ts
import { z } from "zod";
export const DigestConfigSchema = z.object({
tone: z.enum(["terse", "detailed"]).default("terse"),
maxSections: z.number().int().positive().max(8).default(4),
include: z
.array(z.string())
.default(["github-repositories", "github-issues"]),
});
export const DigestInputSchema = z.object({
channelId: z.string().min(1),
lookbackHours: z.number().int().positive().max(168).default(24),
detailsUrl: z.url().optional(),
config: DigestConfigSchema.default({
tone: "terse",
maxSections: 4,
include: ["github-repositories", "github-issues"],
}),
});
export const DigestChannelIdSchema = z.string().min(1);
export type DigestConfig = z.infer<typeof DigestConfigSchema>;
export type DigestInput = z.infer<typeof DigestInputSchema>;
export type GatherActivity = (input: DigestInput) => Promise<unknown>;
export const DigestSchema = z.object({
headline: z.string().min(1),
sections: z
.array(
z.object({
label: z.string().min(1),
body: z.string().min(1),
}),
)
.min(1),
});
export type Digest = z.infer<typeof DigestSchema>;

lib/digest/enrollment.ts turns the single DIGEST_CHANNEL_ID environment variable into the workflow input. Keep schedule-independent choices, like lookback window and tone, in code so the environment stays small.

lib/digest/enrollment.ts
import {
DigestChannelIdSchema,
type DigestConfig,
type DigestInput,
} from "./types";
const DIGEST_LOOKBACK_HOURS = 24;
const DIGEST_DETAILS_URL = "https://vercel.com";
const DIGEST_CONFIG: DigestConfig = {
tone: "terse",
maxSections: 4,
include: ["github-repositories", "github-issues"],
};
export async function loadDigestChannel(): Promise<DigestInput | null> {
const raw = process.env.DIGEST_CHANNEL_ID;
if (!raw) {
return null;
}
const channelId = DigestChannelIdSchema.parse(raw);
return {
channelId,
lookbackHours: DIGEST_LOOKBACK_HOURS,
detailsUrl: DIGEST_DETAILS_URL,
config: DIGEST_CONFIG,
};
}

Keep the cron route thin. It should verify the secret CRON_SECRET), load the single configured channel, start the workflow, and return the run id.

app/api/cron/digest/route.ts
import { start } from "workflow/api";
import { loadDigestChannel } from "@/lib/digest/enrollment";
import { runDailyDigest } from "@/lib/digest/workflow";
export async function GET(request: Request) {
const auth = request.headers.get("authorization");
if (!process.env.CRON_SECRET || auth !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
const channel = await loadDigestChannel();
if (!channel) {
return Response.json({ started: false, reason: "DIGEST_CHANNEL_ID is not set" });
}
const run = await start(runDailyDigest, [channel]);
return Response.json({ started: true, runId: run.runId });
}

vercel.json tells Vercel Cron to call the digest route every day. The route still checks CRON_SECRET, so only authorized cron requests can start a workflow.

vercel.json
{
"crons": [
{
"path": "/api/cron/digest",
"schedule": "0 8 * * *"
}
]
}

Add the CRON_SECRET environment variable to your Vercel project before deploying the application.

Use one workflow to orchestrate the work, but keep expensive or failure-prone operations in separate steps. This makes the workflow retries and run inspection more useful.

lib/digest/workflow.ts
import { fetchGitHubActivity, generateDigest, postDigest } from "./step";
import type { DigestInput } from "./types";
export async function runDailyDigest(channel: DigestInput) {
"use workflow";
try {
const activity = await fetchGitHubActivity(channel);
const digest = await generateDigest(channel, activity);
await postDigest(channel, digest);
return { posted: 1, failed: 0, channelId: channel.channelId };
} catch (error) {
return {
posted: 0,
failed: 1,
channelId: channel.channelId,
error: error instanceof Error ? error.message : String(error),
};
}
}
lib/digest/step.ts
import { generateText, Output } from "ai";
import { postDigestCard } from "./card";
import { buildPrompt } from "./prompt";
import { gatherProjectActivity } from "./sources";
import { DigestSchema, type Digest, type DigestInput } from "./types";
export async function fetchGitHubActivity(input: DigestInput) {
"use step";
return gatherProjectActivity(input);
}
export async function generateDigest(input: DigestInput, activity: unknown) {
"use step";
const { output } = await generateText({
model: process.env.DIGEST_MODEL ?? "anthropic/claude-haiku-4.5",
output: Output.object({
schema: DigestSchema,
name: "daily_digest",
description: "A concise channel digest with headline and sections.",
}),
prompt: buildPrompt(activity, input.config),
});
return output;
}
export async function postDigest(input: DigestInput, digest: Digest) {
"use step";
return postDigestCard(input, digest);
}

lib/digest/sources.ts owns the GitHub data contract. It gets a token from Vercel Connect, discovers connector-visible repositories, counts open issues, and returns a precomputed activity payload for the model.

Use the REST repository endpoints to discover connector-visible repositories. Then use batched GitHub GraphQL queries for issue counts and recent issue nodes.

Avoid the GitHub Search API for per-repository issue counts. Its quota is much lower, and a scheduled digest can exhaust it quickly when it searches once per repository.

Here’s the API request flow:

  • Fetch up to a bounded number of active repositories.
  • Count public and private repositories in code.
  • Query GraphQL in batches for issues(states: OPEN) { totalCount }.
  • Fetch recent issue nodes only for the repositories shown in the digest. Keep recent issue nodes capped, for example 10 per repository.

The digest payload should give the model precomputed totals, not ask it to infer them. Build the payload in a complete helper function:

lib/digest/sources.ts
type RepositorySummary = {
name: string;
visibility: "public" | "private";
};
type RecentIssue = {
repository: string;
title: string;
url: string;
createdAt: string;
};
type IssueReport = {
repository: RepositorySummary;
openIssueCount: number;
recentlyOpenedIssues: RecentIssue[];
};
function buildDigestActivity(
repositories: RepositorySummary[],
issueReports: IssueReport[],
maxProcessed: number,
) {
const publicRepositories = repositories.filter(
(repository) => repository.visibility === "public",
);
const privateRepositories = repositories.filter(
(repository) => repository.visibility === "private",
);
const recentlyOpened = issueReports
.flatMap((report) => report.recentlyOpenedIssues)
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
const openInPublicRepositories = sumOpenIssues(issueReports, "public");
const openInPrivateRepositories = sumOpenIssues(issueReports, "private");
return {
repositories: {
total: repositories.length,
public: publicRepositories.length,
private: privateRepositories.length,
processed: Math.min(repositories.length, maxProcessed),
maxProcessed,
},
issues: {
totalOpen: openInPublicRepositories + openInPrivateRepositories,
openInPublicRepositories,
openInPrivateRepositories,
recentlyOpenedInLookback: recentlyOpened.length,
recentlyOpened,
byRepository: issueReports.map((report) => ({
repository: report.repository.name,
visibility: report.repository.visibility,
openIssueCount: report.openIssueCount,
recentlyOpenedInLookback: report.recentlyOpenedIssues.length,
})),
},
};
}
function sumOpenIssues(
issueReports: IssueReport[],
visibility: RepositorySummary["visibility"],
) {
return issueReports
.filter((report) => report.repository.visibility === visibility)
.reduce((sum, report) => sum + report.openIssueCount, 0);
}

This keeps the prompt focused on writing the digest rather than doing arithmetic over raw issue lists. It also makes the posted summary easier to verify when you inspect a workflow run.

The source file should also throw RetryableError for transient GitHub failures and FatalError for bad configuration, so workflow retries only the failures that can recover.

lib/digest/prompt.ts turns the structured GitHub activity into model instructions. Keep totals and source data in the activity object, then use the prompt only to control tone and output priorities.

lib/digest/prompt.ts
import type { DigestConfig } from "./types";
export function buildPrompt(activity: unknown, config: DigestConfig) {
return [
`Write a ${config.tone} daily GitHub issues digest.`,
`Return at most ${config.maxSections} sections.`,
`Only include these source areas: ${config.include.join(", ")}.`,
"Include the total number of public repositories and private repositories.",
"Include the total number of open issues in public repositories and private repositories.",
"If repository totals may be limited by the fetch cap, call that out briefly.",
"Highlight newly opened issues, affected repositories, owners, labels, and notable themes.",
"If there are no recently opened issues, still summarize the repository and open issue totals.",
"Use clear labels and concise bodies.",
"Return a headline and sections that match the requested schema.",
JSON.stringify(activity, null, 2),
].join("\n\n");
}

Create the Slack message card in the lib/digest/card.tsx :

lib/digest/card.tsx
import { Actions, Card, CardText, LinkButton, Section } from "chat";
import { getBot } from "@/lib/bot";
import type { Digest, DigestInput } from "./types";
export async function postDigestCard(input: DigestInput, digest: Digest) {
const channel = getBot().channel(input.channelId);
await channel.post(
Card({
title: digest.headline,
children: [
...digest.sections.map((section) =>
Section([CardText(`**${section.label}** ${section.body}`)]),
),
Actions([
LinkButton({
url: input.detailsUrl ?? "https://vercel.com",
label: "View details",
}),
]),
],
}),
);
return { posted: true as const, channelId: input.channelId };
}

To trigger the cron route, start the app:

Terminal
pnpm dev

In another terminal, run the following curl command to trigger the cron job:

Terminal
curl -H "Authorization: Bearer $CRON_SECRET" \
http://localhost:3000/api/cron/digest
CleanShot_2026-06-16_at_21.08.32_2x.png

To inspect workflow runs include the steps timeline and retries during development, run the following command:

Terminal
pnpm exec workflow web

You now have a daily digest bot with Chat SDK, Workflow SDK, and Vercel Connect. These primitives are extensible for more comprehensible use cases like a PR review bot or an incident watchlist to help you navigate public reports on GitHub.

Each item below lists a symptom, its cause, and the fix.

Make sure CRON_SECRET is set in the environment where the cron job runs, such as production. Vercel Cron sends Authorization: Bearer $CRON_SECRET, and the route fails closed when the variable is missing or the header does not match.

Symptom: A Vercel Cron run or manual request gets 401 Unauthorized.

Cause: CRON_SECRET is missing from the environment where the route runs, or the request does not include Authorization: Bearer $CRON_SECRET.

Fix: Set CRON_SECRET in production and any preview environment where the cron route should run. For local tests, pull the environment with vercel env pull or set the variable in the shell before calling the route.

Symptom: The workflow runs, but no message appears in Slack.

Cause: The Slack app is not in the configured channel, DIGEST_CHANNEL_ID is not in Chat SDK channel id format, or CONNECTOR_SLACK is missing from the environment.

Fix: Invite the Slack app to the channel, set DIGEST_CHANNEL_ID to a value such as slack:C123ABC, and confirm the Slack connector is linked to the Vercel project environment.

Symptom: The digest includes fewer repositories than expected.

Cause: The GitHub connector is installed on a limited repository allowlist, or the connector is not linked to the environment running the workflow.

Fix: Update the GitHub connector installation to include the repositories you want, then confirm CONNECTOR_GITHUB is available in the same Vercel environment as the workflow.

Check that issue counts use GraphQL batching, not GitHub Search API calls. Search API limits are easier to hit and should not be used once per repository.

Symptom: The fetchGitHubActivity step fails with a rate limit error.

Cause: GitHub rate limits can still apply to connector tokens, especially if the workflow queries too many repositories or uses the Search API for counts.

Fix: Keep repository fetching bounded, use GraphQL batching for issue counts, and avoid per-repository GitHub Search API calls. The template marks rate limits as RetryableError so workflow can retry after a delay.

When Slack events are routed through Vercel Connect, Connect verifies the event before forwarding it. The Slack adapter still expects a webhookVerifier, so provide one that delegates trust to Connect for that route. If you receive events directly from Slack, use Slack's signing secret instead.

Symptom: The Slack webhook route fails with a signing secret error.

Cause: The Slack adapter requires a webhook verifier, but Vercel Connect has already verified Connect-forwarded Slack events before they reach the app.

Fix: For Connect-forwarded Slack events, use a verifier that delegates trust to Connect for that route. If events come directly from Slack, configure verification with Slack's signing secret instead.

Use the Chat SDK function-call Card API in workflow steps instead of JSX if the generated workflow route does not recognize the JSX Card shape at runtime.

Symptom: The Slack post step fails with Invalid JSX element: must be a Card element.

Cause: The workflow route may not recognize a JSX Card shape at runtime.

Fix: Use the Chat SDK function-call Card API in lib/digest/card.tsx, as shown above.

Symptom: GitHub or Slack token requests work in production but fail locally.

Cause: The local VERCEL_OIDC_TOKEN written by vercel env pull has expired, or the local project is linked to the wrong Vercel project.

Fix: Run vercel link to confirm the project, then run vercel env pull again.

No. Slack and GitHub authenticate through Vercel Connect. The app stores connector IDs such as CONNECTOR_SLACK and CONNECTOR_GITHUB, then requests short-lived app-scoped tokens at runtime.

You can test the scheduled outbound flow locally by running the app and calling the cron route with CRON_SECRET. Inbound Slack events, such as mentions or slash commands, should be tested against a preview or production deployment because Slack events are forwarded through Vercel Connect.

Workflow makes the digest durable and easier to debug. GitHub fetching, AI generation, and Slack posting run as separate steps, so transient failures can retry without mixing all work into one request.

Update the GitHub connector installation in Vercel Connect or GitHub so it has access to the repositories you want. The app reads repositories visible to that connector.

Was this helpful?

supported.