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

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

### 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": "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`](/docs/search/run) 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`](/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 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

```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": "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`](/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`, 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 filters** → `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 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](/docs/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`](/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 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

- [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)
