# 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`](/docs/entities/deals/get) returns the whole deal. The model needs a small part of the fields, so take them selectively.

### cURL

```bash
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`](/docs/entities/activities/search). The link is defined by the `ownerTypeId` and `ownerId` pair: `2` — deal, `3` — contact, `4` — company, `1` — lead.

### cURL

```bash
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`](/docs/ai/chat/completions). The list of available identifiers is returned by [`GET /v1/ai/models`](/docs/ai/models/list).

The answer is meant for a machine, not a human, so we request [guaranteed JSON](/docs/ai/chat/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

```bash
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](/docs/ai/credentials).

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`](/docs/entities/tasks/create) 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

```bash
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](/docs/ai/consumption).

The full list of error codes — [Errors](/docs/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

- [Create a chat completion](/docs/ai/chat/completions)
- [Guaranteed JSON response](/docs/ai/chat/json)
- [Get a deal](/docs/entities/deals/get)
- [Search activities](/docs/entities/activities/search)
- [Create a task](/docs/entities/tasks/create)
