Back to The Times of Claw

Build a Custom Kanban App in DenchClaw

Build a fully custom Kanban board app in DenchClaw using the App Builder — with drag-and-drop columns, custom fields, WIP limits, and DuckDB writeback.

Mark Rachapoom
Mark Rachapoom
·6 min read
Build a Custom Kanban App in DenchClaw

Build a Custom Kanban App in DenchClaw

DenchClaw ships with a built-in Kanban view for any object, but sometimes you need more: WIP limits, custom card designs, swimlanes by owner, or a Kanban board that spans multiple objects at once. The App Builder is the right tool for that.

This guide builds a custom Kanban app that goes beyond the default view: WIP limits per column, swimlanes, custom card designs, and a configurable board layout.

What You're Building#

  • Configurable Kanban board pulling from any DenchClaw object
  • Drag-and-drop with WIP limits (visual warning when limit exceeded)
  • Swimlanes by owner
  • Quick card creation within a column
  • Custom card templates per object type

Step 1: App Setup#

mkdir -p ~/.openclaw-dench/workspace/apps/custom-kanban.dench.app

.dench.yaml:

name: Custom Kanban
description: Configurable Kanban with WIP limits and swimlanes
icon: layout
version: 1.0.0
permissions:
  - read:crm
  - write:crm
display: tab

