Про «ИИ-агента для продаж» пишут так, будто это одна кнопка: подключил, и он сам звонит, квалифицирует, дожимает. На демо так и выглядит. В проде между «агент послушал звонок» и «в CRM появилась правильная задача менеджеру» лежит десяток слоёв, и в каждом всё тихо ломается. Это разбор такого пайплайна по слоям, с кодом, цифрами и граблями, на которые мы наступали, пока доводили агента до боевого режима.

Сразу про границу ожиданий. Полностью автономный SDR, который сам ведёт сделку, пока миф. Реально работает агент-ассистент: снимает рутину (расшифровка и разбор звонков, черновики follow-up, обновление карточек, квалификация входящих), решение остаётся за человеком. И мерить его надо не «дилами за 30 дней», а стоимостью квалифицированной встречи на горизонте 90 дней. По этой метрике сразу видно, агент приносит деньги или просто шумит.

Архитектура целиком

Прежде чем нырять в слои, общая картина. За «агентом» стоит конвейер из очередей и воркеров, где на саму LLM приходится примерно треть кода, остальные две трети это обвязка вокруг.

Архитектура: пайплайн обработки звонка от вебхука до CRM
Архитектура: пайплайн обработки звонка от вебхука до CRM

Стек у нас: NestJS, очереди на BullMQ поверх Redis, Postgres + Prisma, S3 для записей, мульти-провайдерный слой над LLM. Почему очереди, а не «вызвать по цепочке в одном хендлере»: транскрипция длинного звонка это десятки секунд, разбор это секунды, запись в CRM упирается в лимиты. Если делать всё синхронно в одном запросе, первый же тяжёлый звонок забьёт пул и положит приём вебхуков. Каждый слой это отдельная очередь со своим конкуррентси, ретраями и DLQ.

Конкуррентси у слоёв разное, потому что узкие места разные. Транскрипция упирается в провайдера и в CPU на нарезке, держим её низкой. Извлечение это сетевые вызовы к LLM, тут можно шире. Запись в CRM ограничена лимитом аккаунта, и тут специально мало, с запасом под лимит:

TRANSCRIBE = {"concurrency": 4}    # упирается в провайдера и нарезку
EXTRACT    = {"concurrency": 8}    # IO к LLM, можно шире
CRM_SYNC   = {"concurrency": 2, "rate_limit": "5/s"}  # держим запас под лимит аккаунта

Приём вебхуков не делает ничего тяжёлого: кладёт запись в S3 и ставит задачу в очередь. Поэтому всплеск звонков растит длину очереди transcribe, но не роняет приём. Тяжёлый слой притормаживает сам себя, остальной конвейер этого не замечает.

И последнее по архитектуре, без чего отладка в проде превращается в гадание. Через все очереди тащим один call_id в данных задачи. По нему потом собирается таймлайн одного звонка из логов:

call_id=4f3a ingest      ok   0.2s
call_id=4f3a transcribe  ok   14.1s  provider=turbo
call_id=4f3a extract     ok   3.0s   grounded=true
call_id=4f3a crm-sync    ok   0.5s   lead=88213

Без сквозного id вы видите, что «что-то падает на тысяче звонков», но не видите, что именно и на каком слое. Дальше по слоям.

Слой 1. Звонок в текст: транскрипция и диаризация

Первая ошибка новичка: думать, что распознавание речи решает задачу. Распознавание (ASR) даёт текст. Кто говорит, отдельная задача (диаризация), её решают pyannote, WhisperX, Sortformer и подобные, обычно с предварительной нарезкой по детектору голосовой активности (VAD). Без диаризации разбор звонка это каша, потому что половина ценности в том, кто произнёс «дорого» и кто пообещал «вышлю договор завтра». Менеджер сказал или клиент это разные выводы и разные задачи.

Сразу лайфхак, который мы вынесли из прода: первым делом смотрим, не отдаёт ли источник стерео. Многие телефонии и SIP пишут менеджера и клиента в разные каналы записи. Если так, диаризация бесплатна: левый канал это один говорящий, правый другой, pyannote вообще не нужен, и это самый надёжный вариант, потому что не зависит от качества кластеризации голосов. Облачная запись Zoom тоже отдаёт дорожки по участникам. Тяжёлую диаризацию по одному смешанному каналу включаем только там, где стерео нет.

