Back to The Times of Claw

Build an AI Email Composer App

Build an AI-powered email composer app in DenchClaw that generates personalized outreach emails using CRM contact data and the DenchClaw AI bridge.

Mark Rachapoom
Mark Rachapoom
·7 min read
Build an AI Email Composer App

Build an AI Email Composer App

Personalized outreach at scale is one of those problems that looks easy but isn't. Copy-paste templates feel robotic. Writing individual emails takes forever. AI can bridge the gap — but only if it has context on who you're emailing.

That's the advantage of building an email composer inside DenchClaw: the AI has access to your full contact database, deal history, and notes. This guide builds an email composer app that pulls contact context from DuckDB, generates personalized drafts, and lets you review and send — all in one place.

What You're Building#

  • Contact selector that pulls from your CRM
  • Context panel showing the contact's company, history, and deal stage
  • AI-powered email generation using dench.chat
  • Editable draft area with tone controls
  • Copy-to-clipboard and send via Gmail (if configured)

Step 1: Create the App#

mkdir -p ~/.openclaw-dench/workspace/apps/email-composer.dench.app

.dench.yaml:

name: Email Composer
description: AI-generated personalized outreach emails using CRM context
icon: mail
version: 1.0.0
permissions:
  - read:crm
  - chat:create
display: tab

