Back to The Times of Claw

Build a Lead Scoring App with DenchClaw

Build a custom lead scoring app in DenchClaw that assigns numerical scores to leads based on CRM fields and behavioral signals, with a configurable scoring model.

Mark Rachapoom
Mark Rachapoom
·7 min read
Build a Lead Scoring App with DenchClaw

Build a Lead Scoring App with DenchClaw

Lead scoring is one of those things that sounds complex but the basic version is straightforward: assign points for attributes you care about, add them up, rank your leads. The hard part is making it configurable and keeping it in sync with your CRM.

This guide builds a lead scoring app as a Dench App: a configurable scoring model editor, a ranked lead table, and one-click score writeback to DuckDB.

What You're Building#

  • Configurable scoring criteria (field value → points)
  • Live lead table ranked by score
  • Score breakdown per lead (which criteria contributed)
  • Score writeback to a Lead Score field in your CRM
  • Export top leads as a prioritized call list

Step 1: App Setup#

mkdir -p ~/.openclaw-dench/workspace/apps/lead-scorer.dench.app

.dench.yaml:

name: Lead Scorer
description: Configurable lead scoring with writeback to CRM
icon: trending-up
version: 1.0.0
permissions:
  - read:crm
  - write:crm
display: tab

Step 2: Scoring Model and HTML#

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Lead Scorer</title>
  <style>
    * { box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; display: grid; grid-template-columns: 340px 1fr; height: 100vh; }
    .config-panel { padding: 20px; overflow-y: auto; border-right: 1px solid #1e293b; background: #0a0f1e; }
    .main-panel { padding: 20px; overflow-y: auto; }
    h3 { font-size: 12px; color: #64748b; text-transform: uppercase; margin: 16px 0 8px; }
    .criterion { background: #1e293b; border-radius: 8px; padding: 12px; margin-bottom: 8px; }
    .criterion-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
    .criterion-label { font-size: 13px; font-weight: 600; }
    .points-input { width: 60px; background: #0f172a; border: 1px solid #334155; color: #10b981; padding: 4px 8px; border-radius: 6px; font-size: 13px; font-weight: 700; text-align: center; }
    input[type="text"], select { width: 100%; background: #0f172a; border: 1px solid #334155; color: #e2e8f0; padding: 6px 10px; border-radius: 6px; font-size: 12px; margin-bottom: 6px; }
    button { padding: 10px 16px; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600; }
    .btn-primary { background: #6366f1; color: white; }
    .btn-secondary { background: #334155; color: #94a3b8; }
    .btn-sm { padding: 6px 12px; font-size: 12px; }
    .score-bar { height: 8px; border-radius: 4px; background: #334155; overflow: hidden; margin-top: 4px; }
    .score-fill { height: 100%; border-radius: 4px; }
    table { width: 100%; border-collapse: collapse; }
    th { padding: 10px 14px; text-align: left; font-size: 11px; color: #64748b; text-transform: uppercase; }
    td { padding: 10px 14px; border-bottom: 1px solid #1e293b; font-size: 13px; }
    .rank { font-weight: 800; color: #64748b; font-size: 18px; }
    .score-badge { padding: 4px 12px; border-radius: 999px; font-weight: 700; font-size: 13px; }
    .breakdown { font-size: 11px; color: #64748b; }
  </style>
</head>
<body>
  <div class="config-panel">
    <h3>Scoring Model</h3>
    <div id="criteriaList"></div>
    <button class="btn-secondary btn-sm" onclick="addCriterion()" style="width:100%;margin-top:8px">+ Add Criterion</button>
    <button class="btn-primary" onclick="scoreLeads()" style="width:100%;margin-top:16px">Score Leads</button>
    <button class="btn-secondary btn-sm" onclick="saveScores()" style="width:100%;margin-top:8px">Save Scores to CRM</button>
    <button class="btn-secondary btn-sm" onclick="exportTopLeads()" style="width:100%;margin-top:8px">Export Top 20</button>
  </div>
  <div class="main-panel">
    <h3>Lead Rankings</h3>
    <div id="leadsTable"><p style="color:#64748b">Configure scoring criteria and click "Score Leads".</p></div>
  </div>
  <script src="scorer.js"></script>
</body>
</html>

Step 3: Scoring Logic#

scorer.js:

// Default scoring criteria
let criteria = [
  { id: 1, label: 'Title is Director or above', field: 'Title', operator: 'contains', value: 'Director', points: 20 },
  { id: 2, label: 'Title is VP or C-level', field: 'Title', operator: 'contains_any', value: 'VP,CTO,CEO,CFO,CMO', points: 30 },
  { id: 3, label: 'Company size > 50', field: 'Company Size', operator: 'greater_than', value: '50', points: 15 },
  { id: 4, label: 'Has email address', field: 'Email Address', operator: 'not_empty', value: '', points: 10 },
  { id: 5, label: 'Source is referral', field: 'Source', operator: 'equals', value: 'Referral', points: 25 },
  { id: 6, label: 'Status is Qualified', field: 'Status', operator: 'equals', value: 'Qualified', points: 20 }
];
 
let scoredLeads = [];
 
function loadFromStorage() {
  const saved = localStorage.getItem('dench_scorer_criteria');
  if (saved) criteria = JSON.parse(saved);
}
 
function saveToStorage() {
  localStorage.setItem('dench_scorer_criteria', JSON.stringify(criteria));
}
 
function renderCriteria() {
  document.getElementById('criteriaList').innerHTML = criteria.map(c => `
    <div class="criterion">
      <div class="criterion-header">
        <span class="criterion-label">${c.label}</span>
        <input type="number" class="points-input" value="${c.points}" min="0" max="100"
               onchange="updatePoints(${c.id}, this.value)">
      </div>
      <input type="text" placeholder="Field name" value="${c.field}" oninput="updateField(${c.id}, 'field', this.value)">
      <select onchange="updateField(${c.id}, 'operator', this.value)">
        ${['equals','contains','contains_any','not_empty','greater_than','less_than'].map(op => 
          `<option ${c.operator === op ? 'selected' : ''}>${op}</option>`
        ).join('')}
      </select>
      ${c.operator !== 'not_empty' ? `<input type="text" placeholder="Value" value="${c.value}" oninput="updateField(${c.id}, 'value', this.value)">` : ''}
      <button class="btn-secondary btn-sm" onclick="removeCriterion(${c.id})" style="width:100%">Remove</button>
    </div>
  `).join('');
}
 
function updatePoints(id, val) {
  const c = criteria.find(c => c.id === id);
  if (c) c.points = Number(val);
  saveToStorage();
}
 
function updateField(id, key, val) {
  const c = criteria.find(c => c.id === id);
  if (c) c[key] = val;
  saveToStorage();
}
 
function addCriterion() {
  const newId = Math.max(...criteria.map(c => c.id), 0) + 1;
  criteria.push({ id: newId, label: 'New Criterion', field: '', operator: 'equals', value: '', points: 10 });
  renderCriteria();
}
 
function removeCriterion(id) {
  criteria = criteria.filter(c => c.id !== id);
  renderCriteria();
  saveToStorage();
}
 
function evaluateCriterion(lead, criterion) {
  const fieldVal = String(lead[criterion.field] || '');
  const testVal = criterion.value;
 
  switch (criterion.operator) {
    case 'equals': return fieldVal.toLowerCase() === testVal.toLowerCase();
    case 'contains': return fieldVal.toLowerCase().includes(testVal.toLowerCase());
    case 'contains_any': return testVal.split(',').some(v => fieldVal.toLowerCase().includes(v.trim().toLowerCase()));
    case 'not_empty': return fieldVal.length > 0;
    case 'greater_than': return Number(fieldVal) > Number(testVal);
    case 'less_than': return Number(fieldVal) < Number(testVal);
    default: return false;
  }
}
 
async function scoreLeads() {
  const leads = await dench.db.query(`
    SELECT id, "Full Name", "Email Address", "Company", "Title", "Status",
           "Source", "Company Size", "Last Contacted"
    FROM v_people
    WHERE "Status" IN ('Lead', 'Qualified', 'Nurturing')
  `);
 
  const maxScore = criteria.reduce((sum, c) => sum + c.points, 0);
 
  scoredLeads = leads.map(lead => {
    const matchedCriteria = criteria.filter(c => evaluateCriterion(lead, c));
    const score = matchedCriteria.reduce((sum, c) => sum + c.points, 0);
    const percentage = maxScore > 0 ? Math.round(score / maxScore * 100) : 0;
    return { ...lead, score, percentage, matchedCriteria };
  }).sort((a, b) => b.score - a.score);
 
  renderLeads();
}
 
function scoreColor(pct) {
  if (pct >= 70) return '#10b981';
  if (pct >= 40) return '#f59e0b';
  return '#ef4444';
}
 
function renderLeads() {
  if (scoredLeads.length === 0) {
    document.getElementById('leadsTable').innerHTML = '<p style="color:#64748b">No leads found.</p>';
    return;
  }
 
  const rows = scoredLeads.map((lead, i) => `
    <tr>
      <td class="rank">#${i + 1}</td>
      <td>
        <div style="font-weight:600">${lead['Full Name'] || 'Unknown'}</div>
        <div style="font-size:11px;color:#64748b">${lead.Company || '—'} · ${lead.Title || '—'}</div>
      </td>
      <td>
        <span class="score-badge" style="background:${scoreColor(lead.percentage)}20;color:${scoreColor(lead.percentage)}">
          ${lead.score} pts (${lead.percentage}%)
        </span>
        <div class="score-bar"><div class="score-fill" style="width:${lead.percentage}%;background:${scoreColor(lead.percentage)}"></div></div>
        <div class="breakdown">${lead.matchedCriteria.map(c => c.label).join(' · ') || 'No criteria matched'}</div>
      </td>
      <td style="color:#64748b">${lead.Status}</td>
    </tr>
  `).join('');
 
  document.getElementById('leadsTable').innerHTML = `
    <table>
      <thead><tr><th>Rank</th><th>Lead</th><th>Score</th><th>Status</th></tr></thead>
      <tbody>${rows}</tbody>
    </table>
  `;
}
 
async function saveScores() {
  let saved = 0;
  for (const lead of scoredLeads) {
    await dench.agent.run(`Update person ${lead.id}: set Lead Score to ${lead.score}`);
    saved++;
  }
  await dench.ui.toast({ message: `Saved ${saved} lead scores to CRM`, type: 'success' });
}
 
async function exportTopLeads() {
  const top20 = scoredLeads.slice(0, 20);
  const csv = ['Rank,Name,Company,Title,Email,Score,Percentage']
    .concat(top20.map((l, i) => `${i+1},"${l['Full Name']}","${l.Company || ''}","${l.Title || ''}","${l['Email Address'] || ''}",${l.score},${l.percentage}%`))
    .join('\n');
  
  const blob = new Blob([csv], { type: 'text/csv' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'top-leads.csv';
  a.click();
  URL.revokeObjectURL(url);
}
 
loadFromStorage();
renderCriteria();

Frequently Asked Questions#

How do I add behavioral signals like email opens?#

Add the data to your DuckDB first (via a sync or import), then add criteria that check those fields. For example: { field: 'Email Opens', operator: 'greater_than', value: '2', points: 15 }.

Can I weight scores by recency?#

Add a criterion that checks Last Contacted recency. Or add a decay function: multiply the score by Math.max(0.5, 1 - (daysSinceLastContact / 90)) to reduce scores for stale leads.

How do I share my scoring model with teammates?#

The criteria are stored in localStorage by default. For team sharing, save them to dench.store instead — they'll persist in your DenchClaw workspace and can be synced.

What if I want different models for different segments?#

Add a model selector to the config panel. Store multiple models in dench.store as named presets and load/save them on model switch.

Ready to try DenchClaw? Install in one command: npx denchclaw. Full setup guide →

Mark Rachapoom

Written by

Mark Rachapoom

Building the future of AI CRM software.

Continue reading

DENCH

© 2026 DenchHQ · San Francisco, CA