For AI agents: markdown of this page — /docs-content-en/recipes/ai-assistant.md documentation index — /llms.txt

AI assistant for a deal

Difficulty: advanced | Scopes: crm, task | Stack: cURL / JavaScript

We collect the deal card and its history, hand them to a language model and turn the answer into a task for the manager, linked to that same deal. The manager gets a record in the to-do list with a due date, not a recommendation text in a chat.

What you need

  • A Vibecode API key with the crm and task scopes
  • Node.js 18 or newer

Access to platform models is granted by the vibe:ai scope — it is added to every key, so you do not need to enable it separately. An external account with a model provider is only required if you choose your own model instead of the platform one (step 3).

In all examples $VIBE_URL is the base address https://vibecode.bitrix24.com and $VIBE_API_KEY is your API key.

How the solution works

  1. Read the deal card — title, stage, amount, assignee.
  2. Read the history: calls, meetings and emails linked to this deal.
  3. Build a short text description and hand it to the model, requiring the answer strictly in JSON.
  4. Create a task from the response fields and link it to the deal.

The step examples show individual calls. The ready-to-run script is in the "Full code" section.

Step 1. Deal card

GET /v1/deals/:id returns the whole deal. The model needs a small part of the fields, so take them selectively.

cURL

Terminal
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/deals/741"

JavaScript

javascript
const res = await fetch(`${VIBE_URL}/v1/deals/${dealId}`, {
  headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data: deal } = await res.json()
JSON
{
  "success": true,
  "data": {
    "id": 741,
    "title": "Equipment delivery",
    "stageId": "NEW",
    "stageSemanticId": "P",
    "amount": 1010,
    "currency": "USD",
    "assignedById": 29,
    "createdAt": "2026-03-28T12:55:01.000Z",
    "closed": false
  }
}

The assignedById field identifies the user responsible for the deal. The task from step 4 is assigned to the same person, no separate employee selection is needed.

The stage identifier stageId is less clear to the model than the stage name in the Bitrix24 account. Stage names are returned by GET /v1/statuses with the entityId=DEAL_STAGE filter — put the name into the text, not the code.

Step 2. Deal history

Calls, meetings and emails for the deal are returned by POST /v1/activities/search. The link is defined by the ownerTypeId and ownerId pair: 2 — deal, 3 — contact, 4 — company, 1 — lead.

cURL

Terminal
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

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()
JSON
{
  "success": true,
  "data": [
    {
      "id": 3631,
      "subject": "Call about delivery terms",
      "typeId": 1,
      "createdAt": "2026-07-02T06:10:32.000Z",
      "completed": true
    }
  ],
  "meta": { "total": 1, "hasMore": false, "durationMs": 903 }
}

Values of typeId: 1 — call, 2 — meeting, 3 — task, 6 — email. The number of records is in meta.total, the "there is more" flag is meta.hasMore.

An empty data array is a working case, not an error: a deal may have no activities at all. The model must receive an explicit line about it, otherwise it will invent the history.

Step 3. Analysis by the model

Platform models are called with the same key as the CRM — POST /v1/ai/chat/completions. The list of available identifiers is returned by GET /v1/ai/models.

The answer is meant for a machine, not a human, so we request guaranteed JSON: response_format with the value json_object makes the model return valid JSON, and the field structure is described in the system message.

cURL

Terminal
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": "You help a sales manager. Return JSON: {\"analysis\": \"two or three sentences about the situation\", \"taskTitle\": \"a short task title\", \"taskDescription\": \"what exactly to do\", \"priority\": \"high|normal|low\", \"deadlineDays\": number}"
      },
      {
        "role": "user",
        "content": "Deal: Equipment delivery. Stage: New. Amount: 1010 USD.\nHistory:\n- 7/2 call about delivery terms (completed)"
      }
    ],
    "response_format": { "type": "json_object" },
    "temperature": 0.3
  }'

