slack-agent
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 template
- Only proceed to clone after the plan is approved
General Development Guidance
You are working on a Slack agent project built with the Vercel Slack Agent Template. 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 template uses
@ai-sdk/gateway. The template 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 template'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