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.
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_peopleleads - 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: tabNote 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 (<7 days)</option>
<option value="warm">7–30 days ago</option>
<option value="stale">Stale (>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 →