Суть урока
Посетитель зашёл на сайт в 23:47 в субботу. Увидел кнопку "Написать нам" и написал вопрос. Обычно — тишина до понедельника утром. К тому времени он уже купил у конкурента.
AI чат отвечает через секунду. В любое время. На многих языках. И если не может помочь — честно говорит и записывает данные для звонка оператора утром.
Ключевые концепции
- Crisp.chat + Claude — webhook перехватывает сообщение → Claude отвечает → ответ в чат
- Intercom Fin — встроенный AI агент (проще, дороже)
- Chatwoot — open-source альтернатива, self-hosted + Claude
- Умный эскалейшн — Claude решает → если не может → берёт живой человек
- RAG по документации — Claude знает всё о вашем продукте
- Аналитика вопросов — Claude находит паттерны → вы улучшаете FAQ
Теория
Почему обычный чат — это не работает
Обычный виджет онлайн-чата работает пока есть оператор. Оператора нет → "Мы offline, оставьте email". Часть посетителей на этом уходит.
Три боли:
- Время — вне рабочих часов клиент уходит
- Скорость — даже онлайн оператор отвечает за 2-5 минут (занят другим)
- Масштаб — один оператор может параллельно вести лишь несколько чатов
AI закрывает все три: отвечает мгновенно, 24/7, параллельно тысяче клиентов.
Crisp.chat + Claude: webhook-интеграция
Crisp — европейский чат-инструмент (часто выбирают ради соответствия GDPR, обычно проще и дешевле Intercom). Бесплатный план позволяет поставить виджет на сайт, а доступ к API и вебхукам зависит от тарифа: смотри условия на сайте Crisp.
Логика работы:
Клиент пишет в чате → Crisp webhook → ваш сервер → Claude API → ответ → Crisp API → клиент видит// server.js — Node.js сервер для обработки 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
);
// Имена методов библиотеки crisp-api сверяй с её README: они меняются между версиями
// База знаний о продукте
const KNOWLEDGE_BASE = `
Вы помощник компании [Название компании].
ПРОДУКТ:
- Что это: [описание продукта]
- Цена: [цены]
- Как начать: [шаги]
ЧАСТЫЕ ВОПРОСЫ:
- Вопрос 1: Ответ 1
- Вопрос 2: Ответ 2
ЧЕГО НЕ ДЕЛАТЬ:
- Не придумывать функции которых нет
- Не давать скидки самостоятельно
- Не обещать сроки без уточнения
КОГДА ПЕРЕДАТЬ ОПЕРАТОРУ:
- Жалоба на продукт
- Вопрос о возврате денег
- Техническая проблема которую не можете решить
`;
// Хранилище истории разговоров (в продакшне — Redis или БД)
const conversationHistory = new Map();
// Webhook от Crisp
app.post('/crisp-webhook', async (req, res) => {
const event = req.body;
// Обрабатываем только новые сообщения от клиентов
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' или 'operator'
// Пропускаем сообщения от операторов (чтобы не зациклиться)
if (from === 'operator') {
return res.json({ status: 'ignored' });
}
console.log(`Новое сообщение [${sessionId}]: ${message}`);
try {
// Получаем или создаём историю разговора
if (!conversationHistory.has(sessionId)) {
conversationHistory.set(sessionId, []);
}
const history = conversationHistory.get(sessionId);
// Добавляем сообщение пользователя в историю
history.push({
role: 'user',
content: message
});
// Держим историю разумного размера (последние 10 сообщений)
const recentHistory = history.slice(-10);
// Отправляем в 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("");
// Сохраняем ответ AI в историю
history.push({
role: 'assistant',
content: aiResponse
});
// Определяем нужен ли оператор
const needsHuman = detectEscalationNeeded(aiResponse, message);
// Отправляем ответ в Crisp
await crisp.website.sendMessageInConversation(websiteId, sessionId, {
type: 'text',
from: 'operator',
origin: 'chat',
content: aiResponse
});
// Если нужен оператор — уведомляем
if (needsHuman) {
await crisp.website.sendMessageInConversation(websiteId, sessionId, {
type: 'text',
from: 'operator',
origin: 'chat',
content: '⏳ Сейчас передам вас специалисту. Одну минуту, пожалуйста.'
});
// Меняем статус разговора на "требует внимания" (state: pending, ждёт оператора)
await crisp.website.changeConversationState(
websiteId,
sessionId,
{ state: 'pending' }
);
console.log(`[${sessionId}] Передан оператору`);
}
res.json({ status: 'success', escalated: needsHuman });
} catch (error) {
console.error('Ошибка:', error);
// При ошибке — сразу зовём оператора
await crisp.website.sendMessageInConversation(websiteId, sessionId, {
type: 'text',
from: 'operator',
origin: 'chat',
content: 'Извините, сейчас я не могу помочь. Наш специалист скоро свяжется с вами!'
});
res.status(500).json({ status: 'error' });
}
});
function detectEscalationNeeded(aiResponse, userMessage) {
const escalationPhrases = [
'передам вас', 'соединю вас', 'специалист поможет',
'обратитесь к', 'не могу помочь с этим', 'требует ручного'
];
const userEscalationPhrases = [
'живой человек', 'оператор', 'менеджер', 'не хочу с роботом',
'позовите человека', 'хочу поговорить с человеком'
];
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('Сервер запущен на порту 3000');
});# package.json зависимости
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
# Запуск
node server.js
# Сделать доступным из интернета (для webhook)
npx ngrok http 3000
# Скопируйте URL типа https://abc123.ngrok.io
# В Crisp: раздел вебхуков (названия пунктов меню меняются) → добавить адрес https://abc123.ngrok.io/crisp-webhookIntercom Fin: встроенный AI агент
Intercom — более дорогой вариант (платные тарифы, а AI-агент Fin тарифицируется отдельно; цены на сайте Intercom), но со встроенным AI агентом Fin. Не нужно писать код — просто подключаете документацию и Fin начинает отвечать.
| Параметр | Crisp + Claude | Intercom Fin |
|---|---|---|
| Цена | Тариф Crisp + оплата Claude API | Тариф Intercom + оплата Fin (цены на сайтах) |
| Настройка | Нужен код | Без кода |
| Гибкость | Полная | Ограниченная |
| Качество ответов | Очень высокое | Высокое |
| Аналитика | Своя | Встроенная |
| Лучше для | Разработчики | Нетехнические команды |
Chatwoot: open-source + self-hosted
Chatwoot — бесплатная (open-source) альтернатива Intercom которую вы хостите сами. Полный контроль над данными, без платы за лицензию; за сервер и поддержку отвечаете вы.
# Интеграция Chatwoot + Claude через 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:
"""Отправляет сообщение в разговор Chatwoot"""
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 # Видно клиенту
}
)
return response.json()
def handle_chatwoot_webhook(webhook_data: dict) -> dict:
"""Обрабатывает входящий webhook от Chatwoot"""
event_type = webhook_data.get("event")
# Только входящие сообщения от клиентов
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")
# Пропускаем исходящие (от операторов/AI)
if message_type != "incoming":
return {"status": "ignored"}
# Claude отвечает
response = claude_client.messages.create(
model="claude-sonnet-5-5",
max_tokens=400,
system="Ты помощник компании. Отвечай по теме продукта. Если не знаешь — скажи честно.",
messages=[{"role": "user", "content": message}]
)
ai_answer = "".join(b.text for b in response.content if b.type == "text")
# Отправляем ответ в Chatwoot
send_message_to_chatwoot(conversation_id, ai_answer)
return {"status": "success"}Умный эскалейшн: когда передавать человеку
Ключевой момент любого AI чата — знать когда остановиться. Попытка AI отвечать на всё часто хуже, чем быстрая передача оператору.
def should_escalate_to_human(
user_message: str,
ai_response: str,
conversation_turns: int
) -> tuple[bool, str]:
"""
Определяет нужна ли передача оператору.
Возвращает (нужна_передача, причина)
"""
# Явные запросы на живого человека
human_requests = [
"хочу поговорить с человеком", "позовите оператора",
"живой менеджер", "не хочу с роботом", "это бот?",
"соедините с сотрудником"
]
if any(phrase in user_message.lower() for phrase in human_requests):
return True, "клиент запросил живого оператора"
# Эмоционально заряженные сообщения
anger_signals = [
"это безобразие", "верните деньги", "подам в суд",
"это мошенничество", "отвратительно", "требую"
]
if any(signal in user_message.lower() for signal in anger_signals):
return True, "клиент выражает сильное недовольство"
# AI признаёт что не может помочь
ai_cant_help = [
"не могу помочь", "не в моей компетенции",
"передам специалисту", "уточните у оператора"
]
if any(phrase in ai_response.lower() for phrase in ai_cant_help):
return True, "AI не смог ответить на вопрос"
# Слишком долгий разговор без решения (5+ ходов)
if conversation_turns >= 5:
claude_check = claude_client.messages.create(
model="claude-haiku-4-5", # Быстрая дешёвая модель для проверки
max_tokens=50,
messages=[{
"role": "user",
"content": f"Проблема клиента решена? Ответь только: да/нет. Сообщение: {user_message}"
}]
)
if "нет" in "".join(b.text for b in claude_check.content if b.type == "text").lower():
return True, "проблема не решена за 5 сообщений"
return False, ""RAG: Claude знает всё о вашем продукте
Без базы знаний Claude будет выдумывать факты о вашем продукте. С базой — отвечает точно.
# Простая RAG система для чат-виджета
# В продакшне: Pinecone / Chroma / Qdrant вместо списка строк
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]:
"""Находит релевантные документы (упрощённо через Claude)"""
# В реальной системе: векторный поиск
# Здесь: Claude выбирает из небольшого списка
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"""Вопрос: "{query}"
Какие документы наиболее релевантны? Укажи только номера через запятую (например: 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:
"""Отвечает на вопрос с контекстом из базы знаний"""
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"""Используй ТОЛЬКО информацию из базы знаний.
Если ответа нет в базе — честно скажи "не знаю" и предложи написать в поддержку.
БАЗА ЗНАНИЙ:
{context}
ВОПРОС: {question}"""
}]
)
return "".join(b.text for b in response.content if b.type == "text")
# Пример настройки
kb = SimpleRAG([
{
"title": "Тарифы",
"category": "pricing",
"content": "Базовый план: 990₽/мес (до 5 пользователей). Стандарт: 2900₽/мес (до 20 пользователей). Бизнес: 7900₽/мес (безлимит). Скидка 20% при оплате за год."
},
{
"title": "Интеграции",
"category": "technical",
"content": "Работает с: 1С, amoCRM, Битрикс24, Google Sheets. API доступно на всех тарифах. Webhooks — с тарифа Стандарт."
},
{
"title": "Безопасность",
"category": "technical",
"content": "Данные хранятся на серверах в России. Шифрование AES-256. SOC2 Type II. Резервные копии каждые 6 часов."
}
])
# Тест
questions = [
"Сколько стоит безлимитный тариф?",
"Есть ли интеграция с amoCRM?",
"Можно ли работать с вами из Европы?"
]
for q in questions:
answer = kb.answer_with_context(q)
print(f"Q: {q}")
print(f"A: {answer}\n")Аналитика: что спрашивают чаще всего
def analyze_chat_questions(chat_logs: list[str]) -> dict:
"""Анализирует вопросы из чатов и находит паттерны"""
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"""Проанализируй вопросы клиентов из чат-виджета.
ЛОГИ ЧАТОВ:
{combined}
Нужно:
1. **Топ-10 тем** которые спрашивают чаще всего (с примерными % от всех вопросов)
2. **Вопросы без ответа** — где AI говорил "не знаю" (что добавить в FAQ)
3. **Вопросы на оператора** — что слишком сложно для AI
4. **Временны́е паттерны** — когда активнее всего пишут
5. **Рекомендации** — что изменить на сайте чтобы люди находили ответы сами
Это данные для продуктовой команды — пиши конкретно."""
}]
)
return "".join(b.text for b in response.content if b.type == "text")Практика
Задача: настроить Crisp + Claude webhook за 35 минут
Шаг 1: Регистрация в Crisp (5 мин)
# 1. Идём на crisp.chat
# 2. Создаём аккаунт (бесплатно)
# 3. Добавляем сайт: Settings → Websites → Add
# 4. Копируем код виджета и вставляем на сайт перед </body>
# Для тестирования без реального сайта — в Crisp есть тестовый чатШаг 2: Получаем API credentials (5 мин)
# В Crisp Dashboard:
# Profile → API Access Tokens → Create new token
# Сохраняем:
# - Identifier (как логин)
# - Key (как пароль)
# - Website ID (в URL: app.crisp.chat/website/WEBSITE_ID/)Шаг 3: Запускаем локальный сервер (15 мин)
mkdir crisp-claude-bot && cd crisp-claude-bot
npm init -y
npm install express @anthropic-ai/sdk dotenv ngrok
# Создаём .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// bot.js — минимальная рабочая версия
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 });
// Простейший webhook без истории (для теста)
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: 'Ты помощник на сайте. Отвечай кратко и по делу. Если не знаешь — скажи честно.',
messages: [{ role: 'user', content: question }]
});
const answer = response.content.map((b) => (b.type === "text" ? b.text : "")).join("");
// Отправляем ответ через 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)}... → ответил`);
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
app.listen(process.env.PORT, () =>
console.log(`Бот запущен на порту ${process.env.PORT}`)
);# Запускаем сервер
node bot.js
# В другом терминале — открываем туннель
npx ngrok http 3000
# Копируем URL: https://abc123.ngrok.ioШаг 4: Настраиваем webhook в Crisp (5 мин)
В Crisp Dashboard:
Раздел вебхуков в настройках (названия пунктов меню Crisp меняются)
→ Endpoint URL: https://abc123.ngrok.io/webhook
→ Events: message:send ✅
→ SaveШаг 5: Тестируем (5 мин)
Открываем тестовый чат Crisp (кнопка "Preview" в Dashboard) Пишем любой вопрос Смотрим на ответ AI Проверяем логи в терминале
Инструменты и ресурсы
- Crisp.chat — crisp.chat (есть бесплатный план; доступ к API и вебхукам зависит от тарифа, смотри на сайте)
- Chatwoot — chatwoot.com (open-source, self-hosted бесплатно)
- Intercom — intercom.com (платно, со встроенным AI-агентом Fin; цены на сайте)
- Tawk.to — tawk.to (бесплатный чат; AI-функции и условия смотри на сайте)
- ngrok — ngrok.com (туннель для тестирования webhook локально)
- Anthropic API — названия моделей в коде даны на октябрь 2026; актуальные цены и версии: Актуальное сейчас
Ключевые выводы
Чат на сайте — это не "ещё один канал поддержки". Это точка принятия решения. Клиент пишет в чат в момент когда готов купить или готов уйти. AI который отвечает за секунду в 23:47 даёт шанс удержать уходящего посетителя.
Эскалейшн важнее ответов. Лучший AI-чат — тот который быстро понимает когда нужен человек и передаёт без задержки. Клиент который получил "не знаю, передаю специалисту" — доволен. Клиент которому AI выдумал несуществующую функцию — потерян навсегда.
Аналитика — скрытый бонус. Сотни разговоров = сотни единиц обратной связи. Claude анализирует паттерны за минуты и показывает что нужно улучшить в продукте, сайте и FAQ. Это хороший дополнительный источник к обычным исследованиям пользователей.
Следующий урок
Отметка хранится только в этом браузере и никуда не отправляется. Мой прогресс