Best practices for agents
Rules for agents built on Spicrawl: an error-handling loop, a cheapest-first escalation ladder, cost and token control, result verification, and a reference fetch_page tool in Python and TypeScript.
An agent that uses Spicrawl well does four things: asks for markdown, checks the site's status as well as the HTTP status, acts on the error code instead of retrying blindly, and escalates one step at a time from the cheapest rendering step. This page gives the rules, then a complete fetch_page(url) tool that follows them.
The short version, for an agent's instructions:
- Request `response_format: "markdown"`; set `max_cost` on every call.
- 200 from Spicrawl is not success until `X-Target-Status` is 200 (404/410: page does not exist).
- On error: switch on `code`; retry only if `retryable`, after `retry_after_seconds`; act on `diagnostics.hint`.
- Escalate: fetch -> js_render -> your own proxy (if you have one). Stop at the first that works.
- More than 20 URLs: batch. Logins: sessions.Handle errors by code
Every error is application/problem+json:
{
"type": "https://docs.spicrawl.com/errors#UPSTREAM_CHALLENGE",
"title": "Target served a bot challenge",
"status": 502,
"code": "ERR::UPSTREAM::CHALLENGE",
"retryable": true,
"doc_url": "https://docs.spicrawl.com/errors#UPSTREAM_CHALLENGE",
"request_id": "01J9Z7A1B2C3D4E5F6G7H8J9KA",
"target_status": 403,
"diagnostics": {
"hint": "The page showed a bot challenge that did not clear from a datacenter exit. Retry through your own residential proxy in `proxy`. Nothing was charged. The verdict is per exit and per moment, so a retry sometimes passes."
}
}The loop an agent runs on each failure:
Switch on code
Never parse title or detail; they are for people. code is stable.
Act on diagnostics.hint
When diagnostics.hint is present it names the parameter to change. Change it before retrying the same request.
Retry only when retryable is true
Wait retry_after_seconds (or the Retry-After header) first; back off exponentially when neither is set. Cap retries at 2 or 3. Failed requests cost 0 credits, but a successful retry is billed as a new scrape because the API is not idempotent.
Otherwise, change the request or stop
A non-retryable error will fail the same way again. Fix the request, escalate, or report to the user with the request_id.
| Code | HTTP | Retryable | What the agent does |
|---|---|---|---|
ERR::UPSTREAM::CHALLENGE | 502 | yes | The site served a bot wall. Retry once, then add js_render: true; if it persists, route through the user's own residential proxy, where Spicrawl moves to a stronger rendering path by itself. |
ERR::UPSTREAM::TIMEOUT | 504 | yes | Retry once. On the fetch tier, try js_render: true; with wait_for, raise wait_for_timeout or fix the selector. |
ERR::UPSTREAM::ERROR, CONNECTION_RESET, DNS_FAILED, TLS_FAILED | 502 | yes | Retry once with backoff. |
ERR::UPSTREAM::TOO_MANY_REDIRECTS | 502 | no | Stop; report the URL. |
ERR::ENGINE::RENDER_FAILED | 502 | yes | Retry once, then report the request_id. |
ERR::ENGINE::UNAVAILABLE | 503 | yes | Back off and retry: the rendering tier this request needs is not available on the deployment right now. |
ERR::LIMIT::RATE_LIMITED, CONCURRENCY_EXCEEDED | 429 | yes | Wait retry_after_seconds, then lower your parallelism. |
ERR::LIMIT::MAX_COST_EXCEEDED | 400 | no | The request would cost more than max_cost. Nothing ran. Raise max_cost deliberately or pick a cheaper option. |
ERR::LIMIT::QUOTA_EXCEEDED | 402 | no | Out of credits or over the monthly ceiling. Stop and tell the user. |
ERR::REQUEST::INVALID_PARAMETER, MISSING_PARAMETER, INCOMPATIBLE_FLAGS | 400 | no | Fix the request; detail names the field. Unknown fields are always rejected. |
ERR::SECURITY::SSRF_BLOCKED, URL_BLOCKED, INVALID_URL | 400 | no | The URL cannot be scraped. Stop. |
ERR::EXTRACT::FAILED | 502 | no | The target is not HTML (an image, a binary) or a PDF with parse_pdf: false. Ask for response_format: "html" or drop extraction. |
ERR::SESSION::BUSY | 409 | yes | Another request is using the session. Run requests on one session one at a time. |
ERR::SESSION::EXPIRED, RELEASED | 410 | no | The session's cookies are gone. Create a new session and log in again. |
ERR::AUTH::* | 401, 403 | no | Stop. INSUFFICIENT_SCOPE names the missing scope. |
The full table is on Errors.
Escalate from the cheapest step
Start with the plain fetch and move up only when the result tells you to. You pay only for the attempt that succeeds; failures cost 0.
| Step | Request fields | Credits | Move up when |
|---|---|---|---|
| 1. Fetch | none | 1 | The content is empty or a "Loading…" shell: go to 2. The site refused (X-Target-Status 403, 429, 503) or ERR::UPSTREAM::CHALLENGE: go to 3. |
| 2. Render | js_render: true | 3 | Refused or challenged: go to 3. |
| 3. Your own proxy | js_render: true, proxy: "<your proxy URL>" | 3 | Stop and report; the page is not reachable today. |
Step 1 already passes sites that reject non-browser TLS fingerprints: impersonate is on by default and free, so do not send impersonate: false. Stealth mode Coming soon and Spicrawl's managed residential exits Coming soon are not in this ladder yet; step 3 applies only when the user supplies a proxy. A step-3 request on your http:// proxy that meets a vendor challenge (Cloudflare, DataDome, Akamai, PerimeterX) moves by itself to a stronger rendering path on the same exit, billed only for the step that returned the page (8 credits for full-browser rendering, 25 for stealth rendering Coming soon); a max_cost below a step's price keeps the request off it, so max_cost: 3 keeps it on step 3. See Anti-bot.
If you do not want to write the ladder, send mode: "auto": Spicrawl escalates from a plain fetch to JavaScript rendering, then full-browser rendering, then stealth rendering Coming soon itself, moving up only when the previous step met a challenge, and bills only the step that succeeded. mode: "auto" cannot be combined with js_render, stealth or solve_captcha, and its max_cost check uses the ladder's dearest step (25 on a direct or datacenter exit, 32 on a residential one).
Prices are the published table from x-spicrawl-credits. X-Request-Cost always shows what the request would cost; X-Credits-Charged shows what was billed.
Control cost
max_coston every request. A request whose dearest possible outcome costs more is refused before it runs withERR::LIMIT::MAX_COST_EXCEEDEDat 0 credits. Set it to the price of the step you intend:1for a fetch,3for a render.- The cache saves the fetch, not credits. It is on by default; a hit shows
Cache-State: hitand is billed at the same price as the fetch that stored it. Keep a result you will need again in the agent's own state instead of requesting it again. Setcache: falseonly for time-sensitive data (prices, stock). Requests withsession_idoractionsare never cached. - Cheapest step first. See the ladder above. Do not start with
js_render: trueon pages that do not need it. - Batches have two caps.
max_costcaps each item;credit_budgetcaps the whole job. - Read
X-Credits-Chargedafter each call and keep a running total in the agent's state.
Choose the output
| You need | Send | Credits added | Notes |
|---|---|---|---|
| Text to read or summarise | response_format: "markdown" | 0 | Best for an LLM. The API default is html, so always set it. |
| Structured, heading-sectioned output (to address a page section by section) | response_format: "json" | 0 | content is the main-content markdown and sections splits it at its headings as {level, heading, text}. Prefer it over markdown when your agent needs the outline. Batch json does not return this shape yet; use markdown there. |
| The page's own metadata (title, price, author) | autoparse: true | 0 | Returns JSON-LD, OpenGraph, microdata and embedded app state in data. No selectors, survives redesigns. Try it first. |
| Specific fields, known layout | extract: {"title": "h1", "price": ".price"} | 0 | Selector map, or a JSON Schema whose properties carry selector. Output in data; broken selectors are listed in empty_fields. |
| Specific fields, unknown layout | ai_extract: {"prompt": "product name, price and stock status"} Coming soon | 4 | Give prompt or schema, not both. Output in data. |
| The data behind a JavaScript app | js_render: true, network_capture: {"resource_types": ["xhr", "fetch"]} | 0 | The page's own API responses, often cleaner than the DOM. |
extract, autoparse, links, ai_extract, network_capture and screenshot all switch the response to the JSON envelope, with the page under content and the result under data.
Stay inside a token budget
main_content_onlyis on by default for markdown and strips nav, footer and aside. Leave it on.include_tags: ["main article"]keeps only matching subtrees;exclude_tags: [".cookie-banner", "nav", ".related"]removes elements. Both take CSS selectors and apply beforemain_content_only.- For a list of links rather than the text, send
links: trueand readlinksfrom the envelope. - If the envelope has
truncated: true, the target body exceeded the size cap and was cut.
Scale with batch
Above about 20 URLs, submit one POST /v1/batch job instead of a loop of scrapes. It accepts up to 10,000 URLs per call, runs them with server-side concurrency and retries, and you poll GET /v1/batch/{id} until status is completed, failed or cancelled.
Each batch item is one fetch or render, returned as html (the default), markdown or text with response_format (spicrawl_batch_submit defaults to markdown). Extraction, screenshots, actions and sessions are refused at submission with a 400 that names them, before anything is queued or charged: run those as single scrapes.
Resubmitting a batch creates and bills a second job. See Batch.
Use sessions for logins
For pages behind a login, create a session (POST /v1/sessions), log in once with actions and the session's session_id, then send the same session_id on every later scrape. The session keeps cookies, storage and one exit IP. A session runs one request at a time (ERR::SESSION::BUSY otherwise). Release it with POST /v1/sessions/{id}/release when done; releasing purges its cookies. See Sessions and logins.
Verify results before using them
A 200 from Spicrawl means the scrape ran. Check the result before the agent trusts it:
| Check | Where | Meaning |
|---|---|---|
| Site status | X-Target-Status, or status in the envelope | 200 is the page. 404 and 410 are real answers (billed). Anything else was returned at 0 credits and means the site refused or failed. |
| Empty extraction | empty_fields in the envelope | Selectors that matched nothing, usually a broken selector or an unrendered page. |
| Warnings | X-Warning headers (CODE: message), warnings in the envelope | RENDER_DEGRADED means wait_for never appeared and the page is as it stood. FORMAT_COERCED means your format was switched to JSON. |
| Content | the body or content | Empty or a "Loading" placeholder means render (step 2). |
| Cost | X-Credits-Charged | What was billed. |
| Balance | X-Credits-Remaining | Monthly allowance left after this request. Stop before it reaches the next step's max_cost, rather than discovering the ceiling as a 402. Absent if your organization has no monthly limit. |
Reference tool: fetch_page(url)
Both implementations are a complete agent tool with no SDK. They request markdown, cap cost per step, check the site's status, retry only retryable errors, and escalate fetch → render → your own proxy (when MY_PROXY_URL is set). They return a small dict the agent can read, including the code, hint and request_id on failure.
import os, time, requests
API = "https://api.spicrawl.com/v1/scrape"
# Cheapest first. The second value is max_cost: the published price of that step.
LADDER = [({}, 1), ({"js_render": True}, 3)]
if os.environ.get("MY_PROXY_URL"): # your own proxy; Spicrawl's managed pool is coming soon
LADDER.append(({"js_render": True, "proxy": os.environ["MY_PROXY_URL"]}, 3))
NEW_EXIT = 2 # first step that changes the exit IP
def fetch_page(url: str) -> dict:
"""Agent tool: return a web page as markdown, escalating only as far as needed."""
headers = {"Authorization": f"Bearer {os.environ['SPICRAWL_API_KEY']}"}
step, retries = 0, 0
while step < len(LADDER):
flags, price = LADDER[step]
body = {"url": url, "response_format": "markdown", "max_cost": price, **flags}
r = requests.post(API, json=body, headers=headers, timeout=180)
if r.ok:
target = int(r.headers.get("X-Target-Status") or 0)
if target in (404, 410):
return {"ok": False, "error": f"page does not exist (HTTP {target})"}
if target == 200 and (step > 0 or len(r.text.strip()) >= 200):
return {"ok": True, "markdown": r.text, "engine": r.headers.get("X-Engine"),
"credits": int(r.headers.get("X-Credits-Charged") or 0)}
# 200 but near-empty on the fetch tier: render. Refused (403/429/503): new exit.
step = step + 1 if target == 200 else max(step + 1, NEW_EXIT)
continue
try:
p = r.json()
except ValueError:
p = {"code": f"HTTP_{r.status_code}", "retryable": r.status_code >= 500}
code = p.get("code", "")
if code == "ERR::UPSTREAM::CHALLENGE":
step = max(step + 1, NEW_EXIT)
elif p.get("retryable") and retries < 2:
retries += 1
time.sleep(p.get("retry_after_seconds") or 2 ** retries)
else:
return {"ok": False, "code": code, "detail": p.get("detail"),
"hint": (p.get("diagnostics") or {}).get("hint"),
"request_id": p.get("request_id")}
return {"ok": False, "code": "ESCALATION_EXHAUSTED",
"detail": "every step of the ladder was refused by the site"}What these tools do and why:
max_costper step. A step that would cost more than planned is refused before it runs, so the most one call can bill is the price of the step that succeeded (3 credits).ERR::LIMIT::MAX_COST_EXCEEDEDon a step means the deployment prices that step above the table (for example, a page served by full-browser rendering at 8 credits). The tool returns it with thedetail, which states the real price; raise that step'smax_costif you accept it.- Near-empty threshold. 200 characters of markdown on the fetch tier is treated as an unrendered shell and moves to rendering. Tune it for your targets; a short page such as
https://example.comrenders the same on every step. - Target status. A refusal (403, 429, 503) comes back as HTTP 200 at 0 credits, so the tool reads
X-Target-Statusrather than trustingr.ok. Refusals skip rendering and go straight to your own proxy when one is set, because rendering does not fix IP reputation. - Retries. Only errors with
retryable: true, at most twice per call, afterretry_after_seconds.ERR::UPSTREAM::CHALLENGEescalates instead of repeating the same request. - Failure output. Non-retryable errors return
code,detail,hintandrequest_id, so the agent can explain the failure or fix its request. Passrequest_idtoGET /v1/requests/{id}for the full trace.
Register the function as a tool with a description such as: "Fetch a web page and return its main content as markdown. Use for reading any public URL. Returns ok=false with an error code when the page cannot be fetched."
llms.txt and Markdown docs
How an AI agent should read the Spicrawl docs: /llms.txt, /llms-full.txt, a .md version of every page, the search API, the OpenAPI spec, the Spicrawl MCP server's docs tools, and spicrawl docs in a terminal.
Claude Code
Set up Spicrawl in Claude Code: add the hosted MCP server, install the agent skill, and add CLAUDE.md rules so the agent fetches web pages cheaply and handles errors correctly.