Step 2: HTML Layout#

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Email Composer</title>
  <style>
    * { box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 20px; display: grid; grid-template-columns: 320px 1fr; gap: 20px; height: 100vh; }
    .sidebar { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
    .card { background: #1e293b; border-radius: 12px; padding: 16px; }
    h3 { margin: 0 0 12px; font-size: 13px; color: #64748b; text-transform: uppercase; }
    input, select, textarea { width: 100%; background: #0f172a; border: 1px solid #334155; color: #e2e8f0; padding: 8px 12px; border-radius: 8px; font-size: 13px; }
    textarea { resize: vertical; font-family: inherit; }
    button { padding: 10px 16px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; }
    .btn-primary { background: #6366f1; color: white; width: 100%; }
    .btn-secondary { background: #334155; color: #94a3b8; }
    .btn-sm { padding: 6px 12px; font-size: 12px; }
    .main { display: flex; flex-direction: column; gap: 16px; }
    .context-item { display: flex; justify-content: space-between; font-size: 13px; padding: 4px 0; border-bottom: 1px solid #334155; }
    .context-label { color: #64748b; }
    .email-area { flex: 1; font-size: 14px; line-height: 1.6; min-height: 400px; }
    .tone-pills { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
    .tone-pill { padding: 4px 12px; border-radius: 999px; border: 1px solid #334155; cursor: pointer; font-size: 12px; background: transparent; color: #94a3b8; }
    .tone-pill.active { background: #6366f1; border-color: #6366f1; color: white; }
    .subject-input { margin-bottom: 8px; }
    .actions { display: flex; gap: 8px; justify-content: flex-end; }
    .generating { animation: pulse 1.5s infinite; }
    @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
  </style>
</head>
<body>
  <div class="sidebar">
    <div class="card">
      <h3>Select Contact</h3>
      <input type="text" id="contactSearch" placeholder="Search contacts..." oninput="searchContacts(this.value)">
      <div id="contactList" style="margin-top:8px;max-height:200px;overflow-y:auto;"></div>
    </div>
    <div class="card" id="contextPanel" style="display:none">
      <h3>Contact Context</h3>
      <div id="contextDetails"></div>
    </div>
    <div class="card">
      <h3>Email Settings</h3>
      <div style="margin-bottom:8px">
        <label style="font-size:12px;color:#64748b">Purpose</label>
        <select id="emailPurpose">
          <option value="cold-outreach">Cold outreach</option>
          <option value="follow-up">Follow-up</option>
          <option value="check-in">Check-in</option>
          <option value="proposal">Send proposal</option>
          <option value="thank-you">Thank you</option>
        </select>
      </div>
      <div>
        <label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">Tone</label>
        <div class="tone-pills">
          <button class="tone-pill active" data-tone="professional">Professional</button>
          <button class="tone-pill" data-tone="friendly">Friendly</button>
          <button class="tone-pill" data-tone="direct">Direct</button>
          <button class="tone-pill" data-tone="casual">Casual</button>
        </div>
      </div>
      <textarea id="extraContext" placeholder="Any extra context for the AI? (recent meeting notes, specific ask...)" rows="3"></textarea>
    </div>
    <button class="btn-primary" id="generateBtn" onclick="generateEmail()">Generate Email</button>
  </div>
  <div class="main">
    <div class="card">
      <h3>Email Draft</h3>
      <input type="text" class="subject-input" id="subjectLine" placeholder="Subject line...">
      <textarea class="email-area" id="emailDraft" placeholder="Your email will appear here after generation..."></textarea>
    </div>
    <div class="actions">
      <button class="btn-secondary btn-sm" onclick="copyEmail()">Copy to Clipboard</button>
      <button class="btn-secondary btn-sm" onclick="regenerateEmail()">Regenerate</button>
      <button class="btn-primary btn-sm" onclick="saveAsDraft()">Save as Draft</button>
    </div>
  </div>
  <script src="composer.js"></script>
</body>
</html>

Step 3: Composer Logic#

composer.js:

let selectedContact = null;
let selectedTone = 'professional';
let allContacts = [];
 
async function init() {
  allContacts = await dench.db.query(`
    SELECT id, "Full Name", "Email Address", "Company", "Status", "Title", "Last Contacted", "Notes"
    FROM v_people
    ORDER BY "Full Name"
  `);
  renderContactList(allContacts.slice(0, 20));
}
 
function renderContactList(contacts) {
  const list = document.getElementById('contactList');
  list.innerHTML = contacts.map(c => `
    <div style="padding:8px;cursor:pointer;border-radius:8px;margin-bottom:4px;background:${selectedContact?.id === c.id ? '#6366f120' : 'transparent'}"
         onclick="selectContact('${c.id}')">
      <div style="font-weight:600;font-size:13px">${c['Full Name'] || 'Unknown'}</div>
      <div style="font-size:11px;color:#64748b">${c.Company || ''} · ${c['Email Address'] || 'no email'}</div>
    </div>
  `).join('');
}
 
function searchContacts(query) {
  const q = query.toLowerCase();
  const filtered = allContacts.filter(c =>
    (c['Full Name'] || '').toLowerCase().includes(q) ||
    (c.Company || '').toLowerCase().includes(q) ||
    (c['Email Address'] || '').toLowerCase().includes(q)
  );
  renderContactList(filtered.slice(0, 20));
}
 
async function selectContact(id) {
  selectedContact = allContacts.find(c => c.id === id);
  renderContactList(allContacts.slice(0, 20)); // Re-render to highlight
 
  // Load recent deals for context
  const deals = await dench.db.query(`
    SELECT "Deal Name", "Stage", "Value" FROM v_deals WHERE "Contact" = '${id}' LIMIT 3
  `);
 
  document.getElementById('contextPanel').style.display = 'block';
  document.getElementById('contextDetails').innerHTML = [
    ['Name', selectedContact['Full Name']],
    ['Email', selectedContact['Email Address']],
    ['Company', selectedContact['Company']],
    ['Title', selectedContact['Title'] || '—'],
    ['Status', selectedContact['Status']],
    ['Last Contacted', selectedContact['Last Contacted'] || 'Never'],
    ...(deals.length ? [['Active Deals', deals.map(d => d['Deal Name']).join(', ')]] : [])
  ].map(([label, val]) => `
    <div class="context-item">
      <span class="context-label">${label}</span>
      <span>${val || '—'}</span>
    </div>
  `).join('');
}
 
document.querySelectorAll('.tone-pill').forEach(pill => {
  pill.addEventListener('click', () => {
    document.querySelectorAll('.tone-pill').forEach(p => p.classList.remove('active'));
    pill.classList.add('active');
    selectedTone = pill.dataset.tone;
  });
});
 
async function generateEmail() {
  if (!selectedContact) {
    await dench.ui.toast({ message: 'Select a contact first', type: 'warning' });
    return;
  }
 
  const purpose = document.getElementById('emailPurpose').value;
  const extraContext = document.getElementById('extraContext').value;
  const btn = document.getElementById('generateBtn');
 
  btn.textContent = 'Generating...';
  btn.classList.add('generating');
  btn.disabled = true;
 
  try {
    const session = await dench.chat.createSession();
 
    const prompt = `You are writing a ${selectedTone} business email.
 
Contact Information:
- Name: ${selectedContact['Full Name']}
- Company: ${selectedContact['Company'] || 'Unknown company'}
- Title: ${selectedContact['Title'] || 'Unknown title'}
- Email: ${selectedContact['Email Address']}
- Last contacted: ${selectedContact['Last Contacted'] || 'Never'}
- Status: ${selectedContact['Status']}
- Notes: ${selectedContact['Notes'] || 'None'}
 
Purpose: ${purpose.replace(/-/g, ' ')}
${extraContext ? `Additional context: ${extraContext}` : ''}
 
Write a concise, ${selectedTone} email for this purpose. Use the contact's name and company naturally. Keep it under 200 words. Format: SUBJECT: [subject line]\n\n[email body]`;
 
    const response = await dench.chat.send(session.id, prompt);
 
    const lines = response.split('\n');
    const subjectLine = lines.find(l => l.startsWith('SUBJECT:'))?.replace('SUBJECT:', '').trim() || '';
    const body = lines.filter(l => !l.startsWith('SUBJECT:')).join('\n').trim();
 
    document.getElementById('subjectLine').value = subjectLine;
    document.getElementById('emailDraft').value = body;
  } catch (err) {
    await dench.ui.toast({ message: 'Generation failed: ' + err.message, type: 'error' });
  } finally {
    btn.textContent = 'Generate Email';
    btn.classList.remove('generating');
    btn.disabled = false;
  }
}
 
async function regenerateEmail() { generateEmail(); }
 
async function copyEmail() {
  const subject = document.getElementById('subjectLine').value;
  const body = document.getElementById('emailDraft').value;
  await navigator.clipboard.writeText(`Subject: ${subject}\n\n${body}`);
  await dench.ui.toast({ message: 'Copied to clipboard', type: 'success' });
}
 
async function saveAsDraft() {
  if (!selectedContact) return;
  const body = document.getElementById('emailDraft').value;
  await dench.agent.run(`Save a draft email note for contact ${selectedContact.id}: "${body.substring(0, 200)}..."`);
  await dench.ui.toast({ message: 'Saved as draft note', type: 'success' });
}
 
init();

Extending the Composer#

Batch mode: Loop through contacts and generate emails for all selected ones, exporting a CSV with name, email, subject, body columns ready to paste into your email tool.

Gmail integration: If you have the gog skill configured, add a "Send via Gmail" button that calls dench.agent.run("Send email to [email] with subject [subject] and body [body]").

Templates: Save successful emails as named templates in dench.store, loadable for similar future outreach.

Frequently Asked Questions#

Can I send emails directly from this app?#

If you have Gmail configured via the gog skill, yes. Use dench.agent.run("Send email to X with subject Y and body Z"). The agent handles the actual send via the Gmail API.

How do I improve email quality for my industry?#

Add industry-specific context to your prompt. If you're in SaaS, include the contact's company's product category. If you're in finance, include recent news about their company.

Can I use a different AI model for generation?#

The dench.chat.createSession() uses your configured default model. To specify a model, check your DenchClaw settings. You can also ask DenchClaw to switch models in the agent settings.

How do I add A/B testing for subject lines?#

Generate two variants by calling the AI twice with slightly different prompts. Show both options and let the user pick, or track which subjects get responses in your CRM.

Ready to try DenchClaw? Install in one command: npx denchclaw. Full setup guide →

Mark Rachapoom

Written by

Mark Rachapoom

Building the future of AI CRM software.

Continue reading

DENCH

© 2026 DenchHQ · San Francisco, CA