# dat.ai API Documentation The complete documentation of the dat.ai HTTP API and MCP server, one page after another. Source: https://docs.dat.ai --- # Getting Started HTTP API and MCP for browser automation: real browsers, real IP addresses, a task at a time. **Base URL:** `https://llm.dat.ai` ## Getting Started A dat.ai task is a page opened in a real Chrome from a real IP address. There are three ways to describe a task; all of them share one API key, one billing and one `task_id` in `GET /api/v1/browsing/status`. | Group | Endpoints | You send | | --- | --- | --- | | [Browsing](https://docs.dat.ai/browsing) | `POST /api/v1/browsing/{async,sync}` with `task`, `GET .../status`, `GET .../screenshots/...` | A sentence in natural language; an LLM agent drives the browser | | [Custom scripts](https://docs.dat.ai/browsing/custom-scripts) | `POST /api/v1/browsing/{async,sync}` with `script` | Your own Python, executed inside the browser session | | [SERP Search](https://docs.dat.ai/serp) | `POST /api/v1/search/serp/google` | A Google search URL; the answer is the HTML of the results page | | [AI Search](https://docs.dat.ai/ai-search) | `POST /api/v1/search/ai/{chatgpt,gemini}` | A prompt; the answer is the engine's reply with its sources and the stream | | [MCP](https://docs.dat.ai/mcp) | MCP tools | The browsing tools for Cursor, Claude Code and other MCP clients | | [Account & Balance](https://docs.dat.ai/account) | `GET /api/v1/account/balance` | Nothing; the answer is what is left of each quota and whether a task may start | | [Supply](https://docs.dat.ai/supply) | `GET /api/v1/supply/countries` | Nothing; the answer is the countries with browsers online now, each as a range | ## Quick start Create an API key in the [dashboard](https://dash.dat.ai), then run: ```bash curl -sS -X POST "https://llm.dat.ai/api/v1/browsing/sync" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "task": "Open https://example.com and return the page title", "timeout_sec": 120 }' ``` The answer, twenty seconds later: ```json { "task_id": "web:fa8cf4c6-2e74-4e55-aa4b-76b74971043e", "success": true, "payload": "{\"success\":true,\"result\":\"The page title of https://example.com is 'Example Domain'.\",\"steps\":2,\"no_result\":false,\"traffic_in\":473,\"traffic_out\":722}", "step_cnt": 2, "execution_time_ms": 21889, "traffic_in": 473, "traffic_out": 722, "traffic_total": 1195 } ``` `payload` is a JSON string; its `result` is the agent's answer. Without a key the same call answers **401**, and a task that failed comes back with `success: false` and the reason in `error`: ```json { "error": "Authentication required: missing Authorization header. Please provide Authorization header with Bearer token format: 'Authorization: Bearer '" } ``` ```json { "task_id": "web:...", "success": false, "error": "RuntimeError: the task did not finish within 120000 ms" } ``` ## Task lifecycle 1. **Create** — `POST /api/v1/browsing/async` returns a `task_id` at once; `POST .../sync` waits for the result instead 2. **Poll** — `GET /api/v1/browsing/status?task_id=...` until `status` is `completed`, `failed` or `cancelled` 3. **Result** — the status response then carries `success`, `payload` and, on failure, `error` The SERP and AI search endpoints hide the lifecycle: one request, the page or the reply in the answer. ## For agents The whole documentation is one Markdown file at [`/llms-full.txt`](https://docs.dat.ai/llms-full.txt), with an index at [`/llms.txt`](https://docs.dat.ai/llms.txt). **Copy all docs** in the top bar puts that file on the clipboard; **Copy page** on any page does the same for that page alone. Paste it into an agent's context and it knows the API. ## Next steps - [Authentication](https://docs.dat.ai/authentication) — API keys, headers, error format, billing - [Browsing overview](https://docs.dat.ai/browsing) — country selection, screenshots, retention - [Custom scripts](https://docs.dat.ai/browsing/custom-scripts) — the Python your script can run in the browser - [SERP Search](https://docs.dat.ai/serp) — results pages that are never a captcha; [Google](https://docs.dat.ai/serp/google) - [AI Search](https://docs.dat.ai/ai-search) — a prompt to an answer engine, the reply with its sources; [ChatGPT](https://docs.dat.ai/ai-search/chatgpt), [Gemini](https://docs.dat.ai/ai-search/gemini) - [Supply](https://docs.dat.ai/supply) — the countries with browsers online right now, each as a range - [MCP](https://docs.dat.ai/mcp) — connect Cursor, Claude Code and other MCP clients --- # Authentication ## Authentication Every endpoint requires a Bearer API key. The sections below cover headers, the error format and billing. ## API keys ```http Authorization: Bearer YOUR_API_KEY ``` Create and manage keys in the [dashboard](https://dash.dat.ai) under **API Keys**. Keep keys secret; do not commit them to repositories. The same keys authenticate the [MCP](https://docs.dat.ai/mcp) server. A missing or invalid key gets **401**. ## Common headers | Header | Required | Description | | --- | --- | --- | | `Authorization` | Yes | `Bearer ` | | `Content-Type` | For JSON bodies | `application/json` | | `X-Request-ID` | No | Your own request id; it is written to the server logs next to the task, which helps when you ask about a task later | ## Error format Every error is a JSON object with one field, `error`; a 402 adds `code` and `exhausted`: ```json // 401 { "error": "Authentication required: missing Authorization header. Please provide Authorization header with Bearer token format: 'Authorization: Bearer '" } // 400 { "error": "Invalid input data" } // 402 { "error": "Insufficient balance: the browser_seconds quota is used up and there is no USD balance to cover overage; top up in the dashboard (https://dash.dat.ai) or check GET /api/v1/account/balance", "code": "insufficient_balance", "exhausted": "browser_seconds" } // 404 { "error": "Task not found" } ``` ## HTTP status codes | Code | When | | --- | --- | | **400** | Invalid JSON, an unknown field, a missing required field, a value out of range | | **401** | Missing or invalid API key | | **402** | No active subscription, or insufficient balance | | **403** | Access denied: another account's task, or `script` / `uv_args` not enabled for this key | | **404** | Task or screenshot not found | | **405** | Wrong HTTP method | | **502** | SERP only: no results page could be obtained | | **504** | A `sync` call outlived its wait; the task itself may still finish | ## Billing A task is billed only when it succeeded and produced a result. A task that failed, timed out or was refused by the site costs nothing, and a SERP request pays only for the one attempt that returned the page. Traffic figures (`traffic_in`, `traffic_out`, `traffic_total`) come back with every finished task. When the account balance is too low, or the subscription has expired, the server returns **402** naming the quota that ran out; top up in the [dashboard](https://dash.dat.ai). [`GET /api/v1/account/balance`](https://docs.dat.ai/account) says where the account stands before that, and every task reply carries a `balance_warning` while a quota is used up or nearly so. --- # Account & Balance ## Account & Balance Every task draws on three quotas of the account: **`steps`**, the actions an LLM agent takes (a natural-language `task` only), **`traffic_bytes`**, the bytes the browser moved, and **`browser_seconds`**, the time a browser was busy with the task. A task is billed only when it succeeded (see [Billing](https://docs.dat.ai/authentication#billing)). When a quota reaches zero and there is no USD balance to cover overage, creating a task is refused with **402**. This endpoint says where the account stands before that happens, and the same warning rides on every task reply, so a client hears about a low balance while it can still do something about it. ## GET /api/v1/account/balance ```bash curl -sS "https://llm.dat.ai/api/v1/account/balance" \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Response (200)**, an account whose browser time has run out: ```json { "account": { "uid": "acc_...", "status": "active" }, "subscription": { "status": "active", "plan": "free", "plan_name": "Free" }, "balance": { "steps": { "remaining": 28, "included": 200, "unit": "action" }, "traffic_bytes": { "remaining": 169218973, "included": 500000000, "unit": "gigabyte" }, "browser_seconds": { "remaining": 0, "included": 1800, "unit": "hour" }, "usd": { "remaining": 0 } }, "can_run": { "task": false, "script": false, "search": false }, "blocked": "insufficient balance: the browser_seconds quota is used up and the USD balance is 0.00", "exhausted": ["browser_seconds"], "low": [], "warning": "browser_seconds quota used up and the USD balance is 0.00; tasks that need it are refused with 402. Top up in the dashboard (https://dash.dat.ai); GET /api/v1/account/balance has the figures." } ``` | Field | Meaning | | --- | --- | | `balance..remaining` | What is left of the quota, in its own unit: actions, bytes, seconds | | `balance..included` | What the plan included for the period; `0` when the plan states no amount | | `balance..unit` | The unit the quota is priced in | | `balance.usd.remaining` | The USD balance in dollars. Overage is charged here, so it can be negative | | `can_run.task` | Whether a natural-language `task` may start now: `steps`, `traffic_bytes` and `browser_seconds` above zero, or a positive USD balance | | `can_run.script` | The same for a [custom script](https://docs.dat.ai/browsing/custom-scripts), which spends no steps | | `can_run.search` | The same for a [SERP](https://docs.dat.ai/serp) or [AI search](https://docs.dat.ai/ai-search) request, which spends no steps either | | `blocked` | Why a task may not start, when it may not: the quota, or the account or subscription state | | `exhausted` | The quotas at zero | | `low` | The quotas under a tenth of what the plan included, not yet at zero | | `warning` | The sentence the task replies carry, empty when every quota is comfortable | An account with room in every quota answers with empty `exhausted` and `low`, no `blocked` and no `warning`: ```json { "account": { "uid": "acc_...", "status": "active" }, "subscription": { "status": "active", "plan": "free", "plan_name": "Free" }, "balance": { "steps": { "remaining": 173, "included": 200, "unit": "action" }, "traffic_bytes": { "remaining": 412887340, "included": 500000000, "unit": "gigabyte" }, "browser_seconds": { "remaining": 1522, "included": 1800, "unit": "hour" }, "usd": { "remaining": 0 } }, "can_run": { "task": true, "script": true, "search": true }, "exhausted": [], "low": [] } ``` **Errors:** 401 (missing or invalid API key): ```json { "error": "Authentication required: missing Authorization header. Please provide Authorization header with Bearer token format: 'Authorization: Bearer '" } ``` ## The warning on task replies The same `warning` comes back with every task while it is not empty: - `balance_warning` in the JSON of `POST /api/v1/browsing/async` and `POST /api/v1/browsing/sync`; - the `X-Balance-Warning` header on every task reply, the [SERP page](https://docs.dat.ai/serp/google) included; - `balance_warning` in the result of every [MCP](https://docs.dat.ai/mcp) tool that starts a task. It is absent while every quota is above a tenth of what the plan included. A client that watches for it sees a session's end coming several tasks ahead; a client that does not still gets the plain 402. ## When a task is refused ```json { "error": "Insufficient balance: the browser_seconds quota is used up and there is no USD balance to cover overage; top up in the dashboard (https://dash.dat.ai) or check GET /api/v1/account/balance", "code": "insufficient_balance", "exhausted": "browser_seconds" } ``` The refusal is decided when the task is created; a task already running is never stopped for balance. Top up in the [dashboard](https://dash.dat.ai), or wait for the plan's next period. ## MCP The `account_balance` tool returns this endpoint's JSON. An agent that runs long sessions can call it first and plan the session against `balance` and `can_run`; every task tool also returns `balance_warning` when there is one. --- # Supply ## Supply The fleet is real browsers on real machines, and where they are changes by the minute. A task's `filter.country_iso_included` names the countries it may run from (see [Countries](https://docs.dat.ai/browsing/reference#countries)); a filter that names a country without browsers waits for one until the task's budget runs out. This endpoint says where the browsers are right now. ## GET /api/v1/supply/countries Where a task may run right now: every country with at least one browser online, the biggest first, each with the **range** its browsers fall in rather than a count: `1-10`, `10-50`, `50-100`, `100-500`, `500-1000`, `1000-5000`, `5000-10000`, `10000+` (the lower bound included, the upper not). The reading is refreshed once a minute; `updated_at` says when it was taken. ```bash curl -sS "https://llm.dat.ai/api/v1/supply/countries" \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Response (200)**, cut to the first entries: ```json { "countries": [ { "country_iso": "RU", "nodes": "500-1000" }, { "country_iso": "KZ", "nodes": "500-1000" }, { "country_iso": "UA", "nodes": "100-500" }, { "country_iso": "IN", "nodes": "100-500" }, { "country_iso": "PK", "nodes": "100-500" }, { "country_iso": "UZ", "nodes": "100-500" }, { "country_iso": "US", "nodes": "50-100" }, { "country_iso": "NG", "nodes": "50-100" }, ... ], "updated_at": "2026-09-18T12:19:27Z" } ``` 126 countries had browsers online when this was taken; `RU` is listed here and still excluded from a task unless `country_iso_included` names it. **Errors:** 401 (missing or invalid API key), 503 when the fleet cannot be read at the moment: ```json { "error": "the list of countries is not available right now" } ``` --- # Browsing Overview ## Browsing Browser automation: a natural-language task carried out by an LLM agent, or a [custom Python script](https://docs.dat.ai/browsing/custom-scripts) of your own. Either way the page opens in a real Chrome from a real IP address. ## Endpoints | Method | Path | Description | | --- | --- | --- | | POST | `/api/v1/browsing/async` | Create a task, return `task_id` at once | | POST | `/api/v1/browsing/sync` | Create a task, wait for the result | | GET | `/api/v1/browsing/status` | Status and result by `task_id` | | GET | `/api/v1/browsing/screenshots/{task_id}/{filename}` | Download a screenshot | See the [API Reference](https://docs.dat.ai/browsing/reference) for request and response details. ## Task IDs Browsing tasks use the prefix `web:` (e.g. `web:550e8400-e29b-41d4-a716-446655440000`). ## Request body (summary) Either `task` (natural language) or `script` (custom Python) is required. ```json { "task": "Open https://example.com and summarize the page", "filter": { "country_iso_included": ["DE"] }, "screenshots": { "mode": "final_only", "full_page": true }, "fanout": 1, "timeout_sec": 600 } ``` | Field | Description | | --- | --- | | `task` | Natural-language instruction | | `script` | Custom Python, see [Custom Scripts](https://docs.dat.ai/browsing/custom-scripts); enabled per API key | | `timeout_sec` | The task's budget in seconds, at most `1200` (20 minutes). Omitted: 10 minutes | | `fanout` | How many browsers run the task at once, 1–10, default 1; the first result wins | | `filter.country_iso_included` | The countries the task may run from (`["DE", "US"]`); [`GET /api/v1/supply/countries`](https://docs.dat.ai/supply) says which have browsers now | | `filter.country_iso_excluded` | The countries it must not run from; not together with `country_iso_included`. `RU` is always excluded | | `session_key` | Every task of a session runs from a different browser and IP address, which is how a sample of different vantage points is gathered | | `screenshots.mode` | `final_only`, `every_step` or `on_navigation` | | `screenshots.full_page` | Whole page rather than the viewport | | `emulate_human` | Human-like input: slower, more trusted by the site | | `max_steps` | Cap on the agent's steps, 1–500; reaching it fails the task with what it found so far | | `ttl_days` | How long the request and result are kept, 1–30, default 7 | | `uv_args` | Extra Python packages for a custom script | `POST /api/v1/browsing/async` answers with the task's id at once; `sync` with the result: ```json { "task_id": "web:fa8cf4c6-2e74-4e55-aa4b-76b74971043e" } ``` ```json { "task_id": "web:fa8cf4c6-2e74-4e55-aa4b-76b74971043e", "success": true, "payload": "{\"success\":true,\"result\":\"The page title of https://example.com is 'Example Domain'.\",\"steps\":2,\"no_result\":false,\"traffic_in\":473,\"traffic_out\":722}", "step_cnt": 2, "execution_time_ms": 21889, "traffic_in": 473, "traffic_out": 722, "traffic_total": 1195 } ``` A request the server will not take answers with **400** and the reason, a task that failed with `success: false` and `error`: ```json { "error": "task or script is required" } ``` ```json { "task_id": "web:8b733a4f-9b21-464f-b4c4-176f0a55cdc4", "success": false, "error": "RuntimeError: the task did not finish within 1 steps", "payload": "{\"success\":false,\"error_type\":\"RuntimeError\",\"error_message\":\"the task did not finish within 1 steps\",\"max_steps\":1,\"steps\":1,\"result\":\"The task could not be completed because the step limit was reached before any navigation or data collection could occur. No page titles were retrieved.\",\"traffic_in\":0,\"traffic_out\":0}", "step_cnt": 1, "execution_time_ms": 10250 } ``` ## Status values `queued` → `assigned` → `completed` | `failed` | `cancelled` `assigned` covers the whole run: a browser has been chosen and is working. When the task is over, the status response also carries `success`, `payload`, `error`, `execution_time_ms` and the traffic the task spent (`traffic_in`, `traffic_out`, `traffic_total`). A failed task says why in `error`, whether a browser reported the failure or none ever answered. See [the reference](https://docs.dat.ai/browsing/reference#get-apiv1browsingstatus). ## Sizing a task An agent task is paid for in steps and in time, and both are bounded: `steps` from the account's quota, time by `timeout_sec` (10 minutes when omitted, 20 at most). What a task costs, from tasks run on the service: | The task | Steps | Time | | --- | --- | --- | | One page: open, read, answer | 3–10 | under a minute | | One page with a form or a search on it | 10–30 | 1–3 minutes | | Several pages, one after another | 5–10 per page | about a minute per page | | Eight or nine pages in one task | 40–90 | 5–10 minutes | So a task over eight or nine pages needs `timeout_sec` of 600 or more, and at the default budget it fails about half the time with `the task did not finish within N ms`. Three things make long tasks reliable: - **Split them.** One task per page, or per two or three, started with `async` at once and polled with `status`. Each is short, each failure is its own, and the account pays only for the tasks that succeeded. - **Give them time and a cap.** `timeout_sec: 900` and `max_steps` sized to the work, so a task that wanders is stopped with what it found rather than with nothing. - **Run two browsers.** `fanout: 2` on a long task: a browser whose computer goes to sleep loses the task, and the second browser is what makes that a delay rather than a failure. A [custom script](https://docs.dat.ai/browsing/custom-scripts) spends no steps and is bounded by `timeout_sec` alone; the same advice on splitting applies. ## How long the result is kept The request and the result are kept for 7 days by default, up to 30 with `ttl_days`; screenshot files for 5 days. See the [reference](https://docs.dat.ai/browsing/reference#how-long-the-result-is-kept). --- # Browsing API Reference ## POST /api/v1/browsing/async Creates a browsing task and returns `task_id` at once. **Request body** (JSON): | Field | Type | Required | Description | | --- | --- | --- | --- | | `task` | string | One of `task` / `script` | Natural-language instruction, carried out by an LLM agent in the browser | | `script` | string | One of `task` / `script` | Custom Python instead of a task; enabled per API key. See [Custom Scripts](https://docs.dat.ai/browsing/custom-scripts) | | `timeout_sec` | uint32 | No | The task's budget in seconds, at most `1200` (20 minutes); more is **400**. Omitted: 10 minutes. The legacy `timeout` is in milliseconds | | `fanout` | uint32 | No | How many browsers run the task at once, 1–10, default 1; the first result wins | | `filter.country_iso_included` | string[] | No | The countries the task may run from, ISO 3166-1 alpha-2 (`["DE", "US"]`). See [Countries](#countries) | | `filter.country_iso_excluded` | string[] | No | The countries the task must not run from. **Not together with `country_iso_included`**: send one list or the other | | `session_key` | string | No | Up to 128 characters. Every task of a session runs from a different browser and IP address | | `ttl_days` | uint32 | No | How many days the request and result are kept, 1–30. Omitted: 7. See [How long the result is kept](#how-long-the-result-is-kept) | | `screenshots.mode` | string | With `screenshots` | `final_only`, `every_step` or `on_navigation`. See [Screenshots](#screenshots) | | `screenshots.full_page` | bool | No | Whole page rather than the viewport | | `emulate_human` | bool | No | Human-like input: the pointer travels to its target and text is typed character by character. Slower, more trusted by the site. See [emulate_human](#emulate_human) | | `max_steps` | uint32 | No | Cap on the agent's steps for a `task`, 1–500. Reaching it fails the task with what the agent had by then. See [max_steps](#max_steps) | | `uv_args` | string | No | Extra `uv run` arguments for a custom script, usually packages; up to 1024 characters. Gated by the same flag as `script` | Unknown JSON fields are rejected (**400**), so a typo in a name does not pass silently. **Response (200):** ```json { "task_id": "web:550e8400-e29b-41d4-a716-446655440000" } ``` While a quota is used up or under a tenth of what the plan included, the response also carries `balance_warning`, and the same sentence in the `X-Balance-Warning` header. See [Account & Balance](https://docs.dat.ai/account). ```bash curl -sS -X POST "https://llm.dat.ai/api/v1/browsing/async" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "task": "Open https://example.com and summarize the page", "filter": { "country_iso_included": ["DE"] }, "screenshots": { "mode": "final_only", "full_page": true }, "fanout": 1, "timeout_sec": 600 }' ``` ### Countries `filter` says where the task may run, one way or the other: ```json { "filter": { "country_iso_included": ["DE", "AT", "CH"] } } ``` ```json { "filter": { "country_iso_excluded": ["CN", "TW"] } } ``` `country_iso_included` restricts the task to the listed countries; `country_iso_excluded` keeps it out of them and lets it run anywhere else. **The two are mutually exclusive**: a request with both gets **400**. Codes are ISO 3166-1 alpha-2, case-insensitive. Without a filter the task runs from any country except `RU`, which is excluded unless `country_iso_included` names it. Which countries have browsers right now, and roughly how many, is what [`GET /api/v1/supply/countries`](https://docs.dat.ai/supply) says: a filter that names a country without browsers waits for one until the task's budget runs out. ### emulate_human By default a click is delivered at its target instantly and a string is entered in one go. With `emulate_human` the pointer travels to the target along a curve, the button is held for a human interval, and text is typed character by character with a drifting delay. The task takes about a quarter longer, and the site sees a person rather than a script: worth it wherever the interaction itself is scored, such as forms, logins and interactive apps. ### max_steps An agent works in steps: look at the page, decide, act. A task's other limit is `timeout_sec`, and the two say different things. When the budget runs out the task fails with `the task did not finish within N ms`; when the steps run out it fails with `the task did not finish within N steps`. Both replies carry what the agent had by then: `result` (the answer so far), `steps`, and a screenshot of where it stopped when screenshots were asked for. Without `max_steps` the agent stops at its own limit of 500 steps, which in practice means the budget is reached first. Each step costs one action of the account's `steps` quota and about six seconds. See [Sizing a task](https://docs.dat.ai/browsing#sizing-a-task) for what that means for a task over several pages. ### How long the result is kept A task's **request** (the `task` text or `script`) and its **result** (`payload`) are kept for `ttl_days` after the task is created, 7 days unless you say otherwise, 30 at most, and then deleted. Collect the result within that window; it is not recoverable afterwards. The task itself is not deleted: `status` keeps answering with the status, timings and traffic figures, and once the result is gone it says so with `"payload_expired": true` instead of a `payload`. The day the result goes away is in `payload_expires_on`. Screenshot files are kept for **5 days**. --- ## POST /api/v1/browsing/sync Same request body as async. Waits for the result in the same HTTP request: the task's own budget (`timeout_sec`, 10 minutes when omitted) plus a margin for the result to arrive. When nothing came in that time the answer is **504** `{"error": "Task timeout"}`, while the task itself may still finish and can be read from `status`. Use sync for short tasks; anything that may take more than a minute is better started with async. **Response (200)**, a task that opened example.com and returned its title: ```json { "task_id": "web:fa8cf4c6-2e74-4e55-aa4b-76b74971043e", "success": true, "payload": "{\"success\":true,\"result\":\"The page title of https://example.com is 'Example Domain'.\",\"steps\":2,\"no_result\":false,\"traffic_in\":473,\"traffic_out\":722}", "step_cnt": 2, "execution_time_ms": 21889, "traffic_in": 473, "traffic_out": 722, "traffic_total": 1195 } ``` `payload` is a JSON string; parsed, its `result` is the agent's answer and `steps` how many steps it took. On failure `success` is `false` and `error` says why, with `payload` carrying what the browser reported. A task that ran out of `max_steps`: ```json { "task_id": "web:8b733a4f-9b21-464f-b4c4-176f0a55cdc4", "success": false, "error": "RuntimeError: the task did not finish within 1 steps", "payload": "{\"success\":false,\"error_type\":\"RuntimeError\",\"error_message\":\"the task did not finish within 1 steps\",\"max_steps\":1,\"steps\":1,\"result\":\"The task could not be completed because the step limit was reached before any navigation or data collection could occur. No page titles were retrieved.\",\"traffic_in\":0,\"traffic_out\":0}", "step_cnt": 1, "execution_time_ms": 10250 } ``` A `sync` call that outlived its wait answers **504** `{ "error": "Task timeout" }`; the task may still finish, poll `status`. While a quota is used up or nearly so, the response also carries `balance_warning` (see [Account & Balance](https://docs.dat.ai/account)). **Traffic fields:** | Field | Meaning | | --- | --- | | `traffic_in` | Bytes the task downloaded | | `traffic_out` | Bytes the task uploaded | | `traffic_total` | `traffic_in` + `traffic_out`, the figure your usage is measured against | Traffic is reported only for a task that finished and produced a result. The three fields are omitted when the run failed or the traffic could not be measured. --- ## GET /api/v1/browsing/status Status and result by `task_id`. **Query:** `task_id` (required) ```bash curl -sS "https://llm.dat.ai/api/v1/browsing/status?task_id=web:YOUR_TASK_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Response (200)**, a finished task: ```json { "task_id": "web:fa8cf4c6-2e74-4e55-aa4b-76b74971043e", "status": "completed", "created_at": "2026-09-18T09:40:12Z", "assigned_at": "2026-09-18T09:40:12Z", "started_at": "2026-09-18T09:40:14Z", "completed_at": "2026-09-18T09:40:36Z", "payload_expires_on": "2026-09-25", "success": true, "payload": "{\"success\":true,\"result\":\"The page title of https://example.com is 'Example Domain'.\",\"steps\":2,\"no_result\":false,\"traffic_in\":473,\"traffic_out\":722}", "step_cnt": 2, "execution_time_ms": 21889, "traffic_in": 473, "traffic_out": 722, "traffic_total": 1195 } ``` A task still running answers with the status alone: ```json { "task_id": "web:42efa955-d7cf-47bf-ac81-d516edd984ec", "status": "assigned", "created_at": "2026-09-18T09:25:45Z", "assigned_at": "2026-09-18T09:25:45Z", "payload_expires_on": "2026-09-25" } ``` | `status` | Meaning | | --- | --- | | `queued` | Waiting for a browser | | `assigned` | A browser has been chosen and is working on the task | | `completed` | Finished; `success`, `payload`, `execution_time_ms` are present | | `failed` | Failed; `success: false` and `error` are present, and `payload` when a browser reported one | | `cancelled` | Cancelled | A failed task carries the last browser's reply: the exception a script raised, the budget the agent ran out of, the site's refusal. A task no browser ever answered for — the browser's node went offline and did not come back — fails within about a minute with `error: "the browser's node disconnected before reporting a result"`; a task that ran past its budget with no reply says so too. `status_reason` is `error` or `timeout`. A task that ran out of `max_steps`, with what the agent had by then: ```json { "task_id": "web:8b733a4f-9b21-464f-b4c4-176f0a55cdc4", "status": "failed", "status_reason": "error", "created_at": "2026-09-18T09:41:24Z", "assigned_at": "2026-09-18T09:41:24Z", "completed_at": "2026-09-18T09:41:35Z", "payload_expires_on": "2026-09-25", "success": false, "error": "RuntimeError: the task did not finish within 1 steps", "payload": "{\"success\":false,\"error_type\":\"RuntimeError\",\"error_message\":\"the task did not finish within 1 steps\",\"max_steps\":1,\"steps\":1,\"result\":\"The task could not be completed because the step limit was reached before any navigation or data collection could occur. No page titles were retrieved.\",\"traffic_in\":0,\"traffic_out\":0}", "step_cnt": 1, "execution_time_ms": 10250, "traffic_in": 0, "traffic_out": 0, "traffic_total": 0 } ``` A custom script that raised, and a task whose browser was lost: ```json { "task_id": "web:...", "status": "failed", "status_reason": "error", "success": false, "error": "NameError: name 'page' is not defined", "payload": "{\"success\": false, \"error_type\": \"NameError\", \"error_message\": \"name 'page' is not defined\", \"traceback\": \"...\"}", "execution_time_ms": 4411 } ``` ```json { "task_id": "web:...", "status": "failed", "status_reason": "error", "success": false, "error": "the browser's node disconnected before reporting a result" } ``` `payload` is the task's answer as a JSON **string**; parse it to get at the `result` your task or script produced. `payload_expires_on` is the day after which the result is deleted (see [How long the result is kept](#how-long-the-result-is-kept)); past that day the response carries `"payload_expired": true` and no `payload`, everything else stays. **Errors:** 400 (no `task_id`), 401, 403 (the task belongs to another account), 404 (task not found): ```json { "error": "task_id is required" } { "error": "Task not found" } ``` --- ## Screenshots `screenshots` in the create request asks for captures of the page: | `mode` | What is captured | | --- | --- | | `final_only` | The page as the task ends. The only mode that applies to custom scripts | | `every_step` | After every agent step | | `on_navigation` | After every step that changed the URL | `full_page: true` captures the whole page rather than the viewport. Another value of `mode` is rejected with **400**. The images are stored on the server, and the `screenshots` list in the task's `payload` carries a `url` for each of them: ### GET /api/v1/browsing/screenshots/`task_id`/`filename` ```bash curl -sS "https://llm.dat.ai/api/v1/browsing/screenshots/web%3AYOUR_TASK_ID/screenshot-01.png" \ -H "Authorization: Bearer YOUR_API_KEY" \ -o screenshot.png ``` - `task_id` and `filename` are URL-encoded in the path - Returns the image (`image/png`) on success (**200**) - **403** if the task belongs to another account - **404** if the task or the file does not exist Screenshots exist only when `screenshots` was set in the create request. Files are kept for **5 days**, then deleted. --- ## Errors All browsing endpoints return `{ "error": "..." }`. As the server gives them: ```json // 400 { "error": "task or script is required" } { "error": "timeout_sec must be less than or equal to 1200" } { "error": "filter must specify only one of country_iso_included or country_iso_excluded" } { "error": "task_id is required" } { "error": "Invalid input data" } // 401 { "error": "Authentication required: missing Authorization header. Please provide Authorization header with Bearer token format: 'Authorization: Bearer '" } // 402 { "error": "Insufficient balance: the browser_seconds quota is used up and there is no USD balance to cover overage; top up in the dashboard (https://dash.dat.ai) or check GET /api/v1/account/balance", "code": "insufficient_balance", "exhausted": "browser_seconds" } // 403 { "error": "custom browsing scripts are not enabled for this API key" } // 404 { "error": "Task not found" } // 504 (sync) { "error": "Task timeout" } ``` | Status | Cause | | --- | --- | | **400** | Invalid JSON, an unknown field, neither `task` nor `script`, `timeout_sec` over 1200, `max_steps` over 500 or with a `script`, missing `task_id`, `country_iso_included` together with `country_iso_excluded` | | **401** | Missing or invalid API key | | **402** | No active subscription, or insufficient balance. The body names the quota: `{"error": "...", "code": "insufficient_balance", "exhausted": "browser_seconds"}`. See [Account & Balance](https://docs.dat.ai/account) | | **403** | Task belongs to another account, or `script` / `uv_args` not enabled for this API key | | **404** | Task or screenshot not found | | **405** | Wrong HTTP method for the endpoint | | **504** | `sync` waited past the timeout; the task itself may still finish, poll `status` | --- # Custom Browsing Scripts ## Custom Scripts A custom script replaces the agent with your own Python. It runs inside the browser session the task was given, with the page already open in a real Chrome, and whatever it returns becomes the task's `result`. Use it when you need exact automation rather than a described intent: open pages, wait for content, run JavaScript, fill forms, walk through pagination, follow links, read cookies, take screenshots, return structured JSON. This page is the whole contract: what the script receives, what it can do, what it must return, and how things fail. The recipes at the end are meant to be copied. ## Getting access Custom scripts are off by default and enabled per API key; the same approval covers `uv_args`. Ask in the [dashboard](https://dash.dat.ai) chat. Until it is granted, `script` gets **403**: ```json { "error": "custom browsing scripts are not enabled for this API key" } ``` ## Quick start `script` is the **body of an async function**, not a Python file. Use `await` directly: ```python await browser.navigate_to("https://example.com") return {"ok": True, "title": await browser.get_current_page_title()} ``` Sent as a request: ```bash curl -sS -X POST "https://llm.dat.ai/api/v1/browsing/async" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "script": "await browser.navigate_to(\"https://example.com\")\nreturn {\"ok\": True, \"title\": await browser.get_current_page_title()}", "timeout_sec": 300 }' ``` The answer is a `task_id`; poll `GET /api/v1/browsing/status?task_id=...` until `status` is `completed` or `failed`, then read `payload`: the script's return value is its `result`. ```json { "task_id": "web:3f0c1d2e-9a7b-4c1d-8e2f-5a6b7c8d9e0f" } ``` ```json { "task_id": "web:3f0c1d2e-9a7b-4c1d-8e2f-5a6b7c8d9e0f", "status": "completed", "created_at": "2026-09-18T09:40:12Z", "assigned_at": "2026-09-18T09:40:12Z", "started_at": "2026-09-18T09:40:14Z", "completed_at": "2026-09-18T09:40:31Z", "payload_expires_on": "2026-09-25", "success": true, "payload": "{\"success\": true, \"result\": {\"ok\": true, \"title\": \"Example Domain\"}}", "execution_time_ms": 17200, "traffic_in": 1930, "traffic_out": 640, "traffic_total": 2570 } ``` A script that raised fails the task, and `error` is the exception (the full traceback is in `payload`, see [Errors](#errors)); a key without the feature gets **403**: ```json { "task_id": "web:...", "status": "failed", "status_reason": "error", "success": false, "error": "NameError: name 'page' is not defined", "payload": "{\"success\": false, \"error_type\": \"NameError\", \"error_message\": \"name 'page' is not defined\", \"traceback\": \"...\"}", "execution_time_ms": 4411 } ``` ```json { "error": "custom browsing scripts are not enabled for this API key" } ``` Through [MCP](https://docs.dat.ai/mcp) the same `script` goes into `browsing_run` or `browsing_start`. Do not wrap the code in a function of your own and do not call `asyncio.run(...)`: the script already runs inside an event loop. Indentation is the usual Python indentation of a function body; the first line starts at column zero. ## How a script runs - **One task, one browser session.** The browser is started before the first line runs: `await browser.navigate_to(...)` works at once, `await browser.start()` is not needed. When the script returns, the session ends; nothing carries over to the next task. - **The budget is `timeout_sec`** (default 10 minutes, at most 20). It covers the whole script, waits included. When it runs out the task fails with `the script did not finish within N ms`, and nothing the script had collected is returned: keep every wait bounded and return early rather than late. - **The return value is the result.** It has to be JSON-serialisable (see [Returning results](#returning-results)). Output written with `print` is not returned; put diagnostics in the result. - **An exception fails the task.** The payload then carries the exception's type, message and traceback (see [Errors](#errors)). Catch what you expect and return it as data. - **Every field of the browsing request applies**: `timeout_sec`, `fanout`, `session_key`, `filter`, `screenshots`, `emulate_human`, `ttl_days`, `uv_args`. There is no limit on the script's size. ## What the script sees | Name | What it is | | --- | --- | | `browser` | The browser session, a [browser-use](https://docs.browser-use.com/) `BrowserSession`: navigation, tabs, cookies, headers, screenshots, the page's text | | `session` | The same object under its browser-use name; scripts written against either work | | `human` | Hand-like pointer and keyboard input, see [Interacting with the page](#interacting-with-the-page) | | `asyncio`, `json`, `os`, `sys` | Already imported. Any other standard-library module can be imported in the script | Third-party packages come through `uv_args`, see [Python dependencies](#python-dependencies). The user agent, window size and the usual `navigator` and `screen` properties are already consistent with the browser the task runs in. Do not override the user agent in the script: a value that disagrees with the actual browser is easier to detect than the default. ## Navigating ```python await browser.navigate_to("https://example.com/products?page=2") url = await browser.get_current_page_url() title = await browser.get_current_page_title() ``` `navigate_to` returns once the page has loaded. Content that the page renders afterwards with its own scripts is not guaranteed to be there yet; wait for it (see [Waiting for content](#waiting-for-content)). Google-style redirects and some consent flows make the navigation report `ERR_ABORTED` although the page did open. Treat that one as harmless: ```python async def go(url): try: await browser.navigate_to(url) except Exception as exc: if "ERR_ABORTED" not in str(exc): raise ``` Several pages in one task is simply several calls in a row, in one loop if you like; the session, its cookies and its history persist between them. ### Tabs ```python await browser.navigate_to("https://example.com/a") await browser.navigate_to("https://example.com/b", new_tab=True) # opens and focuses a second tab tabs = await browser.get_tabs() # [TabInfo(url=..., title=..., target_id=...), ...] await browser.get_or_create_cdp_session(target_id=tabs[0].target_id, focus=True) # back to the first ``` Everything below (`evaluate`, `human`, screenshots) acts on the focused tab. ## Reading the page ### Text and interactive elements ```python text = await browser.get_state_as_text() ``` The page's visible text, with the clickable and typeable elements marked and numbered (`[21]`), the way an agent sees it. Good for a quick look or for feeding to a model; for structured data use JavaScript. ### Running JavaScript The complete way, through the CDP session. Define the helper once and use it everywhere: ```python cdp = await browser.get_or_create_cdp_session() async def evaluate(expression): result = await cdp.cdp_client.send.Runtime.evaluate( params={"expression": expression, "returnByValue": True, "awaitPromise": True}, session_id=cdp.session_id, ) return result.get("result", {}).get("value") await browser.navigate_to("https://example.com") data = await evaluate(""" JSON.stringify({ title: document.title, h1: document.querySelector("h1")?.innerText || null, links: Array.from(document.querySelectorAll("a")).map(a => ({text: a.innerText.trim(), href: a.href})), }) """) return {"ok": True, "data": json.loads(data)} ``` The expression is evaluated as is, in the page, with full access to the DOM. Rules of the road: - An object literal needs `JSON.stringify(...)` (or a wrapper: `(() => ({...}))()`); `returnByValue` brings plain values back, DOM nodes and functions do not survive the trip. - A promise is awaited (`awaitPromise`), so `await fetch(...)` inside an async IIFE works: `(async () => { const r = await fetch("/api/items"); return JSON.stringify(await r.json()); })()`. - Long strings come back whole; keep the amount reasonable, it all lands in the result. The short way, for a small expression with arguments: ```python page = await browser.get_current_page() count = await page.evaluate("(sel) => document.querySelectorAll(sel).length", ".product") ``` `page.evaluate` takes an arrow function (`(...args) => ...`) and the arguments after it; a bare expression or an `async` function is refused. When in doubt, use the CDP helper. ### Waiting for content There is no "wait for selector" call; poll, with a bound: ```python async def wait_for(js_condition, timeout_s=15, every_s=0.5): """True when the JS expression becomes truthy, False when the time is up.""" deadline = asyncio.get_event_loop().time() + timeout_s while asyncio.get_event_loop().time() < deadline: if await evaluate(js_condition): return True await asyncio.sleep(every_s) return False await browser.navigate_to("https://example.com/search?q=lamp") if not await wait_for('document.querySelectorAll(".result").length > 0'): return {"ok": False, "error": {"type": "Timeout", "message": "results did not appear"}} ``` The same shape waits for a spinner to disappear, for a count to stop growing, or for a streaming answer to settle (poll the text length until it stops changing for a few polls). ## Interacting with the page ### Hand-like input: `human` A click delivered as a single event at exact coordinates and a whole string entered in one go are what scripts do; sites that score behaviour notice. `human` moves the pointer along a curved path, holds the button for a human interval and types with a drifting cadence: | Call | What it does | | --- | --- | | `await human.click_element(selector)` | Scrolls the element into view and clicks its centre. Returns `False` when it is not on the page **yet**: wait for it first | | `await human.type_into(selector, text)` | Clicks the element, then types. Returns `False` when it is not on the page yet | | `await human.type(text)` | Types into whatever has focus. Strings longer than 120 characters are entered at once | | `await human.press(key)` | One key by its name: `press("Enter")` submits a form, `press("Tab")`, `press("Escape")`, `press("ArrowDown")`, `press("Backspace")` | | `await human.scroll(dy)` | Wheel in bursts; positive scrolls down. `scroll(600)`, `scroll(-300)` | | `await human.move(x, y)` | Moves the pointer to viewport coordinates | | `await human.click(x, y)` | Moves there and clicks | ```python await browser.navigate_to("https://en.wikipedia.org/wiki/Special:Search") await wait_for('!!document.querySelector("input[name=search]")') # never type into a box that is not there yet await human.type_into("input[name=search]", "porsche cayman") await human.press("Enter") await wait_for('!location.href.endsWith("Special:Search")') # the form has been submitted ``` It costs time, roughly a quarter of a short task's latency, so call it where the site watches: forms, logins, search boxes, anything scored as an interaction. For reading a page nothing needs to be clicked at all. To have the script's ordinary clicks and typing rerouted through it without changing the script, send `"emulate_human": true` with the request. ### Everything else: JavaScript Selecting an option, ticking a checkbox, opening a menu, submitting a form, dismissing a banner: do it in the page, through `evaluate`. ```python await evaluate('document.querySelector("select[name=sort]").value = "price_asc"; ' 'document.querySelector("select[name=sort]").dispatchEvent(new Event("change", {bubbles: true}))') await evaluate('document.querySelector("button#accept-cookies")?.click()') await evaluate('window.scrollTo(0, document.body.scrollHeight)') ``` A `click()` from JavaScript is instant and invisible to behaviour scoring; use `human` where that matters. ## Cookies and headers ```python cookies = await browser.cookies() # list of {name, value, domain, path, ...} await browser.set_extra_headers({"X-Requested-With": "XMLHttpRequest"}) # on every request from now on ``` Setting a cookie before a page loads goes through CDP: ```python await cdp.cdp_client.send.Network.setCookie( params={"name": "consent", "value": "yes", "domain": ".example.com", "path": "/"}, session_id=cdp.session_id, ) await browser.navigate_to("https://example.com/") ``` `User-Agent` is not a header to set: the browser's own is the right one. ## Screenshots Two ways. The request's `screenshots` option (`{"mode": "final_only", "full_page": true}`) captures the page as the script ends; the images are stored and listed in the payload with a URL, see the [reference](https://docs.dat.ai/browsing/reference#screenshots). Or the script takes its own and returns them: ```python import base64 png = await browser.take_screenshot(full_page=False, format="png") # bytes card = await browser.screenshot_element(".product-card") # bytes, one element return {"ok": True, "screenshot": base64.b64encode(png).decode("ascii")} ``` A full-page screenshot of a long page is large; prefer the element or the viewport, and prefer the request option when the image is only for a person to look at later. ## Returning results Return JSON-serialisable data: strings, numbers, booleans, `None`, lists, dictionaries. The browser, the CDP session, response and file handles are not results; convert them to plain data first. A shape that has worked well: ```python return {"ok": True, "data": {...}, "warnings": []} ``` and for a failure the script recognised itself: ```python return {"ok": False, "error": {"type": "NoResults", "message": "No products found"}, "data": None} ``` The result is read back from `GET /api/v1/browsing/status` as the `result` inside `payload` (a JSON string; parse it). Return what you need rather than whole pages: a result of a few hundred kilobytes is fine, tens of megabytes are not. ## Errors An unhandled exception fails the task, and the payload carries the details: ```json { "success": false, "error_type": "RuntimeError", "error_message": "...", "traceback": "..." } ``` The same details come back from `GET /api/v1/browsing/status` (`success: false`, `error`, `payload`) and from `POST /api/v1/browsing/sync`. That is the right outcome for a bug. For a situation the script can foresee, a missing element, a site that refused, an empty listing, return `ok: false` with a reason instead: the task then counts as a success, is billed as one, and your side sees exactly what happened. ```python try: await browser.navigate_to("https://example.com/listing") if not await wait_for('document.querySelector(".product")'): return {"ok": False, "error": {"type": "NoProducts", "message": "no product cards on the page"}, "url": await browser.get_current_page_url()} except Exception as exc: return {"ok": False, "error": {"type": type(exc).__name__, "message": str(exc)[:300]}} ``` ## Python dependencies Extra packages are installed through `uv_args`, which is available to the same keys as custom scripts. Each `--with` names one package; versions the usual way: ```json { "script": "import httpx\nr = httpx.get(\"https://example.com\")\nreturn {\"status\": r.status_code}", "uv_args": "--with httpx==0.27.0 --with beautifulsoup4", "timeout_sec": 300 } ``` Installation happens when the task starts and adds to its time, and a package cannot be assumed to be cached already. Add only what is needed, and do not pass secrets in `uv_args`. ## Recipes Each one is a complete `script`. They assume the `evaluate` and `wait_for` helpers from above are pasted at the top. ### Extract a list of items ```python await browser.navigate_to("https://example.com/products") await wait_for('document.querySelectorAll(".product-card").length > 0') raw = await evaluate(""" JSON.stringify(Array.from(document.querySelectorAll(".product-card")).map(card => ({ name: card.querySelector(".product-title")?.innerText?.trim() || null, price: card.querySelector(".price")?.innerText?.trim() || null, url: card.querySelector("a")?.href || null, }))) """) products = json.loads(raw or "[]") return {"ok": True, "products": products, "count": len(products)} ``` ### Walk through pagination ```python items, seen = [], set() for page_no in range(1, 11): await browser.navigate_to(f"https://example.com/products?page={page_no}") if not await wait_for('document.querySelectorAll(".product-card").length > 0', timeout_s=10): break batch = json.loads(await evaluate(""" JSON.stringify(Array.from(document.querySelectorAll(".product-card")).map(c => ({ name: c.querySelector(".product-title")?.innerText?.trim(), url: c.querySelector("a")?.href }))) """) or "[]") new = [b for b in batch if b["url"] not in seen] if not new: break items.extend(new); seen.update(b["url"] for b in new) return {"ok": True, "items": items, "pages": page_no} ``` ### Infinite scroll ```python await browser.navigate_to("https://example.com/feed") last = -1 for _ in range(20): await human.scroll(1200) await asyncio.sleep(1.0) count = await evaluate('document.querySelectorAll(".post").length') if count == last: break last = count posts = json.loads(await evaluate( 'JSON.stringify(Array.from(document.querySelectorAll(".post")).map(p => p.innerText.trim()))') or "[]") return {"ok": True, "posts": posts} ``` ### Fill a search form, then read where it led ```python await browser.navigate_to("https://en.wikipedia.org/wiki/Special:Search") if not await wait_for('!!document.querySelector("input[name=search]")'): return {"ok": False, "error": {"type": "NoSearchBox", "message": "search box not rendered"}} await human.type_into("input[name=search]", "porsche cayman") await human.press("Enter") if not await wait_for('!location.href.endsWith("Special:Search")', timeout_s=20): return {"ok": False, "error": {"type": "Timeout", "message": "the form did not submit"}} await wait_for('!!document.querySelector("#firstHeading")') return { "ok": True, "url": await browser.get_current_page_url(), "heading": await evaluate('document.querySelector("#firstHeading")?.innerText || null'), "intro": await evaluate('document.querySelector("#mw-content-text p:not(.mw-empty-elt)")?.innerText?.slice(0, 400) || null'), } ``` ### Several URLs, each failure isolated ```python urls = ["https://example.com", "https://www.iana.org/domains/reserved", "https://httpbin.org/status/500"] out = [] for url in urls: try: await browser.navigate_to(url) out.append({"url": url, "ok": True, "title": await browser.get_current_page_title()}) except Exception as exc: out.append({"url": url, "ok": False, "error": f"{type(exc).__name__}: {exc}"[:200]}) return {"ok": True, "results": out} ``` ### Call the site's own API from inside the page The request carries the page's cookies and origin, the way the site's own front end makes it: ```python await browser.navigate_to("https://example.com/catalog") data = await evaluate(""" (async () => { const r = await fetch("/api/catalog?page=1", {headers: {"Accept": "application/json"}}); return JSON.stringify({status: r.status, body: await r.json()}); })() """) return {"ok": True, "api": json.loads(data)} ``` ### Read the page as an agent would ```python await browser.navigate_to("https://example.com/pricing") return {"ok": True, "text": await browser.get_state_as_text()} ``` ## Common problems | Symptom | Cause and fix | | --- | --- | | 403 `custom browsing scripts are not enabled for this API key` | The key is not approved for scripts (`uv_args` is gated by the same flag). Ask in the dashboard chat | | 400 `task or script is required` | The body has neither `task` nor `script` | | 400 `screenshot_mode must be one of: ...` | For a script use `"screenshots": {"mode": "final_only"}` | | `SyntaxError` on the first line | The script is a function body: no `async def main()` wrapper, no `asyncio.run`, first line at column zero | | `AttributeError: ... has no attribute ...` | Not a method of the browser session. Navigate with `browser.navigate_to(...)`, read and act through `evaluate` and `human` | | `NameError: name 'page' is not defined` | There is no `page`: the script sees `browser`, `session`, `human` and the helpers listed in [What the script sees](#what-the-script-sees) | | `status` says `failed` and nothing else useful | Read `error` in the status reply: it is the exception the script raised, with `payload` carrying the traceback. Until the browser answers, the status is `assigned` | | `ValueError: JavaScript code must start with (...args) => format` | `page.evaluate` wants an arrow function; use the CDP `evaluate` helper for anything else | | `the script did not finish within N ms` | The budget ran out. Bound every wait, raise `timeout_sec` (up to 1200), return partial data early rather than nothing late | | The result is empty or stale | The page rendered after `navigate_to` returned. Wait for the element you need before reading | | 504 on `sync` | The HTTP request outlived the wait. Start the task with `async` and poll `status` | ## Checklist - The key is approved for scripts. - The script is a function body: `await` at top level, no wrapper, no `asyncio.run`. - Every wait has a timeout, and `timeout_sec` covers the whole script. - The page is waited for before it is read. - The return value is plain JSON data, sized for what you need. - Expected failures come back as `ok: false` with a reason; only bugs raise. - Long tasks use `async` and poll `status`. --- # SERP Search ## SERP Search The SERP endpoints open a search engine's results page the way a user's own browser would and return **the page** as the engine served it. You send a search URL and never any code: the task is dat.ai's own script, run as a [Browsing API](https://docs.dat.ai/browsing) task with the same key, the same billing and the same `task_id` in `GET /api/v1/browsing/status`. A captcha, a challenge or an empty page is **never an answer**: such an attempt counts as a failure on the node, and the server tries again from other browsers and IP addresses, first within `fanout`, then with the next round of `attempts`. You pay only for the task that returned the page. | Engine | Endpoint | Page | | --- | --- | --- | | [Google](https://docs.dat.ai/serp/google) | `POST /api/v1/search/serp/google` | Results pages, AI Overview opened, AI Mode (`udm=50`) waited for | Every engine under `/api/v1/search/serp/` takes the same request shape (`url`, `locale`, `cookies`, `headers`, `timeout_sec`, `fanout`, `attempts`, `session_key`, `filter`, `ttl_days`) and answers with the page's HTML and the `X-Task-ID` header. --- # Google ## Google `POST /api/v1/search/serp/google` opens one Google search URL the way the client's own browser would and returns the **HTML of the results page**. You send no code: the task is dat.ai's own SERP script, run the same way as a [Browsing API](https://docs.dat.ai/browsing) task, with the same key, the same billing and the same `task_id` in `GET /api/v1/browsing/status`. A captcha, "unusual traffic", "verifying your request", a walk off Google, an empty page: **none of these is an answer**. Such an attempt counts as a failure and the server tries again from elsewhere: first within `fanout` (the first browser to return a full results page wins), then with the next round of `attempts` from browsers and IP addresses not yet tried. A page with only a few results or "did not match any documents", which Google also shows a browser it is reluctant to serve, does not win at once: the other browsers of the same `fanout` get up to 20 seconds to answer, and the page with the most results is returned. By default the countries where Google refuses more often than it answers are excluded (`RU` always is). You pay only for the task that returned the page, once, however many browsers of its `fanout` answered. ## Request `POST /api/v1/search/serp/google`, JSON body: | Field | Type | Description | | --- | --- | --- | | `url` | string | **Required.** A `https://www.google./search?q=...` URL with all its parameters (`hl`, `gl`, `uule`, `udm`, ...); it goes to the browser as is. Google no longer honours `num`: a results page holds about 10 results whatever it says. `udm=50` asks for [AI Mode](#ai-mode). Other hosts and paths are rejected | | `locale` | string | A language such as `en-GB`. It becomes the `Accept-Language` header, shaped the way Chrome shapes it, and `navigator.languages`: `en-GB` → `en-GB,en-US;q=0.9,en;q=0.8`, `de-DE` → `de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7`, `fr` → `fr,en-US;q=0.9,en;q=0.8`. Without it the browser speaks its own language | | `cookies` | object | Cookies placed on the Google domain before the first request: `{"NID": "..."}`. A value is a string or a list of strings; a list is set in order and the browser keeps the last. The cookie consent (`SOCS`) is set by the server itself, so the consent wall is not your problem; a `SOCS` or `CONSENT` of your own replaces ours. On an AI Mode page the server sets none and clicks the wall through instead | | `headers` | object | Extra headers on every request the page makes: `{"Cache-Control": "max-age=0"}`. A value is a string or a list (a list is joined with `, `). Not allowed: `User-Agent` (a foreign UA is the surest way to a captcha), `Cookie`, `Host`, `Connection`, `Content-Length`, `Sec-*`, `Proxy-*`; `Accept-Language` only when there is no `locale` | | `timeout_sec` | number | The budget of **one attempt**, default `90`, maximum `1200` | | `fanout` | number | How many browsers run an attempt at once, 1–10, default `3`. Only the browser that returns the page is billed | | `attempts` | number | How many rounds are run in turn when a round produced no page, 1–5, default `3`. Every round is a new task from browsers and IP addresses not used before | | `resolve_links` | bool | Default `false`: the page comes back as Google rendered it. With `true`, Google's redirect links in the page (`/goto?url=...`, `/url?...&url=...`) are replaced with the addresses they lead to. For these sessions Google renders **every** outbound link, organic results and AI Overview citations included, only as such tokens; the real URLs are not in the HTML at all. Resolution runs in the same session, one small request to google.com per link, in parallel; typically +3–8 s per task. Ask for it when you need the addresses rather than the text and positions | | `session_key` | string | As in browsing: the rounds of one request never repeat a browser anyway; the key is for fitting the SERP into an existing session | | `filter.country_iso_included` | string[] | The countries the search may run from, as in [browsing](https://docs.dat.ai/browsing/reference#countries). A country named here is not taken away by the default exclusions | | `filter.country_iso_excluded` | string[] | The countries it must not run from, added to the default exclusions. Not together with `country_iso_included` | | `ttl_days` | number | How long the task body is kept, as in browsing | Unknown fields are rejected with **400**. ## Response **200**: the body is the page's HTML, `Content-Type: text/html; charset=utf-8`. | Header | Meaning | | --- | --- | | `X-Task-ID` | The task that returned the page (visible in `GET /api/v1/browsing/status`) | | `X-Serp-Final-URL` | Where the browser ended up | | `X-Serp-Links` | How many redirect links now carry their real address out of how many were found (`38/40`); absent when the page had none or `resolve_links` was not set | | `X-Serp-AI-Overview` | `expanded` when the page had an AI Overview and it was opened (the HTML holds the full text rather than the collapsed third), `present` when it would not open; absent when the page had no such block | | `X-Serp-AI-Mode` | On an [AI Mode](#ai-mode) page: `complete` when the answer finished streaming before the page was read, `partial` when time ran out with some of it there | **Errors:** 400 (invalid request), 401, 402 (subscription or balance), **502** when no results page could be obtained (Google refused, a captcha and the like), **504** when no answer came in time. The error body is a JSON object with one field, `error`, as the server gives it: ```json // 400 — not a Google search URL, a header the browser must keep its own, a field the endpoint does not know { "error": "url must be a Google search page, e.g. https://www.google.com/search?q=..." } { "error": "headers: User-Agent cannot be set: the node's own browser identifies itself; a different User-Agent is what gets a node refused" } { "error": "Invalid input data" } // 401 — no key, or a wrong one { "error": "Authentication required: missing Authorization header. Please provide Authorization header with Bearer token format: 'Authorization: Bearer '" } // 402 — a quota at zero and no balance to cover it (see Account & Balance) { "error": "Insufficient balance: the browser_seconds quota is used up and there is no USD balance to cover overage; top up in the dashboard (https://dash.dat.ai) or check GET /api/v1/account/balance", "code": "insufficient_balance", "exhausted": "browser_seconds" } // 502 — no browser tried returned a results page { "error": "no results page: Google refused the search (captcha)" } { "error": "no results page: Google's consent page could not be passed" } { "error": "no results page: Google returned an empty page" } { "error": "no results page: the search could not be completed" } // 504 — no answer within the attempts' time { "error": "no results page: no answer in time" } ``` The whole request takes up to `attempts × (timeout_sec + ~45 s)`; with long budgets keep `attempts` small. ## AI Mode Google's AI Mode is a search page of its own: one answer that streams in, instead of a list of results. Ask for it with `udm=50` in the URL, everything else unchanged: ``` https://www.google.com/search?q=how+does+photosynthesis+work&udm=50&hl=en&gl=us ``` The task waits for the answer to finish streaming before it reads the page, and `X-Serp-AI-Mode` says whether it did. The answer is in the `[data-subtree="aimc"]` element of the HTML. A browser Google will not give AI Mode to ("AI Mode is not currently available") counts as a refusal like a captcha does, and the request moves on to another one within `fanout` and `attempts`. Google refuses AI Mode far more readily than a results page, so the default `fanout` of `3` matters here: keep it, or raise it. On this page a `locale` goes as the `Accept-Language` header only; `navigator.languages` stays the browser's own. ## Example ```bash curl -sS -X POST "https://llm.dat.ai/api/v1/search/serp/google" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -o serp.html -D - \ -d '{ "url": "https://www.google.com/search?q=kids+soccer+pants&ie=utf-8&oe=utf-8&hl=en&gl=us&uule=w+CAIQIFISCQkhfJQWRMKJEYZ4AAR0XHaC&glp=1&gws_rd=cr&fg=1", "locale": "en-GB", "headers": {"Cache-Control": "max-age=0"}, "fanout": 2, "attempts": 3 }' ``` **Response (200)**: the headers say which task answered and what the page holds, the body is the page (about 3 MB for a results page with an AI Overview), saved to `serp.html` here: ```http HTTP/2 200 content-type: text/html; charset=utf-8 x-task-id: web:f6fba073-a71e-4e74-87bf-d0c39c71dcf0 x-serp-final-url: https://www.google.com/search?q=kids+soccer+pants&hl=en&gl=us&sei=0QataoTVEbGN9u8PmLSz6Qg x-serp-ai-overview: expanded x-serp-links: 21/21 ... ``` `x-serp-links` is there because the request asked for `resolve_links`; on a page without an AI Overview `x-serp-ai-overview` is absent. A request that got no page answers with a JSON error instead, see [Response](#response). --- # AI Search ## AI Search The AI Search endpoints put one prompt to an answer engine in a real browser and return **the reply**: its text, the same text as Markdown and as the page's HTML, the pages it cites, and the stream the page received while the reply was arriving. You send no code and no account: the task is dat.ai's own script, run as a [Browsing API](https://docs.dat.ai/browsing) task with the same key, the same billing and the same `task_id` in `GET /api/v1/browsing/status`. An address the engine will not serve, a challenge page, a page with no composer, a reply that never came: **none of these is an answer**. Such an attempt counts as a failure and the server tries again from elsewhere: first within `fanout` (the first reply wins), then with the next round of `attempts` from browsers and IP addresses not yet tried. You pay only for the task that returned the reply. | Engine | Endpoint | The page | Stream | | --- | --- | --- | --- | | [ChatGPT](https://docs.dat.ai/ai-search/chatgpt) | `POST /api/v1/search/ai/chatgpt` | chatgpt.com, the lightweight guest app | The engine's streamed HTML patches | | [Gemini](https://docs.dat.ai/ai-search/gemini) | `POST /api/v1/search/ai/gemini` | gemini.google.com, as a guest | Google's batchexecute frames | Other engines follow under the same prefix with the same request and reply. Claude is not among them: claude.ai has no guest mode, a browser that is not signed in is sent to the login page. ## Request `POST /api/v1/search/ai/`, JSON body, the same for every engine: | Field | Type | Description | | --- | --- | --- | | `prompt` | string | **Required.** The message, up to 8000 characters. It goes into a fresh conversation as typed | | `locale` | string | A language such as `en-GB`. It becomes the `Accept-Language` header, shaped the way Chrome shapes it; the page is drawn in that language. The reply follows the language of the prompt | | `raw_stream` | bool | Default `true`: the reply comes with the stream the page received (see [Response](#response)). `false` leaves it out | | `timeout_sec` | number | The budget of **one attempt**, default `150`, maximum `1200` | | `fanout` | number | How many browsers put the prompt at once, 1–10, default `2`. Only the browser that returns the reply is billed | | `attempts` | number | How many rounds are run in turn when a round produced no reply, 1–5, default `3`. Every round is a new task from browsers and IP addresses not used before | | `session_key` | string | As in browsing: the rounds of one request never repeat a browser anyway; the key is for fitting the request into an existing session | | `filter.country_iso_included` | string[] | The countries the request may run from, as in [browsing](https://docs.dat.ai/browsing/reference#countries) | | `filter.country_iso_excluded` | string[] | The countries it must not run from. Not together with `country_iso_included` | | `ttl_days` | number | How long the task body is kept, as in browsing | Unknown fields are rejected with **400**. ## Response **200**: a JSON object, the same shape for every engine. `X-Task-ID` in the headers names the task that returned it. ```json { "task_id": "web:1d1f7e6e-...", "provider": "chatgpt", "prompt": "What is the capital of Australia and how big is its population? Cite sources.", "final_url": "https://chatgpt.com/uc/6a9e...", "status": "complete", "answer": { "text": "The capital of Australia is Canberra ...", "markdown": "The capital of **Australia is Canberra** ...\n\n- **Canberra’s population:** about **484,630 people** ... [1]\n", "html": "

