DenchClaw Widgets: At-a-Glance Metrics on Your Dashboard
DenchClaw widgets let you pin live CRM metrics to your dashboard using .dench.app's widget mode — no server, no build step, pure HTML and DuckDB queries.
DenchClaw widgets are mini apps that live on your dashboard and show you live metrics at a glance — open deals, contact counts, pipeline velocity, today's tasks, or any number you care about. They're built with the same App Builder as full panel apps, but with display: "widget" in the manifest and a compact layout optimized for quick scanning.
Here's how to build them from scratch.
What Makes a Widget Different from an App#
A full DenchClaw app (display: "panel") opens in a dedicated panel. A widget (display: "widget") renders inline on your dashboard alongside other widgets. Widgets:
- Auto-refresh on an interval you configure
- Are designed for reading, not interaction
- Tend to be 200–400px tall
- Query DuckDB for live data on every refresh
You can have multiple widgets running simultaneously. Each is its own .dench.app folder with its own data queries and layout.
See App Builder examples and DenchClaw reports for more ways to visualize your workspace data.
Step 1: Scaffold the Widget Folder#
mkdir -p ~/denchclaw-workspace/apps/pipeline-widget.dench.app
cd ~/denchclaw-workspace/apps/pipeline-widget.dench.app
touch .dench.yaml
touch index.htmlStep 2: Configure the Manifest for Widget Mode#
name: "Pipeline Overview"
description: "Live count of deals by stage"
display: "widget"
icon: "📊"
version: "1.0.0"
refresh: 30 # seconds between auto-refreshesThe refresh key tells DenchClaw to reload the widget's data every 30 seconds. Omit it for manual-refresh only.
Step 3: Build a Pipeline Metrics Widget#
Here's a complete widget that shows deal counts by pipeline stage, with color-coded indicators:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f0f0f;
color: #e5e5e5;
padding: 16px;
min-height: 100%;
}
.widget-header {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #555;
margin-bottom: 12px;
}
.metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.metric-card {
background: #1a1a1a;
border: 1px solid #222;
border-radius: 8px;
padding: 12px;
}
.metric-card .label {
font-size: 11px;
color: #666;
margin-bottom: 4px;
}
.metric-card .value {
font-size: 26px;
font-weight: 700;
line-height: 1;
}
.metric-card .change {
font-size: 11px;
margin-top: 4px;
}
.positive { color: #22c55e; }
.neutral { color: #94a3b8; }
.warning { color: #f59e0b; }
.last-updated {
font-size: 10px;
color: #333;
margin-top: 12px;
text-align: right;
}
.loading { opacity: 0.4; }
</style>
</head>
<body>
<div class="widget-header">📊 Pipeline Overview</div>
<div class="metrics-grid" id="metrics">
<div class="metric-card loading">
<div class="label">Loading...</div>
<div class="value">—</div>
</div>
</div>
<div class="last-updated" id="updated"></div>
<script>
async function loadMetrics() {
try {
// Total contacts
const [{ total_contacts }] = await dench.db.query(`
SELECT COUNT(*) as total_contacts FROM objects WHERE type = 'contact'
`);
// Active deals (objects with a deal-type status)
const [{ active_deals }] = await dench.db.query(`
SELECT COUNT(*) as active_deals
FROM objects o
JOIN entries e ON e.object_id = o.id
JOIN entry_fields ef ON ef.entry_id = e.id
JOIN fields f ON f.id = ef.field_id
WHERE f.name = 'Status' AND ef.value IN ('Lead', 'Prospect', 'Negotiation')
`);
// Won deals
const [{ won_deals }] = await dench.db.query(`
SELECT COUNT(*) as won_deals
FROM objects o
JOIN entries e ON e.object_id = o.id
JOIN entry_fields ef ON ef.entry_id = e.id
JOIN fields f ON f.id = ef.field_id
WHERE f.name = 'Status' AND ef.value = 'Won'
`);
// Tasks due today
const [{ tasks_today }] = await dench.db.query(`
SELECT COUNT(*) as tasks_today
FROM objects o
WHERE o.type = 'task'
AND date_trunc('day', o.created_at) = current_date
`);
const metricsEl = document.getElementById('metrics');
metricsEl.innerHTML = `
<div class="metric-card">
<div class="label">Contacts</div>
<div class="value neutral">${total_contacts}</div>
<div class="change neutral">in workspace</div>
</div>
<div class="metric-card">
<div class="label">Active Deals</div>
<div class="value warning">${active_deals}</div>
<div class="change warning">in pipeline</div>
</div>
<div class="metric-card">
<div class="label">Deals Won</div>
<div class="value positive">${won_deals}</div>
<div class="change positive">closed ✓</div>
</div>
<div class="metric-card">
<div class="label">Tasks Today</div>
<div class="value neutral">${tasks_today}</div>
<div class="change neutral">created today</div>
</div>
`;
document.getElementById('updated').textContent =
'Updated ' + new Date().toLocaleTimeString();
} catch (err) {
document.getElementById('metrics').innerHTML =
'<div class="metric-card"><div class="label" style="color:#ef4444">Error loading data</div></div>';
dench.ui.toast('Widget error: ' + err.message, { type: 'error' });
}
}
loadMetrics();
</script>
</body>
</html>Step 4: Add Auto-Refresh Logic#
If you want the widget to refresh more granularly than the manifest's refresh interval — say, to animate a counter or show a live clock — use dench.cron inside the widget:
// Refresh metrics every 60 seconds from within the widget
dench.cron.every(60000, () => {
loadMetrics();
});
// Initial load
loadMetrics();You can also listen to CRM events to refresh immediately when data changes:
dench.events.on('crm:contact:created', loadMetrics);
dench.events.on('crm:entry:updated', loadMetrics);This is more efficient than polling — the widget only refreshes when something actually changed.
Step 5: Build a Compact "Today's Contacts" Widget#
Here's a second widget pattern — a vertical list instead of a grid:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f0f0f;
color: #e5e5e5;
padding: 14px;
}
.header { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; }
.contact-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #1a1a1a;
font-size: 13px;
}
.contact-row:last-child { border-bottom: none; }
.avatar {
width: 28px; height: 28px;
border-radius: 50%;
background: #1e3a5f;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; color: #60a5fa;
flex-shrink: 0;
}
.name { font-weight: 500; }
.status { font-size: 11px; color: #666; margin-top: 1px; }
.empty { color: #555; font-size: 13px; padding: 12px 0; }
</style>
</head>
<body>
<div class="header">🕐 Recent Contacts</div>
<div id="list"><div class="empty">Loading...</div></div>
<script>
async function load() {
const contacts = await dench.db.query(`
SELECT o.name, MAX(ef.value) FILTER (WHERE f.name = 'Status') as status
FROM objects o
LEFT JOIN entries e ON e.object_id = o.id
LEFT JOIN entry_fields ef ON ef.entry_id = e.id
LEFT JOIN fields f ON f.id = ef.field_id
WHERE o.type = 'contact'
GROUP BY o.id, o.name
ORDER BY o.created_at DESC
LIMIT 6
`);
const listEl = document.getElementById('list');
if (!contacts.length) {
listEl.innerHTML = '<div class="empty">No contacts yet</div>';
return;
}
listEl.innerHTML = contacts.map(c => {
const initials = c.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
return `
<div class="contact-row">
<div class="avatar">${initials}</div>
<div>
<div class="name">${c.name}</div>
<div class="status">${c.status || 'No status'}</div>
</div>
</div>
`;
}).join('');
}
load();
dench.events.on('crm:contact:created', load);
</script>
</body>
</html>Widget Design Principles#
After building a few of these, patterns emerge. Here are the rules that produce the best widgets:
1. One question per widget#
A widget should answer exactly one question: "How many deals are open?" or "Who did I add recently?" Resist the urge to pack everything in. Build multiple focused widgets instead.
2. Numbers > descriptions#
Widgets are scanned, not read. Lead with a big number. Add a tiny label underneath. Color-code to convey direction (green = good, amber = watch, red = act now).
3. Error gracefully#
Your DuckDB queries might fail if the schema evolves. Always wrap in try/catch and show a fallback state — never a blank widget.
4. Keep queries fast#
Widgets refresh frequently. Avoid full-table scans on large datasets. Add LIMIT clauses, use indexed fields, and cache results in dench.store if the query is slow.
5. Use events over polling#
dench.events fires instantly when data changes. This is almost always better than setInterval() for keeping widgets current.
Combining Widgets Into a Dashboard#
You can run multiple widgets simultaneously. From the DenchClaw dashboard, pin the ones you want visible and arrange them. Each widget is independent — they load in parallel, have their own refresh intervals, and don't share state unless you use DuckDB as the shared layer.
A good starting dashboard for a founder might have:
- Pipeline overview (deal counts by stage)
- Recent contacts (who was added today)
- Task queue (what's due or overdue)
- Activity feed (last 5 events from
dench.events)
This gives you a complete picture of your workspace in a single glance — which is exactly what a CRM dashboard should do.
FAQ#
Can a widget be interactive (buttons, clicks)?
Yes — widgets render full HTML. You can add buttons that trigger dench.db.query() mutations or call dench.chat. Just keep the primary purpose informational.
How do I share a widget with my team?
Drop the .dench.app folder into a shared directory or commit it to your git repo alongside the workspace. Since everything is local-first and file-based, sharing is just file sharing.
Can I use Chart.js or D3 in a widget?
Absolutely. Load them from a CDN via <script src=""> in your index.html. DenchClaw imposes no restrictions on what libraries your app uses.
What's the minimum widget height? There's no enforced minimum. In practice, anything under 120px feels cramped. 200–350px is the sweet spot for metric cards.
Do widgets slow down DenchClaw? Each widget runs in its own isolated context. Multiple active widgets are lightweight. The main performance concern is expensive DuckDB queries — keep them under 50ms.
Ready to try DenchClaw? Install in one command: npx denchclaw. Full setup guide →
