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:search and vibe:ai scopes
  • 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

  1. Search the internet for the user's question and get a results array with links to pages.
  2. Pass the sources to the model with an instruction to cite them by [N] markers — the number matches results[].id.
  3. 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.

POST /v1/search returns a results array where each element carries id, title, url and content.

cURL

Terminal
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

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.

JSON
{
  "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

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": "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

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.

JSON
{
  "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

Terminal
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

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 provider the request goes through the default engine configured on the instance (shown by the defaultProvider field in GET /v1/me). The platform engine already returns a synthesized answer, 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 through POST /v1/search/credentials.
  • Search without synthesis, for RAG on top of your own LLMprovider: "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.

JSON
{
  "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 advanced mode is in GET /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

javascript
// 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)

See also