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.
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: tabStep 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 →
