# Web search + LLM (RAG)

**Difficulty:** medium | **Scopes:** vibe:search, vibe:ai | **Stack:** cURL / JavaScript

Pairing [`POST /v1/search`](/docs/search/run) with [`POST /v1/ai/chat/completions`](/docs/ai/chat) 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](/docs/search/credentials))

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.

## Step 1. Web search

[`POST /v1/search`](/docs/search/run) 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](/docs/search/run#how-to-choose-the-depth).

### cURL

```bash
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`](/docs/search/run) page.

```json
{
  "query": "Bitrix24 updates over the last month",
  "provider": "bitrix-search",
  "answer": "Tasks and CRM were updated in July. New fields were added.",
  "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`](/docs/ai/chat) together with an instruction to cite the sources.

### cURL

```bash
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

```bash
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_20261029081225_f4c9ab99","provider":"bitrix-search","search_depth":"basic"}

event: answer_delta
data: {"content":"Based"}

event: answer_delta
data: {"content":" on the available documentation"}

event: done
data: {"query":"Bitrix24 updates over the last month","provider":"bitrix-search","search_depth":"basic","answer":"Based on the available documentation for late September and early October 2026, the following updates have been noted:\n\n* **AI-Powered Solutions**: **Alaio Vibecode** has been highlighted as a platform for building custom AI-powered solutions for Bitrix24, specifically designed for cases where ready-made solutions like custom reports or integrations are missing [1].\n* **REST API Enhancements**: As of September 3, 2026, a new **ONAPPUSERREADY event** was added to the Bitrix24 REST API. This allows for receiving long-lived authorization for an application system user immediately after installation [2].\n* **Project Management**: Recent developments include **Project Vibe**, which focuses on reshaping Projects by building upon chat-centered task workflows [3].\n\nUsers can track ongoing incremental changes by clicking the **\"What's new\"** option within the **My plan widget** inside the Bitrix24 interface [4].","results":[{"id":1,"url":"https://helpdesk.bitrix24.com/open/26062415/","title":"What's new? September 2026","content":"Last update: October 02, 2026. Share. Alaio Vibecode is a platform for building AI-powered solutions for Bitrix24. Alaio Vibecode is useful when your Bitrix24 does not include a ready-made solution you need, such as a custom report or integration.","score":null,"publishedDate":null},{"id":2,"url":"https://apidocs.bitrix24.com/whats-new.html","title":"What's New? | Bitrix24 REST API and Marketplace Applications","content":"September 3, 2026September 3, 2026. Added the ONAPPUSERREADY event for receiving long-lived authorization for an application system user after installation. May 08, 2026May 08, 2026. Updated the page How to Integrate External Telephony with Bitrix24.","score":null,"publishedDate":null},{"id":3,"url":"https://www.bitrix24.com/promo/spring-2026-release/","title":"Feel your new work Vibe","content":"The latest Bitrix24 update is here, loaded with improvements and new features. Project Vibe. Building on the chat-centered task work premiered in the previous update, this time we've reshaped Projects.","score":null,"publishedDate":null},{"id":4,"url":"https://helpdesk.bitrix24.com/open/22728562/","title":"What's new in Bitrix24","content":"Last update: September 18, 2026. Keep up to date with all the latest news about Bitrix24 tools using the What's new in Bitrix24 section. Quick answer. Click on the My plan widget - What's new.","score":null,"publishedDate":null}],"images":[],"ignored_filters":[],"search_id":"ws_20261029081225_f4c9ab99","upstream_search_id":"20261029_081225_f8ed5214","cost_vibes":0,"billing_source":"quota","duration_ms":10234}
```

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.

For the full list of stream events and fields, see [`POST /v1/search`](/docs/search/run#streaming-sse).

## 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`](/docs/keys-auth)). 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`](/docs/search/credentials/create).
- **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`](/docs/search/providers).

More on the differences — [Web Search for AI](/docs/search#when-to-choose-which-provider).

## 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
  }
}
```

For the full list of error codes, see [Errors](/docs/errors).

**Rate limit.** A Bitrix24 account is limited to 60 search requests per minute. The effective value of `/v1/search`'s own rate limit is returned in the `x-ratelimit-limit` header. Requests that exceed the limit are rejected with the `RATE_LIMITED` code and status 429. The `Retry-After` header specifies how long to wait. 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`](/docs/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](/docs/ai).

## 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
      // exponential backoff is used only when the server does not specify a delay.
      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

- [Web Search for AI](/docs/search)
- [Search (POST /v1/search)](/docs/search/run)
- [Your own keys (BYOK)](/docs/search/credentials)
- [AI Router](/docs/ai)
- [Limits and optimization](/docs/optimization)
