slack-agent

SKILL.md

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:

  1. No package.json with @slack/bolt → Treat as new, start Phase 1
  2. Has project but no customized manifest.json → Start Phase 2
  3. Has project but no .env file → Start Phase 3
  4. Has .env but not tested → Start Phase 4
  5. Tested but not deployed → Start Phase 5
  6. 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 plan
  • 1b-approve-plan.md - Present plan for user approval before cloning
  • 2-create-slack-app.md - Customize manifest, create app in Slack
  • 3-configure-environment.md - Set up .env with credentials
  • 4-test-locally.md - Dev server + ngrok tunnel
  • 5-deploy-production.md - Vercel deployment
  • 6-setup-testing.md - Vitest configuration

IMPORTANT: For new projects, you MUST:

  1. Read ./wizard/1-project-setup.md first
  2. Ask the user what kind of agent they want to build
  3. Generate a custom implementation plan using ./reference/agent-archetypes.md
  4. Present the plan for approval (Phase 1b) BEFORE cloning the template
  5. 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-bolt for 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

  1. Run linting immediately:

    pnpm lint
    
    • If errors exist, run pnpm lint --write for auto-fixes
    • Manually fix remaining issues
    • Re-run pnpm lint to verify
  2. Check for corresponding test file:

    • If you modified foo.ts, check if foo.test.ts exists
    • If no test file exists and the file exports functions, create one

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.ts files or server/__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

  1. 60+ lines of boilerplate eliminated - No manual content-type detection, URL verification, or form parsing
  2. Automatic timeout handling - Built-in 3-second ack with ackTimeoutMs: 3001
  3. Background processing - Uses Vercel Fluid Compute's waitUntil automatically
  4. 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 minute
  • 0 * * * * - Every hour
  • 0 0 * * * - Daily at midnight
  • 0 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:

  1. Enable Vercel OIDC in Project Settings > Security
  2. Configure AWS IAM trust relationship for your Vercel project
  3. Set AWS_ROLE_ARN environment 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/openai which requires an API key. After cloning, check package.json and 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:

  1. Vercel Blob - For file/document storage

  2. 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
  3. Third-party databases - For specific 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

  1. No database setup - State lives in workflow memory
  2. Extended execution - Not limited by serverless timeouts
  3. Natural programming model - Use loops and local variables
  4. 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 selections
  • assistant/ - Slack Assistant events
  • commands/ - Slash commands
  • events/ - App events (mentions, joins)
  • messages/ - Message handling
  • shortcuts/ - Global/message shortcuts
  • views/ - 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 token
  • SLACK_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:

  • .env files
  • 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 lint passes with no errors
  • pnpm typecheck passes with no errors
  • pnpm test passes 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
Weekly Installs
3
GitHub Stars
1
First Seen
1 day ago
Installed on
opencode3
gemini-cli3
claude-code3
github-copilot3
codex3
kimi-cli3