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