
## Deep research

`POST /v1/research`

Launches a deep iterative search with a multi-step agentic loop "search → read → analyze". Returns a structured report with sections, sources, and optional follow-up questions. The endpoint always works in streaming mode via Server-Sent Events — a single request lasts 10–60 seconds and does not fit into a typical synchronous HTTP timeout.

## Request fields (body)

| Field | Type | Req. | Default | Description |
|------|-----|:-----:|-----------|---------|
| `query` | string | yes | — | Research query text. From 1 to 2000 characters |
| `provider` | string | no | instance default research engine | A provider with research support: `tavily`, `exa`, `you-com`, `linkup`, `perplexity`, or `jina`. The platform-managed `bitrix-search` supports research depending on the instance. For the current list, see [`GET /v1/search/providers`](/docs/search/providers) — the providers with `capabilities.modes.research: true` |
| `maxSteps` | number | no | 5 | Maximum number of agentic-loop steps. From 1 to 10. The adapter caps the value if the external provider does not accept that many steps |
| `lang` | string | no | `auto` | Research language: `ru`, `en`, or `auto` |
| `includeDomains` | string[] | no | `[]` | Search only within the listed domains. Up to 50 host names. Some engines take no more than 20 — on overflow the list is truncated, and the `done` frame carries `ignored_filters` with `includeDomains_truncated` |
| `excludeDomains` | string[] | no | `[]` | Exclude domains. Up to 50 host names. Some engines take no more than 20 — on overflow the list is truncated, and the `done` frame carries `ignored_filters` with `excludeDomains_truncated` |

## Examples

### curl — personal key

```bash
curl -N -X POST https://vibecode.bitrix24.com/v1/research \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "Compare the capabilities of Cursor 3 and Windsurf for AI coding",
    "provider": "tavily",
    "maxSteps": 5,
    "lang": "en"
  }'
```

### curl — OAuth app

```bash
curl -N -X POST https://vibecode.bitrix24.com/v1/research \
  -H "X-Api-Key: YOUR_APP_KEY" \
  -H "Authorization: Bearer USER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "Compare the capabilities of Cursor 3 and Windsurf for AI coding",
    "provider": "tavily",
    "maxSteps": 5,
    "lang": "en"
  }'
```

### JavaScript — personal key

```javascript
const res = await fetch('https://vibecode.bitrix24.com/v1/research', {
  method: 'POST',
  headers: {
    'X-Api-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    query: 'Compare the capabilities of Cursor 3 and Windsurf for AI coding',
    provider: 'tavily',
    maxSteps: 5,
    lang: 'en',
  }),
})

const reader = res.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 })
  const events = buffer.split('\n\n')
  buffer = events.pop() ?? ''
  for (const block of events) {
    const eventLine = block.split('\n').find(l => l.startsWith('event: '))
    const dataLine = block.split('\n').find(l => l.startsWith('data: '))
    if (!eventLine || !dataLine) continue
    const event = eventLine.slice(7)
    const data = JSON.parse(dataLine.slice(6))
    if (event === 'done') {
      console.log('Done:', data.synthesized_answer.slice(0, 200))
      console.log('Sources:', data.sources.length)
    }
  }
}
```

### JavaScript — OAuth app

```javascript
const res = await fetch('https://vibecode.bitrix24.com/v1/research', {
  method: 'POST',
  headers: {
    'X-Api-Key': 'YOUR_APP_KEY',
    'Authorization': 'Bearer USER_SESSION_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    query: 'Compare the capabilities of Cursor 3 and Windsurf for AI coding',
    provider: 'tavily',
    maxSteps: 5,
    lang: 'en',
  }),
})

const reader = res.body.getReader()
```

## Event stream (SSE)

The response arrives as a `text/event-stream`. Each event is a pair `event: <type>` + `data: <JSON>`, separated by a blank line. The stream is guaranteed to end with one of two events: `done` (success) or `error` (failure).

| Event | When it occurs | `data` fields |
|---------|-----------------|-------------|
| `start` | At the start of the request | `research_id`, `provider` |
| `thinking` | Intermediate agent reasoning | `content` — a text fragment |
| `tool_call` | The agent invoked an internal tool | `tool` (`web_search` / `content_extraction` / `external_tool`), `description` |
| `tool_result` | A tool returned a result | `description`, `items_count` |
| `section_complete` | A research section is finished, with its sources attached | `section.title`, `section.content`, `section.sourceIds` |
| `answer_delta` | The next fragment of the final answer | `content` — a chunk of text |
| `done` | Final block | see the "Fields of the `done` event" table below |
| `error` | Failure during processing | `error.code`, `error.message` |

