Building an OpenClaw Dashboard with the Bridge API
Build an OpenClaw dashboard app using the Bridge API and DenchClaw app-builder: connect to DuckDB, render charts, and surface CRM data in a custom web UI.
OpenClaw's Bridge API lets you build web applications that talk directly to your local DuckDB database. Combined with DenchClaw's app-builder system, you can create a custom dashboard in a few hours — no backend server, no API endpoints to maintain, no data leaving your machine.
This guide walks through building a sales pipeline dashboard from scratch. You'll render real-time deal data, stage breakdowns, and team metrics in a web UI that lives in your DenchClaw workspace.
Before starting, make sure DenchClaw is installed and you have CRM data in your database. If not, follow the CRM setup guide first, then return here.
How the Bridge API Works#
The Bridge API is a local HTTP server that DenchClaw exposes on startup. It provides:
- Database access — run DuckDB queries over HTTP
- Workspace API — read documents, objects, entries
- Agent actions — trigger agent tasks from UI
Since it's local, there's no authentication overhead for your own machine. The API is available at http://localhost:3000/api/bridge (or whatever port you've configured).
This is the same API that DenchClaw's built-in UI uses. Your custom dashboard is a first-class citizen, not a workaround.
App Structure#
DenchClaw apps live in ~/.openclaw-dench/workspace/apps/. Each app is a directory with a .dench.yaml manifest and a src/ folder:
apps/pipeline-dashboard/
├── .dench.yaml
└── src/
├── index.html
├── app.js
└── style.css
Step 1: Create the App Manifest#
# apps/pipeline-dashboard/.dench.yaml
name: "Pipeline Dashboard"
version: "1.0.0"
description: "Sales pipeline visualization with deal stages, team metrics, and forecasting"
entry: "src/index.html"
icon: "📊"Step 2: Build the HTML Structure#
<!-- apps/pipeline-dashboard/src/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pipeline Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="dashboard">
<header class="dashboard-header">
<h1>Sales Pipeline</h1>
<div class="header-stats">
<div class="stat" id="weighted-value">
<span class="stat-label">Weighted Pipeline</span>
<span class="stat-value" id="weighted-value-num">—</span>
</div>
<div class="stat" id="open-deals">
<span class="stat-label">Open Deals</span>
<span class="stat-value" id="open-deals-num">—</span>
</div>
<div class="stat" id="avg-deal">
<span class="stat-label">Avg Deal Size</span>
<span class="stat-value" id="avg-deal-num">—</span>
</div>
</div>
</header>
<div class="charts-grid">
<div class="chart-card">
<h2>Deals by Stage</h2>
<canvas id="stageChart"></canvas>
</div>
<div class="chart-card">
<h2>Monthly Closes (90 days)</h2>
<canvas id="closesChart"></canvas>
</div>
</div>
<div class="deals-table-section">
<h2>Open Deals</h2>
<table id="deals-table">
<thead>
<tr>
<th>Deal</th>
<th>Company</th>
<th>Value</th>
<th>Stage</th>
<th>Probability</th>
<th>Close Date</th>
</tr>
</thead>
<tbody id="deals-tbody">
<tr><td colspan="6">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<script src="app.js"></script>
</body>
</html>Step 3: Write the Bridge API Client#
// apps/pipeline-dashboard/src/app.js
const BRIDGE_URL = 'http://localhost:3000/api/bridge';
// Query DuckDB via the Bridge API
async function query(sql) {
const response = await fetch(`${BRIDGE_URL}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sql })
});
if (!response.ok) {
throw new Error(`Bridge query failed: ${response.statusText}`);
}
const data = await response.json();
return data.rows;
}
// Format currency
function formatCurrency(value) {
if (!value) return '$0';
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `$${(value / 1_000).toFixed(0)}K`;
return `$${value.toFixed(0)}`;
}
// Load header statistics
async function loadStats() {
const rows = await query(`
SELECT
SUM(value * probability / 100) AS weighted_value,
COUNT(*) AS open_deals,
AVG(value) AS avg_deal
FROM v_deals
WHERE stage NOT IN ('Closed Won', 'Closed Lost')
`);
const stats = rows[0];
document.getElementById('weighted-value-num').textContent =
formatCurrency(stats.weighted_value);
document.getElementById('open-deals-num').textContent =
stats.open_deals;
document.getElementById('avg-deal-num').textContent =
formatCurrency(stats.avg_deal);
}
// Load stage breakdown chart
async function loadStageChart() {
const rows = await query(`
SELECT
stage,
COUNT(*) AS count,
SUM(value) AS total_value
FROM v_deals
WHERE stage NOT IN ('Closed Won', 'Closed Lost')
GROUP BY stage
ORDER BY
CASE stage
WHEN 'Discovery' THEN 1
WHEN 'Demo' THEN 2
WHEN 'Proposal' THEN 3
WHEN 'Negotiation' THEN 4
ELSE 5
END
`);
const ctx = document.getElementById('stageChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: rows.map(r => r.stage),
datasets: [{
label: 'Total Value',
data: rows.map(r => r.total_value),
backgroundColor: [
'#6366f1', '#8b5cf6', '#a78bfa', '#c4b5fd'
],
borderRadius: 6
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => `${formatCurrency(ctx.raw)} (${rows[ctx.dataIndex].count} deals)`
}
}
},
scales: {
y: {
ticks: {
callback: (value) => formatCurrency(value)
}
}
}
}
});
}
// Load monthly closes chart
async function loadClosesChart() {
const rows = await query(`
SELECT
DATE_TRUNC('month', close_date) AS month,
COUNT(*) AS count,
SUM(value) AS total_value
FROM v_deals
WHERE stage = 'Closed Won'
AND close_date >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY 1
ORDER BY 1
`);
const ctx = document.getElementById('closesChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: rows.map(r => new Date(r.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })),
datasets: [{
label: 'Closed Revenue',
data: rows.map(r => r.total_value),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
plugins: {
tooltip: {
callbacks: {
label: (ctx) => `${formatCurrency(ctx.raw)} (${rows[ctx.dataIndex].count} deals)`
}
}
},
scales: {
y: { ticks: { callback: (v) => formatCurrency(v) } }
}
}
});
}
// Load deals table
async function loadDealsTable() {
const rows = await query(`
SELECT
d.title,
c.name AS company,
d.value,
d.stage,
d.probability,
d.close_date
FROM v_deals d
LEFT JOIN v_companies c ON d.company_id = c.id
WHERE d.stage NOT IN ('Closed Won', 'Closed Lost')
ORDER BY d.close_date ASC NULLS LAST
LIMIT 50
`);
const tbody = document.getElementById('deals-tbody');
tbody.innerHTML = rows.map(row => `
<tr>
<td>${row.title}</td>
<td>${row.company || '—'}</td>
<td>${formatCurrency(row.value)}</td>
<td><span class="stage-badge stage-${row.stage?.toLowerCase().replace(' ', '-')}">${row.stage}</span></td>
<td>${row.probability ? row.probability + '%' : '—'}</td>
<td>${row.close_date ? new Date(row.close_date).toLocaleDateString() : '—'}</td>
</tr>
`).join('');
}
// Initialize dashboard
async function init() {
try {
await Promise.all([
loadStats(),
loadStageChart(),
loadClosesChart(),
loadDealsTable()
]);
} catch (err) {
console.error('Dashboard load error:', err);
document.querySelector('.dashboard').innerHTML = `
<div class="error">
Failed to load dashboard data. Make sure DenchClaw is running.
<br><small>${err.message}</small>
</div>
`;
}
}
init();
// Auto-refresh every 30 seconds
setInterval(init, 30_000);Step 4: Add Basic Styles#
/* apps/pipeline-dashboard/src/style.css */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f0f13;
color: #e5e7eb;
min-height: 100vh;
padding: 2rem;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header-stats {
display: flex;
gap: 2rem;
}
.stat {
text-align: right;
}
.stat-label {
display: block;
font-size: 0.75rem;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: 600;
color: #f9fafb;
}
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 2rem;
}
.chart-card {
background: #1a1a24;
border: 1px solid #2d2d3d;
border-radius: 12px;
padding: 1.5rem;
}
.chart-card h2 {
font-size: 0.875rem;
color: #9ca3af;
margin-bottom: 1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
table {
width: 100%;
border-collapse: collapse;
background: #1a1a24;
border-radius: 12px;
overflow: hidden;
}
th, td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #2d2d3d;
}
th {
font-size: 0.75rem;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #16161f;
}
.stage-badge {
padding: 0.25rem 0.625rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
}
.stage-discovery { background: #1e3a5f; color: #93c5fd; }
.stage-demo { background: #312e81; color: #a5b4fc; }
.stage-proposal { background: #3b1f5e; color: #c4b5fd; }
.stage-negotiation { background: #4c1d95; color: #ddd6fe; }Step 5: Launch Your Dashboard#
Open DenchClaw and run:
Open the pipeline dashboard app
Or navigate directly in your browser to the apps panel. Your dashboard will load, query DuckDB, and render real-time data.
The auto-refresh means any deals added or updated through your agent will appear within 30 seconds without a manual reload.
Adding Agent Actions to the Dashboard#
The Bridge API also lets you trigger agent actions from button clicks. Add a "Refresh Leads" button that runs an enrichment task:
async function triggerAgentTask(task) {
const response = await fetch(`${BRIDGE_URL}/agent/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task })
});
return response.json();
}
document.getElementById('refresh-leads-btn').addEventListener('click', () => {
triggerAgentTask('Enrich all leads with missing company data. Update DuckDB with results.');
});This closes the loop: the dashboard visualizes your data, and buttons trigger agent actions that update it.
FAQ#
Does the dashboard work offline? Yes — since it queries your local DuckDB, it works without internet. The only thing that requires connectivity is external enrichment or AI features.
Can I share the dashboard with teammates?
You can share the app files from your apps/ directory. Teammates install them in their own workspace. Since data is local, each person sees their own data.
What charting libraries work best? Chart.js is the easiest to integrate. D3.js gives more control for complex visualizations. Both work well with the Bridge API response format.
How do I add authentication to the Bridge API? For local use, no authentication is needed. If you expose the app externally (via ngrok or a tunnel), use OpenClaw's built-in token auth for the bridge endpoint.
Can I use React or Vue for the app?
Yes. The app builder supports any frontend framework. Use a build step (Vite) and point the .dench.yaml entry at your compiled dist/index.html.
Ready to try DenchClaw? Install in one command: npx denchclaw. Full setup guide →
