Back to The Times of Claw

Build a Lead Tracker App with the Dench App Builder

Build a custom lead tracking app in DenchClaw with filtering, status updates, and enrichment actions using the Dench App Builder and bridge API.

Mark Rachapoom
Mark Rachapoom
·6 min read
Build a Lead Tracker App with the Dench App Builder

Build a Lead Tracker App with the Dench App Builder

The default DenchClaw CRM table view is great for browsing data, but sometimes you want a custom UI tuned exactly to how your team works. A lead tracker app can show only the columns you care about, add one-click status update buttons, surface enrichment actions, and highlight stale leads automatically — all without touching the standard CRM configuration.

This guide builds a focused lead tracker as a Dench App: a filterable table of leads with quick-action buttons and color-coded staleness indicators.

What You're Building#

  • A custom table showing your v_people leads
  • Filter controls: by status, by last contact date range, by assigned owner
  • Color coding: green (contacted < 7 days), yellow (7-30 days), red (>30 days)
  • Quick actions: "Mark Contacted", "Assign to Me", "Open Entry"
  • Auto-refresh every 2 minutes

Step 1: Create the App Structure#

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

.dench.yaml:

name: Lead Tracker
description: Focused lead management with staleness indicators
icon: users
version: 1.0.0
permissions:
  - read:crm
  - write:crm
display: tab

Note the write:crm permission — the app needs it to update lead statuses.