The capital of Australia is Canberra ...

" }, "web_search": true, "sources": [ { "url": "https://www.abs.gov.au/statistics/people/population/regional-population/2024-25", "title": "Regional population, 2024-25 financial year | Australian Bureau of Statistics", "domain": "abs.gov.au", "attribution": "Australian Bureau of Statistics" } ], "citations": [ { "url": "https://www.abs.gov.au/statistics/people/population/regional-population/2024-25", "title": "Regional population, 2024-25 financial year | Australian Bureau of Statistics", "source": 1, "text": "Canberra’s population: about 484,630 people as of 30 June 2025, according to the Australian Bureau of Statistics (ABS).", "supporting": false } ], "raw_stream": "..." } ``` | Field | Meaning | | --- | --- | | `provider` | The engine, as in the path | | `status` | `complete` when the reply finished streaming before it was read, `partial` when the attempt's time ran out with some of it there | | `answer.text` | The reply as the page shows it, plain text | | `answer.markdown` | The same reply as Markdown: headings, lists, emphasis, code, tables and links. A citation in the text is `[n]`, `n` being the source's place in `sources`. Empty when the engine's page could not give it | | `answer.html` | The reply's HTML as the page rendered it | | `web_search` | Whether the engine searched the web for this reply, that is, whether it cites anything | | `sources` | Every page the reply draws on, in the order the citations count, with the title and the attribution the engine shows; an engine that says more about a page says it here. The engine's own click tags (`utm_source`) are removed from the addresses | | `citations` | One entry per place in the reply that points at a page: the address, its number in `sources`, and the sentence or list item it stands in. `supporting` marks the pages an engine lists as additional to a citation | | `raw_stream` | What the page received while the reply arrived, verbatim; what that is depends on the engine, see its page. Absent with `raw_stream: false` or when the page got no stream | **Errors:** 400 (invalid request), 401, 402 (subscription or balance), 404 (an engine that does not exist), **502** when no reply could be obtained (the engine refused every browser tried), **504** when no answer came in time. The error body is a JSON object with one field, `error`, as the server gives it: ```json // 400 — no prompt, a value out of range, a field the endpoint does not know { "error": "prompt is required" } { "error": "attempts must be between 1 and 5" } { "error": "locale must be a language tag such as en-GB or de" } { "error": "Invalid input data" } // 401 — no key, or a wrong one { "error": "Authentication required: missing Authorization header. Please provide Authorization header with Bearer token format: 'Authorization: Bearer '" } // 402 — a quota at zero and no balance to cover it (see Account & Balance) { "error": "Insufficient balance: the browser_seconds quota is used up and there is no USD balance to cover overage; top up in the dashboard (https://dash.dat.ai) or check GET /api/v1/account/balance", "code": "insufficient_balance", "exhausted": "browser_seconds" } // 404 — an engine that does not exist { "error": "unknown answer engine" } // 502 — every browser tried was refused, or none produced a reply { "error": "no reply: the engine refused this browser" } { "error": "no reply: the engine gave no reply" } { "error": "no reply: the request could not be completed" } // 504 — no answer within the attempts' time { "error": "no reply: no answer in time" } ``` The whole request takes up to `attempts × (timeout_sec + ~45 s)`; with long budgets keep `attempts` small. --- # ChatGPT ## ChatGPT `POST /api/v1/search/ai/chatgpt` puts one prompt to [chatgpt.com](https://chatgpt.com) the way a guest in a browser would and returns **the reply**: its text, the same text as Markdown and as the page's HTML, the pages it cites, and the stream the page received while the reply was arriving. You send no code and no account: the task is dat.ai's own script, run as a [Browsing API](https://docs.dat.ai/browsing) task with the same key, the same billing and the same `task_id` in `GET /api/v1/browsing/status`. An address the engine will not serve ("Unable to load site"), a challenge page, a page with no composer, a reply that never came: **none of these is an answer**. Such an attempt counts as a failure and the server tries again from elsewhere: first within `fanout` (the first reply wins), then with the next round of `attempts` from browsers and IP addresses not yet tried. You pay only for the task that returned the reply. The engine decides for itself whether to search the web for a prompt; a prompt that asks for sources gets them. The request and the reply are the ones every engine shares, see [AI Search](https://docs.dat.ai/ai-search); what is ChatGPT's own is below. ## What is ChatGPT's own | Field | On this engine | | --- | --- | | `final_url` | The guest conversation, `https://chatgpt.com/uc/` | | `sources` | One per address, in order of first mention; `attribution` is the name the engine shows for the page, which is not always its domain | | `citations` | The citation pills in the text; `supporting` marks the pages the engine lists as additional to a pill ("+1") | | `raw_stream` | Not a Server-Sent Events body: the page fetches the reply as streamed HTML patches (`text/vnd.openai.web-mobile-partial+html`), handed back as they came | | `status: partial` | The reply had not finished streaming in `timeout_sec`; what was on the page is returned | Refused on the node, so that the request moves on to another browser: an address the engine will not serve ("Unable to load site", roughly one node in four or five), a challenge page, a page with no composer, a reply that had not begun after 30 seconds. ## Example ```bash curl -sS -X POST "https://llm.dat.ai/api/v1/search/ai/chatgpt" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "prompt": "What is the capital of Australia and how big is its population? Cite sources.", "locale": "en-GB", "raw_stream": false }' ``` **Response (200)**, as the engine answered it (the sources and citations cut to two of four; `raw_stream` is absent because the request turned it off): ```json { "task_id": "web:c90aee70-6bf2-4867-a763-eb743370d13b", "provider": "chatgpt", "prompt": "What is the capital of Australia and how big is its population? Cite sources.", "final_url": "https://chatgpt.com/uc/6aad0701-6e8c-83ea-8848-e616011b93f9", "status": "complete", "answer": { "text": "The capital of Australia is Canberra, located in the Australian Capital Territory (ACT).\n\nAs for population:\n\nCanberra (Greater Capital City Statistical Area): about 484,630 people as of 30 June 2025, according to the Australian Bureau of Statistics (ABS).\n\nAustralian Capital Territory overall: about 489,700 people as of 31 March 2026. ...", "markdown": "The capital of Australia is **Canberra**, located in the Australian Capital Territory (ACT).\n\nAs for population:\n\n- **Canberra (Greater Capital City Statistical Area):** about **484,630 people** as of **30 June 2025**, according to the Australian Bureau of Statistics (ABS). [1]\n- **Australian Capital Territory overall:** about **489,700 people** as of **31 March 2026**. [2]\n...", "html": "

