AI extraction
options.ai hands the page to a language model, after the scrape:
- extract from a description (
prompt), a JSON Schema (schema), or both; - write selectors for that extraction (
config), so the next scrapes run without AI; - find selectors from an example of the values you want (
example), most often without calling the model at all; - repair fields whose selectors stopped matching because the site changed (
repair); - summarize (
summary) and classify (classify) the page.
Whatever you ask, it's answered together: the model explores the page once for all of it.
The model never copies a value. It writes fields (CSS selectors, or JSON paths for the data apps embed) and the API extracts the values with them, then checks them. So data only ever holds what is really on the page: a field the model gets wrong gives nothing, never an invented value.
Try it on the demo product page:
Actions
Data
{
"target_url": "https://example.com/furniture",
"options": {
"ai": {
"prompt": "every product: name, price and link",
"schema": {
"type": "object",
"properties": {
"products": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"price": { "type": "number" },
"url": { "type": "string" }
}
}
}
}
}
}
}
}fields is optional with prompt or schema: what the model's fields extract becomes data. With fields too, your fields fill data and the model's go to ai.data.
{
"data": {
"products": [
{ "name": "Oak chair", "price": 89, "url": "https://example.com/p/1" },
…
]
},
"ai": {
"model": "haiku",
"steps": 1,
"trace": ["result: ok"],
"sent_chars": 3460,
"page_chars": 33248,
"cost_usd": 0.00095,
"cached": false
},
…
}Plain-words fields
Any field can be a description instead of a selector: { "ai": "…" }, next to fields with selectors.
Actions
Data
The AI writes a real field for each description (selector, attribute, transforms, a list with its items), checks it on the page, and the API extracts the value with it. The response says what was written, and config holds the request as resolved, reusable without AI:
"ai_fields": {
"cached": false,
"cost_usd": 0.0021,
"fields": {
"price": { "field": { "selector": ".current-price", "transform": ["price"] }, "version": 1, "cached": false }
}
}- Written once: the field is remembered per site page and description (numbers and ids in the path don't count, so
/product/12and/product/13share it). Later requests run it without the model:"cached": true, at the price of a plain selector. - Repaired when it breaks: a remembered field is checked on every page. When it matches nothing, a list collapses (20 items → 1), a number or a link becomes text, or it suddenly catches a whole block, the model writes a new version from the current page, which replaces it (
"repaired"says why,"version"goes up; the previous versions are kept). - A repair that finds nothing (the value just isn't on this page, like a sold-out badge) isn't tried again for a day, so a missing value never makes you pay the model on every request.
- Kept per account: descriptions may hold values you don't want shared.
- A description stands alone (
{ "ai": "…" }, no other key), and describes a whole list rather than one of itsitems. - With
options.ai.cache_only, only remembered fields are used: the others come backnullwith an error.
In the playground, switch a selector from CSS to AI to describe it; once it has run, the block shows the selector found, and Use this selector keeps it.
How the model reads the page
The model never gets the whole page. It reads it the way coding agents read source code: through a map, then small chunks it picks, with nothing but short notes carried from one turn to the next.
- Local parsing. The API turns the page into a file of numbered lines: simplified HTML, one element per line (no scripts, styles or SVG; only the attributes selectors use), then the JSON the app embeds for the browser (
script-data:__NEXT_DATA__, Nuxt or Redux states…), one value per line. - Lists, found locally. Runs of similar elements (product cards, table rows) and JSON arrays of objects are detected without the model: the outline gives each list's lines, its count and where its first item is. A list nested in every item of another (5 stars in each product) shows once. The model reads one item and writes fields for it; the API runs them on every item.
- First call. The task, how to work, the outline (lists, headings, landmarks, ids, app data, with their line numbers) and the lines matching the words of your prompt, schema, labels or field names. Matches inside the 2nd, 3rd… item of a list aren't shown, only their line numbers. Often that's enough to answer at once.
- Exploration. At each turn, the model either answers or asks for up to 4 actions, run locally:
grep: a regex (case-insensitive, RE2). The first matching lines, then the line number of every match.lines [from, to]: reads those lines, at most 40 lines or 3,000 characters.between [start, stop]: reads from the first line matchingstartto the next line matchingstop, same limits.browser: opens the page in a browser and runs actions. See Browser.
- Compressed state. A turn sends the same task and outline (cached by the model provider), then only: the model's own notes (what it found where, what's left), pointers to what it already read (
lines 84-105; grep /price/), and the results of its last actions. Earlier chunks are never sent again; the model reads them again if it needs to. - Checked answers. When the model answers, the API runs its fields on the whole page and checks them: a field that is invalid, missing or matches nothing, a list column empty in every item, a value that doesn't fit your
schema(a price as text where a number is expected), a category outside your labels. Problems go back to the model, which fixes its fields. It must answer by the last turn (max_steps, 8 by default). Whatever is still wrong then is dropped and listed inwarnings.
The response tells what happened:
ai. | Meaning |
|---|---|
steps | Model calls made |
trace | What the model did, in order: grep /price/, lines 84-105, between /<table/ /<\/table>/, browser […], result: 2 problem(s), result: ok |
sent_chars | Characters sent, all calls together |
page_chars | Length of the page's text, for comparison |
cost_usd | Cost reported by the model provider |
cached | true when the same question about the same page was answered from memory, at no cost |
data | With fields of your own: what the model's fields extracted (otherwise it's the response's data) |
summary, category | With summary and classify |
repaired | With repair: the fields that got new selectors |
error | When the model failed: { code, message }. See Errors |
Generate a config
The fields the model writes are a scraper config. With config: true, they're returned in config, at the root of the response:
Actions
Data
{
"data": {
"products": [
{ "name": "Oak chair", "price": { "amount": 89, "currency": "EUR" }, "url": "…" },
…
]
},
"config": {
"target_url": "https://example.com/furniture",
"fields": {
"products": {
"selector": "ul.products > li.card",
"items": {
"name": { "selector": ".name" },
"price": { "selector": ".price", "transform": ["price"] },
"url": { "selector": "a", "attribute": "href" }
}
}
}
},
"ai": { "model": "haiku", "steps": 2, … }
}Send config back as a plain request, with your own options (minus ai), and that page type scrapes without a model call. When the model needed a browser to see the data, config.options holds browser: true and the actions it ran. With a schema, fields are named after its top-level properties.
The API drops a field whose selector is invalid. It keeps a field whose selector matches nothing on the page, but adds a warning.
Saved configs
With an account, every config the model writes is saved: the response gives its id in config_id (and config_version). Use it in any request instead of the fields, and that page type scrapes without a model call:
{ "target_url": "https://example.com/product/42", "config": "cfg_5f2a9c1e0b7d4e63" }"cfg_…@2" replays a given version. To have the model write a config again (the site changed), set "ai": { "prompt": "…", "config": "cfg_…" }: the answer becomes that config's next version, the older ones stay. Configs are listed in the dashboard (Developers → AI configs) and at GET /v1/configs, GET /v1/configs/{id} (with its versions), PATCH (rename) and DELETE.
Browser
Some data isn't in the HTML the site sends: it's rendered by JavaScript, or it appears after a click, a scroll or a search. The model can then use a browser. It sends actions in the same format as options.actions:
{ "browser": [{ "click": "button.load-more" }, { "scroll": "bottom", "times": 3 }, { "wait_for": ".results" }] }[] just renders the page with JavaScript. The API opens the page in a browser with your request's options (proxy, fingerprint, session, your own actions first), runs the model's actions, and gives the model the new page, with a fresh outline. The model can browse again: each time, the page is reloaded and all of its actions so far are replayed, so config.options.actions replays them too.
An invalid action, or one that fails in the browser, is reported to the model, which can try something else.
With browser set in options, that browser is used. Otherwise it's the default: stealth Chromium.
browse: false turns the tool off: a browser costs a few seconds per use.
From an example
Give the values as they appear on the page, by field name, and get the fields that extract them:
Actions
Data
{
"data": { "title": "Desk lamp", "price": 1299.5, "reviewers": ["Ana", "Ben"] },
"config": {
"target_url": "http://localhost:8787/demo/product",
"fields": {
"title": { "selector": "h1[itemprop=\"name\"]" },
"price": { "selector": "p.price", "transform": ["number"] },
"reviewers": { "selector": "b[itemprop=\"author\"]", "multiple": true }
}
},
"ai": { "model": "none", "steps": 0, … }
}- Locally first, for free. For each value, the API looks for the element whose text is exactly that value (or an attribute holding it:
content,alt,href…), then tries its selectors from the most stable: id,itemprop,data-testid-like attributes, meaningful classes (never generated ones likecss-1x2y3z), then within its parent. It keeps the first one that gives the value back. A number matches the text it's written in (1299.5finds1 299,50 €and addstransform: ["number"]). A list matches when every example item is among its values, and the field then returns the other items too. - The model, for the rest. Values that can't be placed that way, like lists of objects or text spread over several elements, go to the model, with the example. Its fields must give the example back: if they don't, it's told what they got, and fixes them.
config is always returned with example. ai.model is "none" when everything was found locally.
Combine example with prompt or schema to describe what the values are, and with your own HTML.
From your own HTML
Send html instead of fetching a page: the fields, and options.ai, run on it. target_url is optional and only resolves relative links. Nothing is fetched, so no other option applies.
This request (several targets, or HTML) is edited in JSON.
Repair
With repair: true, after extraction, each field that came back empty (null or []) is sent to the model, along with its old definition. The model writes a new field for each. A new field is used only if it finds something, and ai.repaired lists the fields that were fixed:
Here .current-price doesn't exist on the demo page, as if the site had renamed its class:
Actions
Data
{
"data": { "title": "Oak chair", "price": "89,00 €" },
"config": {
"target_url": "…",
"fields": {
"title": { "selector": "h1" },
"price": { "selector": "span.price-now" }
}
},
"ai": { "repaired": ["price"], … }
}When nothing is empty, the model isn't called. Add config: true to get the whole repaired config back, then store it in place of the old one.
A field can be empty on purpose, for example an optional promo badge. Repair can't tell the difference: list only fields that should always be there.
Summary and classification
Actions
Data
ai.summary is a string. ai.category is exactly one of the labels, or is missing if the model answered something else. summary: true gives a few sentences in the page's language.
Both are answered together with an extraction or a repair.
Options
| Property | Type | Default | Description |
|---|---|---|---|
prompt | string | — | What to extract, in plain words. 2000 characters max |
example | object | — | Values as they appear on the page, by field name (a value, or a list). The fields that extract them are returned in config. See From an example |
schema | object | — | JSON Schema of the extracted data |
config | boolean or string | false | Return the fields written for prompt/schema (and the repaired ones) in config, at the root of the response, and save them (config_id). A config id: saved as its next version. See Saved configs |
repair | boolean | false | Write new selectors for the fields that came back empty |
summary | boolean | string | — | Summarize the page; a string gives instructions (length, language) |
classify | string[] | — | 2 to 50 labels; the page gets one in ai.category |
model | "haiku" | "sonnet" | "opus" | "haiku" | Haiku is the cheapest and is enough for most pages |
max_steps | integer | 8 | Model calls at most, 1 to 15. The last one must answer |
cache_only | boolean | false | Only replay an answer already computed for the same page and request; never call the model (error AI_NOT_CACHED otherwise). The docs playgrounds use it when signed out |
max_chars | integer | 30000 | Size of one prompt at most, 2,000 to 100,000: past it, the model must answer |
browse | boolean | true | Let the model open the page in a browser and run actions |
ai applies to the whole request: it can't be set in a fallback strategy. In steps, batch and crawls, it runs on every page, separately.
Errors
When the model fails, the scrape still succeeds: data holds what the fields found, and ai.error says what went wrong. The error is also added to warnings:
ai.error.code | Meaning |
|---|---|
AI_UNAVAILABLE | The Claude Code CLI isn't installed or isn't signed in |
AI_ERROR | The model failed, timed out (2 minutes per call) or didn't answer with JSON |
Setup
The model runs through the Claude Code CLI, which is installed in the Docker image. It needs one of these in .env:
CLAUDE_CODE_OAUTH_TOKEN: runclaude setup-tokenwith your Claude subscription;ANTHROPIC_API_KEY: billed per token through the API.
Your own model
The AI can run on another model than Claude: any API that speaks Anthropic's Messages format, like Ollama (Gemma, Llama, Qwen…), LiteLLM in front of any provider, or a gateway. Set its URL, its key and two model names: a fast one for extraction and actions, a large one for captchas and pictures, which must read images.
- With your own key (BYOK): in Dashboard → Developers → AI provider. Your requests then use it for everything AI (extraction, AI actions, captchas the AI solves), and those AI calls aren't billed in credits: your provider bills you. The rest of a request (pages, browser, proxies, captchas the service solves) is billed as usual. The URL must be https on a public address. The key is encrypted and never shown again.
- For the whole platform: admins set the default in Admin → AI provider.
- Self-hosted engine:
LLM_BASE_URL,LLM_API_KEY,LLM_MODEL_SMALLandLLM_MODEL_LARGEin.env.
The first one set wins, in that order; without any, it's Claude. "Test the connection" sends one tiny request through the same path as your scrapes.
A smaller model than Claude reads pages less reliably: answers are still checked against the page (a field the model invents fails its check), but expect more steps, and captchas a vision model can't see won't be solved.
The CLI runs with no tools, no settings and no saved session. It only gets these variables, never the proxy key, because the page it reads is untrusted and could contain instructions aimed at the model.