Build a Competitive Intelligence Tracker App
Build a competitive intelligence tracker app in DenchClaw that monitors competitors, tracks where you're winning and losing, and surfaces deal insights based on competitor mentions.
Build a Competitive Intelligence Tracker App
Knowing why you lose deals is as important as knowing how to win them. If you're losing to the same competitor every time a certain company type is in play, that pattern should surface automatically — not require a quarterly postmortem.
This guide builds a competitive intelligence tracker as a Dench App: track competitors, log win/loss reasons, and surface competitive patterns from your deal data.
What You're Building#
- Competitor roster with win rate per competitor
- Deal-level competitor tracking (which competitors were in each deal)
- Win/loss analysis by competitor, company size, deal size, and rep
- Competitive battlecards (AI-generated talking points)
- Alert when a competitor is mentioned in a new deal
Step 1: App Setup#
mkdir -p ~/.openclaw-dench/workspace/apps/competitive-tracker.dench.app.dench.yaml:
name: Competitive Intel
description: Track competitors, win rates, and deal patterns
icon: target
version: 1.0.0
permissions:
- read:crm
- write:crm
- chat:create
display: tabYou'll also need a Competitor field (or tags field) on your deals object. If you don't have one, ask DenchClaw: "Add a Competitor field to my deals object".
Step 2: HTML Layout#
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Competitive Intelligence</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 20px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; margin-bottom: 24px; }
.comp-card { background: #1e293b; border-radius: 12px; padding: 16px; cursor: pointer; border: 2px solid transparent; transition: border-color 0.15s; }
.comp-card:hover, .comp-card.selected { border-color: #6366f1; }
.comp-name { font-weight: 700; font-size: 16px; margin-bottom: 8px; }
.win-rate { font-size: 24px; font-weight: 800; }
.win-rate.good { color: #10b981; }
.win-rate.ok { color: #f59e0b; }
.win-rate.bad { color: #ef4444; }
.comp-sub { font-size: 12px; color: #64748b; margin-top: 4px; }
.bar-bg { background: #334155; border-radius: 4px; height: 6px; margin-top: 8px; }
.bar-fill { height: 6px; border-radius: 4px; }
.section { background: #1e293b; border-radius: 12px; padding: 20px; margin-bottom: 20px; }
h2 { font-size: 16px; margin: 0 0 16px; }
table { width: 100%; border-collapse: collapse; }
th { font-size: 11px; color: #64748b; text-transform: uppercase; padding: 8px 12px; text-align: left; }
td { padding: 10px 12px; border-bottom: 1px solid #334155; font-size: 13px; }
.badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; }
.won { background: #10b98120; color: #10b981; }
.lost { background: #ef444420; color: #ef4444; }
button { padding: 8px 16px; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; }
.btn-primary { background: #6366f1; color: white; }
.btn-secondary { background: #334155; color: #94a3b8; }
.battlecard { background: #0f172a; border-radius: 8px; padding: 16px; white-space: pre-wrap; font-size: 13px; line-height: 1.6; color: #94a3b8; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; }
.tab { padding: 6px 14px; border-radius: 8px; border: none; background: transparent; color: #64748b; cursor: pointer; font-size: 13px; }
.tab.active { background: #334155; color: #e2e8f0; }
</style>
</head>
<body>
<h1 style="font-size:20px;margin-bottom:20px">Competitive Intelligence</h1>
<div class="grid" id="competitorGrid"></div>
<div class="section" id="detailSection" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 id="selectedCompName">Competitor Name</h2>
<button class="btn-primary" onclick="generateBattlecard()">Generate Battlecard</button>
</div>
<div class="tabs">
<button class="tab active" onclick="showSubTab('deals')">Deals</button>
<button class="tab" onclick="showSubTab('patterns')">Patterns</button>
<button class="tab" onclick="showSubTab('battlecard')">Battlecard</button>
</div>
<div id="subtab-deals"></div>
<div id="subtab-patterns" style="display:none"></div>
<div id="subtab-battlecard" style="display:none"><div class="battlecard" id="battlecardContent">Click "Generate Battlecard" to create talking points.</div></div>
</div>
<script src="tracker.js"></script>
</body>
</html>Step 3: Tracker Logic#
tracker.js:
let competitors = [];
let selectedComp = null;
async function loadCompetitors() {
// Get all distinct competitors from deals
const compDeals = await dench.db.query(`
SELECT
"Competitor" AS name,
COUNT(*) AS total,
SUM(CASE WHEN "Stage" = 'Closed Won' THEN 1 ELSE 0 END) AS won,
SUM(CASE WHEN "Stage" = 'Closed Lost' THEN 1 ELSE 0 END) AS lost,
SUM(CASE WHEN "Stage" NOT IN ('Closed Won', 'Closed Lost') THEN 1 ELSE 0 END) AS open,
SUM(CASE WHEN "Stage" = 'Closed Won' THEN CAST("Value" AS DOUBLE) ELSE 0 END) AS value_won,
SUM(CASE WHEN "Stage" = 'Closed Lost' THEN CAST("Value" AS DOUBLE) ELSE 0 END) AS value_lost
FROM v_deals
WHERE "Competitor" IS NOT NULL AND "Competitor" != ''
GROUP BY "Competitor"
ORDER BY total DESC
`);
competitors = compDeals.map(c => ({
...c,
winRate: c.total > 0 ? Math.round((c.won / (c.won + c.lost || 1)) * 100) : null
}));
renderGrid();
}
function renderGrid() {
const grid = document.getElementById('competitorGrid');
grid.innerHTML = competitors.map(comp => {
const wr = comp.winRate;
const wrClass = wr === null ? 'ok' : wr >= 60 ? 'good' : wr >= 40 ? 'ok' : 'bad';
return `
<div class="comp-card ${selectedComp?.name === comp.name ? 'selected' : ''}" onclick="selectCompetitor('${comp.name}')">
<div class="comp-name">${comp.name}</div>
<div class="win-rate ${wrClass}">${wr !== null ? wr + '%' : 'N/A'}</div>
<div class="comp-sub">Win rate · ${comp.total} deals</div>
<div class="bar-bg">
<div class="bar-fill" style="width:${wr || 0}%;background:${wrClass === 'good' ? '#10b981' : wrClass === 'ok' ? '#f59e0b' : '#ef4444'}"></div>
</div>
<div class="comp-sub" style="margin-top:8px">
${comp.won}W · ${comp.lost}L · ${comp.open} open
</div>
</div>
`;
}).join('') || '<p style="color:#64748b">No competitor data found. Add a "Competitor" field to your deals.</p>';
}
async function selectCompetitor(name) {
selectedComp = competitors.find(c => c.name === name);
document.getElementById('selectedCompName').textContent = name;
document.getElementById('detailSection').style.display = 'block';
renderGrid(); // Re-render to show selection
await loadCompetitorDeals(name);
}
async function loadCompetitorDeals(compName) {
const deals = await dench.db.query(`
SELECT id, "Deal Name", "Company", "Value", "Stage", "Owner", "Close Date",
"Loss Reason"
FROM v_deals
WHERE "Competitor" = '${compName}'
ORDER BY CAST("Value" AS DOUBLE) DESC NULLS LAST
LIMIT 30
`);
document.getElementById('subtab-deals').innerHTML = `
<table>
<thead><tr><th>Deal</th><th>Value</th><th>Stage</th><th>Owner</th><th>Loss Reason</th></tr></thead>
<tbody>
${deals.map(d => `
<tr>
<td><div style="font-weight:600">${d['Deal Name'] || '—'}</div><div style="font-size:11px;color:#64748b">${d.Company || '—'}</div></td>
<td style="color:#10b981;font-weight:700">$${Number(d.Value || 0).toLocaleString()}</td>
<td><span class="badge ${d.Stage === 'Closed Won' ? 'won' : d.Stage === 'Closed Lost' ? 'lost' : ''}">${d.Stage || '—'}</span></td>
<td style="color:#64748b">${d.Owner || '—'}</td>
<td style="color:#94a3b8">${d['Loss Reason'] || '—'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
// Load patterns
const patterns = await dench.db.query(`
SELECT "Loss Reason", COUNT(*) AS count
FROM v_deals
WHERE "Competitor" = '${compName}' AND "Stage" = 'Closed Lost' AND "Loss Reason" IS NOT NULL
GROUP BY "Loss Reason" ORDER BY count DESC
`);
document.getElementById('subtab-patterns').innerHTML = patterns.length ? `
<h3 style="font-size:13px;color:#64748b;margin-bottom:12px">Most common loss reasons vs. ${compName}</h3>
${patterns.map(p => `
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #334155;font-size:13px">
<span>${p['Loss Reason']}</span>
<span style="color:#ef4444;font-weight:700">${p.count} deals</span>
</div>
`).join('')}
` : '<p style="color:#64748b;font-size:13px">No loss reason data available. Add a "Loss Reason" field to your deals.</p>';
}
function showSubTab(tab) {
['deals', 'patterns', 'battlecard'].forEach(t => {
document.getElementById(`subtab-${t}`).style.display = t === tab ? 'block' : 'none';
});
document.querySelectorAll('.tab').forEach((btn, i) => {
btn.classList.toggle('active', ['deals', 'patterns', 'battlecard'][i] === tab);
});
}
async function generateBattlecard() {
if (!selectedComp) return;
document.getElementById('battlecardContent').textContent = 'Generating...';
showSubTab('battlecard');
const session = await dench.chat.createSession();
const prompt = `Generate a sales battlecard for competing against ${selectedComp.name}.
Our product is DenchClaw — a local-first, open-source AI CRM.
Competitor: ${selectedComp.name}
Our win rate vs them: ${selectedComp.winRate || 'unknown'}%
Deals won: ${selectedComp.won}, deals lost: ${selectedComp.lost}
Create a battlecard with:
1. Competitor Overview (2 sentences)
2. Why We Win (3 bullet points with specific advantages)
3. Why We Lose (2-3 honest reasons)
4. Winning Objection Responses (top 3 objections and responses)
5. Qualifying Questions (2-3 questions that favor us)
6. Trap-Setting Questions (2 questions that expose competitor weaknesses)
Keep it punchy and practical for a sales rep to use before a call.`;
const result = await dench.chat.send(session.id, prompt);
document.getElementById('battlecardContent').textContent = result;
}
loadCompetitors();