Back to The Times of Claw

Building an AI Chat App with DenchClaw

Build a fully functional AI chat app inside DenchClaw using the App Builder, window.dench bridge API, and your local DuckDB data in minutes.

Mark Rachapoom
Mark Rachapoom
·8 min read
Building an AI Chat App with DenchClaw

Building an AI chat app with DenchClaw takes less time than you think. Using the App Builder and the window.dench bridge API, you can wire up a fully functional, context-aware chat interface that queries your local DuckDB, persists conversation history, and responds using the AI model powering DenchClaw — all in plain HTML, CSS, and JavaScript. No build tools. No backend. No API keys.

Here's exactly how to do it.

What You're Building#

By the end of this guide, you'll have a .dench.app that:

  • Renders a chat interface in a DenchClaw panel
  • Streams AI responses using dench.chat
  • Queries your CRM data from DuckDB to give the AI real context
  • Saves conversation history to dench.store
  • Shows toast notifications on errors

Step 1: Create the .dench.app Folder#

Every DenchClaw app lives in a .dench.app folder inside your workspace. Start by creating the structure:

mkdir -p ~/denchclaw-workspace/apps/ai-chat.dench.app
cd ~/denchclaw-workspace/apps/ai-chat.dench.app
touch .dench.yaml
touch index.html

Step 2: Write the Manifest#

The .dench.yaml file tells DenchClaw how to display and run your app. For a chat app, you want a full panel — not a widget:

name: "AI Chat"
description: "Context-aware AI chat with your CRM data"
display: "panel"
icon: "💬"
version: "1.0.0"

If you wanted this as a floating widget instead, you'd set display: "widget" — but for a full chat interface, panel mode is the right call.

Step 3: Build the Chat UI#

Open index.html and build the interface. Here's a complete, production-quality implementation:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>AI Chat</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
 
    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: #0f0f0f;
      color: #e5e5e5;
      height: 100vh;
      display: flex;
      flex-direction: column;
    }
 
    #header {
      padding: 16px 20px;
      border-bottom: 1px solid #222;
      font-weight: 600;
      font-size: 15px;
      display: flex;
      align-items: center;
      gap: 8px;
    }
 
    #messages {
      flex: 1;
      overflow-y: auto;
      padding: 20px;
      display: flex;
      flex-direction: column;
      gap: 12px;
    }
 
    .message {
      max-width: 80%;
      padding: 12px 16px;
      border-radius: 12px;
      font-size: 14px;
      line-height: 1.6;
      white-space: pre-wrap;
    }
 
    .message.user {
      align-self: flex-end;
      background: #2563eb;
      color: #fff;
      border-bottom-right-radius: 4px;
    }
 
    .message.assistant {
      align-self: flex-start;
      background: #1a1a1a;
      border: 1px solid #2a2a2a;
      border-bottom-left-radius: 4px;
    }
 
    .message.thinking {
      opacity: 0.5;
      font-style: italic;
    }
 
    #input-area {
      padding: 16px 20px;
      border-top: 1px solid #222;
      display: flex;
      gap: 10px;
    }
 
    #input {
      flex: 1;
      background: #1a1a1a;
      border: 1px solid #333;
      border-radius: 8px;
      padding: 10px 14px;
      color: #e5e5e5;
      font-size: 14px;
      resize: none;
      outline: none;
      font-family: inherit;
    }
 
    #input:focus { border-color: #2563eb; }
 
    #send-btn {
      background: #2563eb;
      color: white;
      border: none;
      border-radius: 8px;
      padding: 10px 18px;
      cursor: pointer;
      font-size: 14px;
      font-weight: 500;
      white-space: nowrap;
    }
 
    #send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
 
    #context-bar {
      padding: 8px 20px;
      font-size: 12px;
      color: #666;
      background: #0a0a0a;
      border-bottom: 1px solid #1a1a1a;
    }
  </style>
