vapi

Интеграция 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 URLhttps://m5-3000.c10r.io/api/ws/swim/webhooks/vapi
Toolsтолько endCall (отдельного tool для поиска клиента нет)
firstMessageModeassistant-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.

  1. Входящий звонок приходит на Twilio-номер → Twilio дёргает вебхук голоса CRM.
  2. CRM определяет, какой VAPI-ассистент должен ответить (по номеру / IVR-шагу type === 'sip' с voiceAIAssistantId).
  3. Перед возвратом TwiML CRM патчит VAPI-номер на выбранного ассистента (SIP), затем отдаёт TwiML, который соединяет звонок с Vapi.
  4. Жёсткий таймаут 4 сек (окно ответа TwiML у Twilio ~10 сек): если Vapi тормозит — звонок всё равно продолжается без назначения.

5. Узнавание клиента по номеру (обращение по имени)

Имя подтягивается не tool-запросом во время разговора, а CRM снабжает ассистента контекстом в момент снятия трубки.

Файл: lib/telephony/caller-context.tsbuildCallerContext({ workspaceDb, callerNumber }):

  1. Поиск контакта по номеруfindExistingContact(workspaceDb, 'call', callerNumber).
  2. Очистка имениcleanCallerName(): отбрасывает инициалы (одиночные буквы от CNAM-обогащения, напр. «V»), номера-похожие значения, приводит ALL-CAPS к Title Case (BOGUTSKIIBogutskii). Пусто, если контакт неизвестен.
  3. Предзагрузка заказовgetOrders({ contactId, limit: 10, sortBy: createdAt desc }); заполняет customer_order_count и customer_orders (одна строка-саммари на заказ через summarizeOrderForVoice(), статус — человекочитаемое имя стадии).
  4. Никогда не бросает исключение — при любой ошибке деградирует до одного номера, чтобы звонок всегда шёл дальше.

Доставка контекста в ассистента

Два транспорта, поля одинаковые:

  • 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.tsPull-эндпоинт контекста (для 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. Как воспроизвести / настроить с нуля

  1. Ключ Vapi — положить в Doppler как VAPI_API_KEY и/или в credential vault воркспейса (provider: 'vapi').
  2. Ассистент в Vapi — модель gpt-4o, транскрайбер Deepgram, голос, Server URL = https://<host>/api/ws/<workspace>/webhooks/vapi. В firstMessage/промпте использовать переменные {{customer_name}}, {{customer_orders}} и т.д.
  3. Номер — Twilio-номер, привязанный к ассистенту (SIP-шаг в маршруте).
  4. Проверка — позвонить с номера, который есть в контактах 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 и важен регистр) — не путать.