Para agentes de IA: markdown desta página — /docs-content-en/recipes/web-search-with-llm.md índice da documentação — /llms.txt
Os artigos da documentação estão disponíveis atualmente em inglês.
Web search + LLM (RAG)
Difficulty: medium | Scopes: vibe:search, vibe:ai | Stack: cURL / JavaScript
Pairing POST /v1/search with POST /v1/ai/chat/completions for LLM answers grounded in fresh sources from the internet.
What you need
- A Vibecode API key with the
vibe:searchandvibe:aiscopes - A non-zero Vibe credit balance or your own BYOK key for a search engine (Your own keys)
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
- Search the internet for the user's question and get a
resultsarray with links to pages. - Pass the sources to the model with an instruction to cite them by
[N]markers — the number matchesresults[].id. - Turn on streaming when the interface shows the answer as it arrives.
The step examples show individual calls. The ready-to-run script is in the "Full code" section.
Step 1. Web search
POST /v1/search returns a results array where each element carries id, title, url and content.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/search" \
-d '{
"query": "Bitrix24 updates over the last month",
"search_depth": "advanced",
"max_results": 5,
"lang": "en"
}'
JavaScript
const searchRes = await fetch(`${VIBE_URL}/v1/search`, {
method: 'POST',
headers: {
'X-Api-Key': VIBE_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'Bitrix24 updates over the last month',
search_depth: 'advanced',
max_results: 5,
lang: 'en',
}),
})
const search = await searchRes.json()
The response carries a synthesized answer and a results array — the recipe reads id, title, url and content from it. The full field set is on the POST /v1/search page.
{
"query": "Bitrix24 updates over the last month",
"provider": "bitrix-search",
"answer": "Tasks and CRM were updated in July [1]. New fields were added [2].",
"results": [
{
"id": 1,
"url": "https://example.com/updates",
"title": "Platform updates",
"content": "A short snippet of the page found…",
"rawContent": null,
"score": null,
"publishedDate": null
}
],
"cost_vibes": 5
}
Step 2. The model's answer based on the sources found
The pages found are collected into a text block and sent to POST /v1/ai/chat/completions together with an instruction to cite the sources.
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": "Answer using only the sources below. Accompany each fact with a [N] marker, where N is the source id." },
{ "role": "user", "content": "Question: Bitrix24 updates over the last month\n\nSources:\n[1] Title\nhttps://example.com\nSnippet text" }
]
}'
JavaScript
const sourcesBlock = search.results
.map((r) => `[${r.id}] ${r.title}\n${r.url}\n${r.content}`)
.join('\n\n')
const chatRes = 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:
'Answer using only the sources below. Accompany each fact with a [N] marker, where N is the source id.',
},
{
role: 'user',
content: `Question: ${search.query}\n\nSources:\n${sourcesBlock}`,
},
],
}),
})
const chat = await chatRes.json()
console.log(chat.choices[0].message.content)
The response comes in an OpenAI-compatible format — without the { success, data } wrapper. The text sits in 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": "Tasks and CRM were updated in July [1]." } }
],
"usage": { "prompt_tokens": 145, "completion_tokens": 93, "total_tokens": 238 }
}
Step 3. Streaming
When the interface shows progress (a chat application, an assistant), use stream: true — events arrive as they are produced.
cURL
curl -sN -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
"$VIBE_URL/v1/search" \
-d '{
"query": "Bitrix24 updates over the last month",
"search_depth": "advanced",
"stream": true
}'
JavaScript
const streamRes = await fetch(`${VIBE_URL}/v1/search`, {
method: 'POST',
headers: {
'X-Api-Key': VIBE_API_KEY,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
body: JSON.stringify({
query: 'Bitrix24 updates over the last month',
search_depth: 'advanced',
stream: true,
}),
})
const reader = streamRes.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let idx
while ((idx = buffer.indexOf('\n\n')) !== -1) {
const block = buffer.slice(0, idx)
buffer = buffer.slice(idx + 2)
const eventLine = block.match(/^event: (.+)$/m)
const dataLine = block.match(/^data: (.+)$/m)
if (!eventLine || !dataLine) continue
const event = eventLine[1]
const payload = JSON.parse(dataLine[1])
if (event === 'thinking') process.stdout.write('.')
if (event === 'answer_delta') process.stdout.write(payload.content)
if (event === 'done') console.log('\nDone')
}
}
The stream carries seven kinds of events: start — the search has begun, thinking — the model's reasoning, tool_call and tool_result — the call to the search engine and its outcome, answer_delta — the next piece of the answer, done — the final frame with the complete answer and the list of sources, error — a refusal.
event: start
data: {"search_id":"ws_20260721091402_8e21a3c7","provider":"bitrix-search","search_depth":"advanced"}
event: answer_delta
data: {"content":"Over the last month there were "}
event: done
data: {"query":"Bitrix24 updates over the last month","answer":"…","results":[…],"images":[]}
Assembling the answer from the answer_delta pieces is not mandatory — the done frame carries it in full together with the sources. Which intermediate frames arrive at all depends on the instance engine: a buffered one sends start and then done, so the interface must also work when nothing streams in between.
The full list of stream events and fields — POST /v1/search.
Which engine to choose
- Without an explicit
providerthe request goes through the default engine configured on the instance (shown by thedefaultProviderfield inGET /v1/me). The platform engine already returns a synthesizedanswer, so the LLM step can be skipped. Whether that answer carries[N]citation markers depends on the instance engine — see the capability matrix. - Sources with domain or time filters → check the capability matrix first, because the default engine may already support them. If it does not, use
provider: "tavily"(BYOK). A Tavily token is required — it is added throughPOST /v1/search/credentials. - Search without synthesis, for RAG on top of your own LLM →
provider: "brave"(BYOK).
For richer context, engines that support it can return the full page text via include_raw_content: true — it arrives in results[].rawContent and is fed to the LLM instead of the short snippet. The include_images: true parameter adds a top-level images array of image links to the response. Which engines support this is shown by GET /v1/search/providers.
More on the differences — Web Search for AI.
Limitations
Request limits. The max_results parameter accepts a value from 1 to 20, the default is 5. A higher value is rejected. The query text is capped at 400 characters. A long user question is condensed to its essence before the search rather than sent whole.
Refusals come without success. With a zero balance the search returns 402. The envelope here differs from the other endpoints: it carries no success field, so check for results rather than for success.
{
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "Insufficient vibes for this search.",
"userMessage": "Not enough Vibe credits for this request.",
"hint": "Add a BYOK key to search for free — see GET /v1/search/providers",
"required": 5
}
}
The full list of error codes — Errors.
Rate limit. A Bitrix24 account is limited to 60 search requests per minute. Going over is rejected with the RATE_LIMITED code and status 429, and how long to wait is stated by the Retry-After header. Batch-processing a queue of questions runs into that limit, so put a pause between cycles and repeat the rejected request instead of dropping it.
Sanitizing is on you. rawContent is unsanitized text from the open web and images[] are third-party links. Both reach the response as-is: sanitize the text before rendering it in a browser, and validate the links.
Source quality. The model's answer rests only on the sources you pass, but the recipe does not verify them. If the search returned a page with wrong data, the model will cite it with a marker as fact.
Cost of one cycle
- Search: the platform engine is billed in Vibe credits — the current price of the
advancedmode is inGET /v1/search/providers. For BYOK providers (Tavily / Brave and others) — 0 Ꝟ on the platform side. - LLM: depends on the model and the number of tokens. Pricing — in AI Router.
Full code
// rag.mjs — a model answer based on fresh sources from the internet
// The .mjs extension is required: the script uses top-level await, and a .js file
// without "type": "module" is read by Node as CommonJS and fails to parse.
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')
// A 429 refusal means either the 60-searches-per-minute cap (RATE_LIMITED) or a busy
// portal queue. The request never reached the provider, so a repeat is safe and
// costs nothing. How long to wait is stated by the Retry-After header; a random
// fraction of a second is added to the pause.
const MAX_RETRIES = 5
async function postJson(url, payload) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, {
method: 'POST',
headers: {
'X-Api-Key': VIBE_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
const body = await res.json().catch(() => null)
if (res.status === 429 && attempt < MAX_RETRIES) {
// Honor the advised pause as-is: scaling it by the attempt number would
// turn Retry-After: 3 into 48 seconds on the fifth attempt. The
// exponent is only for the case where the server named no pause.
const advised = res.headers.get('Retry-After') ?? body?.error?.retryAfter ?? null
// A 60 s ceiling: the advised pause is honored, but an unexpectedly large
// value from an intermediate proxy must not stall the loop.
const base = advised === null ? Math.min(2 ** attempt, 30) : Number(advised)
const wait = Math.min(base, 60) + Math.random()
console.warn(`Rate limit reached, retrying in ${Math.round(wait)} s`)
await new Promise((resolve) => setTimeout(resolve, wait * 1000))
continue
}
return body
}
}
// ── 1. Web search ───────────────────────────────────────────
const search = await postJson(`${VIBE_URL}/v1/search`, {
query: 'Bitrix24 updates over the last month',
search_depth: 'advanced',
max_results: 5,
lang: 'en',
})
// Search and the model report a refusal in the response body. Without the check a
// zero balance yields a TypeError on search.results.map instead of a readable
// 402 INSUFFICIENT_BALANCE.
if (!search.results) throw new Error(search.error?.message ?? 'search rejected')
// ── 2. Assemble the sources block for the LLM ───────────────
const sourcesBlock = search.results
.map((r) => `[${r.id}] ${r.title}\n${r.url}\n${r.content}`)
.join('\n\n')
// ── 3. Request to the LLM with an instruction to cite ───────
const chat = await postJson(`${VIBE_URL}/v1/ai/chat/completions`, {
model: 'bitrix/bitrixgpt-5.5',
messages: [
{
role: 'system',
content:
'Answer using only the sources below. Accompany each fact with a [N] marker, where N is the source id.',
},
{
role: 'user',
content: `Question: ${search.query}\n\nSources:\n${sourcesBlock}`,
},
],
})
if (!chat.choices?.length) throw new Error(chat.error?.message ?? 'the model returned no answer')
console.log(chat.choices[0].message.content)