JavaScript

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)
JSON
{
  "id": "chatcmpl-9b8b1933947bb4fa",
  "object": "chat.completion",
  "model": "bitrix/bitrixgpt-5.5",
  "choices": [
    {
      "index": 0,
      "finish_reason": "stop",
      "message": {
        "role": "assistant",
        "content": "{\"analysis\":\"The deal is at an early stage. The order details need to be clarified and the deal moved on to a commercial proposal.\",\"taskTitle\":\"Prepare and send a commercial proposal\",\"taskDescription\":\"Draft a proposal on the terms discussed in the call on 7/2 and send it to the client for approval.\",\"priority\":\"normal\",\"deadlineDays\":2}"
      }
    }
  ],
  "usage": { "prompt_tokens": 145, "completion_tokens": 93, "total_tokens": 238 }
}

The response comes in an OpenAI-compatible format — without the { success, data } wrapper that the other Vibecode endpoints have. The text sits in choices[0].message.content as a string and has to be parsed with JSON.parse.

You connect your own model from an external provider with the same code: the request and response formats match, only the address and the key in the header change. How to connect your own provider key — your own keys.

Describe in the system message every field you are going to read. A field missing from the description will be returned by the model under a different name or not returned at all, and advice.taskTitle is already undefined by the time the task is created.

Step 4. Task from the model answer

POST /v1/tasks creates a task. The link to the deal is defined by the ufCrmTask field — an array of strings with an entity prefix: D_ for a deal, C_ for a contact, CO_ for a company, L_ for a lead.

cURL

Terminal
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/tasks" \
  -d '{
    "title": "Prepare and send a commercial proposal",
    "description": "Draft a proposal on the terms discussed in the call and send it to the client for approval.",
    "responsibleId": 29,
    "priority": 1,
    "deadline": "2026-07-23T17:00:00+02:00",
    "ufCrmTask": ["D_741"]
  }'