Step 2: Build the HTML Layout#

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Lead Tracker</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; padding: 20px; }
    .filters { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
    select, input { background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 8px 12px; border-radius: 8px; font-size: 14px; }
    table { width: 100%; border-collapse: collapse; background: #1e293b; border-radius: 12px; overflow: hidden; }
    th { padding: 12px 16px; text-align: left; font-size: 12px; color: #64748b; text-transform: uppercase; background: #0f172a; }
    td { padding: 12px 16px; border-top: 1px solid #334155; font-size: 14px; }
    .stale-green { border-left: 3px solid #10b981; }
    .stale-yellow { border-left: 3px solid #f59e0b; }
    .stale-red { border-left: 3px solid #ef4444; }
    .btn { padding: 4px 10px; border: none; border-radius: 6px; cursor: pointer; font-size: 12px; }
    .btn-primary { background: #6366f1; color: white; }
    .btn-secondary { background: #334155; color: #94a3b8; }
    .status-badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
    .loading { text-align: center; padding: 40px; color: #64748b; }
  </style>
</head>
<body>
  <div class="filters">
    <select id="statusFilter">
      <option value="">All Statuses</option>
      <option value="Lead">Lead</option>
      <option value="Qualified">Qualified</option>
      <option value="Nurturing">Nurturing</option>
    </select>
    <select id="stalenessFilter">
      <option value="">All</option>
      <option value="fresh">Contacted recently (&lt;7 days)</option>
      <option value="warm">7–30 days ago</option>
      <option value="stale">Stale (&gt;30 days)</option>
    </select>
    <input type="text" id="searchInput" placeholder="Search by name or company...">
  </div>
  <div id="tableContainer"><div class="loading">Loading leads...</div></div>
  <script src="tracker.js"></script>
</body>
</html>

Step 3: Write the Tracker Logic#

tracker.js:

let allLeads = [];
 
async function loadLeads() {
  const results = await dench.db.query(`
    SELECT
      id,
      "Full Name",
      "Email Address",
      "Company",
      "Status",
      "Last Contacted",
      "Owner",
      DATE_DIFF('day', CAST("Last Contacted" AS DATE), CURRENT_DATE) AS days_since_contact
    FROM v_people
    WHERE "Status" IN ('Lead', 'Qualified', 'Nurturing')
    ORDER BY days_since_contact DESC NULLS FIRST
  `);
  allLeads = results;
  renderTable(applyFilters());
}
 
function applyFilters() {
  const status = document.getElementById('statusFilter').value;
  const staleness = document.getElementById('stalenessFilter').value;
  const search = document.getElementById('searchInput').value.toLowerCase();
 
  return allLeads.filter(lead => {
    if (status && lead.Status !== status) return false;
    if (staleness === 'fresh' && lead.days_since_contact > 7) return false;
    if (staleness === 'warm' && (lead.days_since_contact <= 7 || lead.days_since_contact > 30)) return false;
    if (staleness === 'stale' && lead.days_since_contact <= 30) return false;
    if (search) {
      const name = (lead['Full Name'] || '').toLowerCase();
      const company = (lead.Company || '').toLowerCase();
      if (!name.includes(search) && !company.includes(search)) return false;
    }
    return true;
  });
}
 
function stalenessClass(days) {
  if (days === null || days === undefined) return 'stale-red';
  if (days <= 7) return 'stale-green';
  if (days <= 30) return 'stale-yellow';
  return 'stale-red';
}
 
function statusColor(status) {
  const colors = { Lead: '#6366f1', Qualified: '#10b981', Nurturing: '#f59e0b' };
  return colors[status] || '#64748b';
}
 
function renderTable(leads) {
  if (leads.length === 0) {
    document.getElementById('tableContainer').innerHTML = '<div class="loading">No leads match your filters.</div>';
    return;
  }
 
  const rows = leads.map(lead => `
    <tr class="${stalenessClass(lead.days_since_contact)}">
      <td><strong>${lead['Full Name'] || '—'}</strong></td>
      <td>${lead.Company || '—'}</td>
      <td>${lead['Email Address'] || '—'}</td>
      <td>
        <span class="status-badge" style="background:${statusColor(lead.Status)}20;color:${statusColor(lead.Status)}">
          ${lead.Status || '—'}
        </span>
      </td>
      <td style="color:${lead.days_since_contact > 30 ? '#ef4444' : '#94a3b8'}">
        ${lead.days_since_contact != null ? `${lead.days_since_contact}d ago` : 'Never'}
      </td>
      <td>
        <button class="btn btn-primary" onclick="markContacted('${lead.id}')">Contacted</button>
        <button class="btn btn-secondary" onclick="openEntry('${lead.id}')">Open</button>
      </td>
    </tr>
  `).join('');
 
  document.getElementById('tableContainer').innerHTML = `
    <table>
      <thead><tr>
        <th>Name</th><th>Company</th><th>Email</th><th>Status</th><th>Last Contact</th><th>Actions</th>
      </tr></thead>
      <tbody>${rows}</tbody>
    </table>
  `;
}
 
async function markContacted(entryId) {
  await dench.db.query(`
    UPDATE entries SET updated_at = CURRENT_TIMESTAMP WHERE id = '${entryId}'
  `);
  // Also update the Last Contacted field
  await dench.agent.run(`Update the "Last Contacted" field for entry ${entryId} to today's date`);
  await dench.ui.toast({ message: 'Marked as contacted', type: 'success' });
  loadLeads();
}
 
function openEntry(entryId) {
  dench.apps.navigate(`/entry/${entryId}`);
}
 
// Wire up filters
['statusFilter', 'stalenessFilter'].forEach(id => {
  document.getElementById(id).addEventListener('change', () => renderTable(applyFilters()));
});
document.getElementById('searchInput').addEventListener('input', () => renderTable(applyFilters()));
 
// Auto-refresh every 2 minutes
setInterval(loadLeads, 120000);
dench.events.on('entry:updated', loadLeads);
 
loadLeads();

Step 4: Tune for Your Schema#

The query assumes your v_people view has fields named Full Name, Email Address, Company, Status, Last Contacted, and Owner. If your field names differ, update the SQL query.

Check your actual field names:

duckdb ~/.openclaw-dench/workspace/workspace.duckdb "SELECT column_name FROM information_schema.columns WHERE table_name = 'v_people'"

Adding Bulk Actions#

Once the basic tracker works, add bulk selection:

// Add checkboxes to each row
// Track selectedIds = new Set()
// Add a bulk action bar: "Mark 5 leads as contacted"
 
async function bulkMarkContacted(ids) {
  const idList = ids.map(id => `'${id}'`).join(',');
  await dench.agent.run(`Update Last Contacted to today for entries: ${idList}`);
  await dench.ui.toast({ message: `${ids.length} leads marked as contacted`, type: 'success' });
  loadLeads();
}

Frequently Asked Questions#

How do I add a lead from inside the app?#

Use dench.agent.run("Create a new lead with name X, email Y, company Z"). Or open a modal form and call the CRM write API directly. The write:crm permission grants full write access.

Can I embed this in a widget instead of a full tab?#

Yes. Change display: tab to display: widget in .dench.yaml and add widget dimensions. You'd simplify the view to show a count summary rather than the full table.

How do I add custom columns from different objects?#

Use a JOIN in your DuckDB query. For example, to add deal count per lead: LEFT JOIN (SELECT person_id, COUNT(*) as deals FROM v_deals GROUP BY person_id) d ON d.person_id = v_people.id.

What if my team has multiple owners?#

Add an ownerFilter select element and extend applyFilters() to filter by lead.Owner. Populate it dynamically from a SELECT DISTINCT "Owner" FROM v_people query.

Can I export the filtered leads to CSV?#

Yes. After filtering, build a CSV string from the allLeads data and use the browser's download API: const blob = new Blob([csvContent], {type: 'text/csv'}); const url = URL.createObjectURL(blob); ...

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