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

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>

Talk to Sales

Tell us about your team and we'll show you how Dench fits your workflow.

By submitting this form you agree to be contacted by our team. See our Privacy Policy and Terms of Service.

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 →

Related articles

Keep reading

View all