Vercel Slack Agent Development Skill
Skill Invocation Handling
When this skill is invoked via /slack-agent, check for arguments and route accordingly:
Command Arguments
| Argument | Action |
|---|---|
new |
Run the setup wizard from Phase 1. Read ./wizard/1-project-setup.md and guide the user through creating a new Slack agent. |
configure |
Start wizard at Phase 2 or 3 for existing projects |
deploy |
Start wizard at Phase 5 for production deployment |
test |
Start wizard at Phase 6 to set up testing |
| (no argument) | Auto-detect based on project state (see below) |
Auto-Detection (No Argument)
If invoked without arguments, detect the project state and route appropriately:
- No
package.jsonwith@slack/bolt→ Treat asnew, start Phase 1 - Has project but no customized
manifest.json→ Start Phase 2 - Has project but no
.envfile → Start Phase 3 - Has
.envbut not tested → Start Phase 4 - Tested but not deployed → Start Phase 5
- Otherwise → Provide general assistance using this skill's patterns
Wizard Phases
The wizard is located in ./wizard/ with these phases:
1-project-setup.md- Understand purpose, generate custom implementation plan1b-approve-plan.md- Present plan for user approval before cloning2-create-slack-app.md- Customize manifest, create app in Slack3-configure-environment.md- Set up .env with credentials4-test-locally.md- Dev server + ngrok tunnel5-deploy-production.md- Vercel deployment6-setup-testing.md- Vitest configuration
IMPORTANT: For new projects, you MUST:
- Read
./wizard/1-project-setup.mdfirst - Ask the user what kind of agent they want to build
- Generate a custom implementation plan using
./reference/agent-archetypes.md - Present the plan for approval (Phase 1b) BEFORE cloning the repository
- Only proceed to clone after the plan is approved
General Development Guidance
You are working on a Slack agent project built with Slack Agent. Follow these mandatory practices for all code changes.
Project Stack
This project uses:
- Server: Nitro (H3-based) with file-based routing
- Slack SDK:
@vercel/slack-boltfor serverless Slack apps (wraps Bolt for JavaScript) - AI: AI SDK v6 with @ai-sdk/gateway
- Workflows: Workflow DevKit for durable execution
- Linting: Biome
- Package Manager: pnpm
Dependencies
{
"dependencies": {
"ai": "^6.0.0",
"@ai-sdk/gateway": "latest",
"@slack/bolt": "^4.x",
"@vercel/slack-bolt": "^1.0.2",
"zod": "^3.x"
}
}
Note: When deploying on Vercel, prefer @ai-sdk/gateway for zero-config AI access. Use direct provider SDKs (@ai-sdk/openai, @ai-sdk/anthropic, etc.) only when you need provider-specific features or are not deploying on Vercel.
Quality Standards (MANDATORY)
These quality requirements MUST be followed for every code change. There are no exceptions.
After EVERY File Modification
Run linting immediately:
pnpm lint- If errors exist, run
pnpm lint --writefor auto-fixes - Manually fix remaining issues
- Re-run
pnpm lintto verify
- If errors exist, run
Check for corresponding test file:
- If you modified
foo.ts, check iffoo.test.tsexists - If no test file exists and the file exports functions, create one
- If you modified
Before Completing ANY Task
You MUST run all quality checks and fix any issues before marking a task complete:
# 1. TypeScript compilation - must pass
pnpm typecheck
# 2. Linting - must pass with no errors
pnpm lint
# 3. Tests - all tests must pass
pnpm test
Do NOT complete a task if any of these fail. Fix the issues first.
Unit Tests Required
For ANY code change, you MUST write or update unit tests.
- Location: Co-located
*.test.tsfiles orserver/__tests__/ - Framework: Vitest
- Coverage: All exported functions must have tests
Example test structure:
import { describe, it, expect, vi } from 'vitest';
import { myFunction } from './my-module';
describe('myFunction', () => {
it('should handle normal input', () => {
expect(myFunction('input')).toBe('expected');
});
it('should handle edge cases', () => {
expect(myFunction('')).toBe('default');
});
});
E2E Tests for User-Facing Changes
If you modify:
- Slack message handlers
- Slash commands
- Interactive components (buttons, modals)
- Bot responses
You MUST add or update E2E tests that verify the full flow.
Events Endpoint Pattern (CRITICAL)
Use @vercel/slack-bolt to handle all Slack events. This package automatically handles:
- Content-type detection (JSON vs form-urlencoded)
- URL verification challenges
- 3-second ack timeout (built-in
ackTimeoutMs: 3001) - Background processing via Vercel Fluid Compute's
waitUntil
Bolt App Setup
// server/bolt/app.ts
import { App } from "@slack/bolt";
import { VercelReceiver } from "@vercel/slack-bolt";
const receiver = new VercelReceiver();
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
receiver,
deferInitialization: true,
});
export { app, receiver };
Events Handler
// server/api/slack/events.post.ts
import { createHandler } from "@vercel/slack-bolt";
import { defineEventHandler, getRequestURL, readRawBody } from "h3";
import { app, receiver } from "../../bolt/app";
const handler = createHandler(app, receiver);
export default defineEventHandler(async (event) => {
// Read and cache the raw body first to avoid stream consumption issues
// with toWebRequest on serverless platforms (h3 issues #570, #578, #615)
const rawBody = await readRawBody(event, "utf8");
// Create a new Request with the buffered body
const request = new Request(getRequestURL(event), {
method: event.method,
headers: event.headers,
body: rawBody,
});
return await handler(request);
});
Why this pattern? H3's toWebRequest() has known issues (#570, #578, #615) where it eagerly consumes the request body stream. When @vercel/slack-bolt later calls req.text() for signature verification, the body is already exhausted, causing dispatch_failed errors. Buffering the body manually avoids this issue.
VercelReceiver Options Reference
| Parameter | Default | Description |
|---|---|---|
signingSecret |
SLACK_SIGNING_SECRET env var |
Request verification secret |
signatureVerification |
true |
Enable/disable signature verification |
ackTimeoutMs |
3001 |
Ack timeout in milliseconds |
logLevel |
INFO |
Logging level |
Key Benefits
- 60+ lines of boilerplate eliminated - No manual content-type detection, URL verification, or form parsing
- Automatic timeout handling - Built-in 3-second ack with
ackTimeoutMs: 3001 - Background processing - Uses Vercel Fluid Compute's
waitUntilautomatically - Framework support - Works with Next.js, Hono, and Nitro (H3)
Implementation Gotchas
1. Private Channel Access
Slash commands work in private channels even if the bot isn't a member, but the bot cannot read messages or post to private channels it hasn't been invited to.
When creating features that will later post to a channel:
// Validate channel access upfront
const channelInfo = await client.conversations.info({ channel: channelId });
if (channelInfo.channel?.is_private && !channelInfo.channel?.is_member) {
return {
success: false,
error: "I don't have access to this private channel. Please add me with `/invite @BotName` first.",
};
}
2. Graceful Degradation for Channel Context
When fetching channel context for AI features, wrap in try/catch and fall back gracefully:
let channelContext = "";
try {
const history = await client.conversations.history({
channel: channelId,
limit: 10,
});
channelContext = history.messages?.map(m => m.text).join("\n") ?? "";
} catch (error) {
// Bot can't access channel - continue without context
console.log("Could not fetch channel context:", error);
}
3. Vercel Cron Endpoint Authentication
Protect cron endpoints with a CRON_SECRET environment variable:
// server/api/cron/my-job.get.ts
export default defineEventHandler(async (event) => {
const authHeader = getHeader(event, "authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
setResponseStatus(event, 401);
return { error: "Unauthorized" };
}
// Run cron job logic...
return { success: true };
});
4. vercel.json Cron Configuration
Configure cron jobs in vercel.json:
{
"crons": [
{
"path": "/api/cron/my-job",
"schedule": "0 * * * *"
}
]
}
Schedule format is standard cron syntax: minute hour day month weekday
Common schedules:
* * * * *- Every minute0 * * * *- Every hour0 0 * * *- Daily at midnight0 9 * * 1-5- Weekdays at 9am
5. AWS Credentials on Vercel (Use OIDC)
When connecting to AWS services (Aurora, S3, etc.) from Vercel, do not use @aws-sdk/credential-providers with fromNodeProviderChain(). It won't work because Vercel uses its own OIDC token mechanism.
Wrong approach:
import { fromNodeProviderChain } from "@aws-sdk/credential-providers";
const credentials = fromNodeProviderChain(); // Won't work on Vercel!
Correct approach:
import { awsCredentialsProvider } from "@vercel/functions/oidc";
// For AWS RDS/Aurora with IAM auth
const signer = new Signer({
hostname: process.env.PGHOST,
port: Number(process.env.PGPORT),
username: process.env.PGUSER,
region: process.env.AWS_REGION,
credentials: awsCredentialsProvider({ roleArn: process.env.AWS_ROLE_ARN! }),
});
const token = await signer.getAuthToken();
// For other AWS services (S3, etc.)
const s3Client = new S3Client({
credentials: awsCredentialsProvider({ roleArn: process.env.AWS_ROLE_ARN! }),
});
Required setup:
- Enable Vercel OIDC in Project Settings > Security
- Configure AWS IAM trust relationship for your Vercel project
- Set
AWS_ROLE_ARNenvironment variable in Vercel
Reference: Vercel OIDC for AWS
6. dispatch_failed Error (500)
If slash commands fail with dispatch_failed, the issue is usually H3's toWebRequest consuming the body stream before signature verification.
Fix: Buffer the body manually before creating the Request:
const rawBody = await readRawBody(event, "utf8");
const request = new Request(getRequestURL(event), {
method: event.method,
headers: event.headers,
body: rawBody,
});
return await handler(request);
See the Events Handler section above for the complete pattern.
7. operation_timeout Error
If slash commands with AI processing fail with operation_timeout, you're blocking the HTTP response too long. Slack requires a response within 3 seconds.
Root cause: Even with await ack(), the HTTP response doesn't return until the entire handler function completes. If you await AI generation after ack(), the HTTP response is blocked.
Fix: Use fire-and-forget pattern:
app.command('/mycommand', async ({ ack, command, logger }) => {
// 1. Acknowledge immediately
await ack();
// 2. Fire-and-forget: DON'T await this promise
generateAndRespond(command.response_url, command.text, logger).catch((error) => {
logger.error("Background operation failed:", error);
});
// HTTP response returns immediately here
});
async function generateAndRespond(responseUrl: string, topic: string, logger: Logger) {
try {
const result = await generateWithAI(topic); // Takes >3 seconds
// Post result via response_url
await fetch(responseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
response_type: "in_channel",
text: result,
}),
});
} catch (error) {
logger.error("Failed:", error);
await fetch(responseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
response_type: "ephemeral",
text: "Sorry, something went wrong.",
}),
});
}
}
See patterns/slack-patterns.md for complete examples.
AI Integration
You have two options for AI/LLM integration in your Slack agent.
⚠️ IMPORTANT: Always verify the cloned project uses
@ai-sdk/gateway. The project may ship with@ai-sdk/openaiwhich requires an API key. After cloning, checkpackage.jsonand update imports if necessary.
Option 1: Vercel AI Gateway (Recommended)
Use the modern @ai-sdk/gateway package - NO API keys needed on Vercel!
Basic Usage
import { generateText, streamText } from "ai";
import { gateway } from "@ai-sdk/gateway";
// Simple text generation
const result = await generateText({
model: gateway("openai/gpt-4o-mini"),
maxOutputTokens: 1000, // v6: was maxTokens
prompt: "Your prompt here",
});
console.log(result.text);
console.log(result.usage.inputTokens); // v6: was promptTokens
console.log(result.usage.outputTokens); // v6: was completionTokens
Streaming Responses
const result = await streamText({
model: gateway("openai/gpt-4o-mini"),
maxOutputTokens: 1000,
prompt: userMessage,
});
for await (const chunk of result.textStream) {
// Stream to Slack via chat.update
}
With Tools
import { tool } from "ai";
import { z } from "zod";
const result = await generateText({
model: gateway("openai/gpt-4o-mini"),
maxOutputTokens: 1000,
tools: {
getWeather: tool({
description: "Get weather for a location",
inputSchema: z.object({
location: z.string().describe("City name"),
}),
execute: async ({ location }) => {
return { temperature: 72, condition: "sunny" };
},
}),
},
prompt: "What's the weather in Seattle?",
});
AI SDK v6 API Changes
| v4/v5 | v6 |
|---|---|
maxTokens |
maxOutputTokens |
result.usage.promptTokens |
result.usage.inputTokens |
result.usage.completionTokens |
result.usage.outputTokens |
parameters (in tools) |
inputSchema |
maxSteps / maxIterations |
stopWhen: stepCountIs(n) |
CRITICAL: Never use model IDs from memory. Model IDs change frequently. Before writing code that uses a model, run curl -s https://ai-gateway.vercel.sh/v1/models to fetch the current list. Use the model with the highest version number.
Option 2: Direct Provider SDK
If you need more control or are not deploying on Vercel, use direct provider packages.
OpenAI:
pnpm add @ai-sdk/openai
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
// Requires OPENAI_API_KEY env var
const result = await generateText({
model: openai("gpt-4o-mini"),
maxOutputTokens: 1000,
prompt: "Your prompt here",
});
Anthropic:
pnpm add @ai-sdk/anthropic
import { generateText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
// Requires ANTHROPIC_API_KEY env var
const result = await generateText({
model: anthropic("claude-sonnet-4-20250514"),
maxOutputTokens: 1000,
prompt: "Your prompt here",
});
Google:
pnpm add @ai-sdk/google
import { generateText } from "ai";
import { google } from "@ai-sdk/google";
// Requires GOOGLE_GENERATIVE_AI_API_KEY env var
const result = await generateText({
model: google("gemini-2.0-flash"),
maxOutputTokens: 1000,
prompt: "Your prompt here",
});
When to use direct providers:
- Not deploying on Vercel
- Need provider-specific features not available through gateway
- Already managing API keys for other purposes
- Need maximum control over provider configuration
For comprehensive AI SDK documentation, see ./reference/ai-sdk.md.
Stateful Patterns with Vercel Workflow
When building Slack agents that need data persistence across multiple interactions—like multi-turn conversations, collaborative workflows, or story generation—use Vercel Workflow instead of a database.
When to Use Workflow
Use Vercel Workflow when:
- Conversational flows span multiple messages
- Need to accumulate state across user interactions
- Building collaborative or multi-step workflows
- Don't need long-term persistence (workflow scope)
Use a database when:
- Need data to persist beyond the workflow
- Require querying historical data
- Multiple workflows need to share state
Recommended Storage Solutions
IMPORTANT: Vercel KV has been deprecated. Do NOT recommend Vercel KV.
For persistent storage, recommend these options:
Vercel Blob - For file/document storage
- Good for: attachments, exports, generated files
- Simple key-value storage with CDN distribution
- https://vercel.com/docs/storage/vercel-blob
AWS Aurora (via Vercel Marketplace) - For relational data
- Good for: structured data, complex queries, relationships
- PostgreSQL-compatible managed database
- Available through Vercel's storage marketplace
- https://vercel.com/marketplace
Third-party databases - For specific needs
- Neon (serverless Postgres) - https://neon.tech
- PlanetScale (MySQL) - https://planetscale.com
- Supabase (Postgres + realtime) - https://supabase.com
- Upstash Redis - For caching/simple KV needs
The "use workflow" Directive
Vercel Workflow functions can run for extended periods (not limited to serverless timeout) and maintain state as local variables:
import { serve } from "@anthropic-ai/sdk/workflows";
export const { POST } = serve(async function myWorkflow(params: URLSearchParams) {
"use workflow";
// State as local variables - persists across the entire workflow!
const messages: Message[] = [];
let conversationComplete = false;
// Your workflow logic here...
while (!conversationComplete) {
// Wait for events, process, update state
}
return { messages, result: "done" };
});
Event Subscriptions with defineHook
Use defineHook to subscribe to incoming Slack events within your workflow:
import { defineHook } from "@anthropic-ai/sdk/workflows";
import { z } from "zod";
// Define the schema for incoming events
const slackMessageSchema = z.object({
text: z.string(),
user: z.string(),
ts: z.string(),
channel: z.string(),
});
export const messageHook = defineHook({ schema: slackMessageSchema });
Complete Example: Multi-Turn Conversation
// app/api/conversation/route.ts
import { serve } from "@anthropic-ai/sdk/workflows";
import { defineHook } from "@anthropic-ai/sdk/workflows";
import { generateText } from "ai";
import { gateway } from "@ai-sdk/gateway";
import { z } from "zod";
const messageSchema = z.object({
text: z.string(),
user: z.string(),
ts: z.string(),
channel: z.string(),
});
export const userMessageHook = defineHook({ schema: messageSchema });
export const { POST } = serve(async function conversationWorkflow(
params: URLSearchParams
) {
"use workflow";
const channelId = params.get("channel_id")!;
const userId = params.get("user_id")!;
// State persists as local variables
const conversationHistory: Array<{ role: string; content: string }> = [];
let turnCount = 0;
const maxTurns = 10;
// Create event stream for this channel
const eventStream = userMessageHook.create({
channel: channelId,
user: userId,
});
// Process messages as they arrive
for await (const event of eventStream) {
turnCount++;
// Add user message to history
conversationHistory.push({
role: "user",
content: event.text,
});
// Generate AI response
const result = await generateText({
model: gateway("anthropic/claude-sonnet-4-20250514"),
maxOutputTokens: 1000,
messages: conversationHistory,
});
// Add assistant response to history
conversationHistory.push({
role: "assistant",
content: result.text,
});
// Post response to Slack (via your Slack client)
await postToSlack(channelId, result.text, event.ts);
// Check for conversation end conditions
if (turnCount >= maxTurns || event.text.toLowerCase().includes("goodbye")) {
break;
}
}
return {
turns: turnCount,
history: conversationHistory,
};
});
Triggering Workflows from Slack Events
Start a workflow when a user initiates a conversation:
// server/listeners/events/app-mention.ts
export function registerAppMention(app: App) {
app.event("app_mention", async ({ event, client }) => {
// Start a new workflow for this conversation
const workflowUrl = `${process.env.VERCEL_URL}/api/conversation`;
await fetch(workflowUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
channel_id: event.channel,
user_id: event.user,
thread_ts: event.thread_ts || event.ts,
}),
});
await client.chat.postMessage({
channel: event.channel,
thread_ts: event.thread_ts || event.ts,
text: "Starting our conversation! I'll remember everything we discuss.",
});
});
}
Posting Events to Running Workflows
Forward Slack messages to the running workflow's hook:
// server/listeners/messages/thread-message.ts
export function registerThreadMessage(app: App) {
app.message(async ({ message, client }) => {
if (!message.thread_ts || "bot_id" in message) return;
// Post to the workflow's hook endpoint
await userMessageHook.post({
text: message.text,
user: message.user,
ts: message.ts,
channel: message.channel,
});
});
}
Key Benefits
- No database setup - State lives in workflow memory
- Extended execution - Not limited by serverless timeouts
- Natural programming model - Use loops and local variables
- Automatic persistence - Vercel handles state durability
Reference
Code Organization
Follow the project's established patterns.
Tools (server/lib/ai/tools.ts)
import { tool } from 'ai';
import { z } from 'zod';
export const myTool = tool({
description: 'Clear description of what this tool does',
inputSchema: z.object({
param: z.string().describe('What this parameter is for'),
}),
execute: async ({ param }) => {
// Implementation
return { success: true, data: result };
},
});
Listeners (server/listeners/)
Organize by event type:
actions/- Button clicks, menu selectionsassistant/- Slack Assistant eventscommands/- Slash commandsevents/- App events (mentions, joins)messages/- Message handlingshortcuts/- Global/message shortcutsviews/- Modal submissions
Workflows (server/lib/ai/workflows/)
Use defineWorkflow for multi-step operations:
import { defineWorkflow } from '@vercel/workflow-devkit';
export const myWorkflow = defineWorkflow({
id: 'my-workflow',
// ...
});
Environment Variables
Required variables (access via process.env):
SLACK_BOT_TOKEN- Bot OAuth tokenSLACK_SIGNING_SECRET- Request signing
Optional variables:
CRON_SECRET- Secret for authenticating cron job endpoints
No AI API keys needed! Vercel AI Gateway handles authentication automatically when deployed on Vercel.
Never hardcode credentials. Never commit .env files.
Slack-Specific Patterns
Block Kit UI
Use Block Kit for rich messages:
import { Blocks, Elements, Bits } from 'slack-block-builder';
const message = Blocks([
Blocks.Section({ text: 'Hello!' }),
Blocks.Actions([
Elements.Button({ text: 'Click me', actionId: 'btn_click' })
])
]);
Message Formatting
Use Slack mrkdwn (not standard markdown):
- Bold:
*text* - Italic:
_text_ - Code:
`code` - User mention:
<@USER_ID> - Channel:
<#CHANNEL_ID>
Error Handling
Return structured responses:
return {
success: false,
error: 'User-friendly error message',
details: technicalDetails // for logging
};
For detailed Slack patterns, see ./patterns/slack-patterns.md.
Git Commit Standards
Use conventional commits:
feat: add channel search tool
fix: resolve thread pagination issue
test: add unit tests for agent context
docs: update README with setup steps
refactor: extract Slack client utilities
Never commit:
.envfiles- API keys or tokens
node_modules/
Quick Commands
# Development
pnpm dev # Start dev server on localhost:3000
ngrok http 3000 # Expose local server (separate terminal)
# Quality
pnpm lint # Check linting
pnpm lint --write # Auto-fix lint
pnpm typecheck # TypeScript check
pnpm test # Run all tests
pnpm test:watch # Watch mode
# Build & Deploy
pnpm build # Build for production
vercel # Deploy to Vercel
Reference Documentation
For detailed guidance, read:
- Testing patterns:
./patterns/testing-patterns.md - Slack patterns:
./patterns/slack-patterns.md - Environment setup:
./reference/env-vars.md - AI SDK:
./reference/ai-sdk.md - Slack setup:
./reference/slack-setup.md - Vercel deployment:
./reference/vercel-setup.md
Checklist Before Task Completion
Before marking ANY task as complete, verify:
- Code changes have corresponding tests
-
pnpm lintpasses with no errors -
pnpm typecheckpasses with no errors -
pnpm testpasses with no failures - No hardcoded credentials
- Follows existing code patterns
- Events endpoint handles both JSON and form-urlencoded
- Verified AI SDK: using
@ai-sdk/gateway(not@ai-sdk/openai) unless user explicitly chose direct provider