Для AI-агентов: markdown этой страницы — /docs-content/recipes/ai-assistant.md индекс документации — /llms.txt
AI-ассистент для сделки
Сложность: продвинутый | Скоупы: crm, task | Стек: cURL / JavaScript
Собираем карточку сделки и её историю, отдаём языковой модели и превращаем ответ в задачу менеджеру, привязанную к этой же сделке. Менеджер получает не текст рекомендации в чате, а запись в списке дел со сроком.
Что понадобится
- API-ключ Вайбкод со скоупами
crmиtask - Node.js 18 или новее
Доступ к моделям платформы даёт скоуп vibe:ai — проверьте, что он есть у ключа. В форме создания ключа он отмечен заранее. Внешняя учётная запись у поставщика моделей нужна только если вы выбираете свою модель вместо платформенной (шаг 3).
Во всех примерах $VIBE_URL — базовый адрес https://vibecode.bitrix24.tech, $VIBE_API_KEY — ваш API-ключ.
Как устроено решение
- Читаем карточку сделки — название, стадию, сумму, ответственного.
- Читаем историю: звонки, встречи и письма, привязанные к этой сделке.
- Собираем короткое текстовое описание и отдаём модели, требуя ответ строго в JSON.
- Из полей ответа создаём задачу и привязываем её к сделке.
Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».
Шаг 1. Карточка сделки
GET /v1/deals/:id отдаёт сделку целиком. Модели нужна малая часть полей, поэтому берём их выборочно.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/deals/741"
JavaScript
const res = await fetch(`${VIBE_URL}/v1/deals/${dealId}`, {
headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data: deal } = await res.json()
{
"success": true,
"data": {
"id": 741,
"title": "Поставка оборудования",
"stageId": "NEW",
"stageSemanticId": "P",
"amount": 1010,
"currency": "RUB",
"assignedById": 29,
"createdAt": "2026-03-28T12:55:01.000Z",
"closed": false
}
}
Поле assignedById — ответственный за сделку. На него же будет назначена задача из шага 4, отдельно выбирать сотрудника не нужно.
Идентификатор стадии stageId понятен модели хуже, чем название стадии на портале. Названия стадий отдаёт GET /v1/statuses с фильтром entityId=DEAL_STAGE — подставляйте в текст название, а не код.
Шаг 2. История сделки
Звонки, встречи и письма по сделке возвращает POST /v1/activities/search. Привязку задаёт пара ownerTypeId и ownerId: 2 — сделка, 3 — контакт, 4 — компания, 1 — лид.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/activities/search" \
-d '{
"filter": { "ownerTypeId": 2, "ownerId": 741 },
"select": ["id", "subject", "typeId", "createdAt", "completed"],
"sort": { "createdAt": "desc" },
"limit": 20
}'
JavaScript
const res = await fetch(`${VIBE_URL}/v1/activities/search`, {
method: 'POST',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
filter: { ownerTypeId: 2, ownerId: dealId },
select: ['id', 'subject', 'typeId', 'createdAt', 'completed'],
sort: { createdAt: 'desc' },
limit: 20,
}),
})
const { data: activities } = await res.json()
{
"success": true,
"data": [
{
"id": 3631,
"subject": "Звонок по условиям поставки",
"typeId": 1,
"createdAt": "2026-07-02T06:10:32.000Z",
"completed": true
}
],
"meta": { "total": 1, "hasMore": false, "durationMs": 903 }
}
Значения typeId: 1 — звонок, 2 — встреча, 3 — задача, 6 — письмо. Число записей — в meta.total, признак «есть ещё» — meta.hasMore.
Пустой массив data — рабочий случай, а не ошибка: у сделки может не быть ни одного дела. Модель должна получить об этом явную строку, иначе она додумает историю.
Шаг 3. Разбор моделью
Модели платформы вызываются тем же ключом, что и CRM — POST /v1/ai/chat/completions. Список доступных идентификаторов отдаёт GET /v1/ai/models.
Ответ нужен машине, а не человеку, поэтому запрашиваем гарантированный JSON: response_format со значением json_object заставляет модель вернуть корректный JSON, а структуру полей описываем в системном сообщении.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/ai/chat/completions" \
-d '{
"model": "bitrix/bitrixgpt-5.5",
"messages": [
{
"role": "system",
"content": "Ты помогаешь менеджеру по продажам. Верни JSON: {\"analysis\": \"две-три фразы о ситуации\", \"taskTitle\": \"краткое название задачи\", \"taskDescription\": \"что именно сделать\", \"priority\": \"high|normal|low\", \"deadlineDays\": число}"
},
{
"role": "user",
"content": "Сделка: Поставка оборудования. Стадия: Новая. Сумма: 1010 RUB.\nИстория:\n- 02.07 звонок по условиям поставки (завершён)"
}
],
"response_format": { "type": "json_object" },
"temperature": 0.3
}'
JavaScript
const res = await fetch(`${VIBE_URL}/v1/ai/chat/completions`, {
method: 'POST',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'bitrix/bitrixgpt-5.5',
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: describe(deal, activities) },
],
response_format: { type: 'json_object' },
temperature: 0.3,
}),
})
const completion = await res.json()
const advice = JSON.parse(completion.choices[0].message.content)
{
"id": "chatcmpl-9b8b1933947bb4fa",
"object": "chat.completion",
"model": "bitrix/bitrixgpt-5.5",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "{\"analysis\":\"Сделка на начальной стадии. Требуется уточнение деталей заказа и переход к коммерческому предложению.\",\"taskTitle\":\"Подготовить и отправить коммерческое предложение\",\"taskDescription\":\"Сформировать предложение по условиям, обсуждённым в звонке 02.07, и отправить клиенту на согласование.\",\"priority\":\"normal\",\"deadlineDays\":2}"
}
}
],
"usage": { "prompt_tokens": 145, "completion_tokens": 93, "total_tokens": 238 }
}
Ответ приходит в формате, совместимом с OpenAI, — без обёртки { success, data }, которая есть у остальных эндпоинтов Вайбкод. Текст лежит в choices[0].message.content строкой, её надо разобрать через JSON.parse.
Свою модель у внешнего поставщика подключают тем же кодом: формат запроса и ответа совпадает, меняются адрес и ключ в заголовке. Как подключить собственный ключ поставщика — свои ключи.
Опишите в системном сообщении каждое поле, которое собираетесь читать. Поле, которого нет в описании, модель вернёт под другим именем или не вернёт вовсе, и advice.taskTitle окажется undefined уже на этапе создания задачи.
Шаг 4. Задача из ответа модели
POST /v1/tasks создаёт задачу. Привязку к сделке задаёт поле ufCrmTask — массив строк с префиксом сущности: D_ для сделки, C_ для контакта, CO_ для компании, L_ для лида.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/tasks" \
-d '{
"title": "Подготовить и отправить коммерческое предложение",
"description": "Сформировать предложение по условиям, обсуждённым в звонке, и отправить клиенту на согласование.",
"responsibleId": 29,
"priority": 1,
"deadline": "2026-07-23T17:00:00+02:00",
"ufCrmTask": ["D_741"]
}'
JavaScript
const priorityMap = { high: 2, normal: 1, low: 0 }
const deadline = new Date(Date.now() + (advice.deadlineDays ?? 3) * 86400_000)
const res = await fetch(`${VIBE_URL}/v1/tasks`, {
method: 'POST',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: advice.taskTitle,
description: `${advice.taskDescription}\n\nОценка ситуации: ${advice.analysis}`,
responsibleId: deal.assignedById,
priority: priorityMap[advice.priority] ?? 1,
deadline: deadline.toISOString(),
ufCrmTask: [`D_${dealId}`],
}),
})
const { data: task } = await res.json()
{
"success": true,
"data": {
"id": 3937,
"title": "Подготовить и отправить коммерческое предложение",
"status": 2,
"priority": 1,
"responsibleId": 29,
"deadline": "2026-07-23T15:00:00.000Z",
"ufCrmTask": ["D_741"],
"createdDate": "2026-07-21T09:46:20.000Z"
}
}
Числовые поля задачи возвращаются числами — "id": 3937, "priority": 1, "responsibleId": 29. Сравнение через === с числом работает без приведения типа.
Даты в ответе приходят в UTC с суффиксом Z, независимо от того, в какой записи вы их отправили. Момент времени тот же, запись другая: 2026-07-23T17:00:00+02:00 вернётся как 2026-07-23T15:00:00.000Z. Сравнивайте даты через Date, а не построчно.
Строка вместо массива в ufCrmTask не даёт ошибки — значение не сохраняется, и задача остаётся без привязки к сделке. Передавайте ["D_741"], а не "D_741".
Название длиннее 250 символов не отклоняется, а молча обрезается до 250 — отказа в ответе не будет. Модель об этой границе не знает, поэтому просите её в системном сообщении держать taskTitle коротким, а если точность названия важна, сверяйте title в ответе на создание задачи.
Ограничения
Модель не проверяет факты. Она формулирует следующее действие по тому тексту, который вы ей дали, и при пустой истории сделки выдаст общую рекомендацию с той же уверенностью, что и при подробной. Ставьте в заголовок задачи пометку об источнике, чтобы менеджер видел, кто её поставил.
Дубль на повторном запуске. Задача создаётся при каждом запуске. Повторный прогон по той же сделке поставит вторую такую же, поэтому «Полный код» перед созданием ищет уже стоящие задачи по привязке ufCrmTask.
Сверка по метке. Сверять при этом надо не название, а постоянную метку в его начале и состояние задачи. Название сочиняет модель, и на повторном прогоне оно будет другим — сверка по названию дубль пропустит. Метка отличает задачи сценария от тех, что менеджер поставил руками, а состояние отсекает уже закрытые: 1-4 — задача открыта, 5-7 — завершена, отложена или отклонена.
Не больше 50 задач. Поиск берёт до 50 задач сделки. У сделки с большим числом задач проверка станет неполной — сузьте выборку фильтром по состоянию или увеличьте limit.
Данные сделки уходят в модель. В текст запроса уходят название, сумма и темы дел по сделке. Решите до запуска, какие поля допустимо отправлять модели, и не добавляйте в описание содержимое писем и комментариев целиком.
Секунды на ответ. Время ответа модели измеряется секундами и растёт вместе с длиной истории. Ограничивайте выборку дел параметром limit и не запускайте разбор синхронно внутри обработчика веб-интерфейса.
Расход квоты. Запросы к моделям платформы расходуют месячную квоту компании. Сколько уже израсходовано и какие модели бесплатны — расход и лимиты.
Полный перечень кодов — Ошибки.
Полный код
// deal-assistant.js — разбор сделки моделью и постановка задачи
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('Переменная окружения VIBE_API_KEY не задана')
const MODEL = process.env.VIBE_MODEL ?? 'bitrix/bitrixgpt-5.5'
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
const ACTIVITY_TYPE = { 1: 'звонок', 2: 'встреча', 3: 'задача', 6: 'письмо' }
const PRIORITY = { high: 2, normal: 1, low: 0 }
// Постоянная метка в начале названия. По ней сценарий узнаёт СВОИ задачи на
// повторном прогоне и не трогает те, что менеджер поставил руками.
const TASK_TAG = '[AI]'
const SYSTEM_PROMPT = `Ты помогаешь менеджеру по продажам. Верни JSON:
{"analysis": "две-три фразы о ситуации",
"taskTitle": "краткое название задачи",
"taskDescription": "что именно сделать",
"priority": "high|normal|low",
"deadlineDays": число дней на выполнение}`
async function getDeal(dealId) {
const res = await fetch(`${VIBE_URL}/v1/deals/${dealId}`, { headers })
const body = await res.json()
if (!body.success) throw new Error(body.error?.message ?? 'сделка не прочитана')
return body.data
}
async function getActivities(dealId) {
const res = await fetch(`${VIBE_URL}/v1/activities/search`, {
method: 'POST',
headers,
body: JSON.stringify({
filter: { ownerTypeId: 2, ownerId: Number(dealId) },
select: ['id', 'subject', 'typeId', 'createdAt', 'completed'],
sort: { createdAt: 'desc' },
limit: 20,
}),
})
const body = await res.json()
if (!body.success) throw new Error(body.error?.message ?? 'история не прочитана')
return body.data
}
function describe(deal, activities) {
const history = activities.length === 0
? 'По сделке нет ни одного дела.'
: activities
.map(a => {
const date = new Date(a.createdAt).toLocaleDateString('ru-RU')
const type = ACTIVITY_TYPE[a.typeId] ?? 'дело'
return `- ${date} ${type}: ${a.subject} (${a.completed ? 'завершено' : 'в работе'})`
})
.join('\n')
return [
`Сделка: ${deal.title}`,
`Стадия: ${deal.stageId}`,
`Сумма: ${deal.amount} ${deal.currency}`,
'',
'История:',
history,
].join('\n')
}
async function ask(deal, activities) {
const res = await fetch(`${VIBE_URL}/v1/ai/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: describe(deal, activities) },
],
response_format: { type: 'json_object' },
temperature: 0.3,
}),
})
const completion = await res.json()
if (completion.error) throw new Error(completion.error.message)
const content = completion.choices?.[0]?.message?.content
if (!content) throw new Error('модель вернула пустой ответ')
const advice = JSON.parse(content)
if (!advice.taskTitle) throw new Error('в ответе модели нет поля taskTitle')
return advice
}
// Повторный прогон по той же сделке иначе поставит менеджеру вторую такую же
// задачу: связь ufCrmTask допускает сколько угодно задач на одну сделку.
// Прогон по 300 сделкам дважды дал бы 600 задач.
async function hasOpenAiTask(dealId) {
const res = await fetch(`${VIBE_URL}/v1/tasks/search`, {
method: 'POST',
headers,
body: JSON.stringify({
filter: { ufCrmTask: `D_${dealId}` },
select: ['id', 'title', 'status'],
limit: 50,
}),
})
const body = await res.json()
if (!body.success) throw new Error(body.error?.message ?? 'поиск задач не выполнен')
// Сверка идёт по постоянной метке и состоянию, а НЕ по названию: название
// сочиняет модель, на повторном прогоне оно будет другим, и сверка по нему
// пропустит дубль. Открытыми считаются состояния 1-4 (новая, ждёт
// выполнения, выполняется, ждёт контроля); 5-7 — завершена, отложена,
// отклонена.
return body.data.some(task => task.title.startsWith(TASK_TAG) && task.status < 5)
}
async function createTask(dealId, deal, advice) {
const days = Number(advice.deadlineDays) || 3
const deadline = new Date(Date.now() + days * 86400_000)
const res = await fetch(`${VIBE_URL}/v1/tasks`, {
method: 'POST',
headers,
body: JSON.stringify({
title: `${TASK_TAG} ${String(advice.taskTitle)}`,
description: `${advice.taskDescription ?? ''}\n\nОценка ситуации: ${advice.analysis ?? ''}`.trim(),
responsibleId: deal.assignedById,
priority: PRIORITY[advice.priority] ?? 1,
deadline: deadline.toISOString(),
ufCrmTask: [`D_${dealId}`],
}),
})
const body = await res.json()
if (!body.success) throw new Error(body.error?.message ?? 'задача не создана')
return body.data
}
async function main() {
const dealId = process.argv[2]
if (!dealId) {
console.log('Запуск: node deal-assistant.js <ID сделки>')
process.exit(1)
}
const deal = await getDeal(dealId)
const activities = await getActivities(dealId)
console.log(`${deal.title}: дел в истории — ${activities.length}`)
// Проверка идёт ДО обращения к модели: повторный прогон по той же сделке
// не должен платить за разбор, результат которого будет отброшен.
if (await hasOpenAiTask(dealId)) {
console.log(`По сделке ${dealId} уже стоит открытая задача ассистента — пропускаем`)
return
}
const advice = await ask(deal, activities)
console.log(`Оценка: ${advice.analysis}`)
const task = await createTask(dealId, deal, advice)
console.log(`Задача ${task.id}: ${task.title}`)
}
main().catch(error => {
console.error('разбор прерван:', error.message)
process.exitCode = 1
})