Library · Customer support and voice agents

AI chat on your website: Crisp + Claude, a smart widget

Builder55 minUpdated: October 2026
61 of 105 in the library

Time: ~20 min theory + 35 min practice


The gist

A visitor lands on your website at 11:47 p.m. on a Saturday. They see a "Message us" button and type a question. Usually, that's followed by silence until Monday morning. By then, they've already bought from a competitor.

An AI chat answers within a second. Any time. In many languages. And if it can't help, it says so honestly and takes down the details so an operator can call back in the morning.

🎨 Picture this: A store in a mall with a sales associate on the floor. They don't doze off in the corner: they greet everyone who walks in and ask right away, "How can I help?" That's exactly what an AI chat widget does: it greets a website visitor at the moment they're ready to talk.


Key concepts

  • Crisp.chat + Claude: a webhook catches the message → Claude answers → the answer goes into the chat
  • Intercom Fin: a built-in AI agent (simpler, more expensive)
  • Chatwoot: an open-source alternative, self-hosted + Claude
  • Smart escalation: Claude handles it → if it can't → a live person takes over
  • RAG over your documentation: Claude knows everything about your product
  • Question analytics: Claude finds patterns → you improve your FAQ

Theory

Why an ordinary chat doesn't work

An ordinary live chat widget works only while an operator is around. No operator → "We're offline, leave your email." Some visitors leave right there.

Three pain points:

  1. Time: outside business hours, the customer leaves
  2. Speed: even an online operator takes 2-5 minutes to reply (they're busy with something else)
  3. Scale: one operator can only handle a few chats at once

AI covers all three: it answers instantly, 24/7, to a thousand customers at the same time.

Crisp.chat + Claude: webhook integration

Crisp is a European chat tool (often chosen for GDPR compliance, and usually simpler and cheaper than Intercom). The free plan lets you put the widget on your website, but access to the API and webhooks depends on the plan: check the terms on the Crisp website.

How it works:

Code
Customer types in the chat → Crisp webhook → your server → Claude API → answer → Crisp API → customer sees it
javascript
// server.js — a Node.js server for handling the Crisp webhook
const express = require('express');
const Anthropic = require('@anthropic-ai/sdk');
const { Crisp } = require('crisp-api');

const app = express();
app.use(express.json());

const claude = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY
});

const crisp = new Crisp();
crisp.authenticateTier(
  'plugin',
  process.env.CRISP_API_IDENTIFIER,
  process.env.CRISP_API_KEY
);
// Check the crisp-api library's method names against its README: they change between versions

// Product knowledge base
const KNOWLEDGE_BASE = `
You are the assistant for [Company name].

PRODUCT:
- What it is: [product description]
- Price: [prices]
- How to get started: [steps]

FREQUENTLY ASKED QUESTIONS:
- Question 1: Answer 1
- Question 2: Answer 2

WHAT NOT TO DO:
- Don't make up features that don't exist
- Don't offer discounts on your own
- Don't promise timelines without checking

WHEN TO HAND OFF TO AN OPERATOR:
- A complaint about the product
- A question about a refund
- A technical problem you can't solve
`;

// Conversation history store (in production: Redis or a database)
const conversationHistory = new Map();