Вторая грабля: качество на реальном звуке. На чистой записи Whisper Large-v3 даёт WER около 3 процентов. Колл это не студия: телефонная полоса 8 кГц, перебивания, фон опенспейса. На таком материале WER уезжает к 8-12 процентам, и деградация неравномерная. Хуже всего модель врёт на именах собственных, терминах и названиях продуктов, то есть как раз на том, что важно отделу продаж. «Тариф Прибой» превращается в «тариф приплыл», имя «Гульназ» в «Гульнара». Лечится двумя вещами: подсказкой словаря в промпт распознавания (initial_prompt с вашими продуктами, тарифами, частыми фамилиями) и постобработкой, где близкие по написанию варианты подменяются на канонические из справочника.

Тут есть нюанс, на котором легко обжечься. У whisper окно initial_prompt маленькое, порядка пары сотен токенов. Весь справочник продуктов и фамилий туда не влезет, и если набить под завязку, модель начинает галлюцинировать эти слова там, где их не было. Поэтому в промпт кладём только горячий короткий список (текущие тарифы, частые имена), а основную канонизацию делаем уже постобработкой по полному справочнику. И язык фиксируем явно (ru), иначе на коротких репликах whisper иногда перескакивает на другой язык и выдаёт транслит.

Третья грабля: длинные записи. Окно качественного распознавания около 30 секунд, часовой звонок приходится резать. Если резать встык, на границах теряются слова и иногда смена говорящего. Режем с перекрытием и сшиваем по таймкодам, отбрасывая дубли в зоне нахлёста:

WINDOW = 30.0      # сек
OVERLAP = 5.0      # сек нахлёста, чтобы не терять слова на стыке

def make_chunks(duration):
    t, chunks = 0.0, []
    while t < duration:
        chunks.append((t, min(t + WINDOW, duration)))
        t += WINDOW - OVERLAP
    return chunks

def stitch(segments):
    # segments: распознанные куски с абсолютными таймкодами слов
    out = []
    for seg in segments:
        for w in seg.words:
            # слово уже попало из предыдущего окна в зоне нахлёста, пропускаем
            if out and w.start < out[-1].end - 0.2:
                continue
            out.append(w)
    return out

Четвёртая грабля: один провайдер это гарантированный простой. У любого ASR-провайдера бывает рост латентности или молчаливый обрезанный ответ. Держим цепочку фоллбэка с явной проверкой результата, а не только на HTTP-ошибку:

async def transcribe(audio, expected_min_chars):
    for provider in (PRIMARY, FALLBACK):           # напр. быстрый turbo → точный whisper-1
        try:
            text = await provider.run(audio, timeout=90)
            if len(text) >= expected_min_chars:    # подозрительно короткий = тоже сбой
                return text, provider.name
        except (Timeout, ProviderError):
            continue
    raise TranscriptionFailed()

expected_min_chars прикидываем от длительности (грубо, по средней скорости речи 9-12 символов в секунду). Без этой проверки система считает обрезанный ответ успехом, и дальше по конвейеру едет полупустой звонок. Эта проверка на «подозрительно коротко» ловила у нас больше реальных сбоев, чем ловля HTTP-ошибок: провайдер чаще отвечает 200 с огрызком, чем честным пятисотым.

Слой 2. Текст в структуру: извлечение фактов

Из расшифровки нужно достать факты в поля: следующий шаг, дата, сумма, возражение, стадия. Свободный текст от LLM тут противопоказан, его не положить в CRM и не проверить. Нужен строгий вывод по схеме через function calling или structured output, где для каждого поля заданы тип и ограничения, а constrained decoding не даёт модели придумать поля или сломать формат.

Ключевой приём: в схему добавляем не только значения, но и evidence, цитату из расшифровки, и confidence. Без опоры на текст поле потом не проверить.

{
  "name": "extract_call_facts",
  "schema": {
    "type": "object",
    "properties": {
      "next_step":   {"type": ["string","null"]},
      "due_date":    {"type": ["string","null"], "format": "date"},
      "amount_rub":  {"type": ["number","null"]},
      "objection":   {"type": ["string","null"]},
      "stage":       {"type": "string", "enum": ["new","qualify","demo","proposal","won","lost"]},
      "evidence":    {"type": "string"},
      "confidence":  {"type": "number", "minimum": 0, "maximum": 1}
    },
    "required": ["stage","evidence","confidence"],
    "additionalProperties": false
  }
}

