For AI agents: markdown of this page — /docs-content-en/recipes/crm-assistant-bot.md documentation index — /llms.txt
Documentation articles are currently available in English.
Assistant bot for deals and workflows
Difficulty: advanced | Scopes: imbot, crm, bizproc, user, vibe:ai | Stack: cURL / JavaScript
An employee messages the bot in a Bitrix24 chat. The bot collects current data from the Bitrix24 account — running workflows and deals changed over a period — and answers with a language model strictly from that data. The model cannot see the Bitrix24 account itself, so the answer is only as accurate as the data the bot passes to it and how it labels times.
What you need
- A Vibecode API key with the
imbot,crm,bizproc,userandvibe:aiscopes. Theuserscope is needed forGET /v1/users/:id, where the script reads the time zone - Node.js 18 or later
- A machine that stays online: the bot receives messages by continuous polling. Requirements for the machine are in Where the bot runs
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
- Register the bot once and save its ID.
- Poll the bot's events to get the employee's question and the dialog to reply to.
- Compute the period boundary in the key owner's time zone and select the deals changed during that period.
- Get the running workflows and attach deal titles to them.
- Pass the data to the model as text, stating the current time and the employee's time zone.
- Send the model's answer to the same dialog.
The step examples show individual calls. A ready-to-run script is in the "Full code" section.
Step 1. Register the bot
POST /v1/bots registers the bot. eventMode: "fetch" means the bot fetches events itself by polling and needs no public address.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/bots" \
-d '{
"code": "process_assistant",
"name": "Process assistant",
"type": "bot", "eventMode": "fetch",
"workPosition": "Answers questions about deals and processes"
}'
JavaScript
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
const res = await fetch(`${VIBE_URL}/v1/bots`, {
method: 'POST',
headers,
body: JSON.stringify({
code: 'process_assistant',
name: 'Process assistant',
type: 'bot', eventMode: 'fetch',
workPosition: 'Answers questions about deals and processes',
}),
})
const { data } = await res.json()
const botId = data.botId
The main response fields are shown below. The bot name comes in the data.users array:
{
"success": true,
"data": {
"botId": 1587,
"bot": { "id": 1587, "code": "process_assistant", "type": "bot", "eventMode": "fetch" }
}
}
Pass data.botId to the "Full code" script in the BOT_ID environment variable. Register the bot only once. A 409 response means a bot with this code is already registered. If the bot was registered through Vibecode, the 409 response also returns its ID in data.botId:
{
"success": false,
"error": { "code": "BOT_ALREADY_EXISTS", "message": "Bot with this code already exists" },
"data": { "botId": 1587, "code": "process_assistant", "name": "Process assistant" }
}
The bot is bound to the key it was registered with, and all further calls must use the same key. Check the ID from the 409 response with GET /v1/bots/:botId: a 403 BOT_ACCESS_DENIED answer means the bot was registered with another key — the steps are in Restoring access to a bot. If the 409 response has no data, the code is taken by a bot created outside the platform, and a transfer does not apply to it — choose a different code.
Step 2. The employee's question
GET /v1/bots/:botId/events returns accumulated events. The employee's question arrives as an ONIMBOTV2MESSAGEADD event.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/bots/1587/events"
JavaScript
const res = await fetch(`${VIBE_URL}/v1/bots/${botId}/events`, { headers })
const { data } = await res.json()
const messages = data.events.filter(e => e.type === 'ONIMBOTV2MESSAGEADD')
The main event fields are shown below:
{
"success": true,
"data": {
"events": [
{
"eventId": 441,
"type": "ONIMBOTV2MESSAGEADD",
"date": "2026-10-01T10:29:24+00:00",
"data": {
"message": {
"id": 42039,
"chatId": 5317,
"authorId": 1317,
"text": "Which processes are running now and what happened to deals over the last week?",
"isSystem": false
},
"chat": { "id": 5317, "dialogId": "1317", "type": "private" },
"user": { "id": 1317, "name": "John Smith", "firstName": "John" }
}
}
],
"nextOffset": 442, "hasMore": false,
"storedOffset": 0, "persisted": true
}
}
The reply is sent to the data.chat.dialogId dialog. data.user.id identifies who asked.
A request without the offset parameter continues from the position the platform stores itself, so after a restart the bot does not receive already processed messages again. Other event types are in the Bot events reference.
Step 3. Deals for the period
The period boundary in the filter is set by a literal without a time zone, for example 2026-09-24T00:00:00. This literal is read in the time zone from the key owner's profile, not in the Bitrix24 account's time zone and not in the time zone of the person who asked. So first find out the key owner and their time zone.
The key owner is returned by GET /v1/me in the data.owner.userId field, and the time zone from their profile by GET /v1/users/:id in the timeZone field. Both calls run once at startup, as in "Full code". The main fields of the GET /v1/users/:id response are shown below:
{
"success": true,
"data": { "id": 1317, "name": "John", "lastName": "Smith", "timeZone": "Asia/Tbilisi" }
}
Convert the "seven days ago" boundary to this time zone and pass it in the POST /v1/deals/search filter. A Z or -05:00 suffix in the filter value changes nothing: the platform drops it, see Time zone in a filter value.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/deals/search" \
-d '{
"filter": { "categoryId": 0, "updatedAt": { "$gte": "2026-09-24T00:00:00" } },
"select": ["id", "title", "stageId", "amount", "currency", "updatedAt"],
"sort": { "updatedAt": "desc" },
"limit": 30
}'
JavaScript
// localLiteral converts a date into a YYYY-MM-DDTHH:MM:SS literal in the given time zone, the code is in "Full code"
const since = new Date(Date.now() - 7 * 24 * 3600 * 1000)
const res = await fetch(`${VIBE_URL}/v1/deals/search`, {
method: 'POST',
headers,
body: JSON.stringify({
filter: { categoryId: 0, updatedAt: { $gte: localLiteral(since, ownerTimeZone) } },
select: ['id', 'title', 'stageId', 'amount', 'currency', 'updatedAt'],
sort: { updatedAt: 'desc' },
limit: 30,
}),
})
const { data: deals, meta } = await res.json()
{
"success": true,
"data": [
{ "id": 8663, "title": "Online store order #993", "stageId": "NEW", "amount": 400, "currency": "USD", "updatedAt": "2026-09-30T10:34:34.000Z" }
],
"meta": { "hasMore": true, "durationMs": 399 }
}
Times in the response come in UTC with the Z suffix, although the boundary in the filter was set in the key owner's time zone. The meta.hasMore flag says there are more deals for the period than the request returned. Pass this to the model as an explicit line, otherwise it will treat the number of returned deals as the total and report that number to the employee.
A stage name is clearer to the model than the stageId code. GET /v1/statuses returns the stage names of the default funnel with the entityId=DEAL_STAGE filter, and of the funnel with ID 9 with the entityId=DEAL_STAGE_9 filter. A single request does not accept several entityId values, so the example is limited to the default funnel — categoryId: 0.
Step 4. Running workflows
GET /v1/workflows returns running workflow instances, most recently changed first.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/workflows?limit=50"
JavaScript
const res = await fetch(`${VIBE_URL}/v1/workflows?limit=50`, { headers })
const { data: workflows } = await res.json()
The main response fields are shown below:
{
"success": true,
"data": [
{
"id": "6a638a15c3eb98.67333533",
"modifiedAt": "2026-07-24T18:51:49+00:00",
"documentId": "DEAL_7885",
"startedAt": "2026-07-24T18:51:49+00:00",
"templateId": "709"
}
],
"meta": { "total": 5 }
}
A process instance is described by IDs: document DEAL_7885, template 709. These codes mean nothing to the model, so deal document IDs are resolved to titles with one POST /v1/deals/search request with the $in operator on id — the fragment is below, and the whole runningWorkflows function is in "Full code".
const dealIds = workflows
.map(w => w.documentId.match(/^DEAL_(\d+)$/)?.[1])
.filter(Boolean)
.map(Number)
// request body: { filter: { id: { $in: dealIds } }, select: ['id', 'title'], limit: 50 }
Workflow times come with a time zone offset, as in the example above. Before passing them to the model, convert them and the deal times to a single time zone, as the formatTime function in "Full code" does.
Step 5. The model's answer
Pass the data to the model as text in the user message, and the rules in the system message: answer only from the data, admit when there is no answer, and do not state the exact number of records if only some of them are shown. State the current time and time zone of the employee who asked: the time zone comes from their profile via GET /v1/users/:id using data.user.id from the event.
POST /v1/ai/chat/completions is called with the same key. Available models are listed by GET /v1/ai/models.
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", "temperature": 0.2, "max_tokens": 500,
"messages": [
{
"role": "system",
"content": "You assist employees in a Bitrix24 chat. Answer only from the \"Bitrix24 data\" block. If the answer is not there, say so. It is now 2026-10-01T10:30:00, times in the data are in the Asia/Tbilisi time zone. Answer briefly."
},
{
"role": "user",
"content": "Bitrix24 data:\nRunning workflows (1):\n- template 709, deal \"Online store order #875\", started 7/24/26, 10:51 PM\n\nQuestion: Which processes are running now?"
}
]
}'
JavaScript
const res = await fetch(`${VIBE_URL}/v1/ai/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'bitrix/bitrixgpt-5.5', temperature: 0.2, max_tokens: 500,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `Bitrix24 data:\n${context}\n\nQuestion: ${question}` },
],
}),
})
const completion = await res.json()
if (!res.ok) throw new Error(completion.error?.message)
const answer = completion.choices[0].message.content
{
"id": "chatcmpl-9249fb3d0e10562c",
"object": "chat.completion",
"model": "bitrix/bitrixgpt-5.5",
"choices": [
{
"index": 0,
"message": {
"content": "One process is running: template 709, deal \"Online store order #875\" (started 7/24/26, 10:51 PM).",
"role": "assistant"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 166,
"completion_tokens": 48,
"total_tokens": 214
}
}
The response comes in an OpenAI-compatible format, without the { success, data } wrapper. The text is in choices[0].message.content. The usage.prompt_tokens field grows with the amount of data you pass.
Step 6. Reply in the chat
POST /v1/bots/:botId/messages sends the model's text to the dialog from step 2.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/bots/1587/messages" \
-d '{ "dialogId": "1317", "fields": { "message": "One process is running on the deal \"Online store order #875\"." } }'
JavaScript
await fetch(`${VIBE_URL}/v1/bots/${botId}/messages`, {
method: 'POST',
headers,
body: JSON.stringify({ dialogId, fields: { message: answer } }),
})
{ "success": true, "data": { "id": 42041, "uuidMap": [], "failureMap": [] } }
While the model prepares the answer, "Full code" shows a "thinking" indicator in the chat with a POST /v1/bots/:botId/typing call.
Limitations
Key owner's permissions. All bot requests run on behalf of the key owner. The bot answers any employee who wrote to it with the data the key owner sees, including deals the employee cannot access. Register the bot with the key of an employee whose access does not exceed what every person asking questions is allowed to see.
Process state. GET /v1/workflows reports that a process is running, for which document, and when it last changed. The response has no current step, task status or step assignee, so the model cannot report on them. The method has no date filter either, only templateId and startedBy: to select "processes for the week", filter in code by the modifiedAt or startedAt field.
Template names. The name of a workflow template is returned by GET /v1/bizproc-templates, but only to an OAuth application key vibe_app_… with the Authorization: Bearer header. A personal key gets 403 OAUTH_REQUIRED. With a personal key, keep the mapping between templateId and the name in the bot settings.
No time zone in the profile. If the timeZone field in the profile is empty, the script takes the time zone from the FALLBACK_TIME_ZONE environment variable and does not start without it. Fill in the field in the key owner's profile so the period boundary is computed in their time zone.
Data volume. The script passes at most 30 deals and 50 processes to the model. The higher the limit, the more each question costs, and with many records the data will not fit into the model's context.
Account queue. Each employee question means several requests to Bitrix24. When many questions come in, requests get 429 QUEUE_OVERFLOW or QUEUE_TIMEOUT. "Full code" retries such a request up to five times, pausing as long as the Retry-After header says. More on retries in Limits and optimization. A 402 model rejection with the code insufficient_balance, ai_quota_exhausted or company_budget_exhausted is not resolved by retrying: top up the balance or raise the quota, and "Full code" tells the employee about it. The full list of codes is in Errors.
Full code
// assistant-bot.mjs — the bot answers employees about Bitrix24 deals and workflows
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.com'
const VIBE_API_KEY = process.env.VIBE_API_KEY
const BOT_ID = Number(process.env.BOT_ID)
if (!VIBE_API_KEY || !BOT_ID) throw new Error('Set the VIBE_API_KEY and BOT_ID environment variables')
const MODEL = process.env.MODEL ?? 'bitrix/bitrixgpt-5.5'
const PERIOD_DAYS = 7, DEALS_LIMIT = 30
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
async function api(path, body, attempt = 0) {
const init = body ? { method: 'POST', headers, body: JSON.stringify(body) } : { headers }
const res = await fetch(`${VIBE_URL}${path}`, init)
if (res.status === 429 && attempt < 5) return new Promise(r => setTimeout(r, Number(res.headers.get('Retry-After') ?? 2) * 1000)).then(() => api(path, body, attempt + 1))
const json = await res.json().catch(() => null)
if (!json?.success) throw new Error(json?.error?.message ?? `request ${path} rejected (${res.status})`)
return json
}
// Date and time in the given time zone as YYYY-MM-DDTHH:MM:SS, without a time zone suffix
const localLiteral = (date, timeZone) =>
date.toLocaleString('sv-SE', { timeZone, hour12: false }).replace(' ', 'T')
const formatTime = (iso, timeZone) => new Date(iso).toLocaleString('en-US', { timeZone, dateStyle: 'short', timeStyle: 'short' })
async function userTimeZone(userId) {
const { data } = await api(`/v1/users/${userId}`)
const timeZone = data.timeZone || process.env.FALLBACK_TIME_ZONE
if (!timeZone) throw new Error(`user ${userId} has no time zone set, set FALLBACK_TIME_ZONE`)
return timeZone
}
// The date filter is read in the key owner's time zone, so the period boundary is computed in it
const { data: me } = await api('/v1/me')
if (!me.owner?.userId) throw new Error('the key has no owner, the filter time zone is unknown')
const OWNER_TIME_ZONE = await userTimeZone(me.owner.userId)
const { data: stageList } = await api('/v1/statuses?filter[entityId]=DEAL_STAGE')
const STAGES = Object.fromEntries(stageList.map(s => [s.statusId, s.name]))
async function recentDeals() {
const since = new Date(Date.now() - PERIOD_DAYS * 24 * 3600 * 1000)
const { data, meta } = await api('/v1/deals/search', {
filter: { categoryId: 0, updatedAt: { $gte: localLiteral(since, OWNER_TIME_ZONE) } },
select: ['id', 'title', 'stageId', 'amount', 'currency', 'updatedAt'],
sort: { updatedAt: 'desc' },
limit: DEALS_LIMIT,
})
return { deals: data, truncated: Boolean(meta?.hasMore) }
}
async function runningWorkflows() {
const { data: workflows, meta } = await api('/v1/workflows?limit=50')
const dealIds = workflows.map(w => w.documentId?.match(/^DEAL_(\d+)$/)?.[1]).filter(Boolean).map(Number)
const titles = {}
if (dealIds.length) {
const { data } = await api('/v1/deals/search', { filter: { id: { $in: dealIds } }, select: ['id', 'title'], limit: 50 })
for (const d of data) titles[`DEAL_${d.id}`] = d.title
}
return Object.assign(workflows.map(w => ({ ...w, documentTitle: titles[w.documentId] })), { total: meta?.total })
}
function describe({ deals, truncated }, workflows, tz) {
const lines = [`Running workflows (${workflows.total ?? workflows.length}):`]
for (const w of workflows) {
const doc = w.documentTitle ? `deal "${w.documentTitle}"` : w.documentId
lines.push(`- template ${w.templateId}, ${doc}, started ${formatTime(w.startedAt, tz)}, changed ${formatTime(w.modifiedAt, tz)}`)
}
lines.push('', `Deals of the default funnel changed over ${PERIOD_DAYS} days:`)
for (const d of deals) {
lines.push(`- #${d.id} "${d.title}": stage "${STAGES[d.stageId] ?? d.stageId}", ${d.amount} ${d.currency}, changed ${formatTime(d.updatedAt, tz)}`)
}
if (!deals.length) lines.push('- no deals changed during the period')
if (truncated) lines.push(`Showing the ${deals.length} most recent deals. There are more for the period. The exact number is not in the data.`)
return lines.join('\n')
}
async function answer(question, context, tz) {
const res = await fetch(`${VIBE_URL}/v1/ai/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
model: MODEL,
temperature: 0.2,
max_tokens: 500,
messages: [
{
role: 'system',
content: 'You assist employees in a Bitrix24 chat. Answer only from the "Bitrix24 data" block. ' +
'If the answer is not there, say so. If the data says only some of the records are shown, do not state their exact number. ' +
`It is now ${localLiteral(new Date(), tz)}, times in the data are in the ${tz} time zone. Answer briefly.`,
},
{ role: 'user', content: `Bitrix24 data:\n${context}\n\nQuestion: ${question}` },
],
}),
})
const completion = await res.json().catch(() => null)
if (!res.ok) throw new Error(`${res.status} ${completion?.error?.message ?? 'the model did not respond'}`)
return completion.choices[0].message.content
}
const reply = (dialogId, message) => api(`/v1/bots/${BOT_ID}/messages`, { dialogId, fields: { message } })
async function handleMessage(data) {
if (data.message.isSystem || data.message.authorId === BOT_ID) return
const dialogId = data.chat.dialogId
await api(`/v1/bots/${BOT_ID}/typing`, { dialogId, statusMessageCode: 'IMBOT_AGENT_ACTION_THINKING' }).catch(() => {})
// Times in the answer are shown in the time zone of the person who asked
const tz = await userTimeZone(data.user.id)
const [deals, workflows] = await Promise.all([recentDeals(), runningWorkflows()])
await reply(dialogId, await answer(data.message.text, describe(deals, workflows, tz), tz))
}
console.log(`Bot ${BOT_ID} is listening for events, period boundary in the ${OWNER_TIME_ZONE} time zone`)
while (true) {
try {
const { data } = await api(`/v1/bots/${BOT_ID}/events`)
for (const event of data.events) {
if (event.type !== 'ONIMBOTV2MESSAGEADD') continue
await handleMessage(event.data).catch(async error => {
console.error('Message not processed:', error.message)
await reply(event.data.chat.dialogId, error.message.startsWith('402') ? 'The balance or the AI quota of the Bitrix24 account has run out. Contact your administrator.' : 'Could not prepare an answer, try again later.').catch(() => {})
})
}
if (data.hasMore) continue
} catch (error) {
console.error('Polling error:', error.message)
}
await new Promise(resolve => setTimeout(resolve, 3000))
}
Run: BOT_ID=1587 VIBE_API_KEY=YOUR_API_KEY node assistant-bot.mjs.