// Webhook from Crisp
app.post('/crisp-webhook', async (req, res) => {
  const event = req.body;

  // Only handle new messages from customers
  if (event.event !== 'message:send') {
    return res.json({ status: 'ignored' });
  }

  const sessionId = event.data?.session_id;
  const message = event.data?.content;
  const websiteId = event.data?.website_id;
  const from = event.data?.from;  // 'user' or 'operator'

  // Skip messages from operators (to avoid a loop)
  if (from === 'operator') {
    return res.json({ status: 'ignored' });
  }

  console.log(`New message [${sessionId}]: ${message}`);

  try {
    // Get or create the conversation history
    if (!conversationHistory.has(sessionId)) {
      conversationHistory.set(sessionId, []);
    }
    const history = conversationHistory.get(sessionId);

    // Add the user's message to the history
    history.push({
      role: 'user',
      content: message
    });

    // Keep the history a reasonable size (last 10 messages)
    const recentHistory = history.slice(-10);

    // Send to Claude
    const response = await claude.messages.create({
      model: 'claude-sonnet-5-5',
      max_tokens: 500,
      system: KNOWLEDGE_BASE,
      messages: recentHistory
    });

    const aiResponse = response.content.map((b) => (b.type === "text" ? b.text : "")).join("");

    // Save the AI's answer to the history
    history.push({
      role: 'assistant',
      content: aiResponse
    });

    // Decide whether an operator is needed
    const needsHuman = detectEscalationNeeded(aiResponse, message);

    // Send the answer to Crisp
    await crisp.website.sendMessageInConversation(websiteId, sessionId, {
      type: 'text',
      from: 'operator',
      origin: 'chat',
      content: aiResponse
    });

    // If an operator is needed, let the customer know
    if (needsHuman) {
      await crisp.website.sendMessageInConversation(websiteId, sessionId, {
        type: 'text',
        from: 'operator',
        origin: 'chat',
        content: '⏳ I\'m connecting you with a specialist now. One moment, please.'
      });

      // Change the conversation status to "needs attention" (state: pending, waiting for an operator)
      await crisp.website.changeConversationState(
        websiteId,
        sessionId,
        { state: 'pending' }
      );

      console.log(`[${sessionId}] Handed off to an operator`);
    }

    res.json({ status: 'success', escalated: needsHuman });

  } catch (error) {
    console.error('Error:', error);

    // On error, call an operator right away
    await crisp.website.sendMessageInConversation(websiteId, sessionId, {
      type: 'text',
      from: 'operator',
      origin: 'chat',
      content: 'Sorry, I can\'t help with that right now. A specialist will get back to you soon!'
    });

    res.status(500).json({ status: 'error' });
  }
});

function detectEscalationNeeded(aiResponse, userMessage) {
  const escalationPhrases = [
    'connecting you', 'transfer you', 'a specialist will help',
    'please contact', 'can\'t help with this', 'requires manual'
  ];

  const userEscalationPhrases = [
    'real person', 'operator', 'manager', 'don\'t want a robot',
    'get me a human', 'talk to a human'
  ];

  const inAiResponse = escalationPhrases.some(p =>
    aiResponse.toLowerCase().includes(p)
  );

  const inUserMessage = userEscalationPhrases.some(p =>
    userMessage.toLowerCase().includes(p)
  );

  return inAiResponse || inUserMessage;
}

app.listen(3000, () => {
  console.log('Server running on port 3000');
});
bash
# package.json dependencies
npm init -y
npm install express @anthropic-ai/sdk crisp-api dotenv

# .env
ANTHROPIC_API_KEY=sk-ant-...
CRISP_API_IDENTIFIER=your_crisp_id
CRISP_API_KEY=your_crisp_key

# Run it
node server.js

# Make it reachable from the internet (for the webhook)
npx ngrok http 3000
# Copy the URL, something like https://abc123.ngrok.io
# In Crisp: the webhooks section (menu names change) → add the address https://abc123.ngrok.io/crisp-webhook

🎨 Picture this: A webhook is like a doorbell. Crisp rings the bell when a customer writes. Your server opens the door, figures out what to say (with Claude), and hands the answer back to Crisp. All of this takes 1-2 seconds.

Intercom Fin: a built-in AI agent

Intercom is a more expensive option (paid plans, and the Fin AI agent is billed separately; prices are on the Intercom website), but it has the built-in Fin AI agent. You don't need to write code: you connect your documentation and Fin starts answering.

Parameter Crisp + Claude Intercom Fin
Price Crisp plan + Claude API usage Intercom plan + Fin usage (prices on the websites)
Setup Code required No code
Flexibility Full Limited
Answer quality Very high High
Analytics Your own Built in
Best for Developers Non-technical teams

Chatwoot: open source + self-hosted

Chatwoot is a free (open-source) alternative to Intercom that you host yourself. Full control over your data, no license fee; you're responsible for the server and maintenance.

python
# Chatwoot + Claude integration in Python
import anthropic
import requests
import os

claude_client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

