Build a Meeting Scheduler App with DenchClaw
Build a meeting scheduler app in DenchClaw that reads your CRM contact list, generates meeting prep notes using AI, and saves follow-up tasks automatically.
Build a Meeting Scheduler App with DenchClaw
The friction before a sales meeting is real: you pull up the contact record, skim their company, remember what you last discussed, figure out what you want to accomplish — and you do all of this in a 2-minute scramble right before the call.
A meeting scheduler app in DenchClaw solves this. It lets you select an upcoming meeting, auto-generates a prep brief from CRM data, and creates a post-meeting follow-up task automatically when you mark the meeting as complete.
What You're Building#
- Contact selector with upcoming meeting dates
- AI-generated meeting prep brief (company context, last discussion, goals)
- Pre-meeting checklist
- Post-meeting notes field that auto-creates follow-up tasks
- Calendar integration (if Google Calendar is configured)
Step 1: App Setup#
mkdir -p ~/.openclaw-dench/workspace/apps/meeting-scheduler.dench.app.dench.yaml:
name: Meeting Prep
description: AI meeting prep briefs and automatic follow-up task creation
icon: calendar
version: 1.0.0
permissions:
- read:crm
- write:crm
- chat:create
display: tabStep 2: HTML Layout#
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Meeting Prep</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; display: grid; grid-template-columns: 320px 1fr; height: 100vh; }
.sidebar { overflow-y: auto; border-right: 1px solid #1e293b; padding: 20px; background: #0a0f1e; }
.main { overflow-y: auto; padding: 24px; }
h3 { font-size: 12px; color: #64748b; text-transform: uppercase; margin: 16px 0 8px 0; }
input, select, textarea { width: 100%; background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 8px 12px; border-radius: 8px; font-size: 13px; margin-bottom: 10px; }
textarea { resize: vertical; font-family: inherit; line-height: 1.5; }
button { padding: 10px 16px; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600; }
.btn-primary { background: #6366f1; color: white; width: 100%; margin-bottom: 8px; }
.btn-secondary { background: #334155; color: #94a3b8; width: 100%; margin-bottom: 8px; }
.contact-card { background: #1e293b; border-radius: 8px; padding: 12px; margin-bottom: 10px; cursor: pointer; border: 1px solid transparent; }
.contact-card:hover, .contact-card.selected { border-color: #6366f1; }
.contact-name { font-weight: 600; font-size: 13px; }
.contact-sub { font-size: 11px; color: #64748b; margin-top: 2px; }
.section { background: #1e293b; border-radius: 12px; padding: 16px; margin-bottom: 16px; }
.section h2 { font-size: 15px; margin: 0 0 12px; font-weight: 700; }
.prep-content { font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
.checklist-item { display: flex; gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px solid #334155; font-size: 13px; }
.checklist-item:last-child { border-bottom: none; }
.checklist-item input[type="checkbox"] { width: 16px; height: 16px; accent-color: #6366f1; }
.status-badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; }
.generating { color: #f59e0b; font-style: italic; font-size: 13px; }
.empty-state { text-align: center; padding: 60px 20px; color: #475569; }
</style>
</head>
<body>
<div class="sidebar">
<h3>Search Contacts</h3>
<input type="text" id="contactSearch" placeholder="Search contacts..." oninput="searchContacts(this.value)">
<h3>Upcoming Meetings</h3>
<div id="contactList"><p style="color:#64748b;font-size:13px">Loading contacts...</p></div>
</div>
<div class="main" id="mainPanel">
<div class="empty-state">
<div style="font-size:40px;margin-bottom:12px">📅</div>
<div>Select a contact to generate your meeting prep brief.</div>
</div>
</div>
<script src="scheduler.js"></script>
</body>
</html>Step 3: Scheduler Logic#
scheduler.js:
let allContacts = [];
let selectedContact = null;
async function init() {
allContacts = await dench.db.query(`
SELECT p.id, p."Full Name", p."Email Address", p."Company", p."Title", p."Status",
p."Last Contacted", p."Notes",
COUNT(d.id) AS deal_count,
SUM(CAST(d."Value" AS DOUBLE)) AS total_value
FROM v_people p
LEFT JOIN v_deals d ON d."Contact" = p.id
GROUP BY p.id, p."Full Name", p."Email Address", p."Company", p."Title", p."Status", p."Last Contacted", p."Notes"
ORDER BY p."Full Name"
LIMIT 100
`);
renderContactList(allContacts);
}
function renderContactList(contacts) {
const list = document.getElementById('contactList');
list.innerHTML = contacts.slice(0, 30).map(c => `
<div class="contact-card ${selectedContact?.id === c.id ? 'selected' : ''}" onclick="selectContact('${c.id}')">
<div class="contact-name">${c['Full Name'] || 'Unknown'}</div>
<div class="contact-sub">${c.Company || '—'} · ${c.Title || c.Status}</div>
${c.deal_count > 0 ? `<div class="contact-sub" style="color:#10b981">$${Number(c.total_value || 0).toLocaleString()} in deals</div>` : ''}
</div>
`).join('') || '<p style="color:#64748b;font-size:13px">No contacts found.</p>';
}
function searchContacts(query) {
const q = query.toLowerCase();
const filtered = allContacts.filter(c =>
(c['Full Name'] || '').toLowerCase().includes(q) ||
(c.Company || '').toLowerCase().includes(q)
);
renderContactList(filtered);
}
async function selectContact(id) {
selectedContact = allContacts.find(c => c.id === id);
renderContactList(allContacts); // Re-render to update selection
document.getElementById('mainPanel').innerHTML = `
<div class="section">
<h2>Meeting with ${selectedContact['Full Name']}</h2>
<div style="font-size:13px;color:#94a3b8">
${selectedContact.Company || '—'} · ${selectedContact.Title || selectedContact.Status} ·
${selectedContact['Email Address'] || 'no email'}
</div>
<div style="margin-top:12px;display:flex;gap:8px">
<input type="datetime-local" id="meetingDate" style="width:220px">
<input type="text" id="meetingPurpose" placeholder="Meeting purpose..." style="flex:1">
</div>
</div>
<div class="section">
<h2>Meeting Prep Brief</h2>
<p class="generating" id="prepStatus">Generating prep brief...</p>
<div class="prep-content" id="prepContent"></div>
</div>
<div class="section">
<h2>Pre-Meeting Checklist</h2>
<div id="checklist">
${generateChecklist(selectedContact).map(item => `
<div class="checklist-item">
<input type="checkbox">
<span>${item}</span>
</div>
`).join('')}
</div>
</div>
<div class="section">
<h2>Post-Meeting Notes</h2>
<textarea id="meetingNotes" rows="6" placeholder="What was discussed? What did you commit to?"></textarea>
<div style="display:flex;gap:8px">
<button class="btn-primary" onclick="completeMeeting()" style="flex:1">Complete & Create Follow-up</button>
<button class="btn-secondary" onclick="saveDraft()" style="flex:1">Save Draft</button>
</div>
</div>
`;
await generatePrepBrief();
}
function generateChecklist(contact) {
const items = [
`Review ${contact.Company || 'their company'}'s recent news`,
`Check last email thread with ${contact['Full Name']}`,
`Review deal history (${contact.deal_count || 0} deals)`
];
if (contact.Notes) items.push('Review contact notes');
if (contact['Last Contacted']) items.push(`Refresh on last discussion (${contact['Last Contacted']})`);
items.push('Prepare 2-3 questions to ask');
items.push('Know your ask / next step before the call');
return items;
}
async function generatePrepBrief() {
const session = await dench.chat.createSession();
const c = selectedContact;
const prompt = `Generate a concise meeting prep brief for a sales meeting. Format with clear sections.
Contact: ${c['Full Name']}
Company: ${c.Company || 'Unknown'}
Title: ${c.Title || 'Unknown'}
Status: ${c.Status}
Last Contacted: ${c['Last Contacted'] || 'Never'}
Deal History: ${c.deal_count || 0} deals worth $${Number(c.total_value || 0).toLocaleString()}
Notes: ${c.Notes || 'None'}
Write a brief with these sections:
1. Company Context (2-3 sentences about what they likely care about)
2. Relationship Summary (where we are in the relationship)
3. Goals for this Meeting (2-3 concrete objectives)
4. Potential Objections & Responses (2 most likely objections)
5. Recommended Next Step (specific ask to end the meeting with)
Keep it practical and under 300 words total.`;
try {
const brief = await dench.chat.send(session.id, prompt);
document.getElementById('prepStatus').style.display = 'none';
document.getElementById('prepContent').textContent = brief;
} catch (err) {
document.getElementById('prepStatus').textContent = 'Failed to generate brief. Check your AI configuration.';
}
}
async function completeMeeting() {
const notes = document.getElementById('meetingNotes').value;
if (!notes) { await dench.ui.toast({ message: 'Add meeting notes first', type: 'warning' }); return; }
// Update Last Contacted and save notes
await dench.agent.run(`Update contact ${selectedContact.id}: set Last Contacted to today. Add note: "${notes.substring(0, 300)}"`);
// Create follow-up task
await dench.agent.run(`Create a task: Follow up with ${selectedContact['Full Name']} from ${selectedContact.Company || 'company'}, due in 3 days, linked to contact ${selectedContact.id}`);
await dench.ui.toast({ message: 'Meeting completed. Follow-up task created.', type: 'success' });
}
async function saveDraft() {
const notes = document.getElementById('meetingNotes').value;
await dench.store.set(`meeting_draft_${selectedContact.id}`, notes);
await dench.ui.toast({ message: 'Draft saved', type: 'success' });
}
init();Frequently Asked Questions#
How do I integrate with Google Calendar?#
If you have the gog skill configured, call dench.agent.run("Show me my meetings for today") to load calendar events. The agent can return meeting attendees, and you can pre-populate the contact selector based on attendee emails.
How do I add the meeting to my calendar from this app?#
Add a button that calls dench.agent.run("Create a calendar event: meeting with ${contact} at ${datetime}"). The gog skill handles the Google Calendar API call.
Can I save meeting prep templates?#
Yes. Add a "Save as Template" button that saves the current checklist and prep prompt to dench.store as named templates. Load templates for similar meeting types (discovery call, renewal, upsell).
How do I track meeting outcomes over time?#
Add a Meeting Outcome field to your contacts object (Won, Lost, Follow-up needed, Not interested) and update it post-meeting. Then query meeting outcome trends in DuckDB.
Ready to try DenchClaw? Install in one command: npx denchclaw. Full setup guide →
