Slack Integration Specification
Purpose: Detailed specification for Slack human-in-the-loop approval
Based on: Vercel Slack Bolt adapter + Block Kit
Status: Reference Implementation
Last Updated: 2026-01-26
Overview
The Slack integration provides human-in-the-loop approval for AI-generated message sequences before they’re sent to prospects. It uses Slack’s Bolt framework with Vercel’s serverless adapter.
Key Features:
- Rich sequence preview with Block Kit
- Approve/Edit/Reject actions
- Edit modal for inline sequence modification
- Timeout handling and reminders
- Approval tracking and metrics
Architecture
Workflow Step
↓
Format approval request
↓
Post to Slack channel ← (Block Kit message)
↓
Wait for user action ← (Button click)
↓
Handle action:
- Approve → Continue workflow
- Edit → Open modal → Save → Continue
- Reject → Cancel workflow
↓
Log approval decision
↓
Continue or cancel sequence
Setup
1. Create Slack App
App Manifest (manifest.json):
{
"display_information": {
"name": "Message Sequence Agent",
"description": "AI-powered message sequence generation and approval",
"background_color": "#2c2d30"
},
"features": {
"bot_user": {
"display_name": "Sequence Agent",
"always_online": true
}
},
"oauth_config": {
"scopes": {
"bot": [
"chat:write",
"chat:write.public",
"channels:read",
"users:read"
]
}
},
"settings": {
"event_subscriptions": {
"request_url": "https://your-domain.vercel.app/api/slack/events",
"bot_events": []
},
"interactivity": {
"is_enabled": true,
"request_url": "https://your-domain.vercel.app/api/slack/interactions"
},
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"token_rotation_enabled": false
}
}2. Install Dependencies
npm install @slack/bolt @vercel/slack-bolt3. Environment Variables
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
SLACK_CHANNEL_ID=C1234567890 # Channel for approvals4. Create Slack Adapter
lib/slack.ts:
import { App, ExpressReceiver } from '@slack/bolt';
import { VercelSlackBoltAdapter } from '@vercel/slack-bolt';
// Create Bolt app
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
receiver: new ExpressReceiver({
signingSecret: process.env.SLACK_SIGNING_SECRET!,
}),
});
// Create Vercel adapter
export const slackAdapter = new VercelSlackBoltAdapter(app);
// Export app for handlers
export { app as slackApp };Message Format
Approval Request Message
Block Kit Structure:
import { Block, KnownBlock } from '@slack/types';
export function createApprovalMessage(
workflowId: string,
prospect: { name: string; company: string; email: string },
campaign: {
type: string;
segment: string;
persona: string;
},
sequence: {
primarySequence: Array<{
step: number;
channel: string;
timing: string;
subject?: string;
body: string;
cta: string;
}>;
},
research: {
personalization: {
signals: string[];
context: string;
};
}
): Array<KnownBlock> {
return [
// Header
{
type: 'header',
text: {
type: 'plain_text',
text: `📧 New Sequence: ${campaign.type}`,
emoji: true,
},
},
// Prospect Details
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*Prospect:*\n${prospect.name}`,
},
{
type: 'mrkdwn',
text: `*Company:*\n${prospect.company}`,
},
{
type: 'mrkdwn',
text: `*Campaign:*\n${campaign.type}`,
},
{
type: 'mrkdwn',
text: `*Segment:*\n${campaign.segment}`,
},
{
type: 'mrkdwn',
text: `*Persona:*\n${campaign.persona}`,
},
{
type: 'mrkdwn',
text: `*Steps:*\n${sequence.primarySequence.length} touchpoints`,
},
],
},
// Divider
{
type: 'divider',
},
// Personalization Context
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Personalization Context:*\n${research.personalization.context}`,
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Key Signals:*\n${research.personalization.signals.map(s => `• ${s}`).join('\n')}`,
},
},
// Divider
{
type: 'divider',
},
// Sequence Preview (First 2 steps)
...sequence.primarySequence.slice(0, 2).map((step, index) => ({
type: 'section' as const,
text: {
type: 'mrkdwn' as const,
text: [
`*Step ${step.step} (${step.timing}):*`,
step.subject ? `📬 _Subject:_ ${step.subject}` : '',
'```',
step.body.substring(0, 500) + (step.body.length > 500 ? '...' : ''),
'```',
`🎯 _CTA:_ ${step.cta}`,
].filter(Boolean).join('\n'),
},
})),
// More steps indicator
...(sequence.primarySequence.length > 2 ? [{
type: 'context' as const,
elements: [{
type: 'mrkdwn' as const,
text: `_+ ${sequence.primarySequence.length - 2} more ${sequence.primarySequence.length - 2 === 1 ? 'step' : 'steps'}_`,
}],
}] : []),
// Divider
{
type: 'divider',
},
// Actions
{
type: 'actions',
block_id: `approval_actions_${workflowId}`,
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: '✅ Approve & Send',
emoji: true,
},
style: 'primary',
action_id: 'approve_sequence',
value: workflowId,
},
{
type: 'button',
text: {
type: 'plain_text',
text: '✏️ Edit',
emoji: true,
},
action_id: 'edit_sequence',
value: workflowId,
},
{
type: 'button',
text: {
type: 'plain_text',
text: '🔍 View Full Sequence',
emoji: true,
},
action_id: 'view_full_sequence',
value: workflowId,
},
{
type: 'button',
text: {
type: 'plain_text',
text: '❌ Reject',
emoji: true,
},
style: 'danger',
action_id: 'reject_sequence',
value: workflowId,
},
],
},
// Footer context
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `Workflow ID: \`${workflowId}\` | Generated: <!date^${Math.floor(Date.now() / 1000)}^{date_short_pretty} at {time}|just now>`,
},
],
},
];
}Action Handlers
1. Approve Action
Handler (lib/slack-handlers.ts):
import { slackApp } from './slack';
slackApp.action('approve_sequence', async ({ ack, body, client }) => {
await ack();
const workflowId = body.actions[0].value;
const userId = body.user.id;
try {
// Update message to show approval
await client.chat.update({
channel: body.channel.id,
ts: body.message.ts,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `✅ *Approved by <@${userId}>*\n\nSequence is now being sent...`,
},
},
],
});
// Resume workflow with approval
await resumeWorkflow(workflowId, {
action: 'approve',
approvedBy: userId,
approvedAt: new Date(),
});
// Send confirmation
await client.chat.postEphemeral({
channel: body.channel.id,
user: userId,
text: '✅ Sequence approved! It will be sent according to the timing schedule.',
});
} catch (error) {
console.error('Approval error:', error);
await client.chat.postEphemeral({
channel: body.channel.id,
user: userId,
text: '❌ Error approving sequence. Please try again.',
});
}
});2. Edit Action
Handler:
slackApp.action('edit_sequence', async ({ ack, body, client }) => {
await ack();
const workflowId = body.actions[0].value;
// Fetch sequence data
const sequenceData = await getSequenceData(workflowId);
// Open modal with editable fields
await client.views.open({
trigger_id: body.trigger_id,
view: {
type: 'modal',
callback_id: 'edit_sequence_modal',
private_metadata: workflowId,
title: {
type: 'plain_text',
text: 'Edit Sequence',
},
submit: {
type: 'plain_text',
text: 'Save & Approve',
},
close: {
type: 'plain_text',
text: 'Cancel',
},
blocks: [
// Step 1 fields
{
type: 'header',
text: {
type: 'plain_text',
text: 'Step 1',
},
},
{
type: 'input',
block_id: 'step_1_subject',
label: {
type: 'plain_text',
text: 'Subject Line',
},
element: {
type: 'plain_text_input',
action_id: 'subject',
initial_value: sequenceData.primarySequence[0].subject || '',
},
},
{
type: 'input',
block_id: 'step_1_body',
label: {
type: 'plain_text',
text: 'Email Body',
},
element: {
type: 'plain_text_input',
action_id: 'body',
multiline: true,
initial_value: sequenceData.primarySequence[0].body,
},
},
{
type: 'input',
block_id: 'step_1_cta',
label: {
type: 'plain_text',
text: 'Call-to-Action',
},
element: {
type: 'plain_text_input',
action_id: 'cta',
initial_value: sequenceData.primarySequence[0].cta,
},
},
// Additional steps...
// (Repeat for each step in sequence)
],
},
});
});3. View Full Sequence Action
Handler:
slackApp.action('view_full_sequence', async ({ ack, body, client }) => {
await ack();
const workflowId = body.actions[0].value;
const sequenceData = await getSequenceData(workflowId);
// Open modal with full sequence
await client.views.open({
trigger_id: body.trigger_id,
view: {
type: 'modal',
callback_id: 'view_full_sequence_modal',
title: {
type: 'plain_text',
text: 'Full Sequence',
},
close: {
type: 'plain_text',
text: 'Close',
},
blocks: [
// Research context
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Research Context:*\n${sequenceData.research.personalization.context}`,
},
},
{
type: 'divider',
},
// All steps
...sequenceData.primarySequence.map((step: any) => [
{
type: 'header',
text: {
type: 'plain_text',
text: `Step ${step.step} (${step.timing})`,
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: [
step.subject ? `📬 *Subject:* ${step.subject}` : '',
'',
'```',
step.body,
'```',
'',
`🎯 *CTA:* ${step.cta}`,
'',
`_Personalization notes:_`,
...step.personalizationNotes.map((note: string) => `• ${note}`),
].filter(Boolean).join('\n'),
},
},
{
type: 'divider',
},
]).flat(),
],
},
});
});4. Reject Action
Handler:
slackApp.action('reject_sequence', async ({ ack, body, client }) => {
await ack();
const workflowId = body.actions[0].value;
const userId = body.user.id;
try {
// Update message to show rejection
await client.chat.update({
channel: body.channel.id,
ts: body.message.ts,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `❌ *Rejected by <@${userId}>*\n\nSequence was not sent.`,
},
},
],
});
// Resume workflow with rejection
await resumeWorkflow(workflowId, {
action: 'reject',
rejectedBy: userId,
rejectedAt: new Date(),
});
// Send confirmation
await client.chat.postEphemeral({
channel: body.channel.id,
user: userId,
text: '❌ Sequence rejected. It will not be sent.',
});
} catch (error) {
console.error('Rejection error:', error);
}
});5. Edit Modal Submission
Handler:
slackApp.view('edit_sequence_modal', async ({ ack, view, body, client }) => {
await ack();
const workflowId = view.private_metadata;
const userId = body.user.id;
// Extract edited values
const values = view.state.values;
const editedSequence = {
step1: {
subject: values.step_1_subject.subject.value,
body: values.step_1_body.body.value,
cta: values.step_1_cta.cta.value,
},
// ... extract other steps
};
try {
// Resume workflow with edited sequence
await resumeWorkflow(workflowId, {
action: 'approve',
approvedBy: userId,
approvedAt: new Date(),
editedSequence,
});
// Post success message
await client.chat.postMessage({
channel: process.env.SLACK_CHANNEL_ID!,
text: `✅ Sequence edited and approved by <@${userId}>`,
});
} catch (error) {
console.error('Edit submission error:', error);
}
});API Routes
Events Endpoint
app/api/slack/events/route.ts:
import { slackAdapter } from '@/lib/slack';
export const POST = slackAdapter.handleRequest;Interactions Endpoint
app/api/slack/interactions/route.ts:
import { slackAdapter } from '@/lib/slack';
export const POST = slackAdapter.handleRequest;Workflow Integration
Send Approval Request
In workflow (workflows/sequence/steps.ts):
import { postApprovalRequest } from '@/lib/slack-helpers';
export async function requestApproval(
workflowId: string,
sequence: Sequence,
research: ResearchReport
) {
// Post to Slack
const messageTs = await postApprovalRequest({
workflowId,
prospect: research.prospect,
campaign: {
type: sequence.campaignType,
segment: sequence.segment,
persona: sequence.persona,
},
sequence,
research,
});
// Wait for approval (with timeout)
const approval = await waitForApproval(workflowId, {
timeout: 24 * 60 * 60 * 1000, // 24 hours
reminderAt: 4 * 60 * 60 * 1000, // 4 hours
});
return approval;
}Wait for Approval
export async function waitForApproval(
workflowId: string,
options: { timeout: number; reminderAt: number }
): Promise<ApprovalResponse> {
// Set timeout
const timeoutPromise = new Promise<ApprovalResponse>((resolve) => {
setTimeout(() => {
resolve({
action: 'reject',
reason: 'Timeout - no response within 24 hours',
});
}, options.timeout);
});
// Set reminder
setTimeout(async () => {
const status = await getApprovalStatus(workflowId);
if (status === 'pending') {
await sendReminder(workflowId);
}
}, options.reminderAt);
// Wait for approval decision
const approvalPromise = new Promise<ApprovalResponse>((resolve) => {
// Poll for approval (or use event listener)
const pollInterval = setInterval(async () => {
const approval = await checkApprovalDecision(workflowId);
if (approval) {
clearInterval(pollInterval);
resolve(approval);
}
}, 5000); // Poll every 5 seconds
});
return Promise.race([approvalPromise, timeoutPromise]);
}Timeout & Reminders
Send Reminder
export async function sendReminder(workflowId: string) {
const { slackApp } = await import('./slack');
await slackApp.client.chat.postMessage({
channel: process.env.SLACK_CHANNEL_ID!,
thread_ts: await getOriginalMessageTs(workflowId),
text: '⏰ Reminder: This sequence is still waiting for approval.',
});
}Handle Timeout
export async function handleTimeout(workflowId: string) {
const { slackApp } = await import('./slack');
// Update original message
await slackApp.client.chat.update({
channel: process.env.SLACK_CHANNEL_ID!,
ts: await getOriginalMessageTs(workflowId),
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: '⏰ *Timeout* - No response received within 24 hours. Sequence was auto-rejected.',
},
},
],
});
// Log timeout
await logApprovalDecision(workflowId, {
action: 'reject',
reason: 'timeout',
timestamp: new Date(),
});
}Approval Metrics
Track Decisions
export interface ApprovalMetrics {
totalRequests: number;
approved: number;
rejected: number;
edited: number;
timedOut: number;
approvalRate: number;
editRate: number;
avgTimeToDecision: number; // milliseconds
byCampaignType: Record<CampaignType, {
approvalRate: number;
editRate: number;
}>;
}
export async function getApprovalMetrics(
dateRange: { start: Date; end: Date }
): Promise<ApprovalMetrics> {
// Query approval decisions from database
const decisions = await db.approvalDecisions.findMany({
where: {
createdAt: {
gte: dateRange.start,
lte: dateRange.end,
},
},
});
// Calculate metrics
const metrics = {
totalRequests: decisions.length,
approved: decisions.filter(d => d.action === 'approve').length,
rejected: decisions.filter(d => d.action === 'reject').length,
edited: decisions.filter(d => d.editedSequence).length,
timedOut: decisions.filter(d => d.reason === 'timeout').length,
};
return {
...metrics,
approvalRate: metrics.approved / metrics.totalRequests,
editRate: metrics.edited / metrics.totalRequests,
avgTimeToDecision: calculateAvgTime(decisions),
byCampaignType: groupByCampaignType(decisions),
};
}Testing
Manual Testing
-
Start ngrok for local development:
ngrok http 3000 -
Update Slack app request URLs with ngrok URL
-
Trigger test sequence:
curl -X POST http://localhost:3000/api/trigger \ -H "Content-Type: application/json" \ -d '{"prospect": {...}, "context": {...}}' -
Check Slack channel for approval request
-
Test each action (approve, edit, view, reject)
Automated Testing
// tests/slack-integration.test.ts
import { createApprovalMessage } from '@/lib/slack-helpers';
describe('Slack Integration', () => {
it('creates valid Block Kit message', () => {
const blocks = createApprovalMessage(
'test-workflow-id',
{ name: 'Test', company: 'Test Co', email: 'test@test.com' },
{ type: 'event-follow-up', segment: 'booth-visitor', persona: 'executive' },
{ primarySequence: [/* ... */] },
{ personalization: { signals: [], context: 'Test' } }
);
expect(blocks).toHaveLength(greaterThan(0));
expect(blocks[0].type).toBe('header');
});
// Add more tests...
});