Step 2: HTML Layout#

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Custom Kanban</title>
  <style>
    * { box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 0; overflow-x: auto; }
    .topbar { background: #1e293b; padding: 12px 20px; display: flex; gap: 12px; align-items: center; border-bottom: 1px solid #334155; }
    .topbar select, .topbar input { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; padding: 6px 12px; border-radius: 8px; font-size: 13px; }
    .board { display: flex; gap: 0; min-height: calc(100vh - 53px); overflow-x: auto; padding: 20px; gap: 16px; }
    .column { min-width: 270px; max-width: 310px; background: #1e293b; border-radius: 12px; display: flex; flex-direction: column; flex-shrink: 0; }
    .col-header { padding: 14px 16px; border-bottom: 1px solid #334155; }
    .col-title { font-weight: 700; font-size: 14px; display: flex; justify-content: space-between; align-items: center; }
    .col-count { font-size: 12px; color: #64748b; background: #0f172a; padding: 2px 8px; border-radius: 999px; }
    .col-count.over-wip { background: #ef444420; color: #ef4444; }
    .wip-limit { font-size: 11px; color: #64748b; margin-top: 3px; }
    .cards-area { flex: 1; padding: 10px; overflow-y: auto; min-height: 100px; }
    .cards-area.drag-over { background: #6366f110; border-radius: 8px; }
    .card { background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 12px; margin-bottom: 8px; cursor: grab; }
    .card:hover { border-color: #6366f1; }
    .card.dragging { opacity: 0.4; }
    .card-title { font-weight: 600; font-size: 13px; margin-bottom: 6px; }
    .card-meta { font-size: 11px; color: #64748b; display: flex; flex-wrap: wrap; gap: 6px; }
    .tag { padding: 2px 7px; border-radius: 999px; font-size: 10px; }
    .add-card-btn { padding: 8px; text-align: center; color: #475569; cursor: pointer; font-size: 13px; border-top: 1px solid #334155; }
    .add-card-btn:hover { color: #94a3b8; background: #334155; border-radius: 0 0 12px 12px; }
    .swimlane-label { background: #0a0f1e; padding: 6px 16px; font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; }
  </style>
</head>
<body>
  <div class="topbar">
    <select id="objectSelect" onchange="loadBoard()">
      <option value="deals">Deals</option>
      <option value="people">People</option>
      <option value="tasks">Tasks</option>
    </select>
    <select id="swimlaneSelect" onchange="loadBoard()">
      <option value="">No swimlanes</option>
      <option value="owner">By Owner</option>
    </select>
    <input type="text" id="searchInput" placeholder="Search cards..." oninput="filterCards(this.value)">
  </div>
  <div id="board" class="board"></div>
  <script src="kanban.js"></script>
</body>
</html>

Step 3: Kanban Logic#

kanban.js:

const BOARD_CONFIG = {
  deals: {
    statusField: 'Stage',
    titleField: 'Deal Name',
    subtitleField: 'Company',
    valueField: 'Value',
    ownerField: 'Owner',
    columns: [
      { name: 'Prospecting', wip: 15, color: '#6366f1' },
      { name: 'Qualified', wip: 10, color: '#8b5cf6' },
      { name: 'Proposal', wip: 8, color: '#a78bfa' },
      { name: 'Negotiation', wip: 5, color: '#10b981' },
      { name: 'Closed Won', wip: null, color: '#f59e0b' }
    ]
  },
  people: {
    statusField: 'Status',
    titleField: 'Full Name',
    subtitleField: 'Company',
    ownerField: 'Owner',
    columns: [
      { name: 'Lead', wip: 50, color: '#6366f1' },
      { name: 'Qualified', wip: 20, color: '#8b5cf6' },
      { name: 'Nurturing', wip: 30, color: '#a78bfa' },
      { name: 'Customer', wip: null, color: '#10b981' }
    ]
  }
};
 
let entries = [];
let dragId = null;
let dragObject = null;
 
async function loadBoard() {
  const objectName = document.getElementById('objectSelect').value;
  const config = BOARD_CONFIG[objectName];
  if (!config) return;
 
  const view = `v_${objectName}`;
  entries = await dench.db.query(`SELECT * FROM ${view} LIMIT 200`);
  renderBoard(config, objectName);
}
 
function renderBoard(config, objectName) {
  const swimlane = document.getElementById('swimlaneSelect').value;
  const board = document.getElementById('board');
  board.innerHTML = '';
 
  if (swimlane === 'owner') {
    const owners = [...new Set(entries.map(e => e[config.ownerField] || 'Unassigned'))].sort();
    owners.forEach(owner => {
      const ownerEntries = entries.filter(e => (e[config.ownerField] || 'Unassigned') === owner);
      const label = document.createElement('div');
      // Note: swimlane layout would need CSS grid for proper row arrangement
      // Simplified: render per-owner columns in sequence
    });
  }
 
  config.columns.forEach(col => {
    const colEntries = entries.filter(e => e[config.statusField] === col.name);
    const colEl = document.createElement('div');
    colEl.className = 'column';
    colEl.dataset.status = col.name;
 
    const isOverWip = col.wip && colEntries.length > col.wip;
 
    colEl.innerHTML = `
      <div class="col-header" style="border-top: 3px solid ${col.color}; border-radius: 12px 12px 0 0">
        <div class="col-title">
          ${col.name}
          <span class="col-count ${isOverWip ? 'over-wip' : ''}">${colEntries.length}${col.wip ? '/' + col.wip : ''}</span>
        </div>
        ${col.wip ? `<div class="wip-limit">WIP limit: ${col.wip}${isOverWip ? ' ⚠️ EXCEEDED' : ''}</div>` : ''}
      </div>
      <div class="cards-area" data-status="${col.name}">
        ${colEntries.map(entry => renderCard(entry, config)).join('')}
      </div>
      <div class="add-card-btn" onclick="addCard('${col.name}', '${objectName}')">+ Add card</div>
    `;
 
    board.appendChild(colEl);
  });
 
  attachDragHandlers();
}
 
function renderCard(entry, config) {
  const value = config.valueField ? entry[config.valueField] : null;
  return `
    <div class="card" data-id="${entry.id}" draggable="true">
      <div class="card-title">${entry[config.titleField] || 'Untitled'}</div>
      <div class="card-meta">
        ${entry[config.subtitleField] ? `<span>${entry[config.subtitleField]}</span>` : ''}
        ${value ? `<span class="tag" style="background:#10b98120;color:#10b981">$${Number(value).toLocaleString()}</span>` : ''}
        ${entry[config.ownerField] ? `<span class="tag" style="background:#6366f120;color:#6366f1">${entry[config.ownerField]}</span>` : ''}
      </div>
    </div>
  `;
}
 
function attachDragHandlers() {
  document.querySelectorAll('.card').forEach(card => {
    card.addEventListener('dragstart', e => {
      dragId = card.dataset.id;
      dragObject = document.getElementById('objectSelect').value;
      card.classList.add('dragging');
      e.dataTransfer.effectAllowed = 'move';
    });
    card.addEventListener('dragend', () => card.classList.remove('dragging'));
  });
 
  document.querySelectorAll('.cards-area').forEach(area => {
    area.addEventListener('dragover', e => { e.preventDefault(); area.classList.add('drag-over'); });
    area.addEventListener('dragleave', () => area.classList.remove('drag-over'));
    area.addEventListener('drop', async e => {
      e.preventDefault();
      area.classList.remove('drag-over');
      const newStatus = area.dataset.status;
      if (!dragId || !newStatus) return;
      const config = BOARD_CONFIG[dragObject];
      
      // Update entry in CRM
      await dench.agent.run(`Update ${dragObject} entry ${dragId}: set ${config.statusField} to "${newStatus}"`);
      
      // Update local state optimistically
      const entry = entries.find(e => e.id === dragId);
      if (entry) entry[config.statusField] = newStatus;
      renderBoard(config, dragObject);
      
      await dench.ui.toast({ message: `Moved to ${newStatus}`, type: 'success' });
    });
  });
}
 
async function addCard(status, objectName) {
  const config = BOARD_CONFIG[objectName];
  const name = prompt(`New ${objectName.slice(0, -1)} name:`);
  if (!name) return;
  await dench.agent.run(`Create a new ${objectName.slice(0, -1)} with ${config.titleField} "${name}" and ${config.statusField} "${status}"`);
  loadBoard();
}
 
function filterCards(query) {
  const q = query.toLowerCase();
  document.querySelectorAll('.card').forEach(card => {
    const text = card.textContent.toLowerCase();
    card.style.display = text.includes(q) ? '' : 'none';
  });
}
 
// Listen for updates
dench.events.on('entry:created', loadBoard);
dench.events.on('entry:updated', loadBoard);
 
loadBoard();

Frequently Asked Questions#

How do I add a custom card design for a specific object?#

Extend the renderCard() function with an object-specific branch: if (objectName === 'deals') { return renderDealCard(entry, config); }. Create separate render functions for each object type.

What are WIP limits and should I use them?#

Work-in-progress limits prevent your team from starting more work than they can finish. A WIP limit of 5 on "Negotiation" means you shouldn't have more than 5 deals in negotiation at once. This forces focus and reduces context-switching. Kanban practitioners recommend starting with loose limits and tightening them over time.

Can I add due date indicators to cards?#

Yes. Add a dueDate check in renderCard(): if the entry has a Due Date field and it's past due, add a red border or badge to the card.

How do I persist column configuration changes?#

Save column configs to dench.store using await dench.store.set('kanban_config_deals', JSON.stringify(config)), and load them at startup with await dench.store.get('kanban_config_deals').

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