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.
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 →