Back to The Times of Claw

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.

Mark Rachapoom
Mark Rachapoom
·8 min read
Building an OpenClaw Dashboard with the Bridge API

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 →

Mark Rachapoom

Written by

Mark Rachapoom

Building the future of AI CRM software.

Continue reading

DENCH

© 2026 DenchHQ · San Francisco, CA