CHATWOOT_URL = os.environ["CHATWOOT_URL"]  # https://your-chatwoot.com
CHATWOOT_TOKEN = os.environ["CHATWOOT_TOKEN"]  # User Access Token
CHATWOOT_ACCOUNT_ID = os.environ["CHATWOOT_ACCOUNT_ID"]

def send_message_to_chatwoot(
    conversation_id: int,
    message: str,
    message_type: str = "outgoing"
) -> dict:
    """Sends a message to a Chatwoot conversation"""

    response = requests.post(
        f"{CHATWOOT_URL}/api/v1/accounts/{CHATWOOT_ACCOUNT_ID}/conversations/{conversation_id}/messages",
        headers={
            "api_access_token": CHATWOOT_TOKEN,
            "Content-Type": "application/json"
        },
        json={
            "content": message,
            "message_type": message_type,
            "private": False  # Visible to the customer
        }
    )
    return response.json()


def handle_chatwoot_webhook(webhook_data: dict) -> dict:
    """Handles an incoming webhook from Chatwoot"""

    event_type = webhook_data.get("event")

    # Only incoming messages from customers
    if event_type != "message_created":
        return {"status": "ignored"}

    message = webhook_data.get("content", "")
    conversation_id = webhook_data.get("conversation", {}).get("id")
    message_type = webhook_data.get("message_type")

    # Skip outgoing messages (from operators/AI)
    if message_type != "incoming":
        return {"status": "ignored"}

    # Claude answers
    response = claude_client.messages.create(
        model="claude-sonnet-5-5",
        max_tokens=400,
        system="You are the company's assistant. Answer questions about the product. If you don't know, say so honestly.",
        messages=[{"role": "user", "content": message}]
    )

    ai_answer = "".join(b.text for b in response.content if b.type == "text")

    # Send the answer to Chatwoot
    send_message_to_chatwoot(conversation_id, ai_answer)

    return {"status": "success"}

Smart escalation: when to hand off to a human

The key to any AI chat is knowing when to stop. An AI trying to answer everything is often worse than a quick handoff to an operator.

python
def should_escalate_to_human(
    user_message: str,
    ai_response: str,
    conversation_turns: int
) -> tuple[bool, str]:
    """
    Decides whether to hand off to an operator.
    Returns (needs_handoff, reason)
    """

    # Explicit requests for a live person
    human_requests = [
        "talk to a human", "get me an operator",
        "real manager", "don't want a robot", "is this a bot?",
        "connect me with someone"
    ]
    if any(phrase in user_message.lower() for phrase in human_requests):
        return True, "customer asked for a live operator"

    # Emotionally charged messages
    anger_signals = [
        "this is outrageous", "refund my money", "i'll sue",
        "this is a scam", "disgusting", "i demand"
    ]
    if any(signal in user_message.lower() for signal in anger_signals):
        return True, "customer is expressing strong dissatisfaction"

    # The AI admits it can't help
    ai_cant_help = [
        "can't help", "outside my scope",
        "transfer you to a specialist", "check with an operator"
    ]
    if any(phrase in ai_response.lower() for phrase in ai_cant_help):
        return True, "AI couldn't answer the question"

    # Conversation running too long without a resolution (5+ turns)
    if conversation_turns >= 5:
        claude_check = claude_client.messages.create(
            model="claude-haiku-4-5",  # Fast, cheap model for the check
            max_tokens=50,
            messages=[{
                "role": "user",
                "content": f"Is the customer's problem resolved? Answer only: yes/no. Message: {user_message}"
            }]
        )
        if "no" in "".join(b.text for b in claude_check.content if b.type == "text").lower():
            return True, "problem not resolved within 5 messages"

    return False, ""

RAG: Claude knows everything about your product

Without a knowledge base, Claude will make up facts about your product. With one, it answers accurately.

python
# A simple RAG system for a chat widget
# In production: Pinecone / Chroma / Qdrant instead of a list of strings

