Skip to main content

AI Sales Enablement Content Generation with Codex [2026]

· 9 min read
sunder
Founder, marketbetter.ai

Your sales enablement team is drowning. New product launches, competitive updates, pricing changes, messaging pivots—and somehow they're supposed to keep 50 reps trained on all of it.

Here's the problem: Sales enablement doesn't scale with headcount. You can't hire your way out of content debt.

But AI can help. In this guide, I'll show you how to use OpenAI Codex (GPT-5.3) to automatically generate training materials, battle cards, and playbooks—so your enablement team can focus on strategy, not formatting.

AI Sales Enablement Content Automation

The Sales Enablement Content Crisis

Let's be honest about the state of sales enablement:

  • Battle cards are 6 months out of date
  • Training materials reference features that no longer exist
  • Playbooks assume a sales motion you abandoned last quarter
  • Competitive intel is scattered across 47 Slack messages and 12 Google Docs

Your reps are flying blind because enablement can't keep up.

Average sales enablement team: 2 people supporting 50+ reps. Content pieces to maintain: 200+ documents. Update frequency needed: Weekly. Update frequency actual: "When we get to it."

Why Codex for Enablement

OpenAI's Codex (GPT-5.3, released February 5, 2026) is the most capable agentic model for this work because:

  1. Code generation — Create interactive training modules, not just docs
  2. Multi-file operations — Update 50 battle cards in one run
  3. Mid-turn steering — Redirect generation based on feedback
  4. Tool use — Pull from CRM, product docs, Slack, and more

The killer feature: Codex can read your actual product, pull competitive intel, and synthesize training materials that are always current.

Building Your AI Enablement Engine

Step 1: Define Your Content Types

First, map what you need to generate:

const contentTypes = {
battleCards: {
structure: [
'competitor_overview',
'strengths',
'weaknesses',
'our_differentiators',
'landmines_to_plant',
'objection_handlers',
'customer_wins'
],
updateTrigger: ['competitor_news', 'product_update', 'win_loss_feedback'],
format: 'notion_page'
},

playbooks: {
structure: [
'situation_overview',
'target_persona',
'key_messages',
'discovery_questions',
'demo_flow',
'objection_responses',
'next_steps'
],
updateTrigger: ['messaging_change', 'new_feature', 'icp_shift'],
format: 'google_doc'
},

trainingModules: {
structure: [
'learning_objectives',
'key_concepts',
'real_examples',
'practice_scenarios',
'quiz_questions',
'certification_criteria'
],
updateTrigger: ['product_launch', 'process_change', 'quarterly'],
format: 'interactive_html'
},

oneSheets: {
structure: [
'headline_value_prop',
'key_benefits',
'proof_points',
'call_to_action'
],
updateTrigger: ['campaign_launch', 'messaging_refresh'],
format: 'figma_template'
}
};

Step 2: Set Up Codex with Context

Give Codex access to your source truth:

// enablement-agent.js
const { Codex } = require('@openai/codex');

const enablementAgent = new Codex({
model: 'gpt-5.3-codex',
tools: ['file_read', 'file_write', 'web_search', 'api_call'],
context: `You are a Sales Enablement Content Generator.

Your job is to create and update sales training materials that help reps win more deals.

SOURCES OF TRUTH:
- Product documentation: /docs/product/
- Competitive intel: /docs/competitive/
- Win/loss reports: /crm/win-loss/
- Customer stories: /docs/customers/
- Pricing: /docs/pricing/

CONTENT PRINCIPLES:
1. Be specific and actionable—no generic fluff
2. Include real customer examples when available
3. Anticipate rep questions and objections
4. Make content scannable (bullets > paragraphs)
5. Include talk tracks reps can use verbatim

VOICE:
- Confident but not arrogant
- Data-driven with proof points
- Conversational, not corporate`
});

Step 3: Battle Card Generator

Generate battle cards that actually get used:

async function generateBattleCard(competitor) {
// Gather fresh intel
const competitorData = await gatherCompetitorIntel(competitor);
const recentWins = await getWinsAgainst(competitor);
const recentLosses = await getLossesTo(competitor);

const battleCard = await enablementAgent.run(`
Generate a sales battle card for ${competitor}.

COMPETITOR DATA:
${JSON.stringify(competitorData, null, 2)}

RECENT WINS AGAINST THEM (last 90 days):
${JSON.stringify(recentWins, null, 2)}

RECENT LOSSES TO THEM (last 90 days):
${JSON.stringify(recentLosses, null, 2)}

Generate the battle card with these sections:

## Quick Stats
- Founded, HQ, funding, employee count
- Key customers
- Pricing (if known)

## Where They Win
- Their genuine strengths (be honest)
- Types of deals they tend to win

## Where We Win
- Our differentiators vs them
- Types of deals we tend to win

## Landmines to Plant
- Questions that expose their weaknesses
- Features to emphasize in demos

## Objection Handlers
For each common objection, provide:
- Objection
- Response framework
- Proof point to cite

## Talk Track
A 30-second positioning statement when ${competitor} comes up

## Customer Wins
Specific stories of customers who chose us over them
`);

return battleCard;
}

Step 4: Training Module Generator

Create interactive training that reps actually complete:

async function generateTrainingModule(topic, config) {
const module = await enablementAgent.run(`
Create an interactive sales training module on: ${topic}

Target audience: ${config.audience}
Estimated completion time: ${config.duration} minutes
Prerequisites: ${config.prerequisites.join(', ')}

Generate as HTML with embedded interactivity:

1. LEARNING OBJECTIVES
- What will they be able to do after this module?

2. CORE CONTENT
- Break into 3-5 digestible sections
- Include real examples from our customer base
- Add "Try This" interactive elements

3. SCENARIO PRACTICE
- 3 realistic scenarios they might encounter
- Multiple choice responses with feedback
- Explain why correct answers work

4. TALK TRACK BUILDER
- Interactive section where they build their own pitch
- Template with fill-in-the-blank sections
- Example completed version

5. QUIZ
- 5 questions testing key concepts
- Mix of multiple choice and short answer
- Passing score: 80%

6. RESOURCES
- Links to related content
- Cheat sheet for quick reference
- Who to ask for help

Output as a complete, styled HTML document that can be hosted in our LMS.
`);

return module;
}

AI-Powered Sales Training Generator

Using Mid-Turn Steering

Codex's mid-turn steering lets you refine generation in real-time:

async function createPlaybookWithFeedback(playbookType) {
// Start generation
const session = await enablementAgent.startTask(`
Create a sales playbook for: ${playbookType}
Start with discovery questions.
`);

// Review first section
const discoverySection = await session.checkpoint();
console.log('Discovery section:', discoverySection);

// Steer based on review
await session.steer(`
Good start, but:
- Add more questions about budget timing
- Include a question about competitive evaluation
- Make questions more open-ended
`);

// Continue with refinements
await session.continue();

// Final output
return session.complete();
}

Automation: Always-Current Content

Set up automated updates so content never goes stale:

// content-updater.js

// Trigger: Competitor releases new feature
async function onCompetitorUpdate(competitor, update) {
const affectedContent = await findContentMentioning(competitor);

for (const content of affectedContent) {
const updated = await enablementAgent.run(`
Update this content to reflect new competitor information:

ORIGINAL CONTENT:
${content.body}

NEW INFORMATION:
${competitor} just ${update.summary}

Details: ${update.details}

Update the content to:
1. Reflect any new competitive threats
2. Update our positioning if needed
3. Add new objection handlers if relevant
4. Keep the same format and structure

Return only the updated content.
`);

await saveContent(content.id, updated);
await notifyEnablement(`Updated ${content.title} based on ${competitor} news`);
}
}

// Trigger: Product team ships feature
async function onProductUpdate(feature) {
// Update relevant playbooks
const playbooks = await findPlaybooksForFeature(feature.area);

for (const playbook of playbooks) {
await enablementAgent.run(`
Add information about our new feature to this playbook:

NEW FEATURE: ${feature.name}
DESCRIPTION: ${feature.description}
KEY BENEFITS: ${feature.benefits.join(', ')}
TARGET PERSONA: ${feature.persona}

CURRENT PLAYBOOK:
${playbook.body}

Updates needed:
1. Add feature to "What We Offer" section
2. Create demo talking points
3. Add discovery questions that surface need for this feature
4. Update objection handlers if this addresses known concerns
`);
}

// Create training module for new feature
await generateTrainingModule(`New Feature: ${feature.name}`, {
audience: 'all-sales',
duration: 15,
prerequisites: ['product-fundamentals']
});
}

Content Quality Checklist

Have Codex self-check every piece of content:

async function validateContent(content, type) {
const validation = await enablementAgent.run(`
Validate this ${type} against our quality standards:

CONTENT:
${content}

CHECK FOR:
1. ✓ Specificity — Are claims backed by data/examples?
2. ✓ Accuracy — Do features/pricing match current product?
3. ✓ Actionability — Can a rep use this immediately?
4. ✓ Scannability — Is it easy to skim?
5. ✓ Completeness — Are all required sections present?
6. ✓ Freshness — Are examples and data current?
7. ✓ Voice — Does it match our brand tone?

Return:
{
"score": 0-100,
"issues": ["list of specific problems"],
"suggestions": ["how to fix each issue"],
"approved": true/false
}
`);

return JSON.parse(validation);
}

Integration with Enablement Stack

Seismic/Highspot Integration

Push generated content to your enablement platform:

async function publishToSeismic(content) {
const formatted = await enablementAgent.run(`
Format this content for Seismic upload:
${content}

- Add proper metadata tags
- Set appropriate permissions (sales-team)
- Include search keywords
- Add related content suggestions
`);

await seismic.content.create({
title: formatted.title,
body: formatted.body,
tags: formatted.tags,
permissions: ['sales-team'],
keywords: formatted.keywords
});
}

Slack Integration for Requests

Let reps request content updates directly:

// Slack command: /enablement-request
app.command('/enablement-request', async ({ command, ack, respond }) => {
await ack();

const request = command.text;

// Quick triage
const triage = await enablementAgent.run(`
Triage this enablement request:
"${request}"

Determine:
1. Content type needed (battle card, playbook, training, etc.)
2. Priority (urgent, normal, nice-to-have)
3. Can this be auto-generated or needs human input?
4. Estimated time to create
`);

if (triage.canAutoGenerate) {
const content = await generateContent(triage.contentType, request);
await respond(`✅ Created! Here's your ${triage.contentType}: ${content.url}`);
} else {
await createEnablementTicket(request, triage);
await respond(`📋 Request logged. Enablement team will review.`);
}
});

Measuring Impact

Track whether AI-generated content actually works:

MetricBefore AIAfter AIChange
Battle cards accessed/month45312+593%
Content freshness (avg age)4.2 months2.1 weeks8x fresher
Rep content requests28/month8/month-71%
Time to create training40 hours4 hours-90%
Enablement NPS3471+37 pts

Reps use content more when it's actually helpful and current. Shocking, I know.

Getting Started

  1. Audit current content — What's stale? What's missing?
  2. Prioritize — Start with battle cards (highest impact, clearest structure)
  3. Set up sources — Connect Codex to product docs, CRM, competitive intel
  4. Generate first batch — Create 3-5 battle cards manually to test quality
  5. Automate triggers — Set up auto-updates on competitor/product changes
  6. Measure and iterate — Track usage and gather rep feedback

Free Tool

Try our AI Lead Generator — find verified LinkedIn leads for any company instantly. No signup required.

Scale Enablement Without Scaling Headcount

MarketBetter helps GTM teams work smarter with AI-powered automation. From battle cards to training modules, we help you keep reps equipped with what they need to win.

Book a Demo →

See how AI can transform your sales enablement.

AI Sales Meeting Transcription: Build a Free Gong Alternative with OpenClaw [2026]

· 9 min read
MarketBetter Team
Content Team, marketbetter.ai

Gong costs $1,200-1,600 per user per year. For a 10-person sales team, that's $12,000-16,000 annually—just for call recording and basic insights.

What if you could get 80% of the value for $50/month?

This guide shows you how to build a sales meeting transcription and analysis system using:

  • OpenClaw for orchestration (free)
  • Whisper for transcription (free or cheap API)
  • Claude for analysis ($0.01-0.05 per call)

The result: automatic meeting summaries, action items, deal intelligence, and CRM sync—without the enterprise price tag.

AI transcribing sales call to meeting notes with CRM sync

What Enterprise Tools Like Gong Actually Do

Before we build a replacement, let's understand what we're replacing:

Gong's Core Features

  1. Call recording — Records Zoom/Teams/phone calls
  2. Transcription — Speech-to-text for the full conversation
  3. Topic detection — Identifies when pricing, competition, etc. come up
  4. Action items — Extracts next steps from calls
  5. Deal intelligence — Tracks deal progression across calls
  6. Coaching insights — Talk ratio, filler words, etc.
  7. CRM sync — Pushes notes to Salesforce/HubSpot

What Actually Matters

Here's the dirty secret: most teams use like 20% of Gong's features. The stuff that actually moves deals:

  • Searchable transcripts
  • Auto-generated summaries
  • Action items pushed to CRM
  • Basic deal tracking

We can build all of that.

Cost comparison: Enterprise tools vs free open source alternative

The Architecture

Here's our stack:

[Zoom/Teams Recording]

[Cloud Storage (S3/GCS)]

[Whisper Transcription]

[Claude Analysis]

┌────┴────┐
↓ ↓
[HubSpot] [Slack Alert]

OpenClaw orchestrates the whole flow—watching for new recordings, processing them, and routing the outputs.

Architecture showing Zoom to Whisper to Claude to CRM workflow

Prerequisites

  • OpenClaw installed (setup guide)
  • Zoom Business plan (for cloud recording) or local recording workflow
  • HubSpot (or your CRM)
  • Anthropic API key
  • Optional: OpenAI API for Whisper (or run locally)

Step 1: Set Up Recording Capture

Option A: Zoom Cloud Recording Webhook

Zoom can automatically upload recordings. Set up a webhook to trigger processing:

// webhooks/zoom.js
const express = require('express');
const { OpenClaw } = require('openclaw');

const app = express();
const openclaw = new OpenClaw();

app.post('/webhooks/zoom', async (req, res) => {
const { event, payload } = req.body;

if (event === 'recording.completed') {
const { download_url, meeting_id, topic, start_time } = payload.object;

// Trigger the transcription pipeline
await openclaw.trigger('meeting-processor', {
recordingUrl: download_url,
meetingId: meeting_id,
title: topic,
timestamp: start_time,
source: 'zoom'
});
}

res.sendStatus(200);
});

app.listen(3000);

Option B: Local Recording Watch Folder

If you record locally, watch a folder for new files:

// watchers/local-recordings.js
const chokidar = require('chokidar');
const { OpenClaw } = require('openclaw');

const openclaw = new OpenClaw();
const RECORDINGS_DIR = '/path/to/recordings';

const watcher = chokidar.watch(RECORDINGS_DIR, {
ignored: /(^|[\/\\])\../,
persistent: true,
awaitWriteFinish: true
});

watcher.on('add', async (filePath) => {
if (filePath.endsWith('.mp4') || filePath.endsWith('.m4a')) {
console.log(`📹 New recording detected: ${filePath}`);

await openclaw.trigger('meeting-processor', {
filePath,
title: path.basename(filePath, path.extname(filePath)),
timestamp: new Date().toISOString(),
source: 'local'
});
}
});

console.log(`👀 Watching ${RECORDINGS_DIR} for new recordings...`);

Step 2: Build the Transcription Pipeline

Using OpenAI Whisper API

// lib/transcribe.js
const fs = require('fs');
const FormData = require('form-data');