Which intermediate events arrive depends on the provider. None of them is mandatory: receiving `start` and `done` is enough for a normal completion.

## Fields of the `done` event

| Field | Type | Description |
|------|-----|---------|
| `query` | string | The original query text |
| `provider` | string | Identifier of the provider that processed the request (for example, `tavily`, `exa`) |
| `synthesized_answer` | string | The full synthesized research answer |
| `outline` | object \| null | The structure of the report's sections. `null` if the provider does not return a breakdown |
| `outline.sections` | array | Array of sections |
| `outline.sections[].title` | string | Section title |
| `outline.sections[].content` | string | Section text |
| `outline.sections[].sourceIds` | number[] | Identifiers of the sources from `sources[]` that the section relies on |
| `sources` | array | Array of research sources |
| `sources[].id` | number | Sequential source number |
| `sources[].url` | string | Page URL |
| `sources[].title` | string | Page title |
| `sources[].fullContent` | string | Full page text. Length is capped by the provider |
| `sources[].publishedDate` | string \| null | Publication date in ISO 8601 format |
| `follow_up_questions` | string[] \| null | Follow-up questions for deepening the research. `null` if the provider does not return them |
| `reasoning` | array \| null | The agent's reasoning steps. `null` if the provider does not return them |
| `reasoning[].step` | number | Sequential step number |
| `reasoning[].description` | string | Short step description |
| `research_id` | string | Request identifier in the format `wr_<14 digits>_<8 hex>` |
| `upstream_search_id` | string \| null | Identifier at the external provider. Useful for incident investigation |
| `cost_vibes` | number | How many Ꝟ were actually charged |
| `duration_ms` | number | Request processing duration in milliseconds |
| `partial_charge` | boolean | Present when parallel requests exhausted the remaining balance before the end of the current charge. The request completed successfully, but less than the nominal cost was actually charged — the total is in `cost_vibes` |
| `charge_log_failed` | boolean | Present when the usage log could not be written. The result is returned, but no funds are charged (`cost_vibes: 0`) |
| `ignored_filters` | string[] | Optional. The filters that were passed but the engine could not apply. For example, `includeDomains_truncated` when more than 20 domains were passed and the list was truncated to 20 |

## Event-stream example

```
event: start
data: {"research_id":"wr_20260504112304_a1b2c3d4","provider":"tavily"}

event: tool_call
data: {"tool":"web_search","description":"Web research started"}

event: tool_result
data: {"description":"got 8 sources","items_count":8}

event: tool_call
data: {"tool":"content_extraction","description":"reading top sources"}

event: answer_delta
data: {"content":"Cursor 3 is a reworked environment "}

event: answer_delta
data: {"content":"for AI development [1][2]. Windsurf, in turn, "}

event: done
data: {"query":"Compare the capabilities of Cursor 3 and Windsurf for AI coding","provider":"tavily","synthesized_answer":"Cursor 3 is a reworked environment for AI development [1][2]. Windsurf, in turn, focuses on agentic flow [3]…","outline":null,"sources":[{"id":1,"url":"https://cursor.com/blog/cursor-3","title":"Meet the new Cursor","fullContent":"…","publishedDate":"2026-04-15T00:00:00Z"}],"follow_up_questions":null,"reasoning":null,"research_id":"wr_20260504112304_a1b2c3d4","upstream_search_id":"tvly-research-abc","cost_vibes":0,"duration_ms":18230}
```

## Error response example

402 — not enough Ꝟ on the balance:

```json
{
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Insufficient vibes for this research request.",
    "userMessage": "Insufficient Vibe credits — top up your balance or add your own BYOK key (see GET /v1/search/providers).",
    "hint": "Add a BYOK key to search for free — see GET /v1/search/providers",
    "required": 25
  }
}
```

Three fields arrive **inside the `error` object**, not at the top level of the response: `userMessage` — text in the user's language, `hint` — guidance on next steps, `required` — how many Ꝟ this request needs. The same set is used for `INSUFFICIENT_BALANCE` and `BILLING_FROZEN`. All other cases are listed in the error table below.