class SimpleRAG:
    def __init__(self, documents: list[dict]):
        """
        documents: [{"title": "...", "content": "...", "category": "..."}]
        """
        self.documents = documents
        self.client = anthropic.Anthropic()

    def find_relevant(self, query: str, top_k: int = 3) -> list[dict]:
        """Finds relevant documents (simplified, using Claude)"""

        # In a real system: vector search
        # Here: Claude picks from a short list

        titles = [f"{i}. {d['title']}" for i, d in enumerate(self.documents)]
        titles_text = "\n".join(titles)

        response = self.client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=100,
            messages=[{
                "role": "user",
                "content": f"""Question: "{query}"

Which documents are most relevant? Give only the numbers, separated by commas (for example: 0,2):
{titles_text}"""
            }]
        )

        try:
            indices = [int(i.strip()) for i in "".join(b.text for b in response.content if b.type == "text").split(",")]
            return [self.documents[i] for i in indices[:top_k] if i < len(self.documents)]
        except Exception:
            return self.documents[:top_k]  # Fallback

    def answer_with_context(self, question: str) -> str:
        """Answers a question with context from the knowledge base"""

        relevant_docs = self.find_relevant(question)
        context = "\n\n".join([
            f"### {d['title']}\n{d['content']}"
            for d in relevant_docs
        ])

        response = self.client.messages.create(
            model="claude-sonnet-5-5",
            max_tokens=300,
            messages=[{
                "role": "user",
                "content": f"""Use ONLY the information from the knowledge base.
If the answer isn't in the knowledge base, honestly say "I don't know" and suggest contacting support.

KNOWLEDGE BASE:
{context}

QUESTION: {question}"""
            }]
        )

        return "".join(b.text for b in response.content if b.type == "text")


# Example setup (the product and its numbers are made up)
kb = SimpleRAG([
    {
        "title": "Pricing",
        "category": "pricing",
        "content": "Basic plan: $12/month (up to 5 users). Standard: $35/month (up to 20 users). Business: $95/month (unlimited). 20% off when you pay yearly."
    },
    {
        "title": "Integrations",
        "category": "technical",
        "content": "Works with: QuickBooks, HubSpot, Salesforce, Google Sheets. The API is available on all plans. Webhooks start with the Standard plan."
    },
    {
        "title": "Security",
        "category": "technical",
        "content": "Data is stored on servers in the US. AES-256 encryption. SOC2 Type II. Backups every 6 hours."
    }
])

# Test
questions = [
    "How much is the unlimited plan?",
    "Do you integrate with HubSpot?",
    "Can I use your service from Europe?"
]

for q in questions:
    answer = kb.answer_with_context(q)
    print(f"Q: {q}")
    print(f"A: {answer}\n")

Analytics: what people ask most often

python
def analyze_chat_questions(chat_logs: list[str]) -> dict:
    """Analyzes questions from chats and finds patterns"""

    client = anthropic.Anthropic()

    combined = "\n---\n".join(chat_logs[:50])

    response = client.messages.create(
        model="claude-sonnet-5-5",
        max_tokens=800,
        messages=[{
            "role": "user",
            "content": f"""Analyze the customer questions from the chat widget.

CHAT LOGS:
{combined}

I need:
1. **Top 10 topics** people ask about most often (with an approximate % of all questions)
2. **Unanswered questions**: where the AI said "I don't know" (what to add to the FAQ)
3. **Questions that went to an operator**: what's too complex for the AI
4. **Time patterns**: when people write the most
5. **Recommendations**: what to change on the website so people find answers on their own

This is data for the product team, so be specific."""
        }]
    )

    return "".join(b.text for b in response.content if b.type == "text")

Practice

Task: set up a Crisp + Claude webhook in 35 minutes

Step 1: Sign up for Crisp (5 min)

bash
# 1. Go to crisp.chat
# 2. Create an account (free)
# 3. Add your website: Settings → Websites → Add
# 4. Copy the widget code and paste it into your site before </body>

# To test without a real website, Crisp has a test chat

Step 2: Get your API credentials (5 min)

bash
# In the Crisp Dashboard:
# Profile → API Access Tokens → Create new token
# Save:
# - Identifier (like a username)
# - Key (like a password)
# - Website ID (in the URL: app.crisp.chat/website/WEBSITE_ID/)

Step 3: Start a local server (15 min)