Пара деталей, без которых этот слой шумит. Температуру держим 0 и фиксируем seed: разбор должен быть воспроизводимым, иначе вы не отличите регресс промпта от обычного разброса модели, и eval превращается в рулетку. Stage делаем enum, а не свободной строкой, чтобы модель не изобретала «почти-демо» и «предпроекта». Few-shot в промпт берём не случайный, а пару трудных примеров из размеченного эталона, из тех типов звонков, где модель чаще ошибалась.

Бывает, что даже structured output отдаёт невалид (на больших схемах это редко, но случается). На этот случай один повторный вызов с текстом ошибки валидации в промпте, и если опять мимо, поле уходит человеку, а не молча теряется:

async def extract(transcript):
    raw = await llm.call(EXTRACT_SCHEMA, transcript, temperature=0, seed=7)
    ok, err = validate(raw, EXTRACT_SCHEMA)
    if not ok:
        raw = await llm.call(EXTRACT_SCHEMA, transcript, temperature=0, seed=7,
                             repair=err)        # один повтор с описанием ошибки
        ok, _ = validate(raw, EXTRACT_SCHEMA)
    return raw if ok else needs_review(transcript)

Но валидной по схеме структуры мало. Модель спокойно вернёт JSON, где написано «клиент согласился на демо в четверг», хотя в звонке этого не было. Формат правильный, факт придуман, и ловить такое дороже всего: в потоке звонков глазами этого не заметишь. Поэтому валидация в два этапа: сначала схема, потом проверка, что значение реально выводится из источника. Дешёвый вариант проверки опоры это нечёткое вхождение цитаты в расшифровку:

from rapidfuzz import fuzz

def grounded(fact, transcript, threshold=85):
    if not fact.get("evidence"):
        return False
    # цитата должна почти дословно встречаться в исходнике
    return fuzz.partial_ratio(fact["evidence"], transcript) >= threshold

def accept(fact, transcript):
    if not grounded(fact, transcript) or fact["confidence"] < 0.6:
        fact["stage"] = "needs_review"   # не пишем в карточку, поднимаем человеку
    return fact

Про порог confidence < 0.6. Подбирали его по эталону, а не наугад. Модельный self-reported confidence сам по себе врёт и почти всегда завышен, поэтому границу искали по размеченным звонкам: брали то значение, на котором в needs_review уходит максимум реальных ошибок и минимум нормальных разборов. На нашем потоке этот шаг отправляет в review заметную долю разборов, и почти все они при ручной проверке оказывались либо реальной неоднозначностью звонка, либо тем тихим додумыванием. То есть фильтр срабатывает там, где и должен. Где нужна не текстовая, а смысловая проверка опоры (перефраз), на спорные поля добавляем второй вызов LLM-валидатора, но только на них, а не на весь вывод, иначе впустую удваиваем стоимость каждого разбора.

Слой 3. Структура в CRM: где всё дублируется

Слой, который недооценивают сильнее всего. По открытым прикидкам команды занижают полную стоимость интеграции на 40-60 процентов, а поддержка синхронизации потом съедает 4-8 часов в месяц: мониторинг ошибок, разбор конфликтов, починка после обновлений платформы. Главный симптом плохой интеграции это битая аналитика: карточки без данных, дубли, неверные теги после авто-квалификации. Сломанная атрибуция дороже, чем кажется, потому что по ней принимают решения о бюджете.

Грабли на примере AmoCRM, но похоже почти у всех CRM.

Вебхуки врут и дублируются. Один ивент может прийти дважды, с задержкой или не в том порядке, плюс есть лимит около 10 вебхуков в секунду. Приём обязан быть идемпотентным, иначе один звонок превращается в три задачи менеджеру:

def idem_key(event):
    # стабильный ключ из сущности и типа события, без времени доставки
    return f"{event['entity']}:{event['id']}:{event['type']}:{event['modified_at']}"

async def handle(event):
    key = idem_key(event)
    if not await redis.set(key, 1, nx=True, ex=86400):   # SET NX = первый выигрывает
        return                                            # дубль, тихо игнорируем
    await queue.add("crm-sync", event)

Тонкость в ключе: в него входит modified_at. Если убрать, то схлопнутся не только дубли доставки, но и легитимное повторное изменение той же сущности через минуту. А если взять время доставки, наоборот, дубли перестанут схлопываться. Ключ должен быть стабильным для одного логического события и разным для двух разных.