</head>
<body>
  <div id="header">💬 AI Chat <span style="color:#666;font-weight:400;font-size:13px;">— powered by DenchClaw</span></div>
  <div id="context-bar">Loading CRM context...</div>
  <div id="messages"></div>
  <div id="input-area">
    <textarea id="input" rows="1" placeholder="Ask anything about your contacts, deals, or tasks..."></textarea>
    <button id="send-btn">Send</button>
  </div>
 
  <script>
    const messagesEl = document.getElementById('messages');
    const inputEl = document.getElementById('input');
    const sendBtn = document.getElementById('send-btn');
    const contextBar = document.getElementById('context-bar');
 
    let crmContext = '';
    let isStreaming = false;
 
    // Load conversation history from persistent store
    const history = dench.store.get('chat-history') || [];
 
    // Restore previous messages
    history.forEach(msg => appendMessage(msg.role, msg.content));
 
    // Build CRM context from DuckDB
    async function loadCRMContext() {
      try {
        const contacts = await dench.db.query(`
          SELECT o.name, ef.value as status
          FROM objects o
          LEFT JOIN entry_fields ef ON ef.entry_id = (
            SELECT e.id FROM entries e WHERE e.object_id = o.id LIMIT 1
          )
          LEFT JOIN fields f ON f.id = ef.field_id AND f.name = 'Status'
          LIMIT 20
        `);
 
        const count = contacts.length;
        crmContext = `You have access to a CRM with ${count} contacts. Recent contacts: ${contacts.slice(0, 5).map(c => c.name).join(', ')}.`;
        contextBar.textContent = `CRM loaded: ${count} contacts available as context`;
      } catch (err) {
        contextBar.textContent = 'CRM context unavailable — chatting without data';
      }
    }
 
    function appendMessage(role, content, isThinking = false) {
      const div = document.createElement('div');
      div.className = `message ${role}${isThinking ? ' thinking' : ''}`;
      div.textContent = content;
      messagesEl.appendChild(div);
      messagesEl.scrollTop = messagesEl.scrollHeight;
      return div;
    }
 
    async function sendMessage() {
      const text = inputEl.value.trim();
      if (!text || isStreaming) return;
 
      isStreaming = true;
      sendBtn.disabled = true;
      inputEl.value = '';
 
      // Add user message
      appendMessage('user', text);
      history.push({ role: 'user', content: text });
 
      // Show thinking indicator
      const thinkingEl = appendMessage('assistant', 'Thinking…', true);
 
      try {
        // Build system prompt with CRM context
        const systemPrompt = `You are a helpful AI assistant embedded inside DenchClaw, a local-first CRM. ${crmContext} Help the user understand their contacts, deals, and relationships. Be concise and actionable.`;
 
        // Stream the response
        let fullResponse = '';
        thinkingEl.textContent = '';
        thinkingEl.classList.remove('thinking');
 
        await dench.chat.stream(
          [
            { role: 'system', content: systemPrompt },
            ...history.slice(-10), // Keep last 10 for context window
          ],
          (chunk) => {
            fullResponse += chunk;
            thinkingEl.textContent = fullResponse;
            messagesEl.scrollTop = messagesEl.scrollHeight;
          }
        );
 
        // Save to history
        history.push({ role: 'assistant', content: fullResponse });
        dench.store.set('chat-history', history.slice(-50)); // Keep last 50 messages
 
      } catch (err) {
        thinkingEl.textContent = 'Something went wrong. Try again.';
        dench.ui.toast('Chat error: ' + err.message, { type: 'error' });
      }
 
      isStreaming = false;
      sendBtn.disabled = false;
      inputEl.focus();
    }
 
    // Send on Enter (Shift+Enter for newline)
    inputEl.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault();
        sendMessage();
      }
    });
 
    sendBtn.addEventListener('click', sendMessage);
 
    // Initialize
    loadCRMContext();
  </script>
</body>
</html>

Step 4: Understand the Bridge API Calls#

Let's break down the window.dench methods used above:

dench.db.query(sql)#

Runs a SQL query against your local DuckDB. Returns a promise resolving to an array of row objects. This is how you give the AI real context — your actual CRM data.

dench.chat.stream(messages, onChunk)#

Streams an AI response token-by-token. messages follows the OpenAI format ([{role, content}]). onChunk fires for each streamed token. Use this instead of dench.chat.complete() for a responsive feel.

dench.store.get(key) / dench.store.set(key, value)#

Persistent key-value store scoped to your app. Survives page refreshes. Use it for conversation history, user preferences, or any state you want to keep around.

dench.ui.toast(message, options)#

Shows a toast notification in the DenchClaw UI. Pass { type: 'error' }, { type: 'success' }, or { type: 'info' }.

Step 5: Add Events for Real-Time Updates#

Want the chat to react when someone adds a new contact? Use dench.events:

dench.events.on('crm:contact:created', async (contact) => {
  crmContext = await buildCRMContext(); // Re-fetch context
  dench.ui.toast(`New contact added: ${contact.name}`);
});

This is what makes DenchClaw apps feel alive — they're not isolated tools, they're integrated into your workspace.

Step 6: Register the App#

With DenchClaw running (npx denchclaw), drop your .dench.app folder into the workspace apps directory. The app will appear in the sidebar automatically. No restart required.

Taking It Further#

Once you have the basic chat working, here's what to build next:

  1. Slash commands — Parse /contact John Doe to look up and inject a contact's full profile into the conversation
  2. Cron-based summaries — Use dench.cron to send daily briefings into the chat
  3. Multi-model routing — Use dench.http.fetch() to call external model APIs for specialized tasks
  4. Shared memory — Store AI-generated insights back into DuckDB with dench.db.query('INSERT ...')

The App Builder guide covers all of these patterns. The bridge API reference has the full method signatures.

Why This Matters#

Most AI chat tools are disconnected from your actual work. They don't know who you're meeting with, what deals are in flight, or which hires you're tracking. DenchClaw's App Builder changes that. Your chat app lives inside your CRM, has read/write access to your data, and can act on your behalf — not just answer questions.

That's the difference between a chatbot and an AI co-pilot.

To understand the full platform, start with what is a dench.app.

FAQ#

Do I need an API key to use dench.chat? No. dench.chat routes through DenchClaw's built-in AI layer. You don't manage API keys in your app code.

Can I use external AI models? Yes. Use dench.http.fetch() to call any external API — OpenAI, Anthropic, Groq, whatever you prefer. You handle auth headers in your fetch call.

Is conversation history secure? Your history is stored locally in dench.store, which persists in your local workspace. Nothing leaves your machine unless you explicitly call an external API.

Can multiple .dench.app instances share the same store? No — dench.store is scoped per app. Use DuckDB (via dench.db.query) as the shared data layer across apps.

What's the difference between dench.chat.stream and dench.chat.complete? stream delivers tokens progressively for a responsive feel. complete waits for the full response. For chat UIs, always use stream.

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