## Errors

Errors before the SSE stream opens are returned as a regular JSON response. Errors that occur after the connection is established arrive as an `error` event in the stream.

| HTTP | Code | Description |
|------|-----|---------|
| 400 | `INVALID_REQUEST` | Empty `query`, `query` longer than 2000 characters, `maxSteps` outside the 1..10 range, `provider` not from the list of supported providers, `includeDomains` or `excludeDomains` with more than 50 elements |
| 402 | `INSUFFICIENT_BALANCE` | The portal balance has insufficient Ꝟ for a paid provider. Switch to BYOK or top up the balance |
| 402 | `BILLING_FROZEN` | The billing account is frozen |
| 403 | `SCOPE_DENIED` | The key lacks the `vibe:search` scope |
| 404 | `PROVIDER_NOT_FOUND` | A provider with this identifier does not exist or is unavailable |
| 404 | `CREDENTIAL_NOT_FOUND` | There is no USER, PORTAL, or PLATFORM key for the provider |
| 404 | `PROVIDER_DOES_NOT_SUPPORT_RESEARCH` | A provider without research support was requested: `brave`, `z-ai`, or `bitrix-search` when the instance engine has no research mode. Such providers are available only in [`POST /v1/search`](/docs/search/run) |
| 429 | `RATE_LIMITED` | The limit of 20 requests per minute per portal — shared by all of its API keys — was exceeded |
| 503 | `FEATURE_NOT_ENABLED` | Deep research is unavailable on this platform |

Errors inside the SSE stream:

| Code | Description |
|-----|---------|
| `UPSTREAM_ERROR` | The provider returned an error during processing. No charge |
| `PROVIDER_DOES_NOT_SUPPORT_RESEARCH` | A server-side adapter guard — effectively a duplicate of the HTTP 404 code, surfacing when the catalog is out of sync |
| `INTERNAL_ERROR` | Internal server error during processing |

The full list of common API errors — [Errors](/docs/errors).

## Known specifics

**Streaming mode only.** There is no synchronous variant of `/v1/research` without SSE — deep research lasts 10–60 seconds and does not fit into ordinary HTTP proxy timeouts. If a client cannot work with a stream, accumulate events on an intermediary server and deliver them in a batch to the end application.

**Limit of 20 requests per minute per Bitrix24 account.** Lower than for [`POST /v1/search`](/docs/search/run), because a single research request occupies the provider's resources dozens of times longer than an ordinary search. The limit is shared by all API keys of the Bitrix24 account — they draw on a single counter, so spreading the load across several keys does not raise the ceiling. When the limit is exceeded, `429 RATE_LIMITED` is returned.

**The platform research engine's pricing depends on the instance.** [`GET /v1/search/providers`](/docs/search/providers) returns the cost of the `research` mode for the platform engine in the `pricing.research` field. For all BYOK providers the cost on the platform side is 0 Ꝟ — you are billed directly by the provider, on your own account with them.

**The key selection cascade differs from `/v1/search`.** In [`POST /v1/search`](/docs/search/run), when `provider` is omitted, the platform first checks the USER/PORTAL default for any provider and only then falls back to the instance default engine. In research it is different: if the `provider` field is omitted, the server **immediately substitutes the default research engine configured on the instance**, without checking USER/PORTAL defaults for other providers. If that engine has no key at all, `404 CREDENTIAL_NOT_FOUND` is returned — even if the user has a default key for `exa` or another research provider. The USER → PORTAL → PLATFORM cascade applies **within** the chosen provider. To go through a different research provider, pass `provider` explicitly.

**Charging happens only after the stream completes successfully.** The balance is checked before the run starts: if it is insufficient, `402 INSUFFICIENT_BALANCE` is returned without contacting the provider. If the stream breaks with a provider error, the balance does not change — there is nothing to charge for.

## See also

- [Web Search for AI](/docs/search) — section overview, pricing, supported capabilities
- [Search (POST /v1/search)](/docs/search/run) — synchronous and streaming search without the agentic loop
- [Providers](/docs/search/providers) — list of engines with research support
- [Your own keys (BYOK)](/docs/search/credentials) — adding BYOK keys for research providers
- [Keys and authorization](/docs/keys-auth) — personal key and authorization key
- [Errors](/docs/errors) — API error code reference
