Per gli agenti AI: markdown di questa pagina — /docs-content-en/recipes/web-search-with-llm.md indice della documentazione — /llms.txt

Gli articoli della documentazione sono attualmente disponibili solo in inglese.

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. The recipe searches in basic mode. Switch to advanced only when the basic response lacks the data you need. See How to choose the depth.

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": "basic",
    "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: 'basic',
    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": "basic",
    "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: 'basic',
    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":"basic"}

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 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.

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. The exact value arrives in the x-ratelimit-limit header (the cap is divided across replicas). 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 basic 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 search rate cap (RATE_LIMITED) or a busy
// account 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: 'basic',
  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