The capital of Australia is Canberra, located in the Australian Capital Territory (ACT).

..." }, "web_search": true, "sources": [ { "url": "https://www.abs.gov.au/statistics/people/population/regional-population/2024-25", "title": "Regional population, 2024-25 financial year | Australian Bureau of Statistics", "domain": "abs.gov.au", "attribution": "Australian Bureau of Statistics" }, { "url": "https://www.abs.gov.au/statistics/people/population/national-state-and-territory-population/mar-2026", "title": "National, state and territory population, March 2026 | Australian Bureau of Statistics", "domain": "abs.gov.au", "attribution": "Australian Bureau of Statistics" } ], "citations": [ { "url": "https://www.abs.gov.au/statistics/people/population/regional-population/2024-25", "title": "Regional population, 2024-25 financial year | Australian Bureau of Statistics", "source": 1, "text": "Canberra (Greater Capital City Statistical Area): about 484,630 people as of 30 June 2025, according to the Australian Bureau of Statistics (ABS).", "supporting": false }, { "url": "https://www.abs.gov.au/statistics/people/population/national-state-and-territory-population/mar-2026", "title": "National, state and territory population, March 2026 | Australian Bureau of Statistics", "source": 2, "text": "Australian Capital Territory overall: about 489,700 people as of 31 March 2026.", "supporting": false } ] } ``` **Errors.** The body is always `{ "error": "..." }`; these are the answers as the server gives them: ```json // 400 — no prompt, a value out of range, a field the endpoint does not know { "error": "prompt is required" } { "error": "attempts must be between 1 and 5" } { "error": "locale must be a language tag such as en-GB or de" } { "error": "Invalid input data" } // 401 — no key, or a wrong one { "error": "Authentication required: missing Authorization header. Please provide Authorization header with Bearer token format: 'Authorization: Bearer '" } // 402 — a quota at zero and no balance to cover it (see Account & Balance) { "error": "Insufficient balance: the browser_seconds quota is used up and there is no USD balance to cover overage; top up in the dashboard (https://dash.dat.ai) or check GET /api/v1/account/balance", "code": "insufficient_balance", "exhausted": "browser_seconds" } // 404 — an engine that does not exist { "error": "unknown answer engine" } // 502 — every browser tried was refused, or none produced a reply { "error": "no reply: the engine refused this browser" } { "error": "no reply: the engine gave no reply" } { "error": "no reply: the request could not be completed" } // 504 — no answer within the attempts' time { "error": "no reply: no answer in time" } ``` --- # Gemini ## Gemini `POST /api/v1/search/ai/gemini` puts one prompt to [Gemini](https://gemini.google.com) the way a guest in a browser would and returns **the reply**: its text, the same text as Markdown and as the page's HTML, the pages it cites and the pages its web search found, and the stream the page received while the reply was arriving. You send no code and no account: the task is dat.ai's own script, run as a [Browsing API](https://docs.dat.ai/browsing) task with the same key, the same billing and the same `task_id` in `GET /api/v1/browsing/status`. A challenge page, a page with no composer, a reply that never came or one the engine dropped halfway: **none of these is an answer**. Such an attempt counts as a failure and the server tries again from elsewhere: first within `fanout` (the first reply wins), then with the next round of `attempts` from browsers and IP addresses not yet tried. You pay only for the task that returned the reply. The engine decides for itself whether to search the web for a prompt; a prompt that asks for sources gets them. The request and the reply are the ones every engine shares, see [AI Search](https://docs.dat.ai/ai-search); what is Gemini's own is below. ## What is Gemini's own | Field | On this engine | | --- | --- | | `final_url` | The guest conversation, `https://gemini.google.com/app/` | | `answer.markdown` | The engine's own Markdown, with `[n]` after every passage a citation covers. Empty in the rare case the stream did not reach the browser; `answer.text` and `sources` are then read from the page | | `sources` | The cited pages first, in order of first citation, then the other pages the web search found. `snippet` is the passage the engine quotes from a cited page; `attribution` is the name it shows for the page. The highlight Google appends to a cited address (`#:~:text=…`) is removed | | `citations` | One entry per page behind a cited passage; `text` is the passage. A passage backed by several pages gives several entries, the first with `supporting: false`, the rest `true` ("+1" in the page) | | `raw_stream` | Google's batchexecute body: a `)]}'` line, then a length line and a JSON line per frame; each frame carries the whole reply so far | | `status: partial` | The reply had not finished streaming in `timeout_sec`; what was on the page is returned | Refused on the node, so that the request moves on to another browser: a challenge or "unusual traffic" page, a redirect to the sign-in page, a page with no composer, a reply that had not begun after 35 seconds, and a reply the engine dropped after it had begun. ## Example ```bash curl -sS -X POST "https://llm.dat.ai/api/v1/search/ai/gemini" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "prompt": "What is the capital of Australia and how big is its population? Cite sources.", "locale": "en-GB", "raw_stream": false }' ``` **Response (200)**, as the engine answered it (the text and Markdown shortened, the sources cut to three of eight; `raw_stream` is absent because the request turned it off): ```json { "task_id": "web:ed5481d6-4d06-4f53-b403-3009d1a01566", "provider": "gemini", "prompt": "What is the capital of Australia and how big is its population? Cite sources.", "final_url": "https://gemini.google.com/app/08818b5c0af6f1cf", "status": "complete", "answer": { "text": "The capital of Australia is Canberra.\n\nPopulation Details\n\nCity Population: Canberra has an estimated population of approximately 484,630 to 523,285, depending on whether measurements focus strictly on the urban center or the broader regional statistical area. ...", "markdown": "The capital of Australia is **Canberra**.[1] \n\n### Population Details\n* **City Population:** Canberra has an estimated population of approximately **484,630 to 523,285**, depending on whether measurements focus strictly on the urban center or the broader regional statistical area. ...\n\n### Sources\n* [Wikipedia: Canberra Demographics and Statistics](https://en.wikipedia.org/wiki/Canberra)\n* [Wikipedia: List of Cities in Australia by Population (ABS data)](https://en.wikipedia.org/wiki/List_of_cities_in_Australia_by_population)[2]", "html": "

