Back to The Times of Claw

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.

Mark Rachapoom
Mark Rachapoom
·8 min read
Build a Custom Notification Center App

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_count

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

Mark Rachapoom

Written by

Mark Rachapoom

Building the future of AI CRM software.

Continue reading

DENCH

© 2026 DenchHQ · San Francisco, CA