Лимиты на запись жёсткие, считанные запросы в секунду на аккаунт. Наивный цикл по сделкам ловит 429 и теряет данные. Очередь с ограничением скорости, ретраями и экспоненциальным бэкоффом:

@worker("crm-sync", concurrency=2, rate_limit="5/s")   # держим запас под лимит аккаунта
async def sync(job):
    for attempt in range(5):
        try:
            return await amo.patch_lead(job.lead_id, job.payload)
        except RateLimited:
            await sleep(min(2 ** attempt, 30) + random()*0.5)  # бэкофф + джиттер
    raise   # уходит в DLQ, не теряем молча

Рассинхрон состояния. Пока агент думал, человек руками подвинул сделку. Писать «поставить стадию demo» опасно, можно затереть свежий ручной ход. Пишем по принципу compare-and-set: меняем только если текущее состояние то, которое мы видели при разборе, иначе не затираем, а поднимаем конфликт на ручное решение.

async def safe_stage(lead_id, expected, new):
    cur = await amo.get_stage(lead_id)
    if cur != expected:
        return await flag_conflict(lead_id, cur, new)   # человек решает
    return await amo.patch_lead(lead_id, {"status_id": new})

Ещё две вещи, которые экономят часы разборов потом.

Первое, маппинг полей плывёт. Кастомные поля в AmoCRM это числовые id, и они меняются, когда кто-то правит воронку или пересобирает поля. Если зашить id в код, в один день агент начнёт молча писать сумму в поле «комментарий». Держим маппинг имён на id в конфиге и проверяем его на старте сервиса: если ожидаемого поля в аккаунте нет, падаем громко при деплое, а не тихо в проде.

Второе, outbox. Между «разобрали звонок» и «записали в CRM» сервис может упасть, и разбор потеряется. Поэтому факт разбора пишем в свою БД транзакционно вместе с пометкой «надо синхронизировать», а отдельный воркер читает эту таблицу и толкает в CRM. Падение между шагами больше не теряет данные, воркер просто доберёт недоставленное:

async def on_extracted(call_id, facts):
    async with db.tx():
        await db.facts.save(call_id, facts)
        await db.outbox.add(call_id, kind="crm-sync")   # в той же транзакции
    # отдельный воркер потом читает outbox и синкает; падение тут не теряет разбор

Скучный слой, но именно он отделяет демо от продакшена. Красивый разбор звонка ничего не стоит, если он лёг в дубль карточки.

Как агент действует наружу: MCP против браузера

Понять звонок это половина. Агенту надо ещё подействовать наружу: обновить сделку, поставить задачу, иногда опубликовать или собрать аналитику из внешнего сервиса. И тут два принципиально разных пути, между которыми важно не ошибиться.

Действия агента наружу: MCP против эмуляции браузера
Действия агента наружу: MCP против эмуляции браузера

Первый путь, эмулировать человека в браузере (Playwright и подобные). Он кажется универсальным: что человек кликает, то и агент кликнет. На практике он оказывается самым хрупким. Капча и антибот, риск бана аккаунта, недетерминированность, и главное, любое изменение вёрстки ломает сценарий. Селектор, который работал вчера, сегодня указывает в пустоту, и агент молча перестаёт что-то делать.

Второй путь, ходить в типизированный API напрямую или через MCP-слой над ним. Тулы с понятными аргументами, OAuth вместо подбора паролей, никакой капчи, всё в рамках условий сервиса, и это детерминированно и тестируемо. Под нашу CRM мы подняли свой MCP-слой над внутренним API: агент вызывает инструмент update_lead(...), а не кликает по интерфейсу. Тул можно покрыть тестом, у клика по верстке такой роскоши нет.

# то, что хочет агент: «обнови сделку 88213, стадия demo, задача на пятницу»

через браузер:  открыть amo → войти → пройти антибот → найти сделку →
                кликнуть стадию → выбрать demo → создать задачу → ...
                (ломается на любом из шагов при смене верстки)

через MCP/API:  update_lead(id=88213, stage="demo",
                            task={due:"fri", text:"прислать договор"})
                (типизировано, идемпотентно, тестируемо)