The capital of Australia is Canberra ...

" }, "web_search": true, "sources": [ { "url": "https://en.wikipedia.org/wiki/Demographics_of_Canberra", "title": "Demographics of Canberra - Wikipedia", "domain": "en.wikipedia.org", "attribution": "Wikipedia", "snippet": "Canberra is Australia's capital and its largest inland city. At the 2021 \ncensus, it had 452,670 residents. This amounted to 1.8% of Australia's \npopulation. At t..." }, { "url": "https://en.wikipedia.org/wiki/List_of_cities_in_Australia_by_population", "title": "List of cities in Australia by population - Wikipedia", "domain": "en.wikipedia.org", "attribution": "Wikipedia", "snippet": "Table_title: Greater Capital City Statistical Areas by population \nTable_content: | Rank | Greater Capital City Statistical Area | State or \nterritory | Estimated..." }, { "url": "https://worldpopulationreview.com/cities/australia", "title": "Australia Cities by Population 2026 - World Population Review", "domain": "worldpopulationreview.com" } ], "citations": [ { "url": "https://en.wikipedia.org/wiki/Demographics_of_Canberra", "title": "Demographics of Canberra - Wikipedia", "source": 1, "text": "The capital of Australia is Canberra.", "supporting": false }, { "url": "https://en.wikipedia.org/wiki/List_of_cities_in_Australia_by_population", "title": "List of cities in Australia by population - Wikipedia", "source": 2, "text": "Wikipedia: List of Cities in Australia by Population (ABS data)", "supporting": false } ] } ``` With `raw_stream: true` (the default) the reply also carries the stream, starting like this: ```text )]}' 177 [["wrb.fr",null,"[null,[\"c_08818b5c0af6f1cf\",\"r_dc0e21799f96372e\"],{\"18\":\"r_dc0e21799f96372e\",...}]"]] 253 [["wrb.fr",null,"[null,[\"c_08818b5c0af6f1cf\",\"r_dc0e21799f96372e\"],{\"7\":[null,[\"google\",[null,null,\"Google Search\",...,\"Searching the web\"],1]],...}]"]] ... ``` **Errors.** The body is always `{ "error": "..." }`; these are the answers as the server gives them: ```json // 400 — no prompt, a value out of range, a field the endpoint does not know { "error": "prompt is required" } { "error": "attempts must be between 1 and 5" } { "error": "locale must be a language tag such as en-GB or de" } { "error": "Invalid input data" } // 401 — no key, or a wrong one { "error": "Authentication required: missing Authorization header. Please provide Authorization header with Bearer token format: 'Authorization: Bearer '" } // 402 — a quota at zero and no balance to cover it (see Account & Balance) { "error": "Insufficient balance: the browser_seconds quota is used up and there is no USD balance to cover overage; top up in the dashboard (https://dash.dat.ai) or check GET /api/v1/account/balance", "code": "insufficient_balance", "exhausted": "browser_seconds" } // 404 — an engine that does not exist { "error": "unknown answer engine" } // 502 — every browser tried was refused, or none produced a reply { "error": "no reply: the engine refused this browser" } { "error": "no reply: the engine gave no reply" } { "error": "no reply: the request could not be completed" } // 504 — no answer within the attempts' time { "error": "no reply: no answer in time" } ``` --- # MCP ## MCP dat.ai exposes a remote [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server over **streamable HTTP**. Compatible clients run the same browsing tasks as the [HTTP API](https://docs.dat.ai/browsing) as MCP tools. | Field | Value | | --- | --- | | Endpoint | `https://llm.dat.ai/mcp` | | Transport | Streamable HTTP (not stdio) | | Auth | `Authorization: Bearer YOUR_API_KEY` | Create a key in the [dashboard](https://dash.dat.ai) under **API Keys**. The MCP server is a thin proxy: every tool maps to an existing HTTP endpoint. ## Setup ### Create an API key In the [dashboard](https://dash.dat.ai) open **API Keys**, create a key, and copy it. Treat it as a secret — do not commit configs that contain real keys. ### Add the server to your client Use the tab for your client below. Replace `YOUR_API_KEY` with the key from step 1. ### Verify with `ping` After the client connects, call the `ping` tool. A healthy response looks like: ```json { "ok": true, "has_api_key": true } ``` If `has_api_key` is `false`, the `Authorization` header did not reach the server — fix the client config and reconnect. ## Client configuration **Cursor** Cursor MCP settings (`mcpServers`): ```json { "mcpServers": { "llm-dat-ai": { "url": "https://llm.dat.ai/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` **Claude Code** CLI: ```bash claude mcp add --transport http llm-dat-ai https://llm.dat.ai/mcp \ --header "Authorization: Bearer YOUR_API_KEY" ``` Or in `.mcp.json` / Claude settings — **`type` is required** when using a URL: ```json { "mcpServers": { "llm-dat-ai": { "type": "http", "url": "https://llm.dat.ai/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` **VS Code** Add to `.vscode/mcp.json` (or user MCP settings): ```json { "servers": { "llm-dat-ai": { "type": "http", "url": "https://llm.dat.ai/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` **Windsurf** Windsurf uses **`serverUrl`** (not `url`) — typically `~/.codeium/windsurf/mcp_config.json`: ```json { "mcpServers": { "llm-dat-ai": { "serverUrl": "https://llm.dat.ai/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Then refresh MCP servers in Cascade → Manage MCPs. **Claude Desktop** Claude Desktop’s config file only speaks **stdio**. Bridge the remote HTTP server with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote): ```json { "mcpServers": { "llm-dat-ai": { "command": "npx", "args": [ "-y", "mcp-remote", "https://llm.dat.ai/mcp", "--header", "Authorization:${AUTH_HEADER}" ], "env": { "AUTH_HEADER": "Bearer YOUR_API_KEY" } } } } ``` Requires Node.js. Keep the token in `env` (not inline in `args`) so spaces in `Bearer …` do not break the launch command. Any other client that supports **remote streamable HTTP** MCP can use the same endpoint and Bearer header. Stdio-only clients need a bridge such as `mcp-remote`. ## Available tools Tools mirror the public HTTP API. Your client shows each tool’s input schema; details below match the server implementation. | Tool | Description | | --- | --- | | `ping` | Health check; returns `ok` and `has_api_key`. Does not start a task. | | `browsing_run` | Run a browsing task and wait → [`POST …/browsing/sync`](https://docs.dat.ai/browsing/reference#post-apiv1browsingsync). | | `browsing_start` | Start a browsing task → [`POST …/browsing/async`](https://docs.dat.ai/browsing/reference#post-apiv1browsingasync); returns `task_id`. | | `browsing_status` | Poll a task by `task_id` → [`GET …/browsing/status`](https://docs.dat.ai/browsing/reference#get-apiv1browsingstatus). A finished task carries `success` and `payload`; a failed one `success: false` and `error`. | | `browsing_screenshot` | Download a task screenshot as MCP image content. The filename comes from the task payload. | | `browsing_list` | List recent browsing tasks (`page` / `limit`). | | `serp_google` | Open a Google search URL and return the results page → [`POST /api/v1/search/serp/google`](https://docs.dat.ai/serp/google). Returns `html` plus `task_id`, `final_url`, `links`, `ai_overview` and, for a `udm=50` URL, `ai_mode`. | | `search_chatgpt` | Put a prompt to ChatGPT in a real browser → [`POST /api/v1/search/ai/chatgpt`](https://docs.dat.ai/ai-search/chatgpt). Returns the endpoint's JSON: `answer` (text, markdown, html), `sources`, `citations`, `raw_stream`, `task_id`. | | `search_gemini` | Put a prompt to Google Gemini in a real browser → [`POST /api/v1/search/ai/gemini`](https://docs.dat.ai/ai-search/gemini). Returns the same JSON as `search_chatgpt`. | | `account_balance` | What is left of each quota, whether a task may start, and the warning before a 402 → [`GET /api/v1/account/balance`](https://docs.dat.ai/account). | | `supply_countries` | The countries with browsers online right now, biggest first, each as a range (`1-10` … `10000+`) → [`GET /api/v1/supply/countries`](https://docs.dat.ai/supply). For choosing `filter.country_iso_included`. | In `browsing_run` and `browsing_status` the `payload` is handed to the model as the JSON object it holds rather than as the string the HTTP API returns: a custom script's return value is `payload.result`. Every tool that starts a task returns `balance_warning` while a quota is used up or nearly so. For the full request and response shapes, see the [Browsing API Reference](https://docs.dat.ai/browsing/reference). ### Tool input example `browsing_run` / `browsing_start` take `task` (natural language) or `script` (custom Python, enabled per API key; see [Custom Scripts](https://docs.dat.ai/browsing/custom-scripts)). Everything the HTTP API can do, MCP can do: the tools take the same fields and go to the same endpoints. ```json { "task": "Open https://example.com and return the page title.", "timeout_sec": 300, "fanout": 1, "screenshots": { "mode": "final_only", "full_page": true }, "filter": { "country_iso_included": ["US"] } } ``` What `browsing_run` returns is the endpoint's JSON, `payload` parsed into an object: ```json { "task_id": "web:fa8cf4c6-2e74-4e55-aa4b-76b74971043e", "success": true, "payload": { "success": true, "result": "The page title of https://example.com is 'Example Domain'.", "steps": 2, "traffic_in": 473, "traffic_out": 722 }, "step_cnt": 2, "execution_time_ms": 21889, "traffic_in": 473, "traffic_out": 722, "traffic_total": 1195 } ``` An endpoint that refused the call makes the tool fail, and the tool error carries the endpoint's status and body: ```text upstream /api/v1/browsing/sync: status 400: {"error":"task or script is required"} upstream /api/v1/search/ai/chatgpt: status 502: {"error":"no reply: the engine refused this browser"} ``` `timeout_sec` is in **seconds**, at most `1200` (20 minutes); `max_steps` caps the agent's steps (1–500). After `browsing_start`, poll with `browsing_status` using the returned `task_id`. A custom script through the same tool: `script` is the body of an async function, exactly as in the HTTP API. ```json { "script": "await browser.navigate_to(\"https://example.com\")\nreturn {\"title\": await browser.get_current_page_title()}", "timeout_sec": 120 } ``` `serp_google` takes the same fields as the [SERP endpoint](https://docs.dat.ai/serp/google); `cookies` and `headers` are one value per name here: ```json { "url": "https://www.google.com/search?q=kids+soccer+pants&hl=en&gl=us", "locale": "en-GB", "fanout": 2, "attempts": 3 } ``` ## Usage patterns | Goal | Approach | | --- | --- | | Check connectivity | `ping` — confirm `has_api_key: true` | | Browser task with immediate result | `browsing_run` | | Long browser task | `browsing_start` → poll `browsing_status` → optional `browsing_screenshot` | | Google results page | `serp_google`; one call, the HTML in the answer | | An answer engine's reply to a prompt | `search_chatgpt` or `search_gemini`; one call, the reply with its sources | A browsing task can run for many minutes. Prefer async tools when the client may time out waiting on a single tool call. The public proxy allows long MCP sessions (on the order of ~12 minutes). ## Errors Failures are returned as MCP tool errors. Common causes: | Cause | Fix | | --- | --- | | Missing or invalid API key | Set `Authorization: Bearer YOUR_API_KEY` with an active key from the dashboard | | `has_api_key: false` on `ping` | Header not forwarded — check client config / `mcp-remote` bridge | | Missing required field | Check the tool schema in your client (`task`/`script`, `task_id`, …) | | Task timeout / nothing available | Retry, raise `timeout_sec`, adjust filters, or use async tools | | Insufficient credits | Top up in the [dashboard](https://dash.dat.ai) billing |