Back to The Times of Claw

Build a Contact Enrichment App in DenchClaw

Build a contact enrichment app in DenchClaw that calls external APIs to fill in missing company data, LinkedIn profiles, and email addresses automatically.

Mark Rachapoom
Mark Rachapoom
·6 min read
Build a Contact Enrichment App in DenchClaw

Build a Contact Enrichment App in DenchClaw

Empty CRM fields are a productivity killer. You have a lead's name and company but no email, no LinkedIn, no company size. Enrichment fixes that — but most enrichment tools are expensive SaaS subscriptions with per-credit pricing.

With DenchClaw's App Builder, you can build your own enrichment app that calls free or affordable APIs, enriches contacts in bulk, and writes directly back to DuckDB. This guide builds exactly that.

What You're Building#

  • A table showing contacts with missing data fields highlighted
  • Batch enrichment: select multiple contacts, hit "Enrich Selected"
  • Integration with a public enrichment API (Clearbit free tier or similar)
  • Field mapping UI to preview enrichment results before saving
  • Progress tracking with a live status log

Step 1: App Setup#

mkdir -p ~/.openclaw-dench/workspace/apps/contact-enricher.dench.app

.dench.yaml:

name: Contact Enricher
description: Bulk enrich contacts with missing company and email data
icon: zap
version: 1.0.0
permissions:
  - read:crm
  - write:crm
  - http:external
display: tab

The http:external permission allows the app to call external APIs via dench.http.fetch(), bypassing CORS restrictions that would otherwise block browser-based API calls.