Так уже делают на практике. У сервисов всё чаще есть официальный MCP: например, у Metricool есть managed MCP для соцсетей, и постинг идёт через их API, а не через эмуляцию клика в их вебе. Правило простое: если у платформы есть API или MCP, всегда берём его, а не браузер.

Браузер остаётся ровно для одного случая, когда API физически нет (старая панель какого-нибудь сервиса, у которого интеграций не предусмотрено). И там мы не строим из себя невидимку: антибот не обходим, на капче сидит человек, а не самописный солвер. Попытка обхитрить защиту это прямой путь к бану аккаунта, который потом дороже любой ручной рутины.

Артефакт для человека: follow-up и КП

Соблазн отдать всё модели вреден по двум причинам. Факты: сгенерированный follow-up легко придумывает условия, которых не было. Прогоняем черновик через тот же grounding-чек против расшифровки и держим человека в цикле на отправке клиенту. Почерк: текст, который пахнет нейросетью, бьёт по доверию сильнее опечатки, поэтому черновик проходит детерминированную проверку на ИИ-стиль (тире, канцелярит, ровный ритм, штампы) до того, как его увидит менеджер. Стоит копейки, а убирает «здравствуйте, надеюсь, это письмо застанет вас в добром здравии».

Как понять, что агент не врёт

Главный вопрос продакшена не «работает ли на демо», а «как мы узнаем, что качество не просело после смены модели или правки промпта». Ручную проверку не масштабировать, и тянет поставить вторую LLM судьёй. Это работает, но с двумя оговорками.

Первая: у судьи на той же модели системная слепота. Он завышает оценку текста, похожего на собственный стиль, и путает узнаваемость с качеством. Эффект растёт почти линейно с тем, насколько судья узнаёт свой почерк (у Eugene Yan есть подробный разбор смещений LLM-судей). Лечится тем, что генератор и судья из разных семейств моделей.

Вторая: судью надо калибровать об эталон. Размечаем руками сотню звонков, гоняем судью против них и подмешиваем эти контрольные пары в каждый прогон:

GOLDEN = load_labeled_cases()   # ~100 звонков, размечены руками

def eval_run(model):
    agree = sum(judge(model(c.input)) == c.label for c in GOLDEN) / len(GOLDEN)
    # бинарный вердикт (ок / не ок), не шкала 1-10: шкала шумит
    return agree

# гейт в CI: не выкатываем модель/промпт, если согласие с эталоном упало
assert eval_run(candidate) >= 0.85

Бинарный вердикт вместо шкалы 1-10 мы выбрали по простой причине: на шкале две модели дают разброс в полтора-два балла на одном ответе, на бинарном «прошло или нет» согласие куда стабильнее. В открытых разборах автоэвалов согласие судьи с человеком поднимали с ~0.71 до ~0.86 именно калибровкой и переходом на бинарные критерии. И главная метрика качества разбора у нас не «понравилось ли судье», а доля выводов, подтверждённых цитатой из источника. Её видно без всякого судьи.

Наблюдаемость: какой слой упал и почему

Конвейер из очередей без наблюдаемости это чёрный ящик, который иногда теряет звонки, и вы не знаете где. Поэтому наблюдаемость не «потом допилим», а часть архитектуры.

По сквозному call_id (про него выше) собираем воронку звонка как бизнес-метрику: принято / расшифровано / разобрано / записано / в review. Разрыв между соседними этапами показывает, где течёт. Если «расшифровано» сильно меньше «принято», проблема в ASR или в фоллбэке. Если «записано» меньше «разобрано», копаем CRM-синк и DLQ.

На каждую очередь вешаем метрики (у нас Prometheus плюс дашборды): длина очереди, время ожидания, p95 обработки и доля задач, ушедших в DLQ. Алертим не на «появилась ошибка» (ошибки будут всегда), а на тренд: растёт DLQ или поехал p95.

DLQ помогает разбираться с инцидентами только если падения в нём разложены по классам. RateLimited это чаще про лимит аккаунта, лечится политикой скорости. ValidationFailed это чаще баг схемы или промпта, лечится кодом. ProviderError это про внешний сервис, лечится фоллбэком. Свалив всё в один «failed», вы теряете эту диагностику и каждый инцидент разбираете с нуля.

Стоимость и латентность: где утекают деньги

Где реально утекают деньги и секунды.