async function transcribeAudio(audioPath) {
const form = new FormData();
form.append('file', fs.createReadStream(audioPath));
form.append('model', 'whisper-1');
form.append('response_format', 'verbose_json');
form.append('language', 'en');

const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
...form.getHeaders()
},
body: form
});

const result = await response.json();

return {
text: result.text,
segments: result.segments, // Includes timestamps
duration: result.duration
};
}

module.exports = { transcribeAudio };

Using Local Whisper (Free, But Slower)

# Install Whisper locally
pip install openai-whisper

# Transcribe
whisper recording.mp4 --model medium --output_format json
// lib/transcribe-local.js
const { exec } = require('child_process');
const util = require('util');
const execAsync = util.promisify(exec);

async function transcribeLocal(audioPath) {
const outputPath = audioPath.replace(/\.[^/.]+$/, '');

await execAsync(
`whisper "${audioPath}" --model medium --output_format json --output_dir /tmp`
);

const transcript = require(`${outputPath}.json`);

return {
text: transcript.text,
segments: transcript.segments,
duration: transcript.segments[transcript.segments.length - 1]?.end || 0
};
}

module.exports = { transcribeLocal };

Step 3: Claude Analysis Agent

Now the magic—Claude reads the transcript and extracts insights:

# agents/meeting-analyzer.yaml
name: MeetingAnalyzer
description: Analyzes sales call transcripts and extracts insights

model: claude-sonnet-4-20250514
temperature: 0.2

system_prompt: |
You are a sales call analyst. Given a meeting transcript, extract:

## OUTPUT FORMAT

### SUMMARY
A 2-3 sentence executive summary of the call.

### KEY DISCUSSION POINTS
Bullet points of main topics covered.

### CUSTOMER PAIN POINTS
Specific problems or challenges mentioned by the prospect.

### BUYING SIGNALS
Any positive indicators (timeline mentioned, budget discussed, stakeholders identified).

### OBJECTIONS/CONCERNS
Any hesitations or pushback from the prospect.

### COMPETITION MENTIONED
Any competitors discussed and context.

### ACTION ITEMS
Specific next steps with owners (format: "[ ] Owner: Action by Date").

### DEAL STAGE RECOMMENDATION
Based on this call, recommended deal stage:
- Discovery
- Qualification
- Demo/Evaluation
- Negotiation
- Closed Won/Lost

### FOLLOW-UP PRIORITY
High / Medium / Low with reasoning.

### COACHING NOTES
Quick notes for the rep (what went well, areas to improve).

## RULES
- Be specific—quote the transcript when relevant
- Don't make up information not in the transcript
- If something is unclear, note it as "unclear from transcript"
- Action items must be actionable and specific
// lib/analyze.js
const Anthropic = require('@anthropic-ai/sdk');

const client = new Anthropic();

async function analyzeTranscript(transcript, metadata) {
const response = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4000,
messages: [{
role: 'user',
content: `Analyze this sales call transcript.

Meeting: ${metadata.title}
Date: ${metadata.timestamp}
Duration: ${metadata.duration} minutes
Attendees: ${metadata.attendees?.join(', ') || 'Unknown'}

---TRANSCRIPT---
${transcript}
---END TRANSCRIPT---`
}]
});

return parseAnalysis(response.content[0].text);
}

function parseAnalysis(rawAnalysis) {
// Parse the structured output into a JSON object
const sections = {};

const sectionPatterns = [
{ key: 'summary', pattern: /### SUMMARY\n([\s\S]*?)(?=###|$)/ },
{ key: 'keyPoints', pattern: /### KEY DISCUSSION POINTS\n([\s\S]*?)(?=###|$)/ },
{ key: 'painPoints', pattern: /### CUSTOMER PAIN POINTS\n([\s\S]*?)(?=###|$)/ },
{ key: 'buyingSignals', pattern: /### BUYING SIGNALS\n([\s\S]*?)(?=###|$)/ },
{ key: 'objections', pattern: /### OBJECTIONS\/CONCERNS\n([\s\S]*?)(?=###|$)/ },
{ key: 'competition', pattern: /### COMPETITION MENTIONED\n([\s\S]*?)(?=###|$)/ },
{ key: 'actionItems', pattern: /### ACTION ITEMS\n([\s\S]*?)(?=###|$)/ },
{ key: 'dealStage', pattern: /### DEAL STAGE RECOMMENDATION\n([\s\S]*?)(?=###|$)/ },
{ key: 'priority', pattern: /### FOLLOW-UP PRIORITY\n([\s\S]*?)(?=###|$)/ },
{ key: 'coaching', pattern: /### COACHING NOTES\n([\s\S]*?)(?=###|$)/ }
];

sectionPatterns.forEach(({ key, pattern }) => {
const match = rawAnalysis.match(pattern);
sections[key] = match ? match[1].trim() : '';
});

return sections;
}

module.exports = { analyzeTranscript };

Step 4: CRM Integration

Push the analysis to HubSpot:

// lib/crm-sync.js
const HubSpot = require('@hubspot/api-client');

const hubspot = new HubSpot.Client({ accessToken: process.env.HUBSPOT_TOKEN });

async function syncToHubSpot(dealId, analysis, recordingUrl) {
// Create engagement (call note)
const noteBody = `
## Meeting Summary
${analysis.summary}

## Key Points
${analysis.keyPoints}

## Pain Points Identified
${analysis.painPoints}

## Buying Signals
${analysis.buyingSignals}

## Action Items
${analysis.actionItems}

---
🎥 [View Recording](${recordingUrl})
📊 Priority: ${analysis.priority}
🎯 Suggested Stage: ${analysis.dealStage}
`;

// Create note
await hubspot.crm.objects.notes.basicApi.create({
properties: {
hs_note_body: noteBody,
hs_timestamp: Date.now()
},
associations: [{
to: { id: dealId },
types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 214 }]
}]
});

// Update deal stage if recommended
const stageMap = {
'Discovery': 'appointmentscheduled',
'Qualification': 'qualifiedtobuy',
'Demo/Evaluation': 'presentationscheduled',
'Negotiation': 'contractsent'
};

const newStage = stageMap[analysis.dealStage.trim()];
if (newStage) {
await hubspot.crm.deals.basicApi.update(dealId, {
properties: { dealstage: newStage }
});
}

// Create tasks for action items
const actionItems = parseActionItems(analysis.actionItems);
for (const item of actionItems) {
await hubspot.crm.objects.tasks.basicApi.create({
properties: {
hs_task_body: item.task,
hs_task_subject: `Follow-up: ${item.task.substring(0, 50)}...`,
hs_task_status: 'NOT_STARTED',
hs_task_priority: analysis.priority.includes('High') ? 'HIGH' : 'MEDIUM',
hs_timestamp: Date.now()
}
});
}
}

function parseActionItems(actionItemsText) {
const items = [];
const lines = actionItemsText.split('\n').filter(l => l.trim().startsWith('[ ]'));

lines.forEach(line => {
const match = line.match(/\[ \] ([^:]+): (.+)/);
if (match) {
items.push({ owner: match[1].trim(), task: match[2].trim() });
}
});

return items;
}

module.exports = { syncToHubSpot };

Step 5: Slack Notifications

Alert the team when calls are processed:

// lib/notify.js
async function sendSlackNotification(channel, analysis, meetingInfo) {
const blocks = [
{
type: 'header',
text: {
type: 'plain_text',
text: `📞 Call Analyzed: ${meetingInfo.title}`
}
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Summary:* ${analysis.summary}`
}
},
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*Priority:* ${analysis.priority.split('\n')[0]}`
},
{
type: 'mrkdwn',
text: `*Stage:* ${analysis.dealStage.split('\n')[0]}`
}
]
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Buying Signals:*\n${analysis.buyingSignals.substring(0, 500)}`
}
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Action Items:*\n${analysis.actionItems.substring(0, 500)}`
}
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: '🎥 View Recording' },
url: meetingInfo.recordingUrl
},
{
type: 'button',
text: { type: 'plain_text', text: '📊 View Deal' },
url: `https://app.hubspot.com/contacts/deals/${meetingInfo.dealId}`
}
]
}
];

await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ blocks })
});
}

module.exports = { sendSlackNotification };

Step 6: The Complete Pipeline

Bring it all together in an OpenClaw agent:

# agents/meeting-processor.yaml
name: MeetingProcessor
description: Orchestrates the full meeting analysis pipeline

triggers:
- type: webhook
path: /process-meeting

flow:
- step: download
action: Download recording from URL or copy from local path

- step: transcribe
action: Run Whisper transcription
tool: transcribe_audio

- step: analyze
action: Analyze transcript with Claude
tool: analyze_transcript

- step: find_deal
action: Match meeting to CRM deal based on attendees
tool: find_hubspot_deal

- step: sync_crm
action: Push analysis to HubSpot
tool: sync_to_hubspot

- step: notify
action: Send Slack notification
tool: send_slack_alert

- step: archive
action: Store transcript and analysis
tool: save_to_storage

Cost Comparison

ComponentEnterprise (Gong)DIY Solution
Per-user licensing$1,400/user/year$0
TranscriptionIncluded$0.006/min (Whisper API)
AnalysisIncluded~$0.02/call (Claude)
StorageIncluded~$5/month (S3)
10-user team, 500 calls/month$14,000/year~$600/year

That's 95% cost savings while getting the features that actually matter.

What You Lose vs. Gong

Let's be honest about trade-offs:

You won't have:

  • Native conversation search across all calls
  • Automatic competitor mention alerts
  • Deal boards with call activity
  • Manager dashboards
  • iOS/Android mobile apps
  • SOC 2 / HIPAA compliance out of the box

You will have:

  • Call transcripts and summaries ✓
  • Action items auto-pushed to CRM ✓
  • Deal stage recommendations ✓
  • Slack notifications ✓
  • Basic coaching notes ✓
  • Your data, your infrastructure ✓

For many early-stage teams, that's plenty.

When to Upgrade to Enterprise

This DIY approach is great until:

  • You have 20+ reps needing coaching dashboards
  • Compliance requires certified vendors
  • You need real-time call guidance
  • Leadership wants exec-level reporting

At that point, the $14K/year for Gong or Chorus becomes worth it. But for a 5-10 person sales team? Build it yourself.


Free Tool

Try our AI Lead Generator — find verified LinkedIn leads for any company instantly. No signup required.

Want AI That Tells You What to Do Next?

Meeting transcription is one piece of the puzzle. MarketBetter's AI SDR playbook connects all your signals—calls, emails, website visits, intent data—into a single daily task list for your team.

Book a demo →


Related reading:

AI Sales Reporting Automation with OpenClaw [2026]

· 8 min read
sunder
Founder, marketbetter.ai

Every Monday morning, your sales ops team spends 4 hours pulling data from Salesforce, formatting Excel spreadsheets, and writing the same commentary they wrote last week.

Meanwhile, your VP of Sales waits until noon to see numbers that are already 12 hours old.

This is insane. It's 2026.

In this guide, I'll show you how to use OpenClaw to automate your entire sales reporting workflow—so your reports are ready at 6am Monday, not noon, and your ops team can focus on actual operations.

AI Sales Reporting Dashboard Automation

The Sales Reporting Problem

Here's what typical B2B sales reporting looks like:

  • Monday 7am: Sales ops starts pulling CRM data
  • Monday 9am: Data exported, formatting begins
  • Monday 11am: First draft of report ready
  • Monday 11:30am: VP asks for "one more cut of the data"
  • Monday 12pm: Report finally sent to leadership
  • Monday 1pm: Someone notices a number is wrong
  • Monday 2pm: Corrected report sent
  • Tuesday: Everyone's moved on; report was barely used

Time wasted: 8+ hours/week Data freshness: 12-18 hours old by the time leadership sees it Accuracy: Variable (humans make mistakes when copy-pasting)

Why OpenClaw for Reporting

OpenClaw is an open-source AI gateway that runs agents 24/7. For sales reporting, this means:

  1. Scheduled execution — Reports generate at 6am automatically
  2. Multi-source data — Pull from CRM, spreadsheets, data warehouse
  3. Natural language insights — AI writes the commentary, not your ops team
  4. Always-on delivery — Reports hit Slack/email before anyone's awake
  5. Self-hosted and free — No $35K/year for a reporting tool

Building Automated Sales Reports

Step 1: Set Up Data Connections

OpenClaw can pull from any API. Here's how to connect your sources:

// data-sources.js

// HubSpot CRM
async function getHubSpotPipeline() {
const deals = await hubspot.crm.deals.getAll({
properties: [
'dealname', 'amount', 'dealstage', 'closedate',
'pipeline', 'hs_forecast_amount', 'hubspot_owner_id'
],
associations: ['companies', 'contacts']
});

return deals.filter(d =>
d.properties.pipeline === 'default' &&
d.properties.closedate >= startOfQuarter()
);
}

// Salesforce
async function getSalesforcePipeline() {
const deals = await salesforce.query(`
SELECT Id, Name, Amount, StageName, CloseDate,
ForecastCategory, OwnerId, Account.Name
FROM Opportunity
WHERE CloseDate >= THIS_QUARTER
AND IsClosed = false
`);

return deals.records;
}

// Data warehouse (for historical data)
async function getHistoricalMetrics(period) {
const result = await bigquery.query(`
SELECT
DATE_TRUNC(close_date, WEEK) as week,
SUM(amount) as closed_won,
COUNT(*) as deals_closed,
AVG(sales_cycle_days) as avg_cycle
FROM sales.opportunities
WHERE close_date >= DATE_SUB(CURRENT_DATE(), INTERVAL ${period} DAY)
AND stage = 'Closed Won'
GROUP BY 1
ORDER BY 1
`);

return result.rows;
}

Step 2: Create the Report Generator

// report-generator.js
const Anthropic = require('@anthropic-ai/sdk');
const claude = new Anthropic();

async function generateWeeklyReport() {
// Gather all data
const pipeline = await getHubSpotPipeline();
const historical = await getHistoricalMetrics(90);
const repMetrics = await getRepPerformance();

// Calculate key metrics
const metrics = {
totalPipeline: sumPipeline(pipeline),
forecastedClose: sumForecast(pipeline),
pipelineChange: calculateWoWChange(pipeline),
topDeals: getTopDeals(pipeline, 5),
atRisk: getAtRiskDeals(pipeline),
closedThisWeek: getClosedDeals('this-week'),
repLeaderboard: calculateLeaderboard(repMetrics)
};

// Generate narrative with Claude
const narrative = await claude.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 2048,
messages: [{
role: 'user',
content: `Generate the executive summary for our weekly sales report.

CURRENT METRICS:
${JSON.stringify(metrics, null, 2)}

HISTORICAL CONTEXT:
${JSON.stringify(historical, null, 2)}

Write a 3-4 paragraph executive summary that:
1. Highlights the most important number (good or bad)
2. Explains what changed this week and why
3. Calls out deals that need attention
4. Ends with a forward-looking statement

Be direct and specific. Executives don't want fluff.
If numbers are concerning, say so clearly.
Include specific rep names and deal names when relevant.`
}]
});

return {
metrics,
narrative: narrative.content[0].text,
generatedAt: new Date().toISOString()
};
}

Step 3: Format for Different Audiences

Different stakeholders need different views:

async function formatForAudience(report, audience) {
const formats = {
'cro': {
sections: ['executive_summary', 'forecast_vs_target', 'top_deals', 'risks'],
detail: 'high-level',
length: 'short'
},
'sales-managers': {
sections: ['team_performance', 'rep_leaderboard', 'coaching_opportunities', 'deals_needing_help'],
detail: 'medium',
length: 'medium'
},
'sales-ops': {
sections: ['full_pipeline', 'data_quality_issues', 'process_bottlenecks', 'forecasting_accuracy'],
detail: 'granular',
length: 'detailed'
},
'board': {
sections: ['arr_progress', 'quota_attainment', 'key_wins', 'market_trends'],
detail: 'strategic',
length: 'concise'
}
};

const config = formats[audience];

const formatted = await claude.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
messages: [{
role: 'user',
content: `Format this sales report for ${audience}:

RAW REPORT:
${JSON.stringify(report, null, 2)}

FORMAT CONFIG:
- Sections to include: ${config.sections.join(', ')}
- Detail level: ${config.detail}
- Length: ${config.length}

Output as clean markdown suitable for Slack or email.
Use tables where appropriate.
Bold key numbers.`
}]
});

