Skip to Content

Knowledge Base Workflow SDK

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 how to use Vercel Connect to set up Slack and GitHub app securely in your project.

Content Engineer

Deploy the template now, or read on for a deeper look at how it all works.

13 min read

17 Jun 2026

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

I want to build a scheduled digest Slack bot using the scheduled digest bot template. Read the setup instructions at https://agent-resources.dev/scheduled-digest-bot-template.md and follow them. They will cover deploying the template, building with the Workflow SDK, how everything works overall, and more.


  Show more

This guide covers the core implementation. The AI assistant prompt above covers all the details your coding agent needs, and you can see the full implementation in the template repository.


Vercel Plugin

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.

npx plugins add vercel/vercel-plugin

Prerequisites

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 the project

cd scheduled-digest-bot

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

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

Wrap your Next.js config in withWorkflow:

pnpmadd chat @chat-adapter/slack @chat-adapter/state-redis ai workflow @vercel/connect zod

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

import{ withWorkflow }from"workflow/next";


"@slack/web-api",

],

"@chat-adapter/state-redis",

"@redis/client",

serverExternalPackages:[

import type {NextConfig}from"next";

"redis",

};

"@chat-adapter/slack",

"@slack/socket-mode",

constnextConfig:NextConfig={

exportdefaultwithWorkflow(nextConfig);

Configure credentials

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


vercel env pull

vercel integration add upstash


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


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

  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:

CONNECTOR_SLACK=slack/digest-bot


Also, add the following environment variables:

CRON_SECRET="replace-with-a-long-random-string"# e.g. "openssl rand --base64 32"

DIGEST_CHANNEL_ID="slack:SLACK_CHANNEL_ID"



Create and link the GitHub connector

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.

CONNECTOR_GITHUB=github/digest-github

const connector = process.env.CONNECTOR_GITHUB;

exportasyncfunctiongetGitHubToken(){

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

}

}

if(!connector){

returngetToken(connector,{subject:{type:"app"}});

import{ getToken }from"@vercel/connect";

thrownewError("CONNECTOR_GITHUB is required.");

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.


Create the Chat SDK bot

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.

import{ createSlackAdapter }from"@chat-adapter/slack";

functiongetSlackBotToken(){

import{ createRedisState }from"@chat-adapter/state-redis";

thrownewError(
);
if(!connector){
}

exportfunctiongetBot(){

returngetToken(connector,{ subject:{ type:"app"}});

bot =newChat({
returntrue;
adapters:{
}).registerSingleton();

state:createRedisState(),

userName: process.env.BOT_USER_NAME??"digest-bot",

}),
},
if(!bot){
return bot;

Define digest schemas and types

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.


include: z

exportconst DigestChannelIdSchema = z.string().min(1);

channelId: z.string().min(1),

exporttypeDigestConfig= z.infer;

});
.array(z.string())
}),

label: z.string().min(1),

exporttypeDigestInput= z.infer;

tone:"terse",
});
.array(
maxSections:4,

body: z.string().min(1),

exporttypeGatherActivity=(input: DigestInput)=>Promise;

.min(1),
}),
sections: z
z.object({

Enroll the digest channel

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.


import{

constDIGEST_DETAILS_URL="https://vercel.com";

DigestChannelIdSchema,

include:["github-repositories","github-issues"],

typeDigestInput,
};
typeDigestConfig,
}

}from"./types";

const channelId = DigestChannelIdSchema.parse(raw);

tone:"terse",
returnnull;
channelId,
maxSections:4,

config:DIGEST_CONFIG,

exportasyncfunctionloadDigestChannel():Promise{

return{
}
if(!raw){
};

Start the workflow from cron

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.


import{ start }from"workflow/api";


}

import{ runDailyDigest }from"@/lib/digest/workflow";

const auth = request.headers.get("authorization");

import{ loadDigestChannel }from"@/lib/digest/enrollment";

const channel =awaitloadDigestChannel();

returnnewResponse("Unauthorized",{ status:401});

if(!channel){

const run =awaitstart(runDailyDigest,[channel]);

}

return Response.json({ started:true, runId: run.runId });

