Интеграция Vapi ↔ CRM (голосовой AI-ассистент «Swim Outlet»)
Единая инструкция: как в CRM (c10r) устроена интеграция с Vapi — голосовой ассистент отвечает на звонки, узнаёт клиента по номеру и обращается по имени, а транскрипт разговора падает на таймлайн контакта.
Это схема-пайплайн (не speech-to-speech): Deepgram слушает → gpt-4o думает → голос Vapi/Clara отвечает. CRM выступает оркестратором: назначает ассистента на звонок и снабжает его контекстом клиента.
1. Общая архитектура
Входящий звонок (PSTN)
│
▼
┌─────────────────┐ caller-context (push, SIP X-* headers)
│ Twilio номер │◄──────────────────────────────────────┐
└────────┬────────┘ │
│ TwiML → SIP │
▼ ┌───────┴────────┐
┌─────────────────┐ server events (webhook) │ CRM (c10r) │
│ Vapi assistant │──────────────────────────────► │ workspace=swim │
│ «Swim Outlet» │ transcript / end-of-call └───────┬────────┘
└─────────────────┘ │
gpt-4o · Deepgram flux · voice Clara контакт по номеру →
имя + заказы
Роли:
- Twilio — телефонная линия (PSTN ↔ SIP).
- Vapi — голосовой AI-агент (STT + LLM + TTS + диалог-логика).
- CRM (c10r) — назначает ассистента на номер в момент звонка, отдаёт контекст клиента (имя, заказы), принимает транскрипт после звонка.
2. Конфигурация ассистента в Vapi
Ассистент «Swim Outlet» (id afbaec07-7680-4c04-b86d-365ca8d75d88):
| Параметр | Значение |
|---|---|
| Модель (LLM) | OpenAI gpt-4o |
| Голос (TTS) | Vapi / Clara |
| Транскрайбер (STT) | Deepgram flux-general-en |
| Server URL | https://m5-3000.c10r.io/api/ws/swim/webhooks/vapi |
| Tools | только endCall (отдельного tool для поиска клиента нет) |
| firstMessageMode | assistant-speaks-first-with-model-generated-message |
firstMessage — Liquid-шаблон, который здоровается по имени, если оно известно:
{% if customer_name %}Hello {{customer_name}}! Welcome back to Swim Outlet.
{% else %}Hello! Welcome to Swim Outlet Assistant.{% endif %}
Динамические переменные, которые ассистент ожидает от CRM:
customer_name, customer_phone, customer_order_count, customer_orders.
3. Где лежит ключ Vapi
- Doppler (секреты окружения):
VAPI_API_KEY. Получить:doppler secrets get VAPI_API_KEY --plain(из каталогаc10r.io). - Credential vault CRM (по воркспейсу):
modules/workspace/credential,provider: 'vapi', полеapiKeyв зашифрованномencryptedData. Код доступа:getCredentialsByProvider({ provider: 'vapi', workspaceDb })→getCredentialWithData(...)(см.lib/voice-ai/vapi-sync.ts).
Быстрая проверка через API:
KEY=$(doppler secrets get VAPI_API_KEY --plain)
# список ассистентов
curl -s https://api.vapi.ai/assistant -H "Authorization: Bearer $KEY"
# конфиг конкретного ассистента
curl -s https://api.vapi.ai/assistant/afbaec07-7680-4c04-b86d-365ca8d75d88 \
-H "Authorization: Bearer $KEY"
4. Маршрутизация звонка на ассистента (call-time assignment)
Файл: lib/voice-ai/vapi-sync.ts.
- Входящий звонок приходит на Twilio-номер → Twilio дёргает вебхук голоса CRM.
- CRM определяет, какой VAPI-ассистент должен ответить (по номеру / IVR-шагу
type === 'sip'сvoiceAIAssistantId). - Перед возвратом TwiML CRM патчит VAPI-номер на выбранного ассистента (SIP), затем отдаёт TwiML, который соединяет звонок с Vapi.
- Жёсткий таймаут 4 сек (окно ответа TwiML у Twilio ~10 сек): если Vapi тормозит — звонок всё равно продолжается без назначения.
5. Узнавание клиента по номеру (обращение по имени)
Имя подтягивается не tool-запросом во время разговора, а CRM снабжает ассистента контекстом в момент снятия трубки.
Файл: lib/telephony/caller-context.ts → buildCallerContext({ workspaceDb, callerNumber }):
- Поиск контакта по номеру —
findExistingContact(workspaceDb, 'call', callerNumber). - Очистка имени —
cleanCallerName(): отбрасывает инициалы (одиночные буквы от CNAM-обогащения, напр. «V»), номера-похожие значения, приводит ALL-CAPS к Title Case (BOGUTSKII→Bogutskii). Пусто, если контакт неизвестен. - Предзагрузка заказов —
getOrders({ contactId, limit: 10, sortBy: createdAt desc }); заполняетcustomer_order_countиcustomer_orders(одна строка-саммари на заказ черезsummarizeOrderForVoice(), статус — человекочитаемое имя стадии). - Никогда не бросает исключение — при любой ошибке деградирует до одного номера, чтобы звонок всегда шёл дальше.
Доставка контекста в ассистента
Два транспорта, поля одинаковые:
- Vapi (push) —
callerContextToSipHeaders()мапит контекст в SIP-заголовкиX-*, которые Vapi превращает в динамические переменные:X-customer_phone→{{customer_phone}}X-customer_name→{{customer_name}}X-customer_order_count→{{customer_order_count}}X-customer_orders→{{customer_orders}}
- Dograh (pull) — эндпоинт
app/api/ws/[wsid]/telephony/caller-context/route.tsотдаёт JSON{ initial_context: { customer_phone, customer_name, ... } }, который Dograh сам забирает pre-call.
Swim Outlet работает через push (Vapi → SIP-заголовки).
Итог: контакт найден → {{customer_name}} подставляется → «Hello, {имя}! Welcome
back». Незнакомый номер → «Hello! Welcome to Swim Outlet Assistant».
6. Транскрипт после звонка
Файл: app/api/ws/[wsid]/webhooks/vapi/route.ts (тот самый Server URL).
- Принимает end-of-call / transcript-события от Vapi.
- Коррелирует звонок к входящему
call-interaction по номеру звонящего (свежий inbound без транскрипта в недавнем окне). - Пишет транскрипт на таймлайн контакта; если контакта нет — создаёт через
findOrCreateContactи кладёт standalone-interaction, чтобы транскрипт не потерялся. - Реплики размечаются
Caller/Assistant.
7. Карта файлов (источник правды)
| Файл | Роль |
|---|---|
lib/voice-ai/vapi-sync.ts | Синк ассистентов/номеров из api.vapi.ai, call-time назначение ассистента на VAPI-номер |
lib/telephony/caller-context.ts | Сборка контекста клиента по номеру (имя + заказы), мапинг в SIP-заголовки |
app/api/ws/[wsid]/telephony/caller-context/route.ts | Pull-эндпоинт контекста (для Dograh) |
app/api/ws/[wsid]/webhooks/vapi/route.ts | Пост-звонковый вебхук: транскрипт на таймлайн контакта |
app/api/ws/[wsid]/voice-ai/assistants/sync/route.ts | Синк ассистентов |
app/api/ws/[wsid]/credentials/… | Хранение/управление ключом Vapi |
| Конфиг ассистента | В самой Vapi: firstMessage-шаблон + Server URL на воркспейс swim |
Сопутствующие доки: .claude/docs/telephony.md, .claude/docs/telephony-implementation.md,
.claude/docs/planning/stories/multi-channel-connections.md.
8. Как воспроизвести / настроить с нуля
- Ключ Vapi — положить в Doppler как
VAPI_API_KEYи/или в credential vault воркспейса (provider: 'vapi'). - Ассистент в Vapi — модель
gpt-4o, транскрайбер Deepgram, голос, Server URL =https://<host>/api/ws/<workspace>/webhooks/vapi. В firstMessage/промпте использовать переменные{{customer_name}},{{customer_orders}}и т.д. - Номер — Twilio-номер, привязанный к ассистенту (SIP-шаг в маршруте).
- Проверка — позвонить с номера, который есть в контактах CRM: ассистент должен поздороваться по имени. Транскрипт после звонка — на таймлайне контакта.
9. Траблшутинг
| Симптом | Причина / проверка |
|---|---|
| Не здоровается по имени | Контакт не найден по номеру (проверь формат номера в CRM), либо имя отфильтровано cleanCallerName как инициал/номер. Проверь, что уходит заголовок X-customer_name. |
| Ассистент не отвечает / не тот | Не сработало call-time назначение (таймаут 4 сек, Vapi тормозил) — звонок пошёл без ассистента. Смотри логи vapi-sync. |
| Нет транскрипта на контакте | Server URL ассистента указывает не на тот воркспейс/хост, либо номер звонящего не сматчился к inbound-interaction. |
| Нет ключа | doppler secrets get VAPI_API_KEY --plain из c10r.io; либо credential vault provider:'vapi'. |
Примечание: голос здесь — Vapi/Clara, задаётся в Vapi. Это отдельная история от локального Dograh на OpenAI Realtime (где голос
marinи важен регистр) — не путать.