return formatted.content[0].text;
}

Automated Weekly Sales Report Generation

Step 4: Schedule with OpenClaw

Set up automated delivery:

# openclaw-config.yaml
agents:
sales-reports:
description: "Weekly sales reporting automation"
schedule:
# Monday 6am - Weekly report
- cron: "0 6 * * 1"
task: |
Generate the weekly sales report.
Format for CRO and post to #executive-updates.
Format for sales managers and post to #sales-team.
Format for ops and send to [email protected].

# Daily 7am - Pipeline snapshot
- cron: "0 7 * * *"
task: |
Generate daily pipeline snapshot.
Only post if significant changes (>5% movement).
Alert if any deal moves backward in stage.

# Friday 4pm - Week preview
- cron: "0 16 * * 5"
task: |
Generate next week preview.
List deals expected to close.
Flag any at-risk deals needing weekend attention.

Step 5: Slack Delivery

Reports land where people actually look:

async function deliverToSlack(report, channel, audience) {
const formatted = await formatForAudience(report, audience);

await slack.postMessage({
channel: channel,
text: `📊 Weekly Sales Report - ${formatDate(new Date())}`,
blocks: [
{
type: 'header',
text: { type: 'plain_text', text: `📊 Weekly Sales Report` }
},
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*Total Pipeline:*\n$${(report.metrics.totalPipeline / 1e6).toFixed(1)}M` },
{ type: 'mrkdwn', text: `*Forecast:*\n$${(report.metrics.forecastedClose / 1e6).toFixed(1)}M` },
{ type: 'mrkdwn', text: `*WoW Change:*\n${report.metrics.pipelineChange > 0 ? '↑' : '↓'} ${Math.abs(report.metrics.pipelineChange)}%` },
{ type: 'mrkdwn', text: `*Closed This Week:*\n$${(report.metrics.closedThisWeek / 1e3).toFixed(0)}K` }
]
},
{
type: 'section',
text: { type: 'mrkdwn', text: `*Executive Summary*\n${report.narrative}` }
},
{
type: 'divider'
},
{
type: 'section',
text: { type: 'mrkdwn', text: `*Top 5 Deals to Watch*\n${formatTopDeals(report.metrics.topDeals)}` }
},
{
type: 'actions',
elements: [
{ type: 'button', text: { type: 'plain_text', text: 'Full Report' }, url: report.fullReportUrl },
{ type: 'button', text: { type: 'plain_text', text: 'Pipeline Dashboard' }, url: report.dashboardUrl }
]
}
]
});
}

Advanced: Real-Time Alerts

Don't wait for weekly reports when something urgent happens:

// real-time-alerts.js

async function monitorPipelineChanges() {
// Check every 15 minutes
const currentState = await getHubSpotPipeline();
const previousState = await getPreviousSnapshot();

const changes = detectChanges(currentState, previousState);

for (const change of changes) {
if (shouldAlert(change)) {
const alert = await generateAlert(change);
await deliverAlert(alert, change.severity);
}
}

await saveSnapshot(currentState);
}

function shouldAlert(change) {
const alertRules = [
// Deal moves backward
(c) => c.type === 'stage_change' && c.direction === 'backward' && c.amount > 50000,

// Large deal close date pushed
(c) => c.type === 'date_change' && c.daysPushed > 14 && c.amount > 100000,

// Deal marked at risk
(c) => c.type === 'forecast_change' && c.newCategory === 'Pipeline',

// Big deal created
(c) => c.type === 'new_deal' && c.amount > 200000,

// Deal closed won
(c) => c.type === 'stage_change' && c.newStage === 'Closed Won'
];

return alertRules.some(rule => rule(change));
}

async function generateAlert(change) {
const context = await getFullDealContext(change.dealId);

const alert = await claude.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 500,
messages: [{
role: 'user',
content: `Generate a brief alert for this pipeline change:

CHANGE: ${JSON.stringify(change)}
DEAL CONTEXT: ${JSON.stringify(context)}

Write a 2-3 sentence alert that:
1. States what changed
2. Explains potential impact
3. Suggests action if needed

Keep it brief - this is a real-time notification.`
}]
});

return alert.content[0].text;
}

Report Templates

Weekly Pipeline Report

# 📊 Weekly Pipeline Report
**Week of [DATE] | Generated [TIMESTAMP]**

## Executive Summary
[AI-generated narrative]

## Key Metrics
| Metric | This Week | Last Week | Change |
|--------|-----------|-----------|--------|
| Total Pipeline | $X.XM | $X.XM | +X% |
| Forecast | $X.XM | $X.XM | +X% |
| Closed Won | $XXK | $XXK | +X% |
| Avg Deal Size | $XXK | $XXK | +X% |

## Top Deals
1. **[Deal Name]** - $XXK - [Stage] - Close: [Date]
- Owner: [Rep Name]
- Next Step: [Action]

## At-Risk Deals
⚠️ **[Deal Name]** - $XXK
- Risk: [Reason]
- Recommended Action: [Suggestion]

## Rep Leaderboard
| Rep | Pipeline | Closed | % of Quota |
|-----|----------|--------|------------|
| [Name] | $XXK | $XXK | XX% |

## Looking Ahead
[AI-generated forward-looking commentary]

ROI Calculation

TaskTime (Manual)Time (AI)Weekly Savings
Data gathering2 hours02 hours
Formatting1.5 hours01.5 hours
Writing commentary1 hour5 min55 min
Distribution30 min030 min
Ad-hoc requests2 hours15 min1.75 hours
Weekly Total7 hours20 min6.7 hours

Annual savings: 6.7 hours × 52 weeks = 348 hours/year

At $75/hour fully loaded, that's $26,100/year saved—and your reports are better, faster, and more accurate.

Getting Started

  1. Map your current reports — What do you send, to whom, when?
  2. Connect data sources — CRM API, data warehouse, spreadsheets
  3. Build the first report — Start with your weekly pipeline report
  4. Test for 2 weeks — Run AI reports alongside manual ones
  5. Switch over — Once confidence is high, automate fully
  6. Expand — Add real-time alerts, board reports, rep dashboards

Free Tool

Try our AI Lead Generator — find verified LinkedIn leads for any company instantly. No signup required.

Stop Building Reports, Start Using Them

MarketBetter helps GTM teams work smarter with AI-powered automation. Our platform tells your SDRs exactly who to contact, when, and what to say—so they can focus on selling instead of spreadsheets.

Book a Demo →

See how teams are automating sales operations with AI.

AI-Powered Upsell Detection: Finding Hidden Revenue with Claude Code [2026]

· 11 min read
sunder
Founder, marketbetter.ai

Your best sales opportunity isn't that cold lead you're chasing. It's the customer you already have.

The data is clear:

  • Acquiring a new customer costs 5-25x more than retaining one
  • Existing customers are 50% more likely to try new products
  • They spend 31% more than new customers
  • Upsell/cross-sell drives 70-95% of revenue for mature SaaS companies

Yet most sales teams treat expansion like an afterthought. CSMs occasionally notice opportunities. AEs occasionally ask. Most revenue sits on the table.

With Claude Code, you can systematically scan every customer signal—usage data, support tickets, call transcripts, email threads—and surface upsell opportunities before customers even know they need more.

This guide shows you exactly how to build that system.

AI upsell detection workflow

The Signals You're Missing

Every customer generates expansion signals. Most companies capture maybe 10% of them:

Usage Signals

  • Hitting plan limits: Storage at 90%, seats maxed out, API calls capped
  • Feature adoption velocity: Using new features faster than average
  • Power user emergence: Individual users far exceeding normal usage
  • Cross-team adoption: New departments/teams starting to use product
  • Usage growth trajectory: 20%+ MoM growth = expansion candidate

Support Signals

  • Feature requests: Asking for capabilities in higher tiers
  • Integration questions: "Can we connect this to X?" = deeper investment
  • Workaround requests: Hacking around limitations = need upgrade
  • Multi-team tickets: Multiple departments engaging = spreading use
  • "Is there a way to..." questions: Implicit feature discovery

Conversation Signals

  • Budget mentions: "We're planning next year's budget..."
  • Team growth: "We're hiring 5 more SDRs..."
  • Initiative mentions: "We're launching a new product line..."
  • Pain escalation: Problems getting worse = urgency to solve
  • Competitive mentions: "We're also looking at X for this use case"

Behavioral Signals

  • Login frequency increases: More engagement = more value extracted
  • New user invites: Account expansion happening organically
  • Report/export usage: Executives looking at data = strategic value
  • API usage growth: Technical integration deepening

Building the Detection System

Here's how to build comprehensive upsell detection with Claude Code:

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│ Data Sources │
│ Usage metrics, Support tickets, Calls, Emails, CRM │
└─────────────────┬───────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Signal Extraction │
│ Claude analyzes each source for expansion indicators │
└─────────────────┬───────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Signal Aggregation │
│ Combine signals into customer-level expansion score │
└─────────────────┬───────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Opportunity Identification │
│ Match signals to specific products/tiers/add-ons │
└─────────────────┬───────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Alert & Action │
│ Notify CSM/AE with context, talking points, timing │
└─────────────────────────────────────────────────────────────┘

Step 1: Usage Signal Detection

# usage_signal_detector.py
from anthropic import Anthropic
import json
from datetime import datetime, timedelta

class UsageSignalDetector:
def __init__(self):
self.client = Anthropic()

def analyze_usage_patterns(self, customer_id: str) -> dict:
"""Analyze customer usage data for expansion signals."""

# Pull usage data from your analytics system
usage_data = self.get_usage_data(customer_id)
plan_limits = self.get_plan_limits(customer_id)

analysis_prompt = f"""
Analyze this customer's usage data for expansion/upsell signals.

## Current Plan
{json.dumps(plan_limits, indent=2)}

## Usage Data (Last 90 Days)
{json.dumps(usage_data, indent=2)}

## Identify
1. Features approaching or exceeding limits
2. Usage growth trends (week-over-week, month-over-month)
3. Power users who might need advanced features
4. New use cases emerging (based on feature adoption)
5. Comparison to similar customers who upgraded

## Output
Return JSON:
{{
"expansion_score": 0-100,
"primary_signal": "most significant expansion indicator",
"signals": [
{{
"type": "limit_approaching|growth_trend|power_user|new_use_case",
"description": "what we observed",
"urgency": "high|medium|low",
"relevant_upsell": "which product/tier addresses this"
}}
],
"recommended_timing": "immediate|this_month|next_quarter",
"talking_points": ["point 1", "point 2", "point 3"]
}}
"""

response = self.client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1500,
messages=[{"role": "user", "content": analysis_prompt}]
)

return json.loads(response.content[0].text)

def get_usage_data(self, customer_id: str) -> dict:
"""Pull usage metrics from your analytics system."""
# Implementation depends on your stack (Mixpanel, Amplitude, custom)
pass

def detect_limit_approach(self, customer_id: str, threshold: float = 0.8) -> list:
"""Find customers approaching plan limits."""

usage = self.get_usage_data(customer_id)
limits = self.get_plan_limits(customer_id)

approaching_limits = []

for metric, current_value in usage.items():
if metric in limits:
limit = limits[metric]
utilization = current_value / limit if limit > 0 else 0

if utilization >= threshold:
approaching_limits.append({
"metric": metric,
"current": current_value,
"limit": limit,
"utilization": f"{utilization:.1%}",
"days_to_limit": self.estimate_days_to_limit(
customer_id, metric, current_value, limit
)
})

return approaching_limits

Step 2: Support Ticket Analysis

# support_signal_detector.py
class SupportSignalDetector:
def __init__(self):
self.client = Anthropic()

def analyze_support_tickets(self, customer_id: str, days: int = 90) -> dict:
"""Analyze support tickets for expansion signals."""

tickets = self.get_recent_tickets(customer_id, days)

if not tickets:
return {"expansion_score": 0, "signals": []}

analysis_prompt = f"""
Analyze these support tickets for upsell/expansion signals.

## Tickets
{json.dumps(tickets, indent=2)}

## Signal Types to Look For
1. Feature requests that exist in higher tiers
2. Questions about integrations/APIs
3. Workaround requests (working around limitations)
4. Multi-department/team involvement
5. Scaling concerns or performance issues
6. Questions about enterprise features (SSO, audit logs, etc.)

## Output
Return JSON:
{{
"expansion_score": 0-100,
"signals": [
{{
"ticket_id": "reference",
"signal_type": "feature_request|integration|workaround|scaling|enterprise",
"quote": "relevant excerpt from ticket",
"implied_need": "what this suggests they need",
"relevant_upsell": "product/tier that addresses this"
}}
],
"pattern_summary": "overall pattern observed",
"recommended_action": "specific next step"
}}
"""

response = self.client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1500,
messages=[{"role": "user", "content": analysis_prompt}]
)

return json.loads(response.content[0].text)

def get_recent_tickets(self, customer_id: str, days: int) -> list:
"""Pull tickets from Zendesk, Intercom, Freshdesk, etc."""
# Implementation depends on your support platform
pass

Step 3: Conversation Analysis

# conversation_signal_detector.py
class ConversationSignalDetector:
def __init__(self):
self.client = Anthropic()

def analyze_call_transcripts(self, customer_id: str) -> dict:
"""Analyze recent call transcripts for expansion signals."""

transcripts = self.get_recent_calls(customer_id)

analysis_prompt = f"""
Analyze these customer call transcripts for expansion/upsell signals.

## Transcripts
{json.dumps(transcripts, indent=2)}

## Signals to Identify
1. Budget discussions or planning mentions
2. Team growth or hiring plans
3. New initiatives or product launches
4. Pain points getting worse
5. Competitive mentions or evaluations
6. Questions about capabilities they don't have
7. Success stories they're sharing (ready to expand)
8. Executive involvement (strategic importance)

## Output
Return JSON:
{{
"expansion_score": 0-100,
"signals": [
{{
"call_date": "date",
"signal_type": "budget|growth|initiative|pain|competitive|capability|success|executive",
"quote": "exact words that indicate the signal",
"context": "surrounding context",
"urgency": "high|medium|low",
"recommended_response": "how to follow up"
}}
],
"key_stakeholders": ["names and roles identified"],
"best_timing": "when to approach based on what was said",
"risk_factors": ["any concerns or objections mentioned"]
}}
"""

response = self.client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2000,
messages=[{"role": "user", "content": analysis_prompt}]
)

return json.loads(response.content[0].text)

def analyze_email_threads(self, customer_id: str) -> dict:
"""Analyze email communications for expansion signals."""

emails = self.get_customer_emails(customer_id)

analysis_prompt = f"""
Analyze these email threads with the customer for expansion signals.

## Emails
{json.dumps(emails, indent=2)}

## Look For
1. Requests that indicate growing needs
2. Forward-looking statements about plans
3. Questions about pricing or contracts
4. Mentions of other teams or use cases
5. Positive feedback (good time to expand)
6. Frustrations that upgrades would solve

## Output
Return JSON with signals found, quotes, and recommendations.
"""

response = self.client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1500,
messages=[{"role": "user", "content": analysis_prompt}]
)

return json.loads(response.content[0].text)

Customer expansion opportunity mapping

Step 4: Signal Aggregation

# expansion_scorer.py
class ExpansionScorer:
def __init__(self):
self.client = Anthropic()
self.usage_detector = UsageSignalDetector()
self.support_detector = SupportSignalDetector()
self.conversation_detector = ConversationSignalDetector()

def calculate_expansion_score(self, customer_id: str) -> dict:
"""Aggregate all signals into a comprehensive expansion score."""

# Gather signals from all sources
usage_signals = self.usage_detector.analyze_usage_patterns(customer_id)
support_signals = self.support_detector.analyze_support_tickets(customer_id)
conversation_signals = self.conversation_detector.analyze_call_transcripts(customer_id)

# Get customer context
customer_data = self.get_customer_data(customer_id)

aggregation_prompt = f"""
Calculate a comprehensive expansion opportunity score for this customer.

## Customer Profile
{json.dumps(customer_data, indent=2)}

## Usage Signals
{json.dumps(usage_signals, indent=2)}

## Support Signals
{json.dumps(support_signals, indent=2)}

## Conversation Signals
{json.dumps(conversation_signals, indent=2)}

## Scoring Framework
- Base score from usage patterns (0-30 points)
- Support signal strength (0-25 points)
- Conversation buying signals (0-25 points)
- Account health/NPS bonus (0-10 points)
- Timing factors (0-10 points)

## Output
Return JSON:
{{
"total_score": 0-100,
"score_breakdown": {{
"usage": 0-30,
"support": 0-25,
"conversation": 0-25,
"health": 0-10,
"timing": 0-10
}},
"confidence": "high|medium|low",
"primary_opportunity": {{
"product": "specific upsell opportunity",
"estimated_value": "ARR increase",
"win_probability": "percentage",
"key_signal": "strongest indicator"
}},
"secondary_opportunities": [...],
"recommended_approach": "how to start the conversation",
"ideal_timing": "when to reach out",
"key_stakeholder": "who to engage",
"talking_points": ["personalized points based on their signals"],
"risk_factors": ["potential objections to prepare for"]
}}
"""

response = self.client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2000,
messages=[{"role": "user", "content": aggregation_prompt}]
)

return json.loads(response.content[0].text)

def generate_expansion_playbook(self, customer_id: str) -> dict:
"""Generate a complete playbook for approaching this expansion opportunity."""

score_data = self.calculate_expansion_score(customer_id)
customer_data = self.get_customer_data(customer_id)

playbook_prompt = f"""
Create a detailed expansion playbook for this opportunity.

## Customer
{json.dumps(customer_data, indent=2)}

## Expansion Analysis
{json.dumps(score_data, indent=2)}

## Generate
1. Email template to open the conversation
2. Call script with discovery questions
3. Objection handling for likely concerns
4. ROI calculation framework
5. Timeline and next steps

Make it specific to this customer's signals, not generic.
"""

response = self.client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=3000,
messages=[{"role": "user", "content": playbook_prompt}]
)

return {
"score_data": score_data,
"playbook": response.content[0].text
}

Step 5: Automated Alerting

# expansion_alerts.py
class ExpansionAlertSystem:
def __init__(self):
self.scorer = ExpansionScorer()
self.alert_threshold = 70 # Score threshold for alerts

def run_daily_scan(self):
"""Scan all customers for expansion opportunities."""

customers = self.get_all_active_customers()
opportunities = []

for customer in customers:
try:
score_data = self.scorer.calculate_expansion_score(customer['id'])

if score_data['total_score'] >= self.alert_threshold:
opportunities.append({
"customer": customer,
"score_data": score_data,
"playbook": self.scorer.generate_expansion_playbook(customer['id'])
})
except Exception as e:
self.log_error(f"Failed to score {customer['id']}: {e}")

# Sort by score and notify
opportunities.sort(key=lambda x: x['score_data']['total_score'], reverse=True)

self.send_alerts(opportunities)

return opportunities

def send_alerts(self, opportunities: list):
"""Send alerts to CSMs/AEs for each opportunity."""

for opp in opportunities:
customer = opp['customer']
score = opp['score_data']

# Format alert message
alert = f"""
🎯 *EXPANSION OPPORTUNITY*

🏢 {customer['name']}
📊 Score: {score['total_score']}/100
💰 Potential: {score['primary_opportunity']['estimated_value']}
🎲 Win Probability: {score['primary_opportunity']['win_probability']}

*Top Signal:* {score['primary_opportunity']['key_signal']}

*Recommended Approach:*
{score['recommended_approach']}

*Talking Points:*
{chr(10).join('• ' + p for p in score['talking_points'][:3])}

*Best Timing:* {score['ideal_timing']}

[View Full Playbook]
"""

# Route to appropriate owner
owner = self.get_account_owner(customer['id'])
self.send_notification(owner, alert, opp['playbook'])

def send_notification(self, owner: dict, alert: str, playbook: dict):
"""Send via appropriate channel (Slack, email, WhatsApp)."""
# Implementation depends on your notification setup
pass

Running It with OpenClaw

Set up automated scanning with OpenClaw cron:

# openclaw.yaml
cron:
jobs:
# Daily expansion opportunity scan
- name: "expansion-scan"
schedule: "0 7 * * 1-5" # 7am weekdays
payload:
kind: systemEvent
text: |
Run the expansion opportunity scan:
1. Score all active customers
2. Identify those above threshold
3. Generate playbooks for top 10
4. Alert account owners via Slack
5. Log results to dashboard
sessionTarget: main

# Weekly expansion summary
- name: "expansion-summary"
schedule: "0 9 * * 1" # Monday 9am
payload:
kind: systemEvent
text: |
Generate weekly expansion pipeline report:
- Total expansion opportunities identified
- Opportunities by segment
- This week's priority accounts
- Progress on last week's opportunities
Send to #sales-leadership channel.
sessionTarget: main

What This Looks Like in Practice

Without AI:

  • CSM notices customer asked about SSO (3 months ago)
  • CSM mentions to AE at quarterly review
  • AE reaches out... customer already evaluated competitors

With AI:

  • Day 1: Customer asks about SSO in support ticket
  • Day 1: AI flags enterprise feature interest, scores opportunity
  • Day 2: AE receives alert with context and talking points
  • Day 3: AE reaches out, references their specific need
  • Day 14: Deal closed—customer upgraded to Enterprise

The difference:

  • Opportunity identified 90 days faster
  • Context preserved (no telephone game)
  • Personalized outreach (not generic "time for a review?")
  • Higher conversion (right message, right time)

Measuring Success

Track these metrics:

def calculate_expansion_metrics(period_days: int = 30) -> dict:
return {
# Detection metrics
"opportunities_identified": 47,
"avg_score_of_opportunities": 78,
"false_positive_rate": 0.12, # Flagged but no real opportunity

# Conversion metrics
"opportunities_acted_on": 38,
"opportunities_converted": 24,
"conversion_rate": 0.63,

# Revenue metrics
"expansion_arr_identified": 890000,
"expansion_arr_closed": 540000,
"avg_deal_size": 22500,

# Efficiency metrics
"time_from_signal_to_outreach_days": 1.8,
"time_from_outreach_to_close_days": 18,

# Comparison (AI vs manual)
"ai_identified_conversion_rate": 0.63,
"manually_identified_conversion_rate": 0.34
}
Free Tool

Try our AI Lead Generator — find verified LinkedIn leads for any company instantly. No signup required.

Getting Started

Week 1: Connect Data Sources

  • Connect usage analytics (Mixpanel, Amplitude, custom)
  • Connect support system (Zendesk, Intercom)
  • Connect CRM (HubSpot, Salesforce)
  • Set up Claude Code API access

Week 2: Build Core Detection

  • Implement usage signal detection
  • Implement support ticket analysis
  • Test on 10 known expansion accounts (validate accuracy)

Week 3: Add Conversation Analysis

  • Connect call recording system (Gong, Chorus)
  • Implement email analysis
  • Build aggregation scoring

Week 4: Deploy & Iterate

  • Set up daily scans via OpenClaw cron
  • Route alerts to CSMs/AEs
  • Track outcomes, tune thresholds
  • Add conversation analysis as transcripts become available

Ready to find the revenue hiding in your customer base? See how MarketBetter identifies expansion opportunities automatically →

Automate LinkedIn Sales Navigator with Claude Code: The Complete Guide [2026]

· 10 min read
MarketBetter Team
Content Team, marketbetter.ai

Your SDRs spend 3-4 hours per day doing the same thing: searching Sales Navigator, copying profiles to a spreadsheet, researching each prospect, then writing "personalized" messages that still sound generic.

That's $50,000+ per year in SDR salary spent on copy-paste work.

What if an AI agent could do the research in seconds and actually write messages that reference real details from each prospect's profile?

That's exactly what we're building in this guide using Claude Code.

LinkedIn Sales Navigator automation workflow with AI processing

Why LinkedIn Automation Is Broken (And How AI Fixes It)

Let's be honest about the current state of LinkedIn automation:

The Old Way: Automation Tools

Tools like LinkedHelper, Dux-Soup, and Expandi can automate connection requests and messages. The problem? LinkedIn detects them, they send the same message to everyone, and your account gets restricted.

The Slightly Better Way: Virtual Assistants

Hire a VA in the Philippines to manually send messages. More personalized, but expensive ($800-1500/month for dedicated), slow, and they still struggle with research depth.

The AI Way: Claude Code + Browser Automation

Use Claude's reasoning to actually understand each prospect's profile, then generate genuinely personalized outreach. Not "Hi {first_name}, I saw you work at {company}"—real personalization.

The difference: Claude can read a VP of Sales' entire career history, their recent posts, their company's latest funding round, and synthesize that into an opening line that feels like you actually did your homework.

The Architecture: What We're Building

Here's the system:

  1. Input: ICP criteria (title, industry, company size, location)
  2. Process: Claude Code researches each prospect using Sales Navigator data
  3. Output: Personalized first-touch messages ready to send

We'll use browser automation (via Playwright) to interact with Sales Navigator, and Claude Code to do the thinking.

AI analyzing LinkedIn profiles and generating personalized outreach

Prerequisites

Before we start:

  • LinkedIn Sales Navigator account (Core or Advanced)
  • Claude API access (via Anthropic)
  • Node.js 18+ installed
  • Basic familiarity with JavaScript

Step 1: Set Up the Project

mkdir linkedin-prospector && cd linkedin-prospector
npm init -y
npm install playwright @anthropic-ai/sdk dotenv
npx playwright install chromium

Create your environment file:

# .env
ANTHROPIC_API_KEY=sk-ant-...
LINKEDIN_EMAIL=[email protected]
LINKEDIN_PASSWORD=your_password

Step 2: Build the Browser Session Manager

First, we need a way to maintain a logged-in LinkedIn session:

// lib/browser.js
const { chromium } = require('playwright');
const path = require('path');

const USER_DATA_DIR = path.join(__dirname, '../.browser-data');

async function getLinkedInSession() {
const browser = await chromium.launchPersistentContext(USER_DATA_DIR, {
headless: true, // Set false for debugging
viewport: { width: 1280, height: 800 },
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
});

const page = await browser.newPage();

// Check if already logged in
await page.goto('https://www.linkedin.com/feed/');
await page.waitForTimeout(2000);

const isLoggedIn = await page.url().includes('/feed');

if (!isLoggedIn) {
await login(page);
}

return { browser, page };
}

async function login(page) {
await page.goto('https://www.linkedin.com/login');
await page.fill('#username', process.env.LINKEDIN_EMAIL);
await page.fill('#password', process.env.LINKEDIN_PASSWORD);
await page.click('button[type="submit"]');
await page.waitForNavigation();

// Handle potential security checkpoint
if (page.url().includes('checkpoint')) {
console.log('⚠️ Security checkpoint detected. Please complete manually.');
await page.waitForNavigation({ timeout: 120000 });
}
}

module.exports = { getLinkedInSession };

Step 3: Build the Sales Navigator Scraper

Now, let's extract prospect data from Sales Navigator searches:

// lib/navigator.js
async function searchProspects(page, criteria) {
const { title, industry, companySize, location } = criteria;

// Navigate to Sales Navigator search
await page.goto('https://www.linkedin.com/sales/search/people');
await page.waitForTimeout(2000);

// Apply filters
if (title) {
await page.click('[data-test-filter-button="CURRENT_TITLE"]');
await page.fill('input[placeholder="Add title"]', title);
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
}

if (companySize) {
await page.click('[data-test-filter-button="COMPANY_HEADCOUNT"]');
// Map size to LinkedIn's options
const sizeMap = {
'small': '11-50',
'medium': '51-200',
'large': '201-500',
'enterprise': '501-1000'
};
await page.click(`text="${sizeMap[companySize]}"`);
await page.waitForTimeout(1000);
}

// Wait for results
await page.waitForSelector('.search-results__result-list');

// Extract prospect data
const prospects = await page.evaluate(() => {
const results = [];
const cards = document.querySelectorAll('.search-results__result-item');

cards.forEach(card => {
const nameEl = card.querySelector('.result-lockup__name');
const titleEl = card.querySelector('.result-lockup__highlight-keyword');
const companyEl = card.querySelector('.result-lockup__position-company');
const linkEl = card.querySelector('a[href*="/sales/lead/"]');

if (nameEl && linkEl) {
results.push({
name: nameEl.textContent.trim(),
title: titleEl?.textContent.trim() || '',
company: companyEl?.textContent.trim() || '',
profileUrl: linkEl.href
});
}
});

return results;
});

return prospects;
}

async function getProspectDetails(page, profileUrl) {
await page.goto(profileUrl);
await page.waitForTimeout(2000);

const details = await page.evaluate(() => {
// Extract comprehensive profile data
const about = document.querySelector('.profile-section-card__contents')?.textContent.trim();

const experience = [];
document.querySelectorAll('.experience-item').forEach(item => {
experience.push({
title: item.querySelector('.experience-item__title')?.textContent.trim(),
company: item.querySelector('.experience-item__subtitle')?.textContent.trim(),
duration: item.querySelector('.date-range')?.textContent.trim(),
description: item.querySelector('.experience-item__description')?.textContent.trim()
});
});

const recentActivity = [];
document.querySelectorAll('.recent-activity-item').forEach(item => {
recentActivity.push(item.textContent.trim().substring(0, 200));
});

return {
about,
experience,
recentActivity,
headline: document.querySelector('.profile-topcard__title')?.textContent.trim(),
location: document.querySelector('.profile-topcard__location')?.textContent.trim()
};
});

return details;
}

module.exports = { searchProspects, getProspectDetails };

Step 4: The Claude Personalization Engine

This is where the magic happens. Claude reads the profile data and generates truly personalized outreach:

// lib/personalize.js
const Anthropic = require('@anthropic-ai/sdk');

const client = new Anthropic();

async function generatePersonalizedMessage(prospect, details, context) {
const prompt = `You are an SDR writing a LinkedIn connection request. Your goal is to get a response, not make a sale.

PROSPECT DATA:
Name: ${prospect.name}
Current Title: ${prospect.title}
Company: ${prospect.company}
Headline: ${details.headline}
Location: ${details.location}

ABOUT SECTION:
${details.about || 'Not available'}

RECENT EXPERIENCE:
${details.experience.slice(0, 3).map(e =>
`- ${e.title} at ${e.company} (${e.duration}): ${e.description?.substring(0, 100) || 'No description'}`
).join('\n')}

RECENT ACTIVITY:
${details.recentActivity.slice(0, 3).join('\n') || 'No recent activity visible'}

YOUR CONTEXT:
Company: ${context.yourCompany}
What you sell: ${context.valueProposition}
Why this prospect might care: ${context.relevance}

INSTRUCTIONS:
1. Write a LinkedIn connection request (300 char max for the note)
2. Reference something SPECIFIC from their profile - not generic
3. Don't pitch. Ask a question or share an insight.
4. Sound like a human, not a sales bot
5. If they have recent activity, reference it naturally

Output format:
CONNECTION_NOTE: [the 300 char note]
FOLLOW_UP_MESSAGE: [a longer message to send after they accept, 500 chars max]
TALKING_POINTS: [3 bullet points for if they respond]`;

const response = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 1000,
messages: [{ role: 'user', content: prompt }]
});

// Parse the response
const text = response.content[0].text;
const connectionNote = text.match(/CONNECTION_NOTE:\s*([\s\S]*?)(?=FOLLOW_UP|$)/)?.[1]?.trim();
const followUp = text.match(/FOLLOW_UP_MESSAGE:\s*([\s\S]*?)(?=TALKING|$)/)?.[1]?.trim();
const talkingPoints = text.match(/TALKING_POINTS:\s*([\s\S]*?)$/)?.[1]?.trim();

return {
connectionNote,
followUp,
talkingPoints,
rawResponse: text
};
}

module.exports = { generatePersonalizedMessage };

Step 5: Putting It All Together

Now let's create the main automation script:

// index.js
require('dotenv').config();
const { getLinkedInSession } = require('./lib/browser');
const { searchProspects, getProspectDetails } = require('./lib/navigator');
const { generatePersonalizedMessage } = require('./lib/personalize');
const fs = require('fs');

const ICP_CRITERIA = {
title: 'VP Sales',
industry: 'SaaS',
companySize: 'medium',
location: 'United States'
};

const CONTEXT = {
yourCompany: 'MarketBetter',
valueProposition: 'AI-powered SDR playbook that tells your team exactly who to contact and what to say',
relevance: 'They manage SDR teams and care about efficiency and pipeline'
};

async function main() {
console.log('🚀 Starting LinkedIn prospector...');

const { browser, page } = await getLinkedInSession();

try {
// Search for prospects
console.log('🔍 Searching Sales Navigator...');
const prospects = await searchProspects(page, ICP_CRITERIA);
console.log(`Found ${prospects.length} prospects`);

const results = [];

// Process each prospect (limit to 10 for rate limiting)
for (const prospect of prospects.slice(0, 10)) {
console.log(`\n📋 Processing: ${prospect.name}`);

// Get detailed profile data
const details = await getProspectDetails(page, prospect.profileUrl);

// Generate personalized message with Claude
console.log('🤖 Generating personalized message...');
const personalization = await generatePersonalizedMessage(
prospect,
details,
CONTEXT
);

results.push({
...prospect,
details,
personalization
});

console.log(`✅ Connection note: ${personalization.connectionNote?.substring(0, 50)}...`);

// Rate limiting - be respectful
await page.waitForTimeout(5000);
}

// Save results
const outputFile = `prospects_${Date.now()}.json`;
fs.writeFileSync(outputFile, JSON.stringify(results, null, 2));
console.log(`\n💾 Saved ${results.length} prospects to ${outputFile}`);

} finally {
await browser.close();
}
}

main().catch(console.error);

Sample Output: What You Get

Here's what the personalized output looks like:

{
"name": "Sarah Chen",
"title": "VP of Sales",
"company": "TechStartup Inc",
"personalization": {
"connectionNote": "Sarah - saw your post about SDR burnout last week. We're seeing the same thing at MarketBetter and built something that might help. Would love to swap notes on what's working for your team.",
"followUp": "Thanks for connecting! Your point about SDRs spending 70% of their day on research really resonated. We've been testing an AI approach that cuts that to about 20 minutes - not replacing the human touch, just the copy-paste work. Happy to share what we've learned if useful.",
"talkingPoints": "- Ask about her current SDR tech stack\n- Share the 70% research time stat from her post\n- Mention the ROI calculator we have"
}
}

That's not a template. That's Claude actually reading her recent LinkedIn post about SDR burnout and crafting a relevant opener.

Time savings comparison showing AI automation versus manual prospecting

The Numbers: Why This Matters

Let's do the math on a 5-person SDR team:

MetricManual ProspectingAI-Assisted
Time per prospect15 minutes2 minutes
Prospects researched/day20100+
Message personalization depthSurface-levelDeep (career, posts, context)
SDR time on research60% of day15% of day
Response rate5-8%15-25%

The ROI: If your SDRs currently book 4 meetings/week and this doubles their response rate, you're looking at 8 meetings/week. That's 208 extra meetings per year per SDR.

At a $10K average deal size and 20% close rate, that's $416K in additional pipeline per SDR.

Important: Stay Compliant

A few rules to keep you out of trouble:

  1. Respect LinkedIn's rate limits — Don't send more than 100 connection requests per week
  2. Don't automate sending — Use this for research and drafting, send manually
  3. Personalization is protection — Generic automated messages get flagged; personalized ones don't
  4. Use your real account — Sales Navigator is meant for this; sketchy tactics aren't

The goal isn't to game LinkedIn. It's to do better research faster so your human-sent messages actually land.

Taking It Further: Integration with Your Stack

Once you've validated this works, consider:

  1. HubSpot integration — Auto-create contacts with the personalization data
  2. Slack alerts — Get notified when high-value prospects are found
  3. A/B testing — Track which Claude-generated openers perform best
  4. CRM sync — Push talking points to the contact record for sales calls

Claude's 200K context window means you can even include company 10-K filings, recent news, and competitive intel in the personalization prompt.

The Bigger Picture

LinkedIn prospecting is changing. The "spray and pray" era is over. Buyers can smell automation from a mile away.

The future belongs to teams that can combine AI research speed with human authenticity. Claude does the homework; your SDRs bring the human touch.

That's not "AI replacing salespeople." That's AI making salespeople better.


Free Tool

Try our AI Lead Generator — find verified LinkedIn leads for any company instantly. No signup required.

Want to See This in Action?

MarketBetter's AI SDR playbook uses similar intelligence to tell your team exactly who to reach out to and what to say. No more guessing. No more 20 browser tabs.

Book a demo →


Related reading:

Claude Code for Sales Email A/B Testing at Scale [2026]

· 8 min read
sunder
Founder, marketbetter.ai

You're A/B testing emails wrong.

Most sales teams test two variants. Maybe three if they're ambitious. They wait 2 weeks for statistical significance. Then they pick a winner and move on.

That's not optimization. That's guessing slowly.

In 2026, Claude Code can generate hundreds of email variants, test them across segments in days not weeks, and continuously optimize based on actual reply data—not open rates, which mean nothing since iOS 15.

This guide shows you how to build an AI-powered email testing system that actually moves the needle.

A/B testing email variants with AI

Why Traditional A/B Testing Fails for Sales Emails

The Math Problem

Traditional A/B testing requires statistical significance. For sales emails with typical reply rates (2-5%), you need:

  • Sample size per variant: 500-1000 sends minimum
  • Test duration: 2-4 weeks to collect enough data
  • Variants testable: 2-3 (more = longer tests)

If you send 1,000 emails per month and test 2 variants:

  • You can run 6 tests per year
  • Each test improves reply rate by ~10-15%
  • Annual improvement: ~90% (compounding)

Not bad. But AI can do 10x better.

The Real Problem: You're Testing the Wrong Things

Most teams test:

  • ❌ Subject line A vs B
  • ❌ CTA button color
  • ❌ First name vs full name

What actually matters:

  • ✅ Value proposition framing
  • ✅ Pain point emphasis
  • ✅ Social proof specificity
  • ✅ Opening hook angle
  • ✅ Call-to-action clarity
  • ✅ Tone match to persona

You can't test these manually at scale. But Claude can.

The AI-Powered Testing Framework

Here's how to test at 10x the speed:

1. Generate Variant Clusters, Not Individual Emails

Instead of writing 2 emails, generate clusters of variants that test specific hypotheses:

# variant_generator.py
from anthropic import Anthropic
import json

class EmailVariantGenerator:
def __init__(self):
self.client = Anthropic()

def generate_variant_cluster(
self,
base_context: dict,
hypothesis: str,
num_variants: int = 10
) -> list:
"""Generate a cluster of variants testing a specific hypothesis."""

prompt = f"""
You are an expert B2B sales copywriter. Generate {num_variants} email variants
that test this hypothesis: {hypothesis}

## Context
- Target persona: {base_context['persona']}
- Company: {base_context['company']}
- Pain points: {base_context['pain_points']}
- Value prop: {base_context['value_prop']}
- Goal: {base_context['goal']}

## Requirements
- Each variant should be meaningfully different (not just word swaps)
- Keep emails under 150 words (nobody reads long cold emails)
- Include a clear, single CTA
- Sound human, not AI-generated

## Output Format
Return a JSON array with each variant:
[
{{
"variant_id": "v1",
"hypothesis_element": "what this variant tests",
"subject": "subject line",
"body": "email body",
"cta": "call to action",
"key_differentiator": "what makes this unique"
}}
]
"""

response = self.client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4000,
messages=[{"role": "user", "content": prompt}]
)

return json.loads(response.content[0].text)

# Example usage
generator = EmailVariantGenerator()

context = {
"persona": "VP of Sales at B2B SaaS company, 50-200 employees",
"company": "MarketBetter",
"pain_points": ["SDR productivity", "lead response time", "data quality"],
"value_prop": "AI-powered SDR workflow automation",
"goal": "Book a demo call"
}

# Generate variants testing different opening hooks
hook_variants = generator.generate_variant_cluster(
context,
hypothesis="Question-based openings outperform statement openings",
num_variants=10
)

# Generate variants testing pain point emphasis
pain_variants = generator.generate_variant_cluster(
context,
hypothesis="Emphasizing time savings beats emphasizing revenue gains",
num_variants=10
)

# Generate variants testing social proof types
proof_variants = generator.generate_variant_cluster(
context,
hypothesis="Specific metrics outperform named customer logos",
num_variants=10
)

Now you have 30 variants testing 3 distinct hypotheses—generated in minutes.

2. Smart Segmentation for Faster Results

Don't send all variants to everyone. Match variants to micro-segments:

# segment_matcher.py
class SegmentMatcher:
def __init__(self, anthropic_client):
self.client = anthropic_client

def match_variants_to_segments(
self,
variants: list,
segments: list
) -> dict:
"""Use Claude to match variants to segments they're most likely to resonate with."""

prompt = f"""
Match email variants to prospect segments based on likely resonance.

## Variants
{json.dumps(variants, indent=2)}

## Segments
{json.dumps(segments, indent=2)}

For each variant, identify:
1. Primary segment (best fit)
2. Secondary segment (good fit)
3. Avoid segment (poor fit)

Return JSON mapping variant_id to segment recommendations.
"""

response = self.client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2000,
messages=[{"role": "user", "content": prompt}]
)

return json.loads(response.content[0].text)

# Define your segments
segments = [
{
"id": "growth_stage",
"description": "Series A-B companies, scaling fast, care about speed",
"typical_pain": "Can't hire SDRs fast enough"
},
{
"id": "enterprise_efficiency",
"description": "Large companies, cost-conscious, care about ROI",
"typical_pain": "SDR team is expensive and underperforming"
},
{
"id": "founder_led",
"description": "Founder still doing sales, limited time",
"typical_pain": "No time for manual prospecting"
},
{
"id": "revops_driven",
"description": "Data-focused teams, care about metrics",
"typical_pain": "Can't measure what's working"
}
]

# Match variants to segments
matcher = SegmentMatcher(Anthropic())
variant_segment_map = matcher.match_variants_to_segments(hook_variants, segments)

Email performance analytics dashboard

3. Continuous Learning Loop

The real power is in the feedback loop:

# learning_loop.py
class EmailLearningLoop:
def __init__(self):
self.client = Anthropic()
self.results_db = ResultsDatabase()

def analyze_results(self, test_id: str) -> dict:
"""Analyze test results and generate insights."""

results = self.results_db.get_test_results(test_id)

analysis_prompt = f"""
Analyze these email A/B test results and provide actionable insights.

## Test Results
{json.dumps(results, indent=2)}

## Analysis Required
1. Which variants performed best and why?
2. What patterns emerge across winning variants?
3. What should we test next based on these learnings?
4. Any surprising results that warrant investigation?
5. Recommended changes to our email playbook

Be specific. Reference actual data from the results.
"""

response = self.client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2000,
messages=[{"role": "user", "content": analysis_prompt}]
)

return {
"analysis": response.content[0].text,
"raw_results": results
}

def generate_next_iteration(
self,
winning_variants: list,
insights: str
) -> list:
"""Generate next round of variants based on learnings."""

prompt = f"""
Based on our A/B test learnings, generate the next iteration of email variants.

## Winning Variants from Last Round
{json.dumps(winning_variants, indent=2)}

## Key Insights
{insights}

## Your Task
Generate 10 new variants that:
1. Build on what worked in the winning variants
2. Test new hypotheses suggested by the insights
3. Push the boundaries of what we've learned

Don't just remix winners—evolve them.

Return JSON array of new variants.
"""

response = self.client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4000,
messages=[{"role": "user", "content": prompt}]
)

return json.loads(response.content[0].text)

def run_learning_cycle(self, test_id: str):
"""Complete one learning cycle."""

# 1. Analyze completed test
analysis = self.analyze_results(test_id)

# 2. Identify winners
winners = [r for r in analysis['raw_results']
if r['reply_rate'] > analysis['avg_reply_rate'] * 1.2]

# 3. Generate evolved variants
next_variants = self.generate_next_iteration(
winners,
analysis['analysis']
)

# 4. Queue next test
new_test_id = self.queue_test(next_variants)

return {
"completed_test": test_id,
"insights": analysis['analysis'],
"winners": winners,
"next_test": new_test_id
}

Production System Architecture

Here's the full system for production:

┌─────────────────────────────────────────────────────────────┐
│ Variant Generation │
│ Claude generates variant clusters per hypothesis │
└─────────────────┬───────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Segment Matching │
│ AI matches variants to micro-segments │
└─────────────────┬───────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Test Execution │
│ Email platform sends variants, tracks engagement │
└─────────────────┬───────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Results Collection │
│ Reply tracking (not opens!), sentiment analysis │
└─────────────────┬───────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ AI Analysis │
│ Claude analyzes results, identifies patterns │
└─────────────────┬───────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Next Iteration │
│ Generate evolved variants, repeat cycle │
└─────────────────────────────────────────────────────────────┘

Reply Tracking (The Only Metric That Matters)

Opens are meaningless. Track replies:

# reply_tracker.py
class ReplyTracker:
def __init__(self):
self.client = Anthropic()

def classify_reply(self, reply_text: str) -> dict:
"""Classify reply sentiment and intent."""

prompt = f"""
Classify this email reply from a sales prospect:

"{reply_text}"

Return JSON:
{{
"sentiment": "positive|neutral|negative",
"intent": "interested|not_interested|asking_questions|objection|out_of_office|unsubscribe",
"buying_signal_strength": 0-10,
"next_action": "book_call|send_info|nurture|disqualify",
"key_insight": "what we learned from this reply"
}}
"""

response = self.client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=500,
messages=[{"role": "user", "content": prompt}]
)

return json.loads(response.content[0].text)

def calculate_variant_score(self, variant_id: str) -> dict:
"""Calculate comprehensive score for a variant."""

replies = self.get_replies_for_variant(variant_id)
sends = self.get_sends_for_variant(variant_id)

classified_replies = [self.classify_reply(r['text']) for r in replies]

return {
"variant_id": variant_id,
"total_sends": len(sends),
"total_replies": len(replies),
"reply_rate": len(replies) / len(sends) if sends else 0,
"positive_reply_rate": len([r for r in classified_replies
if r['sentiment'] == 'positive']) / len(sends) if sends else 0,
"avg_buying_signal": sum(r['buying_signal_strength']
for r in classified_replies) / len(classified_replies) if classified_replies else 0,
"meetings_booked": len([r for r in classified_replies
if r['next_action'] == 'book_call']),
"conversion_to_meeting": len([r for r in classified_replies
if r['next_action'] == 'book_call']) / len(sends) if sends else 0
}

What This Looks Like in Practice

Month 1 (Traditional Testing):

  • Test 2 subject lines
  • Winner: "Quick question about [Company]"
  • Improvement: 12%

Month 1 (AI-Powered Testing):

  • Generate 30 variants across 3 hypothesis clusters
  • Test across 4 segments simultaneously
  • Discover: Question hooks work for growth-stage, but enterprise prefers metrics
  • Discover: Pain-focused body copy beats benefit-focused
  • Discover: Social proof with specific numbers outperforms logos 3:1
  • Cumulative improvement: 47%

Month 3 (AI-Powered, 3 Cycles):

  • 90 variants tested
  • Segment-specific playbooks developed
  • Reply rate: up 180% from baseline
  • Meetings booked: up 220%

The compound effect of continuous learning is massive.

Implementation Checklist

Week 1: Foundation

  • Set up Claude Code with Anthropic API
  • Define your 4-6 prospect segments
  • Document your current best-performing email
  • Set up reply tracking (not just opens)

Week 2: First Test Cycle

  • Generate first variant cluster (10 variants)
  • Define hypothesis being tested
  • Deploy through your email platform
  • Wait for 100+ replies (not sends)

Week 3: Analysis & Iteration

  • Run AI analysis on results
  • Identify winning patterns
  • Generate evolved variants
  • Launch next test cycle

Ongoing

  • Run 2-3 test cycles per month
  • Update segment-specific playbooks
  • Document learnings in team wiki
  • Review quarterly for strategic shifts
Free Tool

Try our AI Lead Generator — find verified LinkedIn leads for any company instantly. No signup required.

The Competitive Advantage

While your competitors are debating whether to test "Quick question" vs "Quick thought" subject lines, you're running 30-variant tests that discover:

  • Enterprise CFOs respond 3x better to ROI framing
  • Startup founders want speed, not savings
  • Mentioning a mutual connection in the first line doubles reply rates
  • Tuesday 10am sends outperform all other times by 40%

This isn't marginal improvement. This is systematic optimization that compounds over time.


Ready to stop guessing and start optimizing? See how MarketBetter automates email sequences with AI →

Codex for CRM Pipeline Cleanup: Automate Your Data Hygiene [2026]

· 7 min read

Your CRM is lying to you. Right now, your pipeline shows deals that will never close, contacts with outdated info, and duplicates inflating your numbers. GPT-5.3 Codex can fix this automatically.

CRM pipeline cleanup automation

The Hidden Cost of Dirty CRM Data

Every sales org has the same problem:

  • 42% of pipeline is stale deals nobody's touched in 90+ days
  • 30% of contacts have outdated job titles or emails
  • 15% of records are duplicates or near-duplicates
  • Forecast accuracy suffers because the numbers are fiction

RevOps teams spend entire quarters on "data cleanup initiatives" that never fully succeed. Reps hate data entry, so the problem just grows back.

Here's a different approach: let Codex handle it continuously.

Why Codex for Data Cleanup?

OpenAI released GPT-5.3-Codex on February 5, 2026, with significant improvements for exactly this use case:

  1. Mid-turn steering: Direct the cleanup while it's running. "Skip deals with activity in the last 30 days" without restarting.

  2. Multi-file understanding: Codex can read your CRM schema, understand relationships, and make intelligent decisions about what to clean.

  3. 25% faster than GPT-5.2: Large-scale data operations complete faster, important when processing thousands of records.

  4. Better at edge cases: The new model handles ambiguous situations better—like deciding if two contacts are duplicates when the data is slightly different.

The Pipeline Cleanup Architecture

Here's how to build an automated cleanup system:

Component 1: Data Assessment Agent

First, understand the scope of the problem:

TASK: Analyze CRM data quality

SCHEMA:
[Your HubSpot/Salesforce schema]

RULES:
1. Identify deals with no activity > 60 days
2. Find contacts with bounced emails or invalid phones
3. Detect potential duplicates (same email or similar name + company)
4. Flag deals stuck in same stage > 45 days
5. Identify orphan records (no associated company or deal)

OUTPUT: JSON report with counts and sample records for each category

This gives you a dashboard of data quality issues before any cleanup begins.

Component 2: Deduplication Engine

Duplicates are the most damaging data quality issue. Here's how Codex handles them:

TASK: Identify and merge duplicate records

MATCHING CRITERIA:
- Exact email match → definite duplicate
- Same company + similar name (Levenshtein distance < 3) → probable duplicate
- Same phone number → probable duplicate
- Same name + same city + same title → possible duplicate

MERGE RULES:
- Keep most recent email
- Keep most recent phone
- Keep earliest created date
- Merge all notes and activities
- Keep the record with more data points as primary

REVIEW THRESHOLD:
- Definite duplicates: auto-merge
- Probable duplicates: auto-merge with audit log
- Possible duplicates: flag for human review

CRM data quality before and after

Component 3: Stale Deal Handler

Deals that haven't moved in months clog your pipeline and destroy forecast accuracy:

TASK: Process stale deals

ASSESSMENT CRITERIA:
- No activity in 90+ days AND deal created > 120 days ago → Move to Lost
- No activity in 60-90 days → Send "Should we close this?" email sequence
- No activity in 30-60 days AND deal value > $50K → Alert rep
- Deal in same stage > 45 days → Request stage update from rep

ACTIONS:
For deals marked Lost:
1. Update close_lost_reason = "Stale - No engagement"
2. Add note with last activity date
3. Move associated contacts to re-engagement nurture
4. Notify rep of closure

Component 4: Contact Enrichment Refresh

Job titles change. People switch companies. Emails go stale:

TASK: Refresh contact data

FOR EACH CONTACT with last_enrichment > 180 days:
1. Query enrichment API (Clearbit, Apollo, etc.)
2. Compare new data to existing data
3. If job title changed → update and notify assigned rep
4. If company changed → create new contact, archive old, notify rep
5. If email bounced → try to find new email, else flag for manual research

FREQUENCY: Weekly, prioritizing contacts on active deals first

Mid-Turn Steering in Action

This is Codex's killer feature for data cleanup. You don't have to plan everything upfront.

Scenario: Cleanup is running, processing stale deals. You realize you want to exclude deals from enterprise accounts.

Old approach: Stop the job. Modify the rules. Restart from the beginning.

With Codex mid-turn steering:

> Pause current processing
> Add filter: exclude deals where account.tier = 'Enterprise'
> Resume processing with new filter

No restart. No reprocessing. The cleanup continues with your new requirement.

This is especially powerful when you're running cleanup for the first time and discovering edge cases you hadn't anticipated.

Integration Setup

HubSpot Integration

// hubspot-cleanup.js
const hubspot = require('@hubspot/api-client');
const { OpenAI } = require('openai');

const client = new hubspot.Client({ accessToken: process.env.HUBSPOT_TOKEN });
const openai = new OpenAI();

async function runCleanup() {
// Fetch all deals
const deals = await client.crm.deals.getAll();

// Send to Codex for analysis
const analysis = await openai.chat.completions.create({
model: 'gpt-5.3-codex',
messages: [
{ role: 'system', content: CLEANUP_SYSTEM_PROMPT },
{ role: 'user', content: JSON.stringify(deals) }
]
});

// Process recommended actions
const actions = JSON.parse(analysis.choices[0].message.content);

for (const action of actions) {
if (action.type === 'close_deal') {
await client.crm.deals.basicApi.update(action.dealId, {
properties: { dealstage: 'closedlost', close_reason: action.reason }
});
}
// ... handle other action types
}
}

Salesforce Integration

Similar pattern with jsforce or the Salesforce REST API. The key is batching your updates to stay within API limits.

Results: What Clean Data Gets You

After running continuous cleanup for one quarter, our customers typically see:

MetricBeforeAfter
Pipeline accuracy45%82%
Forecast variance±35%±12%
Rep time on data entry6 hrs/week1 hr/week
Duplicate records15%<2%
Stale deals in pipeline42%8%

The forecast improvement alone is worth the setup. When your pipeline reflects reality, you can actually plan.

Running Cleanup Continuously

Don't run cleanup as a quarterly initiative. Run it continuously:

Daily:

  • Process new duplicates from yesterday's data entry
  • Check for bounced emails
  • Update stale deal flags

Weekly:

  • Full duplicate scan
  • Contact enrichment refresh for active deal contacts
  • Generate data quality report

Monthly:

  • Historical data audit
  • Review auto-close actions
  • Refine rules based on false positives/negatives

Common Questions

Q: Won't this mess up our historical reporting? A: Keep an audit log of every change. You can always restore or exclude from historical analysis.

Q: What about deals that look stale but are actually active? A: Start with notifications to reps before auto-closing. Track how often reps override. Adjust thresholds based on real patterns.

Q: How do we handle merges when both records have important data? A: Define clear merge rules upfront. When in doubt, concatenate notes and keep both phone numbers. Data is cheap, context is expensive.

Free Tool

Try our AI Lead Generator — find verified LinkedIn leads for any company instantly. No signup required.

Get Started

You can build this yourself with Codex + your CRM's API. Or you can use MarketBetter, where pipeline hygiene is built into the platform.

Our AI continuously monitors your CRM, flags data quality issues, and handles routine cleanup automatically. Reps get prompts to update stale deals. Duplicates get merged. Bad data gets fixed.

Want to see what clean pipeline data looks like? Book a demo and we'll run a data quality assessment on your CRM.


Related reading:

GPT-5.3 Codex Mid-Turn Steering: The Game-Changer for Sales Ops Automation [2026]

· 8 min read
MarketBetter Team
Content Team, marketbetter.ai

Released February 5, 2026. This changes everything.

OpenAI's GPT-5.3-Codex isn't just 25% faster than its predecessor. It introduces a capability that fundamentally changes how we think about AI automation: mid-turn steering.

For the first time, you can redirect an AI agent while it's working—without starting over, without losing context, without waiting for it to finish a wrong approach.

For sales ops teams, this means AI that adapts in real-time to changing requirements. Let me show you why this matters.

Mid-turn steering concept showing human directing AI agent mid-task with course correction arrows

What Is Mid-Turn Steering?

Traditional AI workflows look like this:

Prompt → AI Works → Output → Human Reviews → New Prompt → AI Works Again

Every time you want to adjust direction, you restart the process. For complex tasks—like building a report, analyzing a pipeline, or generating personalized outreach—this creates a painful loop of:

  1. Wait for AI to finish
  2. Realize it went the wrong direction
  3. Craft a new prompt
  4. Wait again
  5. Repeat

Mid-turn steering breaks this pattern:

Prompt → AI Works → Human Steers → AI Adapts → Human Steers → Final Output
↑ ↑
"Focus more on enterprise" "Skip the APAC region"

You're co-piloting instead of backseat driving.

Why This Matters for Sales Ops

Sales operations is full of tasks that require judgment calls mid-stream:

Pipeline Analysis

Without mid-turn steering:

"Analyze our pipeline and identify at-risk deals"

[AI analyzes for 3 minutes]

Output: Lists 47 deals, mostly based on stage duration

You: "No, I meant deals where the champion went dark"

[Start over]

With mid-turn steering:

"Analyze our pipeline and identify at-risk deals"

[AI starts analyzing]

You (mid-turn): "Weight communication gaps heavily"

[AI adjusts, continues]

You (mid-turn): "Actually, focus on deals over $50K only"

[AI filters, continues]

Output: Exactly what you needed, first try

Lead List Building

Without mid-turn steering:

"Build a list of 50 target accounts in fintech"

[AI builds list]

Output: Includes crypto companies, payment processors, neobanks

You: "I meant traditional banks adopting fintech, not fintech startups"

[Start over with clearer prompt]

With mid-turn steering:

"Build a list of 50 target accounts in fintech"

[AI starts building]

You (mid-turn): "Traditional banks only, not startups"

[AI adjusts filters]

You (mid-turn): "Prioritize ones with recent digital transformation announcements"

[AI adds signal filter]

Output: Perfectly targeted list, one pass

Competitive Intelligence

Without mid-turn steering:

"Research what Competitor X announced this quarter"

[AI researches]

Output: Product updates, funding news, executive hires

You: "I need their pricing changes and new integrations specifically"

[Start over]

With mid-turn steering:

"Research what Competitor X announced this quarter"

[AI starts researching]

You (mid-turn): "Focus on pricing and integrations only"

[AI narrows scope]

You (mid-turn): "Compare their new HubSpot integration to ours"

[AI adds competitive angle]

Output: Actionable competitive intel

GPT-5.3 vs previous versions showing 25% speed improvement with benchmark visualization

Practical Applications for GTM Teams

1. Real-Time Report Building

Instead of specifying every detail upfront, collaborate:

// Start the report
const session = await codex.startTask(`
Generate a weekly pipeline report for the executive team.
Include: stage progression, new opportunities, closed deals.
`);

// Steer as it works
await session.steer("Add win/loss reasons for closed deals");
await session.steer("Break down new opps by source");
await session.steer("Highlight any deals that skipped stages");

// Get final output
const report = await session.complete();

2. Dynamic Territory Planning

const session = await codex.startTask(`
Rebalance sales territories based on Q1 performance data.
`);

// Adjust criteria in real-time
await session.steer("Account for the new Austin rep starting Monday");
await session.steer("Keep enterprise accounts with existing reps");
await session.steer("Show me the impact on each rep's quota");

const territories = await session.complete();

3. Personalized Outreach at Scale

const session = await codex.startTask(`
Generate personalized emails for 50 conference attendees.
`);

// Refine the approach
await session.steer("Make them shorter - 3 sentences max");
await session.steer("Reference specific sessions they attended");
await session.steer("Skip anyone who's already a customer");

const emails = await session.complete();

4. Live Deal Analysis

const session = await codex.startTask(`
Analyze the Acme Corp opportunity and recommend next steps.
`);

// Add context as you think of it
await session.steer("They mentioned budget concerns in the last call");
await session.steer("Their competitor just signed with us");
await session.steer("The CFO is the real decision maker, not the VP");

const analysis = await session.complete();

The Technical Advantage

How Mid-Turn Steering Works

GPT-5.3-Codex maintains a live working context that you can modify:

┌─────────────────────────────────────┐
│ WORKING CONTEXT │
├─────────────────────────────────────┤
│ Original prompt │
│ + Steering input 1 │
│ + Steering input 2 │
│ + Current progress state │
│ + Intermediate results │
└─────────────────────────────────────┘

[Continues work with
full accumulated context]

Previous models would lose intermediate work when you interrupted. GPT-5.3 preserves everything and integrates your steering naturally.

Speed Improvements

The 25% speed improvement compounds with steering:

TaskGPT-5.2 (No Steering)GPT-5.3 (With Steering)Total Improvement
Pipeline report180s + 120s redo140s (steered)53% faster
Lead list (50)90s + 60s redo70s (steered)46% faster
Competitive brief120s + 90s redo95s (steered)55% faster
Territory rebalance240s + 180s redo180s (steered)57% faster

The real win isn't raw speed—it's eliminating the redo cycle.

Implementation Patterns

Pattern 1: Progressive Refinement

Start broad, narrow down:

async function buildTargetList(criteria) {
const session = await codex.startTask(`
Build a target account list matching: ${criteria.initial}
`);

// Watch progress and refine
session.onProgress(async (progress) => {
if (progress.accounts > 100) {
await session.steer("Limit to top 50 by revenue");
}
if (progress.includesCompetitorCustomers) {
await session.steer("Exclude known competitor customers");
}
});

return session.complete();
}

Pattern 2: Exception Handling

Catch issues before they compound:

async function analyzeDeals(pipeline) {
const session = await codex.startTask(`
Analyze pipeline health for Q1 forecast.
`);

// Handle edge cases as they appear
session.onAnomaly(async (anomaly) => {
if (anomaly.type === 'missing_data') {
await session.steer(`Skip ${anomaly.deal} - incomplete record`);
}
if (anomaly.type === 'outlier') {
await session.steer(`Flag ${anomaly.deal} for manual review`);
}
});

return session.complete();
}

Pattern 3: Collaborative Building

Multiple stakeholders contribute:

async function buildForecast() {
const session = await codex.startTask(`
Generate Q2 revenue forecast based on current pipeline.
`);

// Sales leader input
await session.steer("Use 60% close rate for enterprise, not 40%");

// Finance input
await session.steer("Apply 10% churn assumption to renewals");

// CEO input
await session.steer("Add scenario for if the big deal slips");

return session.complete();
}

Pattern 4: Learning Loop

Capture steering patterns for future automation:

async function buildWithLearning(task, userId) {
const session = await codex.startTask(task);
const steerings = [];

session.onSteer((input) => {
steerings.push({
trigger: session.currentState(),
steering: input,
userId: userId
});
});

const result = await session.complete();

// Store patterns for future prompts
await saveSteerings(task.type, steerings);

return result;
}

Getting Started with Codex

Installation

npm install -g @openai/codex
codex auth login

Basic Steering Example

const { Codex } = require('@openai/codex');

const codex = new Codex({ model: 'gpt-5.3-codex' });

async function steerableTask() {
const session = await codex.createSession();

// Start task
await session.send(`
Analyze our CRM data and identify upsell opportunities.
Data source: HubSpot
`);

// Wait for initial processing
await session.waitForProgress(0.3); // 30% complete

// Steer based on early results
const preliminary = await session.getProgress();
if (preliminary.includesSmallAccounts) {
await session.steer("Focus on accounts with ARR > $50K only");
}

// Wait for more progress
await session.waitForProgress(0.7); // 70% complete

// Final refinement
await session.steer("Rank by expansion likelihood, not just ARR");

// Get final output
return session.complete();
}

Common Steering Scenarios

Scenario: Report Is Too Long

Steer: "Summarize to one page, keep only top 5 items per section"

Scenario: Missing Context

Steer: "The deal values are in EUR, convert to USD using 1.08"

Scenario: Wrong Focus

Steer: "This is for the board, focus on strategic metrics not operational"

Scenario: Data Quality Issue

Steer: "Ignore any records from before January 2025, data is unreliable"

Scenario: Stakeholder Request

Steer: "CFO wants to see margin impact, add that column"
Free Tool

Try our AI Lead Generator — find verified LinkedIn leads for any company instantly. No signup required.

The Competitive Edge

Mid-turn steering gives you a compounding advantage:

  1. Faster iteration - No restart penalty for course corrections
  2. Better outputs - Human judgment applied at the right moments
  3. Lower frustration - No more "that's not what I meant" loops
  4. Captured knowledge - Steering patterns become future automation

Your competitors are still in prompt → wait → redo → wait cycles. You're collaborating with AI in real-time.

That efficiency gap compounds across every task, every day, every deal.


Ready to see AI-powered sales ops in action? Book a demo to see how MarketBetter leverages the latest AI capabilities for GTM teams.

Related reading:

Build an Inbound Lead Qualification Bot with OpenClaw [2026 Guide]

· 10 min read
MarketBetter Team
Content Team, marketbetter.ai

A website visitor fills out your "Book a Demo" form at 11 PM. Your SDR sees it at 9 AM. By then, they've already talked to two competitors.

Speed-to-lead is the #1 predictor of conversion. Respond within 5 minutes and you're 21x more likely to qualify the lead than if you wait 30 minutes.

But you can't afford to have SDRs working 24/7. So what do you do?

You build a qualification bot that:

  • Responds instantly to every inbound lead
  • Asks the right qualifying questions
  • Scores leads based on your ICP
  • Books meetings directly on your AE's calendar
  • Passes unqualified leads to nurture sequences

And with OpenClaw, you can build this for free.

Inbound lead form flowing to AI qualification to calendar booking

Why Most Chatbots Fail at Lead Qualification

Before we build, let's understand why existing solutions fall short:

Drift, Intercom, Qualified: The $$$$ Problem

These tools work. But they cost $10K-50K+ per year. For early-stage B2B companies, that's your entire marketing budget.

Generic Chatbot Builders: The Dumb Bot Problem

Tools like Chatfuel or ManyChat are designed for e-commerce FAQs, not B2B qualification. They follow rigid scripts and can't handle nuanced sales conversations.

DIY with GPT: The Context Problem

Building a chatbot with raw GPT API calls works—until you need it to remember the conversation, access your CRM, and actually book meetings.

OpenClaw solves all three:

  • Free and open source (no $50K/year)
  • Claude's reasoning handles nuanced conversations
  • Built-in memory, CRM integrations, and calendar access

The Architecture

Here's what we're building:

[Website Chat Widget]

[Webhook to OpenClaw]

[Lead Qualification Agent]

┌───┴───┐
↓ ↓
[Qualified] [Nurture]
↓ ↓
[Book Meeting] [Add to Sequence]

OpenClaw agent processing lead qualification with scoring

The agent:

  1. Receives the initial form data
  2. Engages in real-time chat to qualify
  3. Scores against your ICP criteria
  4. Routes to booking or nurture based on score

Prerequisites

  • OpenClaw installed and running (see setup guide)
  • A website with a chat widget (we'll use Crisp, but any webhook-capable chat works)
  • HubSpot or your CRM of choice
  • Calendly or Cal.com for booking

Step 1: Define Your Qualification Framework

First, write down your ICP criteria. Example for a B2B SaaS selling to sales teams:

# qualification-framework.yaml
qualification_criteria:
must_have:
- company_size: "50+ employees"
- role: ["VP Sales", "Director Sales", "Head of Sales", "SDR Manager", "RevOps"]
- budget_authority: true

nice_to_have:
- industry: ["SaaS", "Tech", "B2B Services"]
- current_tools: ["Outreach", "Salesloft", "Apollo", "HubSpot"]
- pain_points: ["SDR efficiency", "lead quality", "pipeline", "personalization"]

disqualifiers:
- company_size: "<20 employees"
- role: ["Intern", "Student", "Job Seeker"]
- intent: ["Competitor research", "Job inquiry"]

scoring:
qualified_threshold: 70
weights:
company_size: 25
role_match: 25
budget_authority: 20
pain_fit: 15
timeline: 15

Step 2: Create the OpenClaw Agent

Create your qualification agent configuration:

# agents/lead-qualifier.yaml
name: LeadQualifier
description: Qualifies inbound leads and books meetings for qualified prospects

model: claude-sonnet-4-20250514
temperature: 0.3

system_prompt: |
You are an AI sales development representative for MarketBetter.
Your job is to qualify inbound leads and book meetings with our sales team.

## YOUR PERSONALITY
- Friendly but professional
- Curious about their challenges (not interrogating)
- Helpful even if they're not a fit
- Never pushy or aggressive

## QUALIFICATION CRITERIA
MUST HAVE:
- Company has 50+ employees
- They're in a sales leadership role (VP, Director, Manager, RevOps)
- They have authority to evaluate new tools

NICE TO HAVE:
- B2B company
- Currently using sales tools (Outreach, Salesloft, Apollo)
- Active pain around SDR efficiency or lead quality

DISQUALIFIERS:
- Fewer than 20 employees
- Students/job seekers/interns
- Competitor research (handle gracefully)

## SCORING
Award points (0-100 total):
- Company size match: 25 pts
- Role match: 25 pts
- Budget authority: 20 pts
- Pain fit: 15 pts
- Timeline (< 3 months): 15 pts

Qualified = 70+ points

## CONVERSATION FLOW
1. Thank them for reaching out
2. Ask what prompted them to check out MarketBetter today
3. Learn about their current setup (team size, tools, challenges)
4. Probe on specific pain points naturally
5. If qualified (70+), offer to book a meeting
6. If not qualified, offer relevant resources and add to nurture

## TOOLS AVAILABLE
- check_company(domain) - Get company info from Clearbit
- calculate_score(criteria) - Calculate qualification score
- book_meeting(email, name) - Send Calendly link and create HubSpot contact
- add_to_nurture(email, reason) - Add to email nurture sequence

## KEY BEHAVIORS
- Don't ask more than 3 questions in a row without providing value
- If they mention a specific pain, acknowledge it specifically
- Never say "I'm just a bot" - you're an SDR named Alex
- If you're not sure about something, ask

tools:
- name: check_company
description: Enriches company data from their domain
endpoint: /api/clearbit/company

- name: book_meeting
description: Creates HubSpot contact and sends Calendly link
endpoint: /api/hubspot/book

- name: add_to_nurture
description: Adds lead to nurture sequence
endpoint: /api/hubspot/nurture

Step 3: Build the Tool Endpoints

Create the API endpoints that your agent can call:

// api/hubspot/book.js
const HubSpot = require('@hubspot/api-client');

const hubspot = new HubSpot.Client({ accessToken: process.env.HUBSPOT_TOKEN });

async function bookMeeting(email, name, qualificationData) {
// Create or update contact in HubSpot
const contact = await hubspot.crm.contacts.basicApi.create({
properties: {
email,
firstname: name.split(' ')[0],
lastname: name.split(' ').slice(1).join(' '),
lead_source: 'Inbound Chat',
qualification_score: qualificationData.score,
qualification_notes: qualificationData.notes,
lifecyclestage: 'salesqualifiedlead'
}
});

// Generate Calendly link with pre-filled info
const calendlyLink = `https://calendly.com/your-team/demo?name=${encodeURIComponent(name)}&email=${encodeURIComponent(email)}`;

// Log activity in HubSpot
await hubspot.crm.timeline.eventsApi.create({
eventTemplateId: 'chat_qualified',
objectId: contact.id,
tokens: {
score: qualificationData.score,
summary: qualificationData.notes
}
});

return {
calendlyLink,
contactId: contact.id,
message: `Great! Here's a link to book directly with our team: ${calendlyLink}`
};
}

module.exports = { bookMeeting };
// api/clearbit/company.js
const Clearbit = require('clearbit')(process.env.CLEARBIT_KEY);

async function checkCompany(domain) {
try {
const company = await Clearbit.Company.find({ domain });

return {
name: company.name,
size: company.metrics?.employees || 'Unknown',
industry: company.category?.industry || 'Unknown',
description: company.description,
tech: company.tech || [],
sizeCategory: categorizeSiz(company.metrics?.employees)
};
} catch (error) {
return {
name: domain,
size: 'Unknown',
industry: 'Unknown',
sizeCategory: 'Unknown'
};
}
}

function categorizeSize(employees) {
if (!employees) return 'Unknown';
if (employees < 20) return 'too_small';
if (employees < 50) return 'small';
if (employees < 200) return 'mid_market';
if (employees < 1000) return 'enterprise';
return 'large_enterprise';
}

module.exports = { checkCompany };

Step 4: Connect Your Chat Widget

Most chat widgets support webhooks. Here's how to connect Crisp:

// webhooks/crisp.js
const express = require('express');
const { OpenClaw } = require('openclaw');

const app = express();
const openclaw = new OpenClaw();

// Crisp webhook endpoint
app.post('/webhooks/crisp', async (req, res) => {
const { event, data } = req.body;

if (event === 'message:send') {
// Visitor sent a message
const { website_id, session_id, content } = data;

// Get or create OpenClaw session for this chat
const session = await openclaw.getSession(`crisp:${session_id}`);

// Process message through qualification agent
const response = await session.send(content, {
agent: 'lead-qualifier',
metadata: {
source: 'crisp',
website_id,
visitor_data: data.visitor
}
});

// Send response back to Crisp
await sendCrispMessage(website_id, session_id, response.message);
}

res.sendStatus(200);
});

async function sendCrispMessage(websiteId, sessionId, message) {
await fetch(`https://api.crisp.chat/v1/website/${websiteId}/conversation/${sessionId}/message`, {
method: 'POST',
headers: {
'Authorization': `Basic ${Buffer.from(CRISP_ID + ':' + CRISP_KEY).toString('base64')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'text',
content: message,
from: 'operator'
})
});
}

Step 5: Sample Conversation

Here's what a qualification conversation looks like:

Visitor: Hi, I'm interested in learning more about MarketBetter

Bot (Alex): Hey! Thanks for reaching out 👋 I'm Alex from the MarketBetter team. What got you interested in checking us out today?

Visitor: We're looking for ways to make our SDR team more efficient. Currently spending too much time on manual research.

Bot: Oh yeah, manual research is a killer. How big is your SDR team currently?

Visitor: We have 8 SDRs. They probably spend 2-3 hours a day just researching accounts before outreach.

Bot: [Internal: check_company called for visitor domain → 150 employees, SaaS industry]

150-person SaaS company with 8 SDRs doing 2-3 hours of research each—that's basically 2 full headcounts worth of time just on research. Are you all using any tools for that currently, or mostly LinkedIn + Google?

Visitor: We have ZoomInfo for data but the research part is still manual. We tried Apollo's sequences but the personalization was too generic.

Bot: Makes sense—data is table stakes, but the what to do with it part is where most tools fall short.

Quick question: are you the one evaluating tools for the team, or is there someone else involved in that?

Visitor: I'm the VP of Sales, so yeah, my call.

Bot: [Internal: Score calculation → Company: 25, Role: 25, Pain: 15, Authority: 20, Timeline: TBD = 85+ points]

Perfect. Based on what you're describing—8 SDRs, manual research eating into selling time, need for real personalization—this is exactly what MarketBetter was built for.

Would you be up for a 20-minute call with one of our sales engineers? They can show you specifically how teams like yours are cutting that research time down to like 20 minutes a day per rep.

Visitor: Sure, that sounds good.

Bot: Awesome! Here's a link to grab time directly: [Calendly link]

I'll also send you a quick case study from a similar team (B2B SaaS, 10 SDRs) that saw 3x more meetings after switching. Look for it in your inbox!


Result: Qualified lead, meeting booked, case study sent—all within 4 minutes at 11 PM on a Sunday.

Speed comparison showing qualified leads booking fast versus delays

Advanced: Handling Edge Cases

The "Just Browsing" Visitor

# In your agent prompt
If visitor seems early-stage or "just browsing":
1. Don't push for a meeting
2. Offer a relevant resource (ebook, case study, calculator)
3. Ask if they'd like occasional updates
4. Add to top-of-funnel nurture

The Competitor Researcher

# In your agent prompt
If you suspect competitor research:
1. Don't be defensive or hostile
2. Offer publicly available info
3. Politely suggest they book a demo if genuinely interested
4. Log in CRM as "Competitor - [Company Name]"

The Unqualified But Enthusiastic

# In your agent prompt
If they're excited but don't meet criteria (too small, wrong role):
1. Be honest: "We typically work with teams of 5+ SDRs..."
2. Offer free resources that might help them
3. Suggest checking back when they scale
4. Add to nurture for later

Metrics to Track

Once live, monitor these:

MetricTargetWhy It Matters
Response time<30 secondsSpeed-to-lead
Qualification accuracy>85%Are meetings actually qualified?
Conversion to meeting>40% of qualifiedBot effectiveness
No-show rate<20%Lead quality
Pipeline from botTrack $ROI

The Cost Breakdown

ComponentCost
OpenClawFree (self-hosted)
Claude API~$0.01-0.05 per conversation
Crisp (chat widget)$25/month (Starter)
Clearbit (optional)$99/month
Total~$125/month

Compare that to Drift ($2,500/month minimum) or Qualified ($10,000/month+).

Why This Beats Human SDRs for First Response

Let me be clear: this doesn't replace SDRs. It augments them.

Bot does:

  • 24/7 instant response
  • Consistent qualification questions
  • Zero fatigue or bad days
  • Perfect CRM hygiene
  • Immediate scoring and routing

SDRs do:

  • Complex objection handling
  • Relationship building
  • Creative problem-solving
  • Negotiation
  • Closing

The bot handles the 80% of conversations that are straightforward so your SDRs can focus on the 20% that need human touch.

Taking It Live

  1. Start with a pilot — Run bot alongside human SDRs for 2 weeks
  2. Review conversations — Check for failure patterns
  3. Iterate the prompt — Refine based on real conversations
  4. Measure conversion — Compare bot-qualified vs. human-qualified
  5. Scale up — Expand to more pages/higher traffic

Free Tool

Try our AI Lead Generator — find verified LinkedIn leads for any company instantly. No signup required.

Ready to Never Miss a Lead Again?

MarketBetter's AI SDR playbook takes this further—not just qualifying inbound, but telling your entire team exactly who to contact and what to say, every single day.

Book a demo →


Related reading:

Multi-CRM Sync Automation with Codex: HubSpot + Salesforce Unified [2026]

· 9 min read
MarketBetter Team
Content Team, marketbetter.ai

Your company runs HubSpot for marketing. Sales uses Salesforce. The two don't talk.

Every week, someone manually exports contacts from HubSpot, cleans the data in Excel, and imports to Salesforce. Deals created in Salesforce never make it back to HubSpot. Marketing can't see which MQLs became opportunities.

The result: Marketing thinks their campaigns are working. Sales thinks marketing sends garbage leads. Nobody can prove anything because the data lives in two places.

Sound familiar? You're not alone. This is one of the most common problems in B2B GTM.

Enterprise solutions like Workato, Tray.io, or HubSpot's native Salesforce sync cost $20K-50K per year and still require massive configuration.

What if you could build a smarter sync for $100/month?

HubSpot and Salesforce connected through AI orchestration layer

The Problem with Traditional Sync Tools

Native HubSpot-Salesforce Sync

HubSpot offers a native Salesforce integration. It works... sort of. Problems:

  • One-way sync for many fields
  • Limited conflict resolution
  • No custom logic for edge cases
  • Breaks when you have custom objects

iPaaS Tools (Workato, Tray, Zapier)

These are better but:

  • $20K-50K+/year for enterprise plans
  • Complex visual builders that become unmaintainable
  • No intelligence—just "if this, then that"
  • Can't handle nuanced business rules

The Real Challenge: Business Logic

The hard part isn't syncing fields. It's answering questions like:

  • If a contact exists in both systems with different emails, which is correct?
  • When a deal stage changes in Salesforce, how does that map to HubSpot lifecycle stages?
  • If marketing updates a lead score in HubSpot, should it overwrite the Salesforce score?
  • How do you handle records created by integrations vs. humans?

This is where AI shines.

The Architecture

Here's what we're building:

[HubSpot Webhook] ←→ [Sync Engine] ←→ [Salesforce Webhook]

[Codex Agent]

[Conflict Resolution]

[Bidirectional Updates]

The Codex agent handles:

  1. Field mapping — Translates between CRM schemas
  2. Conflict resolution — Decides which record is authoritative
  3. Validation — Ensures data quality before sync
  4. Logging — Tracks every change for debugging

AI agent resolving CRM data conflicts

Prerequisites

  • OpenAI Codex CLI installed
  • HubSpot API key (Private App)
  • Salesforce Connected App credentials
  • Node.js 18+
  • A database for sync state (PostgreSQL recommended)

Step 1: Define Your Sync Map

First, document how fields map between systems:

// config/field-map.js
module.exports = {
contact: {
// HubSpot field -> Salesforce field
'email': 'Email',
'firstname': 'FirstName',
'lastname': 'LastName',
'phone': 'Phone',
'company': 'Company',
'jobtitle': 'Title',
'lifecyclestage': 'Status__c', // Custom field
'hs_lead_score': 'Lead_Score__c',

// Complex mappings
'hs_analytics_source': {
salesforce_field: 'LeadSource',
transform: (value) => {
const sourceMap = {
'ORGANIC_SEARCH': 'Web',
'PAID_SEARCH': 'Paid Search',
'SOCIAL_MEDIA': 'Social',
'EMAIL_MARKETING': 'Email',
'DIRECT_TRAFFIC': 'Direct',
'REFERRALS': 'Referral'
};
return sourceMap[value] || 'Other';
}
}
},

deal: {
'dealname': 'Name',
'amount': 'Amount',
'closedate': 'CloseDate',
'dealstage': {
salesforce_field: 'StageName',
transform: (stage) => {
const stageMap = {
'appointmentscheduled': 'Discovery',
'qualifiedtobuy': 'Qualification',
'presentationscheduled': 'Demo',
'decisionmakerboughtin': 'Proposal',
'contractsent': 'Negotiation',
'closedwon': 'Closed Won',
'closedlost': 'Closed Lost'
};
return stageMap[stage] || 'Unknown';
}
}
},

company: {
'name': 'Name',
'domain': 'Website',
'industry': 'Industry',
'numberofemployees': 'NumberOfEmployees',
'annualrevenue': 'AnnualRevenue'
}
};

Step 2: Build the Sync Engine Core

// lib/sync-engine.js
const HubSpot = require('@hubspot/api-client');
const jsforce = require('jsforce');
const fieldMap = require('../config/field-map');

class SyncEngine {
constructor() {
this.hubspot = new HubSpot.Client({ accessToken: process.env.HUBSPOT_TOKEN });
this.salesforce = new jsforce.Connection({
loginUrl: process.env.SF_LOGIN_URL,
accessToken: process.env.SF_ACCESS_TOKEN,
instanceUrl: process.env.SF_INSTANCE_URL
});
this.syncState = new SyncStateDB(); // Your state store
}

async syncHubSpotToSalesforce(objectType, hubspotId) {
// Get HubSpot record
const hsRecord = await this.getHubSpotRecord(objectType, hubspotId);

// Check if already synced
const existingSfId = await this.syncState.getSalesforceId(objectType, hubspotId);

if (existingSfId) {
// Update existing record
return this.updateSalesforceRecord(objectType, existingSfId, hsRecord);
} else {
// Find or create in Salesforce
const sfRecord = await this.findSalesforceMatch(objectType, hsRecord);

if (sfRecord) {
// Link existing and update
await this.syncState.linkRecords(objectType, hubspotId, sfRecord.Id);
return this.updateSalesforceRecord(objectType, sfRecord.Id, hsRecord);
} else {
// Create new
return this.createSalesforceRecord(objectType, hsRecord, hubspotId);
}
}
}

async findSalesforceMatch(objectType, hsRecord) {
// Use email for contacts, domain for companies, etc.
if (objectType === 'contact') {
const email = hsRecord.properties.email;
if (!email) return null;

const results = await this.salesforce.sobject('Contact')
.find({ Email: email })
.limit(1);

return results[0] || null;
}

if (objectType === 'company') {
const domain = hsRecord.properties.domain;
if (!domain) return null;

const results = await this.salesforce.sobject('Account')
.find({ Website: { $like: `%${domain}%` } })
.limit(1);

return results[0] || null;
}

return null;
}

transformHubSpotToSalesforce(objectType, hsProperties) {
const mapping = fieldMap[objectType];
const sfFields = {};

for (const [hsField, sfConfig] of Object.entries(mapping)) {
const value = hsProperties[hsField];
if (value === undefined || value === null) continue;

if (typeof sfConfig === 'string') {
// Simple mapping
sfFields[sfConfig] = value;
} else {
// Complex mapping with transform
sfFields[sfConfig.salesforce_field] = sfConfig.transform(value);
}
}

return sfFields;
}
}

module.exports = { SyncEngine };

Step 3: Intelligent Conflict Resolution with Codex

Here's where the AI magic comes in. When records conflict, Codex decides:

// lib/conflict-resolver.js
const { Codex } = require('@openai/codex');

const codex = new Codex({ apiKey: process.env.OPENAI_API_KEY });

async function resolveConflict(hubspotRecord, salesforceRecord, fieldName) {
const prompt = `You are a CRM data quality expert. Resolve this field conflict:

FIELD: ${fieldName}

HUBSPOT VALUE:
Value: ${hubspotRecord[fieldName]}
Last Modified: ${hubspotRecord.updatedAt}
Modified By: ${hubspotRecord.updatedBy || 'Unknown'}
Source: ${hubspotRecord.source || 'Unknown'}

SALESFORCE VALUE:
Value: ${salesforceRecord[fieldName]}
Last Modified: ${salesforceRecord.LastModifiedDate}
Modified By: ${salesforceRecord.LastModifiedById || 'Unknown'}

BUSINESS RULES:
1. More recent updates generally win
2. Human edits beat automated updates
3. Sales-owned fields (title, phone) prefer Salesforce
4. Marketing-owned fields (lead source, score) prefer HubSpot
5. Email should match—if different, flag for review

OUTPUT FORMAT:
{
"winner": "hubspot" | "salesforce" | "review_needed",
"reason": "brief explanation",
"confidence": 0.0-1.0
}`;

const response = await codex.complete({
model: 'gpt-5.3-codex',
prompt,
max_tokens: 200,
response_format: { type: 'json_object' }
});

return JSON.parse(response.choices[0].text);
}

async function resolveRecordConflicts(hsRecord, sfRecord, mapping) {
const resolutions = {};
const reviewNeeded = [];

for (const [hsField, sfConfig] of Object.entries(mapping)) {
const sfField = typeof sfConfig === 'string' ? sfConfig : sfConfig.salesforce_field;

const hsValue = hsRecord.properties[hsField];
const sfValue = sfRecord[sfField];

// Skip if same
if (hsValue === sfValue) {
resolutions[hsField] = { value: hsValue, source: 'same' };
continue;
}

// Skip if one is empty
if (!hsValue && sfValue) {
resolutions[hsField] = { value: sfValue, source: 'salesforce' };
continue;
}
if (hsValue && !sfValue) {
resolutions[hsField] = { value: hsValue, source: 'hubspot' };
continue;
}

// Conflict! Let Codex decide
const resolution = await resolveConflict(
{ [hsField]: hsValue, updatedAt: hsRecord.updatedAt },
{ [sfField]: sfValue, LastModifiedDate: sfRecord.LastModifiedDate },
hsField
);

if (resolution.winner === 'review_needed') {
reviewNeeded.push({
field: hsField,
hubspotValue: hsValue,
salesforceValue: sfValue,
reason: resolution.reason
});
} else {
resolutions[hsField] = {
value: resolution.winner === 'hubspot' ? hsValue : sfValue,
source: resolution.winner,
confidence: resolution.confidence,
reason: resolution.reason
};
}
}

return { resolutions, reviewNeeded };
}

module.exports = { resolveConflict, resolveRecordConflicts };

Step 4: Webhook Handlers

Set up webhooks for real-time sync:

// webhooks/hubspot.js
const express = require('express');
const { SyncEngine } = require('../lib/sync-engine');
const { resolveRecordConflicts } = require('../lib/conflict-resolver');

const router = express.Router();
const syncEngine = new SyncEngine();

router.post('/hubspot', async (req, res) => {
const events = req.body;

for (const event of events) {
const { subscriptionType, objectId, propertyName, propertyValue } = event;

// Handle contact updates
if (subscriptionType === 'contact.propertyChange') {
console.log(`📥 HubSpot contact ${objectId} updated: ${propertyName}`);

try {
await syncEngine.syncHubSpotToSalesforce('contact', objectId);
console.log(`✅ Synced to Salesforce`);
} catch (error) {
console.error(`❌ Sync failed: ${error.message}`);
// Queue for retry
await retryQueue.add({ type: 'contact', id: objectId, source: 'hubspot' });
}
}

// Handle deal updates
if (subscriptionType === 'deal.propertyChange') {
console.log(`📥 HubSpot deal ${objectId} updated: ${propertyName}`);
await syncEngine.syncHubSpotToSalesforce('deal', objectId);
}
}

res.sendStatus(200);
});

module.exports = router;
// webhooks/salesforce.js
const express = require('express');
const { SyncEngine } = require('../lib/sync-engine');

const router = express.Router();
const syncEngine = new SyncEngine();

// Salesforce Outbound Message handler
router.post('/salesforce', async (req, res) => {
// Parse SOAP envelope (Salesforce uses SOAP for outbound messages)
const notification = parseOutboundMessage(req.body);

const { objectType, recordId, changedFields } = notification;

console.log(`📥 Salesforce ${objectType} ${recordId} updated`);

try {
await syncEngine.syncSalesforceToHubSpot(objectType, recordId);
console.log(`✅ Synced to HubSpot`);
} catch (error) {
console.error(`❌ Sync failed: ${error.message}`);
}

// Return ACK
res.type('text/xml').send(`
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<notificationsResponse xmlns="http://soap.sforce.com/2005/09/outbound">
<Ack>true</Ack>
</notificationsResponse>
</soapenv:Body>
</soapenv:Envelope>
`);
});

module.exports = router;

Step 5: Sync Dashboard

Build a simple UI to monitor syncs:

// api/sync-status.js
router.get('/status', async (req, res) => {
const stats = await db.query(`
SELECT
object_type,
COUNT(*) as total_synced,
COUNT(CASE WHEN status = 'success' THEN 1 END) as successful,
COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed,
COUNT(CASE WHEN status = 'pending_review' THEN 1 END) as pending_review,
MAX(synced_at) as last_sync
FROM sync_log
WHERE synced_at > NOW() - INTERVAL '24 hours'
GROUP BY object_type
`);

const conflicts = await db.query(`
SELECT *
FROM sync_conflicts
WHERE resolved = false
ORDER BY created_at DESC
LIMIT 50
`);

res.json({
stats,
conflicts,
health: conflicts.length === 0 ? 'healthy' : 'needs_attention'
});
});

Cost comparison: DIY sync vs enterprise iPaaS tools

Cost Comparison

SolutionMonthly CostFeatures
Native HubSpot-SF SyncIncludedBasic, one-way many fields
Workato$2,000-5,000Full iPaaS, complex workflows
Tray.io$1,500-3,000Visual builder, good for non-devs
Syncari$1,000-2,500CRM-focused, good conflict resolution
DIY with Codex$50-150Full control, AI conflict resolution

The DIY approach costs ~95% less while giving you MORE intelligence in conflict resolution.

When This Makes Sense

Build your own when:

  • You have engineering resources
  • Your sync logic is complex/custom
  • Cost is a factor
  • You want full control and ownership

Use enterprise tools when:

  • No engineering bandwidth
  • Need compliance certifications
  • Want vendor support
  • Syncing many systems (not just 2)

Mid-Turn Steering for Bulk Syncs

When doing initial bulk sync (thousands of records), use Codex's mid-turn steering:

# Start bulk sync
codex run bulk-sync --source hubspot --target salesforce --object contacts

# Mid-run, adjust conflict rules
codex steer "For company size conflicts, prefer Salesforce values since sales updates those"

# Or pause problematic records
codex steer "Skip records from [email protected] domain - those are test records"

This lets you fine-tune the sync without starting over.

Common Gotchas

  1. Infinite loops — Mark records as "sync in progress" to prevent HubSpot→SF→HubSpot cycles
  2. Rate limits — Both APIs have limits; implement exponential backoff
  3. Timezone hell — Store all dates in UTC, convert on display
  4. Lookup fields — Sync Account before Contact (parent before child)
  5. Deleted records — Decide: soft delete or hard sync?

Free Tool

Try our AI Lead Generator — find verified LinkedIn leads for any company instantly. No signup required.

Want Unified Data Without the Sync Headaches?

MarketBetter aggregates signals from across your stack—CRM, website, email, intent—into one unified view. No more wondering which system has the truth.

Book a demo →


Related reading: