For AI agents: markdown of this page — /docs-content-en/recipes/web-search-with-llm.md documentation index — /llms.txt

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

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 only on the basis of 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 only on the basis of 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 become ready.

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.

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, and then 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 filtersprovider: "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 ceilings. The max_results parameter accepts a value from 1 to 20, the default is 5. A value above 20 gets the request 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. On an empty balance the search answers 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 ceiling. An account performs no more than 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 ceiling, 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 is the ceiling of 60 searches per minute (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(`The request limit is exhausted, 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 an
// empty 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 only on the basis of 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