bash
mkdir crisp-claude-bot && cd crisp-claude-bot
npm init -y
npm install express @anthropic-ai/sdk dotenv ngrok

# Create .env
cat > .env << EOF
ANTHROPIC_API_KEY=sk-ant-your-key
CRISP_API_IDENTIFIER=your-identifier
CRISP_API_KEY=your-key
CRISP_WEBSITE_ID=your-website-id
PORT=3000
EOF
javascript
// bot.js — a minimal working version
require('dotenv').config();
const express = require('express');
const Anthropic = require('@anthropic-ai/sdk');

const app = express();
app.use(express.json());

const claude = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

// The simplest webhook, without history (for testing)
app.post('/webhook', async (req, res) => {
  const { event, data } = req.body;

  if (event !== 'message:send' || data?.from !== 'user') {
    return res.json({ ok: true });
  }

  const question = data?.content;
  const sessionId = data?.session_id;
  const websiteId = data?.website_id;

  try {
    const response = await claude.messages.create({
      model: 'claude-sonnet-5-5',
      max_tokens: 250,
      system: 'You are an assistant on a website. Keep answers short and to the point. If you don\'t know, say so honestly.',
      messages: [{ role: 'user', content: question }]
    });

    const answer = response.content.map((b) => (b.type === "text" ? b.text : "")).join("");

    // Send the answer through the Crisp REST API
    await fetch(
      `https://api.crisp.chat/v1/website/${websiteId}/conversation/${sessionId}/message`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Basic ${Buffer.from(
            `${process.env.CRISP_API_IDENTIFIER}:${process.env.CRISP_API_KEY}`
          ).toString('base64')}`,
          'Content-Type': 'application/json',
          'X-Crisp-Tier': 'plugin'
        },
        body: JSON.stringify({
          type: 'text',
          content: answer,
          from: 'operator',
          origin: 'chat'
        })
      }
    );

    console.log(`[${sessionId}] Q: ${question.substring(0,50)}... → answered`);
    res.json({ ok: true });

  } catch (err) {
    console.error(err);
    res.status(500).json({ error: err.message });
  }
});

app.listen(process.env.PORT, () =>
  console.log(`Bot running on port ${process.env.PORT}`)
);
bash
# Start the server
node bot.js

# In another terminal, open a tunnel
npx ngrok http 3000
# Copy the URL: https://abc123.ngrok.io

Step 4: Set up the webhook in Crisp (5 min)

Code
In the Crisp Dashboard:
The webhooks section in settings (Crisp's menu names change)
→ Endpoint URL: https://abc123.ngrok.io/webhook
→ Events: message:send ✅
→ Save

Step 5: Test it (5 min)

Type this into the chat
Open the Crisp test chat (the "Preview" button in the Dashboard)
Type any question
Look at the AI's answer
Check the logs in your terminal

Tools and resources

  • Crisp.chat: crisp.chat (there's a free plan; access to the API and webhooks depends on the plan, see the website)
  • Chatwoot: chatwoot.com (open source, free to self-host)
  • Intercom: intercom.com (paid, with the built-in Fin AI agent; prices on the website)
  • Tawk.to: tawk.to (free chat; see the website for AI features and terms)
  • ngrok: ngrok.com (a tunnel for testing webhooks locally)
  • Anthropic API: the model names in the code are as of October 2026; current prices and versions: What's current

Key takeaways

A chat on your website isn't "just another support channel." It's a decision point. Customers write in the chat at the moment they're ready to buy or ready to leave. An AI that answers within a second at 11:47 p.m. gives you a chance to keep a visitor who's about to leave.

Escalation matters more than answers. The best AI chat is the one that quickly recognizes when a human is needed and hands off without delay. A customer who hears "I don't know, I'm connecting you with a specialist" is satisfied. A customer the AI told about a feature that doesn't exist is lost for good.

Analytics are a hidden bonus. Hundreds of conversations = hundreds of pieces of feedback. Claude analyzes the patterns in minutes and shows you what to improve in your product, website and FAQ. It's a good extra source alongside your regular user research.


Next lesson

→ Payments & Billing: Stripe MCP, subscriptions, webhooks

The mark stays in this browser only and is never sent anywhere. My progress