Step 2: HTML Layout#

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Contact Enricher</title>
  <style>
    * { box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; padding: 20px; }
    .toolbar { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; }
    button { padding: 8px 16px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; }
    .btn-primary { background: #6366f1; color: white; }
    .btn-secondary { background: #334155; color: #94a3b8; }
    table { width: 100%; border-collapse: collapse; }
    th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #1e293b; font-size: 13px; }
    th { color: #64748b; font-size: 11px; text-transform: uppercase; background: #0f172a; }
    tr:hover td { background: #1e293b40; }
    .missing { color: #ef4444; font-style: italic; }
    .present { color: #10b981; }
    .log-panel { margin-top: 20px; background: #1e293b; border-radius: 12px; padding: 16px; max-height: 200px; overflow-y: auto; font-family: monospace; font-size: 12px; }
    .log-entry { padding: 3px 0; border-bottom: 1px solid #334155; }
    .log-ok { color: #10b981; }
    .log-err { color: #ef4444; }
    .log-info { color: #94a3b8; }
    input[type="checkbox"] { width: 16px; height: 16px; }
    #progress { display: none; color: #f59e0b; font-size: 13px; }
  </style>
</head>
<body>
  <div class="toolbar">
    <button class="btn-primary" id="enrichBtn">Enrich Selected</button>
    <button class="btn-secondary" id="selectAllBtn">Select All Missing</button>
    <span id="progress">Enriching... <span id="progressCount">0/0</span></span>
  </div>
  <table>
    <thead>
      <tr>
        <th><input type="checkbox" id="selectAll"></th>
        <th>Name</th>
        <th>Email</th>
        <th>Company</th>
        <th>LinkedIn</th>
        <th>Company Size</th>
        <th>Status</th>
      </tr>
    </thead>
    <tbody id="contactsTable"></tbody>
  </table>
  <div class="log-panel" id="logPanel">
    <div class="log-entry log-info">Ready. Select contacts and click Enrich.</div>
  </div>
  <script src="enricher.js"></script>
</body>
</html>

Step 3: Enrichment Logic#

enricher.js:

let contacts = [];
let selectedIds = new Set();
 
async function loadContacts() {
  contacts = await dench.db.query(`
    SELECT id, "Full Name", "Email Address", "Company", "LinkedIn URL", "Company Size"
    FROM v_people
    WHERE "Status" IN ('Lead', 'Qualified')
    ORDER BY "Full Name"
  `);
  renderTable();
}
 
function renderTable() {
  const tbody = document.getElementById('contactsTable');
  tbody.innerHTML = contacts.map(c => `
    <tr>
      <td><input type="checkbox" data-id="${c.id}" ${selectedIds.has(c.id) ? 'checked' : ''} onchange="toggleSelect('${c.id}')"></td>
      <td>${c['Full Name'] || '—'}</td>
      <td class="${c['Email Address'] ? 'present' : 'missing'}">${c['Email Address'] || 'missing'}</td>
      <td class="${c.Company ? 'present' : 'missing'}">${c.Company || 'missing'}</td>
      <td class="${c['LinkedIn URL'] ? 'present' : 'missing'}">${c['LinkedIn URL'] ? '✓' : 'missing'}</td>
      <td class="${c['Company Size'] ? 'present' : 'missing'}">${c['Company Size'] || 'missing'}</td>
      <td id="status-${c.id}">—</td>
    </tr>
  `).join('');
}
 
function toggleSelect(id) {
  if (selectedIds.has(id)) selectedIds.delete(id);
  else selectedIds.add(id);
}
 
document.getElementById('selectAllBtn').addEventListener('click', () => {
  contacts
    .filter(c => !c['Email Address'] || !c.Company || !c['LinkedIn URL'])
    .forEach(c => selectedIds.add(c.id));
  renderTable();
});
 
document.getElementById('selectAll').addEventListener('change', e => {
  if (e.target.checked) contacts.forEach(c => selectedIds.add(c.id));
  else selectedIds.clear();
  renderTable();
});
 
function addLog(message, type = 'info') {
  const panel = document.getElementById('logPanel');
  const entry = document.createElement('div');
  entry.className = `log-entry log-${type}`;
  entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
  panel.insertBefore(entry, panel.firstChild);
}
 
async function enrichContact(contact) {
  const domain = contact.Company
    ? contact.Company.toLowerCase().replace(/[^a-z0-9]/g, '') + '.com'
    : null;
 
  if (!domain) {
    addLog(`${contact['Full Name']}: no company, skipping`, 'err');
    return null;
  }
 
  try {
    // Using Clearbit's free company enrichment endpoint
    const companyData = await dench.http.fetch(
      `https://autocomplete.clearbit.com/v1/companies/suggest?query=${encodeURIComponent(contact.Company)}`,
      { method: 'GET' }
    );
 
    const match = companyData[0];
    if (!match) {
      addLog(`${contact['Full Name']}: no match found for ${contact.Company}`, 'err');
      return null;
    }
 
    return {
      id: contact.id,
      name: contact['Full Name'],
      enriched: {
        'Company': match.name,
        'Company Domain': match.domain,
        'Company Logo': match.logo
      }
    };
  } catch (err) {
    addLog(`${contact['Full Name']}: enrichment failed — ${err.message}`, 'err');
    return null;
  }
}
 
document.getElementById('enrichBtn').addEventListener('click', async () => {
  const toEnrich = contacts.filter(c => selectedIds.has(c.id));
  if (toEnrich.length === 0) {
    await dench.ui.toast({ message: 'Select contacts first', type: 'warning' });
    return;
  }
 
  document.getElementById('progress').style.display = 'block';
  const btn = document.getElementById('enrichBtn');
  btn.disabled = true;
  btn.textContent = 'Enriching...';
 
  let done = 0;
  for (const contact of toEnrich) {
    document.getElementById('progressCount').textContent = `${done}/${toEnrich.length}`;
    document.getElementById(`status-${contact.id}`).textContent = 'Enriching...';
 
    const result = await enrichContact(contact);
    if (result) {
      // Write back to CRM
      const updates = Object.entries(result.enriched)
        .map(([k, v]) => `"${k}" = "${v}"`)
        .join(', ');
 
      await dench.agent.run(`Update contact ${contact.id}: ${updates}`);
      document.getElementById(`status-${contact.id}`).textContent = '✓ Enriched';
      addLog(`${contact['Full Name']}: enriched successfully`, 'ok');
    } else {
      document.getElementById(`status-${contact.id}`).textContent = '✗ Failed';
    }
 
    done++;
    // Rate limit: wait 200ms between calls
    await new Promise(r => setTimeout(r, 200));
  }
 
  btn.disabled = false;
  btn.textContent = 'Enrich Selected';
  document.getElementById('progress').style.display = 'none';
  await dench.ui.toast({ message: `Enriched ${done} contacts`, type: 'success' });
  loadContacts();
});
 
loadContacts();

Step 4: Swap in Your Enrichment API#

The example uses Clearbit's free autocomplete endpoint (no API key, limited data). For production enrichment, swap the dench.http.fetch() call for your preferred API:

Apollo.io (free tier available):

const result = await dench.http.fetch('https://api.apollo.io/api/v1/people/match', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
  body: JSON.stringify({
    api_key: await dench.store.get('apollo_api_key'),
    first_name: firstName,
    last_name: lastName,
    organization_name: company
  })
});

Hunter.io (email finder):

const result = await dench.http.fetch(
  `https://api.hunter.io/v2/email-finder?domain=${domain}&first_name=${firstName}&last_name=${lastName}&api_key=${apiKey}`
);

Store API keys securely: await dench.store.set('apollo_api_key', 'your-key-here').

Frequently Asked Questions#

How do I add API keys without hardcoding them?#

Use dench.store.set('key_name', 'value') to persist keys, and dench.store.get('key_name') to retrieve them. Add a settings panel to your app where users can enter their API keys.

What's the rate limit for external API calls?#

It depends on your API provider. The 200ms delay in the example handles ~5 requests/second. For stricter limits, increase the delay. For Apollo's free tier, stay under 50 requests per hour.

Can I enrich from LinkedIn directly?#

LinkedIn's API is heavily restricted. The most reliable approach is using the DenchClaw browser agent with your existing LinkedIn session — ask DenchClaw: "Enrich my leads from LinkedIn".

How do I preview enrichment results before saving?#

Build a confirmation step: collect all enrichment results first, render a diff view showing old vs. new values, then save only approved changes.

Does this work with the DenchClaw CRM's built-in Action fields?#

Yes. Action fields are server-side scripts, while this app is a client-side UI. They complement each other — use Action fields for per-row enrichment, and this app for bulk enrichment sessions.

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