Back to The Times of Claw

Build a Sales Activity Feed App

Build a real-time sales activity feed app in DenchClaw that shows every CRM action — new leads, stage changes, deals closed, and tasks completed — as a live stream.

Mark Rachapoom
Mark Rachapoom
·6 min read
Build a Sales Activity Feed App

Build a Sales Activity Feed App

Visibility matters in sales. When your team doesn't know what's happening in the pipeline, things fall through cracks. A sales activity feed — a live stream of every CRM action — gives everyone on the team real-time awareness: who just added a new lead, which deal moved to negotiation, who closed.

This guide builds a real-time activity feed as a Dench App, using DenchClaw's dench.events API to stream CRM changes as they happen.

What You're Building#

  • A real-time activity feed showing CRM events as they occur
  • Event categories: new entries, updates, stage changes, deals closed
  • Icon and color coding per event type
  • Expandable event cards with change details
  • Search and filter by user, object type, or date range

Step 1: App Setup#

mkdir -p ~/.openclaw-dench/workspace/apps/activity-feed.dench.app

.dench.yaml:

name: Activity Feed
description: Real-time stream of all CRM activity
icon: activity
version: 1.0.0
permissions:
  - read:crm
display: tab

Step 2: HTML Layout#

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Activity Feed</title>
  <style>
    * { box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; display: flex; flex-direction: column; height: 100vh; }
    .topbar { padding: 14px 20px; background: #1e293b; border-bottom: 1px solid #334155; display: flex; gap: 12px; align-items: center; }
    .topbar h2 { margin: 0; font-size: 15px; flex: 1; }
    .live-dot { width: 8px; height: 8px; border-radius: 50%; background: #10b981; animation: pulse-dot 2s infinite; }
    @keyframes pulse-dot { 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 #10b98140; } 50% { opacity: 0.7; box-shadow: 0 0 0 5px transparent; } }
    .filters { padding: 10px 20px; background: #0a0f1e; border-bottom: 1px solid #1e293b; display: flex; gap: 10px; }
    .filters input, .filters select { background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 6px 12px; border-radius: 8px; font-size: 12px; }
    .feed { flex: 1; overflow-y: auto; padding: 16px 20px; }
    .event { display: flex; gap: 12px; align-items: flex-start; margin-bottom: 12px; animation: slideIn 0.3s ease; }
    @keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
    .event-icon { width: 34px; height: 34px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
    .event-body { flex: 1; background: #1e293b; border-radius: 10px; padding: 10px 14px; }
    .event-title { font-size: 13px; font-weight: 600; margin-bottom: 3px; }
    .event-detail { font-size: 12px; color: #94a3b8; line-height: 1.4; }
    .event-time { font-size: 11px; color: #475569; margin-top: 4px; }
    .event-tag { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 10px; margin-right: 4px; }
    .separator { text-align: center; font-size: 11px; color: #475569; margin: 16px 0; display: flex; align-items: center; gap: 10px; }
    .separator::before, .separator::after { content: ''; flex: 1; height: 1px; background: #1e293b; }
    .pause-btn { padding: 6px 14px; border: 1px solid #334155; background: transparent; color: #64748b; border-radius: 8px; cursor: pointer; font-size: 12px; }
    .pause-btn.paused { background: #ef444420; border-color: #ef4444; color: #ef4444; }
  </style>
</head>
<body>
  <div class="topbar">
    <div class="live-dot" id="liveDot"></div>
    <h2>Activity Feed</h2>
    <button class="pause-btn" id="pauseBtn" onclick="togglePause()">Pause</button>
    <span id="eventCount" style="font-size:12px;color:#64748b">0 events</span>
  </div>
  <div class="filters">
    <input type="text" id="searchFilter" placeholder="Search..." oninput="applyFilters()">
    <select id="typeFilter" onchange="applyFilters()">
      <option value="">All types</option>
      <option value="deal">Deals</option>
      <option value="lead">Leads</option>
      <option value="contact">Contacts</option>
    </select>
    <select id="actionFilter" onchange="applyFilters()">
      <option value="">All actions</option>
      <option value="created">Created</option>
      <option value="updated">Updated</option>
      <option value="stage_changed">Stage changed</option>
      <option value="closed">Closed</option>
    </select>
  </div>
  <div class="feed" id="feed"></div>
  <script src="feed.js"></script>
</body>
</html>

Step 3: Activity Feed Logic#

feed.js:

let events = [];
let paused = false;
let totalEvents = 0;
 
const EVENT_CONFIG = {
  created: { icon: '✨', color: '#10b981', bg: '#10b98120' },
  updated: { icon: '📝', color: '#6366f1', bg: '#6366f120' },
  stage_changed: { icon: '🔄', color: '#f59e0b', bg: '#f59e0b20' },
  closed_won: { icon: '🎉', color: '#10b981', bg: '#10b98130' },
  closed_lost: { icon: '😞', color: '#ef4444', bg: '#ef444420' },
  new_lead: { icon: '👤', color: '#8b5cf6', bg: '#8b5cf620' }
};
 
function formatTimeAgo(date) {
  const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
  if (seconds < 60) return 'Just now';
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
  return new Date(date).toLocaleDateString();
}
 
function createEventCard(event) {
  const config = EVENT_CONFIG[event.actionType] || EVENT_CONFIG.updated;
  return {
    id: event.id || Date.now() + Math.random(),
    actionType: event.actionType,
    html: `
      <div class="event" id="event-${event.id}">
        <div class="event-icon" style="background:${config.bg}">${config.icon}</div>
        <div class="event-body">
          <div class="event-title">${event.title}</div>
          <div class="event-detail">${event.detail}</div>
          ${event.tags ? `<div style="margin-top:6px">${event.tags.map(t => `<span class="event-tag" style="background:${t.color}20;color:${t.color}">${t.label}</span>`).join('')}</div>` : ''}
          <div class="event-time">${formatTimeAgo(event.timestamp)}</div>
        </div>
      </div>
    `,
    rawEvent: event
  };
}
 
function renderFeed() {
  const search = document.getElementById('searchFilter').value.toLowerCase();
  const typeFilter = document.getElementById('typeFilter').value;
  const actionFilter = document.getElementById('actionFilter').value;
 
  const filtered = events.filter(e => {
    if (search && !e.rawEvent.title?.toLowerCase().includes(search) && !e.rawEvent.detail?.toLowerCase().includes(search)) return false;
    if (typeFilter && e.rawEvent.objectType !== typeFilter) return false;
    if (actionFilter && e.actionType !== actionFilter) return false;
    return true;
  });
 
  document.getElementById('feed').innerHTML = filtered.map(e => e.html).join('');
  document.getElementById('eventCount').textContent = `${totalEvents} events`;
}
 
function addEvent(eventData) {
  if (paused) return;
  const card = createEventCard(eventData);
  events.unshift(card);
  if (events.length > 200) events.pop(); // Keep last 200
  totalEvents++;
  renderFeed();
}
 
// Load historical activity (last 24 hours)
async function loadHistory() {
  const recentUpdates = await dench.db.query(`
    SELECT e.id, e.object_id, e.updated_at, e.created_at,
           o.name as object_name,
           ef_name.value as entry_name
    FROM entries e
    JOIN objects o ON o.id = e.object_id
    LEFT JOIN entry_fields ef_name ON ef_name.entry_id = e.id
      AND ef_name.field_id IN (
        SELECT id FROM fields WHERE name IN ('Full Name', 'Deal Name', 'Company', 'Name') LIMIT 1
      )
    WHERE e.updated_at >= CURRENT_TIMESTAMP - INTERVAL '24 hours'
    ORDER BY e.updated_at DESC
    LIMIT 50
  `);
 
  const separator = `<div class="separator">Last 24 hours</div>`;
  const historyEvents = recentUpdates.map(row => {
    const isNew = Math.abs(new Date(row.created_at) - new Date(row.updated_at)) < 5000;
    return createEventCard({
      id: row.id + '_hist',
      actionType: isNew ? 'created' : 'updated',
      objectType: row.object_name,
      title: isNew ? `New ${row.object_name.slice(0, -1)} added` : `${row.object_name.slice(0, -1)} updated`,
      detail: row.entry_name || row.id,
      timestamp: row.updated_at
    });
  });
 
  events = [...events, ...historyEvents];
  totalEvents += historyEvents.length;
  renderFeed();
}
 
// Listen for real-time events
dench.events.on('entry:created', (data) => {
  addEvent({
    id: Date.now(),
    actionType: 'created',
    title: `New entry created`,
    detail: data?.objectName || 'CRM entry',
    timestamp: new Date().toISOString()
  });
});
 
dench.events.on('entry:updated', (data) => {
  addEvent({
    id: Date.now(),
    actionType: 'updated',
    title: `Entry updated`,
    detail: data?.entryName || 'CRM entry',
    timestamp: new Date().toISOString()
  });
});
 
function togglePause() {
  paused = !paused;
  const btn = document.getElementById('pauseBtn');
  btn.textContent = paused ? 'Resume' : 'Pause';
  btn.classList.toggle('paused', paused);
  document.getElementById('liveDot').style.animationPlayState = paused ? 'paused' : 'running';
}
 
function applyFilters() { renderFeed(); }
 
loadHistory();

Frequently Asked Questions#

How do I detect when a deal stage specifically changes?#

DenchClaw's entry:updated event fires on any field change. To detect stage changes specifically, add a Stage history field to your deals object, and check if the Stage field changed by comparing old and new values in the event data.

Can I add audio notifications for closed deals?#

Yes. In the addEvent() function, check if (eventData.actionType === 'closed_won') and play an audio file using the Web Audio API or a simple new Audio('celebration.mp3').play().

How do I export the activity log?#

Add an export button that converts events to CSV format and triggers a download. Include timestamp, action type, object, and description columns.

Can I embed this as a widget?#

Yes. For widget mode, show just the last 5 events without the filter bar, in a compact vertical list format.

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