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.

Mark Rachapoom
Mark Rachapoom
·7 min read
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: tab

Step 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>

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

Related articles

Keep reading

View all