JavaScript

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\nSituation assessment: ${advice.analysis}`,
    responsibleId: deal.assignedById,
    priority: priorityMap[advice.priority] ?? 1,
    deadline: deadline.toISOString(),
    ufCrmTask: [`D_${dealId}`],
  }),
})
const { data: task } = await res.json()
JSON
{
  "success": true,
  "data": {
    "id": "3937",
    "title": "Prepare and send a commercial proposal",
    "status": "2",
    "priority": "1",
    "responsibleId": "29",
    "deadline": "2026-07-23T17:00:00+02:00",
    "ufCrmTask": ["D_741"],
    "createdDate": "2026-07-21T11:46:20+02:00"
  }
}

Numeric task fields are returned as strings — "id": "3937", "priority": "1", "responsibleId": "29". Do not compare them to a number with === — cast the type explicitly.

Dates in the response come with the Bitrix24 account time-zone offset, not in UTC. The moment in time is the one you sent, the notation differs: 2026-07-23T15:00:00Z is returned as 2026-07-23T17:00:00+02:00. Compare dates through Date, not as strings.

A string instead of an array in ufCrmTask does not raise an error — the value is not saved, and the task stays without a link to the deal. Pass ["D_741"], not "D_741".

A title longer than 250 characters is not rejected — it is silently truncated to 250, and the response carries no refusal. The model does not know about this limit, so ask it in the system message to keep taskTitle short, and if the exact title matters, check title in the task-creation response.

Limitations

The model does not verify facts. It formulates the next action from the text you gave it, and with an empty deal history it produces a generic recommendation with the same confidence as with a detailed one. Put a note about the source into the task title so the manager sees who created it.

A duplicate on a repeat run. The task is created on every run. A repeated run on the same deal creates a second identical one, so "Full code" looks for tasks already standing on the ufCrmTask link before it creates one.

Match by the marker. What is compared is not the title but a constant marker at its start plus the task state. The title is composed by the model, and on a repeated run it will be different — a comparison by title misses the duplicate. The marker tells the scenario's own tasks apart from the ones the manager created by hand, and the state cuts off those already closed: 1-4 — the task is open, 5-7 — completed, deferred or declined.

No more than 50 tasks. The lookup takes up to 50 tasks of the deal. On a deal with many tasks the check becomes incomplete — narrow the selection with a filter on the state or raise limit.

Deal data goes to the model. The title, the amount and the activity subjects of the deal go into the request text. Decide before launch which fields may be sent to the model, and do not add the full contents of emails and comments to the description.

Seconds per answer. The model response time is measured in seconds and grows with the length of the history. Limit the activity selection with the limit parameter and do not run the analysis synchronously inside a web-interface handler.

Quota spend. Requests to platform models consume the company monthly quota. How much is already spent and which models are free — consumption and limits.

The full list of error codes — Errors.

Full code

javascript
// deal-assistant.js — deal analysis by a model and task creation
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.com'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('The VIBE_API_KEY environment variable is not set')
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: 'call', 2: 'meeting', 3: 'task', 6: 'email' }
const PRIORITY = { high: 2, normal: 1, low: 0 }
// A constant marker at the start of the title. By it the scenario recognizes ITS
// OWN tasks on a repeated run and leaves alone the ones the manager created by hand.
const TASK_TAG = '[AI]'

const SYSTEM_PROMPT = `You help a sales manager. Return JSON:
{"analysis": "two or three sentences about the situation",
 "taskTitle": "a short task title",
 "taskDescription": "what exactly to do",
 "priority": "high|normal|low",
 "deadlineDays": number of days to complete}`

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 ?? 'deal not read')
  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 ?? 'history not read')
  return body.data
}

function describe(deal, activities) {
  const history = activities.length === 0
    ? 'The deal has no activities.'
    : activities
        .map(a => {
          const date = new Date(a.createdAt).toLocaleDateString('en-US')
          const type = ACTIVITY_TYPE[a.typeId] ?? 'activity'
          return `- ${date} ${type}: ${a.subject} (${a.completed ? 'completed' : 'in progress'})`
        })
        .join('\n')

  return [
    `Deal: ${deal.title}`,
    `Stage: ${deal.stageId}`,
    `Amount: ${deal.amount} ${deal.currency}`,
    '',
    'History:',
    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('the model returned an empty answer')

  const advice = JSON.parse(content)
  if (!advice.taskTitle) throw new Error('the model answer has no taskTitle field')
  return advice
}

// Otherwise a repeat run on the same deal gives the manager a second identical
// task: the ufCrmTask link allows any number of tasks per deal. Running over
// 300 deals twice would produce 600 tasks.
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 ?? 'task lookup failed')
  // The comparison goes by the constant marker and the state, NOT by the title:
  // the title is composed by the model, on a repeated run it will be different,
  // and a comparison by it misses the duplicate. Open states are 1-4 (new,
  // pending, in progress, awaiting control); 5-7 — completed, deferred,
  // declined.
  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\nSituation assessment: ${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 ?? 'task not created')
  return body.data
}

async function main() {
  const dealId = process.argv[2]
  if (!dealId) {
    console.log('Run: node deal-assistant.js <deal ID>')
    process.exit(1)
  }

  const deal = await getDeal(dealId)
  const activities = await getActivities(dealId)
  console.log(`${deal.title}: activities in history — ${activities.length}`)

  // The check runs BEFORE calling the model: a repeat run on the same deal
  // should not pay for an analysis whose result would be discarded.
  if (await hasOpenAiTask(dealId)) {
    console.log(`Deal ${dealId} already has an open assistant task — skipping`)
    return
  }

  const advice = await ask(deal, activities)
  console.log(`Assessment: ${advice.analysis}`)

  const task = await createTask(dealId, deal, advice)
  console.log(`Task ${task.id}: ${task.title}`)
}

main().catch(error => {
  console.error('analysis interrupted:', error.message)
  process.exitCode = 1
})

See also