Reasoning-токены. У reasoning-моделей на один видимый токен ответа уходит два-три скрытых на рассуждение. Публичные калькуляторы считают по видимым, поэтому смета и факт расходятся в разы. Грубый пример на один разбор звонка: вход 4000 токенов расшифровки, видимый выход 400 токенов JSON, но реально модель потратила ещё около 1000 скрытых. Если считать только по видимым 400, недооценка выхода в 3.5 раза. На потоке в тысячи звонков это уже заметная статья расходов. В SLA и в расчёт ёмкости закладываем отдельный множитель на рассуждение.

p95, а не средняя. Под нагрузкой батчинг даже поднимает суммарный throughput, но хвост задержек первым ломает ощущение «тормозит». Менеджер не чувствует среднее, он чувствует тот звонок, который разбирался 40 секунд вместо 8. Гарантию даём по p95 на целевом числе параллельных диалогов, а не по среднему.

Prefix caching. Экономит заметную долю на TTFT, но только если общий префикс байт в байт одинаковый. Любое волатильное поле в начале промпта (время, имя менеджера, id сессии) убивает попадание в кэш целиком:

[ системный промпт + схема + few-shot ]  ← стабильно, кэшируется
[ {{дата}} {{имя}} ]                      ← ЛОМАЕТ кэш, если в начале
[ расшифровка звонка ]                    ← переменное, в хвост

Стабильный системный блок в начало, всё переменное в хвост. На длинном системном промпте это бесплатные десятки процентов TTFT, которые легко потерять одной строкой.

Русский токен-налог. Один и тот же смысл по-русски стоит в полтора-два раза больше токенов, чем по-английски. Системные инструкции и схему держим на английском, пользовательский текст оставляем на русском. Качество разбора не страдает, а постоянная часть контекста, которая едет в каждый запрос, заметно дешевеет.

Общий принцип, который сэкономил нам больше всего: роутить по цене ошибки, а не по «модель посильнее везде». Триаж и черновик отдаём дешёвой модели, дорогую с проверкой включаем только там, где действие необратимо или дешёвый чек уже упал.

Что дал каждый слой

Слой

Грабля

Решение

Эффект

ASR

WER 8-12% на телефонии, врёт на именах

стерео-каналы где есть, глоссарий + постобработка, нахлёст 5с

меньше мусора в разборе, не теряем стыки

Extract

валидный JSON с выдуманным фактом

схема + grounding по цитате, temperature 0, retry-on-invalid

спорные уходят в review, а не в карточку

CRM

дубли и потеря данных на вебхуках/лимитах

идемпотентность + очередь с бэкоффом + CAS + outbox

один звонок = одна задача, аналитика не врёт

Действия наружу

браузер ломается от капчи и смены вёрстки

MCP/API вместо эмуляции, браузер только где нет API

детерминированно, тестируемо, без банов

Eval

качество тихо просело после правки

контрольные пары + кросс-модельный судья

регресс ловится в CI до выката

Наблюдаемость

звонки теряются, непонятно где

сквозной call_id, воронка, DLQ по классам

видно протекающий этап за секунды

Cost

смета мимо в разы

учёт reasoning-токенов, prefix caching, роутинг

предсказуемая стоимость на звонок

Чего не делать

Не доверять выводу модели без объективного чека. Тесты, схема, цитата из источника, контрольные пары. Ревью на глаз пропускает как раз те ошибки, которые модель делает уверенно и гладко.

Не гнаться за полной автономией. Самый дешёвый способ обжечься это убрать человека с шага, где ошибка необратима: письмо клиенту, изменение стадии сделки. Человек на выходе, машина на рутине.

Не эмулировать браузер там, где есть API. Кажется, что «агент как человек» это гибко, но в проде это постоянные поломки и риск бана. Типизированный тул всегда надёжнее клика по верстке.

Не мерить успех активностью. Письма и дилы за первые 30 дней это метрика движения, а не результата. Честная метрика это стоимость квалифицированной встречи на 90 днях.

ИИ-агент для продаж держится на аккуратном конвейере из распознавания, извлечения, интеграции и проверки, где половина работы приходится даже не на модель, а на обвязку вокруг неё. Зато когда обвязка собрана аккуратно, рутина реально уходит с людей.

Если у вас был свой опыт с разбором звонков или синхронизацией с CRM, особенно интересны ваши грабли на диаризации, на вебхуках и на выборе между API и эмуляцией браузера. Поделитесь в комментариях, сверим решения.