exportasyncfunctionGET(request: Request){

return Response.json({ started:false, reason:"DIGEST_CHANNEL_ID is not set"});

Create GitHub digest workflow


{

{

"path":"/api/cron/digest",

"schedule":"0 8 * * *"

"crons":[

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.

]

}

}

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

Scheduling the cron route

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.

import{ fetchGitHubActivity, generateDigest, postDigest }from"./step";

"use workflow";

try{

importtype{ DigestInput }from"./types";
return{
const activity =awaitfetchGitHubActivity(channel);
const digest =awaitgenerateDigest(channel, activity);

Brand & Design

exportasyncfunctionrunDailyDigest(channel: DigestInput){

awaitpostDigest(channel, digest);
return{ posted:1, failed:0, channelId: channel.channelId };
posted:0,
channelId: channel.channelId,

failed:1,

error: error instanceofError? error.message :String(error),

};
}
}catch(error){
}

import{ gatherProjectActivity }from"./sources";

import{ buildPrompt }from"./prompt";

exportasyncfunctionfetchGitHubActivity(input: DigestInput){

output: Output.object({
}
returngatherProjectActivity(input);
}),

name:"daily_digest",

description:"A concise channel digest with headline and sections.",

schema: DigestSchema,
"use step";
"use step";
return output;

returnpostDigestCard(input, digest);

exportasyncfunctionpostDigest(input: DigestInput, digest: Digest){

});
}
"use step";
}

Fetch GitHub issues with Search API

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.

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:

typeRepositorySummary={

name:string;

visibility:"public"|"private";

};

typeRecentIssue={

repository:string;

title:string;

url:string;

typeIssueReport={

openIssueCount:number;

const publicRepositories = repositories.filter(

createdAt:string;
};
repository: RepositorySummary;
};

){

const privateRepositories = repositories.filter(

functionbuildDigestActivity(
issueReports: IssueReport[],
);
(repository)=> repository.visibility ==="public",

repositories: RepositorySummary[],

const openInPublicRepositories =sumOpenIssues(issueReports,"public");

);
maxProcessed:number,
(repository)=> repository.visibility ==="private",
.flatMap((report)=> report.recentlyOpenedIssues)

return{

repositories:{

maxProcessed,

processed: Math.min(repositories.length, maxProcessed),

public: publicRepositories.length,
},
private: privateRepositories.length,
},

issues:{

byRepository: issueReports.map((report)=>({

total: repositories.length,
openInPublicRepositories,
};
openIssueCount: report.openIssueCount,

recentlyOpened,

totalOpen: openInPublicRepositories + openInPrivateRepositories,

})),
openInPrivateRepositories,
repository: report.repository.name,
}

functionsumOpenIssues(

){

issueReports: IssueReport[],

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.

visibility: RepositorySummary["visibility"],

.filter((report)=> report.repository.visibility === visibility)

}

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.

return issueReports

Build the digest prompt

.reduce((sum, report)=> sum + report.openIssueCount,0);

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.

importtype{ DigestConfig }from"./types";


return[

"Include the total number of public repositories and private repositories.",

JSON.stringify(activity,null,2),

"If repository totals may be limited by the fetch cap, call that out briefly.",

"Use clear labels and concise bodies.",

"Include the total number of open issues in public repositories and private repositories."

}

"Return a headline and sections that match the requested schema.",

].join("\n\n");

"If there are no recently opened issues, still summarize the repository and open issue totals."

exportfunctionbuildPrompt(activity:unknown, config: DigestConfig){

"Highlight newly opened issues, affected repositories, owners, labels, and notable themes."

Post a Chat SDK Card

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


children:[

importtype{ Digest, DigestInput }from"./types";

title: digest.headline,

import{ Actions, Card, CardText, LinkButton, Section }from"chat";

),
Card({
Actions([

await channel.post(

url: input.detailsUrl ??"https://vercel.com",

...digest.sections.map((section)=>
LinkButton({
}),
],

label:"View details",

return{ posted:trueasconst, channelId: input.channelId };

]),
);
}
}),

Run the application locally

To trigger the cron route, start the app:


pnpm dev

http://localhost:3000/api/cron/digest

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

curl-H"Authorization: Bearer $CRON_SECRET"\

Troubleshooting

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.

You can reach our customer support team by emailing info@yourcompany.example.com, calling +1 555-555-5556, or using the live chat on our website. Our dedicated team is available 24/7 to assist with any inquiries or issues.

We’re committed to providing prompt and effective solutions to ensure your satisfaction.

We offer a 30-day return policy for all products. Items must be in their original condition, unused, and include the receipt or proof of purchase. Refunds are processed within 5-7 business days of receiving the returned item.

The cron route returns unauthorized

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.

The Slack digest does not post

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.

You can reach our customer support team by emailing info@yourcompany.example.com, calling +1 555-555-5556, or using the live chat on our website. Our dedicated team is available 24/7 to assist with any inquiries or issues.

We’re committed to providing prompt and effective solutions to ensure your satisfaction.

We offer a 30-day return policy for all products. Items must be in their original condition, unused, and include the receipt or proof of purchase. Refunds are processed within 5-7 business days of receiving the returned item.

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.


GitHub API rate limit reached

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.


Slack signing secret error

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.


Invalid JSX element: must be a Card element

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.

Local Connect token requests fail

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.


Related resources

FAQ

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.


Related documentation