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-bolt

3. Environment Variables

SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
SLACK_CHANNEL_ID=C1234567890  # Channel for approvals

4. 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

  1. Start ngrok for local development:

    ngrok http 3000
  2. Update Slack app request URLs with ngrok URL

  3. Trigger test sequence:

    curl -X POST http://localhost:3000/api/trigger \
      -H "Content-Type: application/json" \
      -d '{"prospect": {...}, "context": {...}}'
  4. Check Slack channel for approval request

  5. 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...
});