Build a Document Generator App
Build a document generator app in DenchClaw that creates contracts, NDAs, SOWs, and onboarding docs from CRM data with template variables and AI customization.
Mark Rachapoom
·6 min read
Generating business documents manually is one of those tasks that should have been automated years ago. You have the data — it's in your CRM. You have the templates. The only missing piece is something that merges them automatically and lets you review before sending.
This guide builds a document generator Dench App: select a document type, pick a CRM entry (contact or deal), preview the filled document, and export it as PDF or save it back to DenchClaw.
What You're Building#
- Document type selector: NDA, SOW, Onboarding Checklist, Invoice
- CRM entry picker (contact or deal)
- Template variable system with live preview
- AI customization of template sections
- Export to HTML/PDF and save to entry document
Step 1: App Setup#
mkdir -p ~/.openclaw-dench/workspace/apps/document-generator.dench.app.dench.yaml:
name: Document Generator
description: Generate contracts, NDAs, and docs from CRM data
icon: file-plus
version: 1.0.0
permissions:
- read:crm
- write:crm
- chat:create
display: tabStep 2: HTML Layout and Templates#
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document Generator</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; display: grid; grid-template-columns: 300px 1fr; height: 100vh; }
.config { padding: 20px; border-right: 1px solid #1e293b; overflow-y: auto; background: #0a0f1e; }
.preview { padding: 0; overflow-y: auto; }
.preview-doc { background: white; color: #1a1a1a; padding: 48px; min-height: 100%; font-family: Georgia, serif; font-size: 14px; line-height: 1.7; }
.preview-doc h1 { font-size: 24px; margin-bottom: 8px; }
.preview-doc h2 { font-size: 16px; margin: 24px 0 8px; }
.preview-doc .section { margin-bottom: 20px; }
h3 { font-size: 12px; color: #64748b; text-transform: uppercase; margin: 16px 0 8px; }
select, input { width: 100%; background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 8px 12px; border-radius: 8px; font-size: 13px; margin-bottom: 10px; }
button { padding: 10px 16px; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600; width: 100%; margin-bottom: 8px; }
.btn-primary { background: #6366f1; color: white; }
.btn-secondary { background: #334155; color: #94a3b8; }
.var-preview { background: #1e293b; border-radius: 8px; padding: 12px; font-size: 11px; color: #94a3b8; margin-bottom: 10px; }
.var-preview dt { color: #475569; }
.var-preview dd { color: #e2e8f0; margin: 0 0 4px; font-weight: 600; }
@media print { .config { display: none; } .preview-doc { padding: 20px; } }
</style>
</head>
<body>
<div class="config">
<h3>Document Type</h3>
<select id="docType" onchange="updatePreview()">
<option value="nda">NDA (Non-Disclosure Agreement)</option>
<option value="sow">Statement of Work</option>
<option value="onboarding">Customer Onboarding Checklist</option>
<option value="invoice">Invoice</option>
</select>
<h3>CRM Entry</h3>
<select id="objectType" onchange="loadEntries()">
<option value="deals">Deal</option>
<option value="people">Contact</option>
<option value="companies">Company</option>
</select>
<select id="entrySelect" onchange="updatePreview()">
<option value="">Select entry...</option>
</select>
<div class="var-preview" id="varPreview">Select an entry to see variables.</div>
<button class="btn-primary" onclick="generateWithAI()">Customize with AI</button>
<button class="btn-secondary" onclick="window.print()">Export PDF</button>
<button class="btn-secondary" onclick="saveDocument()">Save to CRM Entry</button>
</div>
<div class="preview">
<div class="preview-doc" id="previewDoc">
<p style="color:#999;font-style:italic">Select a document type and CRM entry to preview.</p>
</div>
</div>
<script src="generator.js"></script>
</body>
</html>Step 3: Generator Logic#
generator.js:
const TEMPLATES = {
nda: (vars) => `
<h1>Non-Disclosure Agreement</h1>
<p>This Non-Disclosure Agreement ("Agreement") is entered into as of <strong>${vars.date}</strong> between <strong>${vars.company_name}</strong> ("Receiving Party") and <strong>Your Company, Inc.</strong> ("Disclosing Party").</p>
<h2>1. Confidential Information</h2>
<p>For purposes of this Agreement, "Confidential Information" means any non-public information disclosed by the Disclosing Party to ${vars.company_name || 'the Receiving Party'}, either directly or indirectly...</p>
<h2>2. Obligations</h2>
<p>${vars.contact_name || 'The Receiving Party'} agrees to: (a) hold the Confidential Information in strict confidence; (b) not disclose the Confidential Information to third parties without prior written consent; (c) use the Confidential Information solely for the purpose of evaluating a potential business relationship.</p>
<h2>3. Term</h2>
<p>This Agreement shall remain in effect for a period of two (2) years from the date first written above.</p>
<h2>4. Signatures</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:40px;margin-top:40px">
<div>
<p>For ${vars.company_name || 'Receiving Party'}:</p>
<div style="border-bottom:1px solid #ccc;margin:20px 0 4px"></div>
<p>${vars.contact_name || 'Name'}</p>
<p style="color:#666">${vars.contact_title || 'Title'}</p>
</div>
<div>
<p>For Your Company, Inc.:</p>
<div style="border-bottom:1px solid #ccc;margin:20px 0 4px"></div>
<p>Authorized Signatory</p>
</div>
</div>`,
sow: (vars) => `
<h1>Statement of Work</h1>
<p><strong>Project:</strong> ${vars.deal_name || 'Project Name'}<br>
<strong>Client:</strong> ${vars.company_name || 'Client Name'}<br>
<strong>Contact:</strong> ${vars.contact_name || '—'}<br>
<strong>Date:</strong> ${vars.date}<br>
<strong>Value:</strong> ${vars.deal_value || 'TBD'}</p>
<h2>Scope of Work</h2>
<p>This Statement of Work describes the services to be provided by Your Company to ${vars.company_name || 'Client'} under this engagement.</p>
<h2>Deliverables</h2>
<ul><li>Phase 1: Discovery and Requirements</li><li>Phase 2: Implementation</li><li>Phase 3: Testing and Launch</li><li>Phase 4: Training and Handoff</li></ul>
<h2>Timeline</h2>
<p>Estimated project duration: 8-12 weeks from signed agreement.</p>
<h2>Investment</h2>
<p>Total project investment: <strong>${vars.deal_value || '$0'}</strong><br>Payment schedule: 50% upon signing, 50% upon delivery.</p>`,
onboarding: (vars) => `
<h1>Customer Onboarding Checklist</h1>
<p><strong>Customer:</strong> ${vars.company_name || '—'}<br>
<strong>Contact:</strong> ${vars.contact_name || '—'}<br>
<strong>Start Date:</strong> ${vars.date}</p>
<h2>Week 1: Setup</h2>
<p>☐ Send welcome email to ${vars.contact_name || 'customer'}<br>☐ Schedule kickoff call<br>☐ Provision account access<br>☐ Share onboarding resources</p>
<h2>Week 2-3: Implementation</h2>
<p>☐ Complete initial configuration<br>☐ Import existing data<br>☐ Configure integrations<br>☐ User training session</p>
<h2>Week 4: Launch</h2>
<p>☐ Go-live review call<br>☐ Confirm all users are active<br>☐ Share support resources<br>☐ Schedule 30-day check-in</p>`,
invoice: (vars) => `
<h1>Invoice</h1>
<p><strong>Invoice #:</strong> INV-${Date.now().toString().slice(-6)}<br>
<strong>Date:</strong> ${vars.date}<br>
<strong>Due Date:</strong> ${vars.due_date}</p>
<h2>Bill To</h2>
<p>${vars.contact_name || '—'}<br>${vars.company_name || '—'}<br>${vars.email || '—'}</p>
<h2>Services</h2>
<table style="width:100%;border-collapse:collapse">
<tr style="border-bottom:1px solid #ddd"><th style="text-align:left;padding:8px">Description</th><th style="text-align:right;padding:8px">Amount</th></tr>
<tr><td style="padding:8px">${vars.deal_name || 'Services'}</td><td style="text-align:right;padding:8px">${vars.deal_value || '$0'}</td></tr>
<tr style="border-top:2px solid #333;font-weight:bold"><td style="padding:8px">Total</td><td style="text-align:right;padding:8px">${vars.deal_value || '$0'}</td></tr>
</table>
<p style="margin-top:30px;color:#666">Payment due within 30 days. Bank transfer or check accepted.</p>`
};
let currentEntry = null;
let currentVars = {};
async function loadEntries() {
const objectType = document.getElementById('objectType').value;
const entries = await dench.db.query(`
SELECT id, "Full Name", "Deal Name", "Company", "Name"
FROM v_${objectType} LIMIT 100
`);
const select = document.getElementById('entrySelect');
select.innerHTML = '<option value="">Select entry...</option>';
entries.forEach(e => {
const opt = document.createElement('option');
opt.value = e.id;
opt.textContent = e['Full Name'] || e['Deal Name'] || e['Name'] || e['Company'] || e.id;
opt.dataset.entry = JSON.stringify(e);
select.appendChild(opt);
});
}
async function updatePreview() {
const select = document.getElementById('entrySelect');
const selectedOpt = select.options[select.selectedIndex];
if (!selectedOpt?.dataset?.entry) return;
const entry = JSON.parse(selectedOpt.dataset.entry);
currentEntry = entry;
// Load full entry details
const objectType = document.getElementById('objectType').value;
const full = await dench.db.query(`SELECT * FROM v_${objectType} WHERE id = '${entry.id}' LIMIT 1`);
const e = full[0] || entry;
const today = new Date();
const dueDate = new Date(today);
dueDate.setDate(dueDate.getDate() + 30);
currentVars = {
date: today.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
due_date: dueDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
company_name: e.Company || e.Name || 'Client',
contact_name: e['Full Name'] || '',
contact_title: e.Title || '',
email: e['Email Address'] || '',
deal_name: e['Deal Name'] || 'Engagement',
deal_value: e.Value ? '$' + Number(e.Value).toLocaleString() : 'TBD'
};
document.getElementById('varPreview').innerHTML = Object.entries(currentVars)
.map(([k, v]) => `<dt>{{${k}}}</dt><dd>${v || '—'}</dd>`)
.join('');
renderTemplate();
}
function renderTemplate() {
const docType = document.getElementById('docType').value;
const template = TEMPLATES[docType];
if (!template) return;
document.getElementById('previewDoc').innerHTML = template(currentVars);
}
async function generateWithAI() {
if (!currentEntry) { await dench.ui.toast({ message: 'Select a CRM entry first', type: 'warning' }); return; }
const docType = document.getElementById('docType').value;
const session = await dench.chat.createSession();
const current = document.getElementById('previewDoc').innerHTML;
const customized = await dench.chat.send(session.id,
`Improve this ${docType} document to be more professional and specific to ${currentVars.company_name}. Keep the same HTML structure but enhance the language. Return only the HTML content: ${current.substring(0, 2000)}`
);
document.getElementById('previewDoc').innerHTML = customized;
}
async function saveDocument() {
if (!currentEntry) return;
const content = document.getElementById('previewDoc').innerText;
await dench.agent.run(`Save a document for entry ${currentEntry.id}: ${content.substring(0, 500)}`);
await dench.ui.toast({ message: 'Document saved to CRM entry', type: 'success' });
}
loadEntries();