Build a Custom Notification Center App
Build a custom notification center app in DenchClaw that aggregates CRM alerts, follow-up reminders, deal updates, and stale lead warnings in one unified inbox.
Build a Custom Notification Center App
DenchClaw sends notifications via your messaging channels (Telegram, Discord, etc.), but sometimes you want everything aggregated in one place inside the CRM itself — a notification center that shows overdue follow-ups, stale deals, deal stage changes, and upcoming tasks all in one feed.
This guide builds a notification center Dench App: a unified inbox that generates alerts from your CRM data and lets you act on them directly.
What You're Building#
- Aggregated notification feed from multiple CRM signals
- Categories: Overdue Follow-ups, Stale Deals, Upcoming Close Dates, New Leads
- One-click actions per notification (Mark Done, Snooze, Open Entry)
- Badge count on the sidebar icon showing unread alerts
- Configurable alert thresholds
Step 1: App Setup#
mkdir -p ~/.openclaw-dench/workspace/apps/notification-center.dench.app.dench.yaml:
name: Notifications
description: Aggregated CRM alerts and follow-up reminders
icon: bell
version: 1.0.0
permissions:
- read:crm
- write:crm
display: tab
badge: unread_countStep 2: HTML Layout#
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Notification Center</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 20px; }
.tabs { display: flex; gap: 4px; margin-bottom: 20px; background: #1e293b; padding: 4px; border-radius: 10px; }
.tab { padding: 8px 16px; border-radius: 7px; cursor: pointer; font-size: 13px; color: #64748b; border: none; background: transparent; }
.tab.active { background: #6366f1; color: white; }
.tab .badge { display: inline-block; background: #ef4444; color: white; font-size: 10px; padding: 1px 5px; border-radius: 999px; margin-left: 4px; }
.notification { background: #1e293b; border-radius: 10px; padding: 14px 16px; margin-bottom: 10px; display: flex; gap: 14px; align-items: flex-start; }
.notification.unread { border-left: 3px solid #6366f1; }
.notification.urgent { border-left: 3px solid #ef4444; }
.notif-icon { font-size: 20px; flex-shrink: 0; }
.notif-content { flex: 1; }
.notif-title { font-weight: 600; font-size: 13px; margin-bottom: 3px; }
.notif-body { font-size: 12px; color: #94a3b8; line-height: 1.4; }
.notif-time { font-size: 11px; color: #475569; margin-top: 4px; }
.notif-actions { display: flex; gap: 6px; margin-top: 8px; }
.btn-action { padding: 4px 10px; border: 1px solid #334155; background: transparent; color: #94a3b8; border-radius: 6px; cursor: pointer; font-size: 11px; }
.btn-action:hover { border-color: #6366f1; color: #a5b4fc; }
.btn-action.primary { background: #6366f1; border-color: #6366f1; color: white; }
.empty-state { text-align: center; padding: 60px 20px; color: #475569; }
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
.settings { margin-bottom: 16px; background: #1e293b; border-radius: 10px; padding: 14px; font-size: 13px; }
.setting-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.setting-row input[type="number"] { width: 60px; background: #0f172a; border: 1px solid #334155; color: #e2e8f0; padding: 4px 8px; border-radius: 6px; font-size: 12px; text-align: center; }
</style>
</head>
<body>
<div class="tabs">
<button class="tab active" onclick="showTab('all')" id="tab-all">All <span class="badge" id="badge-all">0</span></button>
<button class="tab" onclick="showTab('followups')" id="tab-followups">Follow-ups <span class="badge" id="badge-followups">0</span></button>
<button class="tab" onclick="showTab('deals')" id="tab-deals">Deals <span class="badge" id="badge-deals">0</span></button>
<button class="tab" onclick="showTab('new-leads')" id="tab-new-leads">New Leads <span class="badge" id="badge-new-leads">0</span></button>
</div>
<div id="notifications-container"></div>
<script src="notifications.js"></script>
</body>
</html>Step 3: Notification Logic#
notifications.js:
let allNotifications = [];
let dismissedIds = new Set();
let currentTab = 'all';
const SETTINGS = {
staleContactDays: 14,
staleDeallDays: 21,
upcomingCloseDays: 7,
maxNotifications: 50
};
async function loadNotifications() {
const dismissed = await dench.store.get('dismissed_notifications') || [];
dismissedIds = new Set(dismissed);
allNotifications = [];
const today = new Date().toISOString().split('T')[0];
// 1. Overdue follow-ups (contacts not contacted in X days)
const staleContacts = await dench.db.query(`
SELECT id, "Full Name", "Company", "Status", "Last Contacted",
DATE_DIFF('day', CAST("Last Contacted" AS DATE), CURRENT_DATE) AS days_ago
FROM v_people
WHERE "Status" IN ('Lead', 'Qualified', 'Nurturing')
AND DATE_DIFF('day', CAST("Last Contacted" AS DATE), CURRENT_DATE) >= ${SETTINGS.staleContactDays}
ORDER BY days_ago DESC
LIMIT 20
`);
staleContacts.forEach(c => {
const id = `stale_contact_${c.id}`;
if (dismissedIds.has(id)) return;
allNotifications.push({
id,
type: 'followups',
urgent: c.days_ago > 30,
icon: '👤',
title: `Follow up with ${c['Full Name']}`,
body: `${c.Company || 'No company'} — last contacted ${c.days_ago} days ago`,
time: `${c.days_ago} days overdue`,
entryId: c.id,
actions: [
{ label: 'Mark Contacted', action: () => markContacted(c.id, id) },
{ label: 'Snooze 3 days', action: () => snooze(id, 3) },
{ label: 'Open', action: () => openEntry(c.id), primary: true }
]
});
});
// 2. Stale deals
const staleDeals = await dench.db.query(`
SELECT id, "Deal Name", "Company", "Stage", "Value",
DATE_DIFF('day', CAST("Stage Changed Date" AS DATE), CURRENT_DATE) AS days_in_stage
FROM v_deals
WHERE "Stage" NOT IN ('Closed Won', 'Closed Lost')
AND DATE_DIFF('day', CAST("Stage Changed Date" AS DATE), CURRENT_DATE) >= ${SETTINGS.staleDeallDays}
ORDER BY CAST("Value" AS DOUBLE) DESC NULLS LAST
LIMIT 15
`);
staleDeals.forEach(d => {
const id = `stale_deal_${d.id}`;
if (dismissedIds.has(id)) return;
allNotifications.push({
id,
type: 'deals',
urgent: d.days_in_stage > 45,
icon: '💼',
title: `Deal stuck: ${d['Deal Name'] || d.Company}`,
body: `$${Number(d.Value || 0).toLocaleString()} — in "${d.Stage}" for ${d.days_in_stage} days`,
time: `${d.days_in_stage} days in stage`,
entryId: d.id,
actions: [
{ label: 'Advance Stage', action: () => advanceDeal(d.id, id) },
{ label: 'Dismiss', action: () => dismiss(id) },
{ label: 'Open', action: () => openEntry(d.id), primary: true }
]
});
});
// 3. Deals closing soon
const closingSoon = await dench.db.query(`
SELECT id, "Deal Name", "Company", "Value", "Close Date",
DATE_DIFF('day', CURRENT_DATE, CAST("Close Date" AS DATE)) AS days_until
FROM v_deals
WHERE "Stage" NOT IN ('Closed Won', 'Closed Lost')
AND "Close Date" IS NOT NULL
AND DATE_DIFF('day', CURRENT_DATE, CAST("Close Date" AS DATE)) BETWEEN 0 AND ${SETTINGS.upcomingCloseDays}
ORDER BY days_until
`);
closingSoon.forEach(d => {
const id = `closing_${d.id}`;
if (dismissedIds.has(id)) return;
allNotifications.push({
id,
type: 'deals',
urgent: d.days_until <= 2,
icon: '🎯',
title: `Closing in ${d.days_until} days: ${d['Deal Name'] || d.Company}`,
body: `$${Number(d.Value || 0).toLocaleString()} expected close on ${d['Close Date']}`,
time: `Due ${d.days_until === 0 ? 'today' : `in ${d.days_until} days`}`,
entryId: d.id,
actions: [
{ label: 'Open', action: () => openEntry(d.id), primary: true }
]
});
});
// 4. New leads (added in last 24h)
const newLeads = await dench.db.query(`
SELECT id, "Full Name", "Company", "Source"
FROM v_people
WHERE "Status" = 'Lead'
AND created_at >= CURRENT_TIMESTAMP - INTERVAL '24 hours'
ORDER BY created_at DESC
LIMIT 10
`);
newLeads.forEach(lead => {
const id = `new_lead_${lead.id}`;
if (dismissedIds.has(id)) return;
allNotifications.push({
id,
type: 'new-leads',
urgent: false,
icon: '✨',
title: `New lead: ${lead['Full Name']}`,
body: `${lead.Company || 'No company'}${lead.Source ? ` via ${lead.Source}` : ''}`,
time: 'New today',
entryId: lead.id,
actions: [
{ label: 'Qualify', action: () => qualifyLead(lead.id, id) },
{ label: 'Open', action: () => openEntry(lead.id), primary: true }
]
});
});
updateBadges();
renderNotifications();
}
function updateBadges() {
const counts = { all: allNotifications.length, followups: 0, deals: 0, 'new-leads': 0 };
allNotifications.forEach(n => counts[n.type] = (counts[n.type] || 0) + 1);
Object.entries(counts).forEach(([type, count]) => {
const badge = document.getElementById(`badge-${type}`);
if (badge) { badge.textContent = count; badge.style.display = count > 0 ? 'inline' : 'none'; }
});
}
function renderNotifications() {
const filtered = currentTab === 'all' ? allNotifications : allNotifications.filter(n => n.type === currentTab);
const container = document.getElementById('notifications-container');
if (filtered.length === 0) {
container.innerHTML = `<div class="empty-state"><div class="icon">🎉</div><div>All caught up!</div></div>`;
return;
}
container.innerHTML = filtered.map(notif => `
<div class="notification ${notif.urgent ? 'urgent' : 'unread'}" id="notif-${notif.id}">
<div class="notif-icon">${notif.icon}</div>
<div class="notif-content">
<div class="notif-title">${notif.title}</div>
<div class="notif-body">${notif.body}</div>
<div class="notif-time">${notif.time}</div>
<div class="notif-actions">
${notif.actions.map((a, i) => `
<button class="btn-action ${a.primary ? 'primary' : ''}" onclick="handleAction('${notif.id}', ${i})">${a.label}</button>
`).join('')}
</div>
</div>
</div>
`).join('');
}
window.handleAction = (notifId, actionIdx) => {
const notif = allNotifications.find(n => n.id === notifId);
if (notif) notif.actions[actionIdx].action();
};
async function dismiss(id) {
allNotifications = allNotifications.filter(n => n.id !== id);
dismissedIds.add(id);
await dench.store.set('dismissed_notifications', [...dismissedIds].slice(-100));
renderNotifications();
updateBadges();
}
async function markContacted(entryId, notifId) {
await dench.agent.run(`Update contact ${entryId}: set Last Contacted to today`);
dismiss(notifId);
await dench.ui.toast({ message: 'Marked as contacted', type: 'success' });
}
function openEntry(entryId) { dench.apps.navigate(`/entry/${entryId}`); }
async function snooze(notifId, days) {
await dench.store.set(`snooze_${notifId}`, Date.now() + days * 86400000);
dismiss(notifId);
}
async function advanceDeal(dealId, notifId) {
await dench.agent.run(`Advance deal ${dealId} to next stage`);
dismiss(notifId);
}
async function qualifyLead(leadId, notifId) {
await dench.agent.run(`Update contact ${leadId}: set Status to Qualified`);
dismiss(notifId);
}
function showTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.getElementById(`tab-${tab}`)?.classList.add('active');
renderNotifications();
}
// Refresh every 5 minutes
setInterval(loadNotifications, 300000);
dench.events.on('entry:created', loadNotifications);
loadNotifications();Frequently Asked Questions#
How do I add custom notification types?#
Add a new query block in loadNotifications() following the same pattern: query DuckDB, map results to notification objects with an id, type, title, body, and actions array.
Can I send these notifications to Telegram or Discord?#
Yes. Add a "Send to Telegram" action button that calls dench.agent.run("Send me a message about this notification: ..."). Or schedule a cron job that runs the notification queries and sends a daily digest.
How do the snooze thresholds work?#
The current implementation stores a snooze timestamp in dench.store. On next load, check: if (Date.now() < snoozeUntil) skip this notification. You'd need to add this check at the start of loadNotifications().
How do I make the sidebar icon show a badge count?#
The .dench.yaml badge: unread_count field is a planned feature. For now, you can update the app icon dynamically via the DenchClaw app API when the badge feature ships.
Ready to try DenchClaw? Install in one command: npx denchclaw. Full setup guide →
