Press / to search

All documentation
docs Extract Crawl a whole site

Runs onWrit CloudDesktopSelf-hosted

crawl ▸ whole sites

One page, or every page.

The crawl surface is three calls that grow with the job: one page, a map of the URLs, or every page under a scope you set. Save the scope and it becomes callable — POST /api/crawl/definitions/{ref}/run answers from the last run when it is fresh enough, and re-crawls when it is not.

A crawl stays inside the scope you set: same domain by default, path filters you control, a page budget it will not exceed, and a delay between requests.

surfaces ▸ three sizes

Three calls, three sizes of job.

Reach for the smallest one that answers your question. A single page is one metered page; a map is a list of URLs; a crawl walks the scope and fills a dataset you can read, search and export.

CallWhat it doesCost
POST /api/crawl/scrapeOne page, returned as markdown with a character and token count.1 page
POST /api/crawl/mapThe URLs a site exposes, without fetching each one in full.Metered
POST /api/crawlStart a crawl across the scope. Returns a job you poll.Per page
POST /v1/keyless/crawlA few same-domain pages, one level deep, with NO account. Returns the pages inline, not a job.Free, daily-capped
POST /api/crawl/previewThe scope a crawl WOULD use — effective include/exclude/depth plus a kept-vs-dropped sample of URLs.Nothing

One page, two tiers

With a wt_ key the call is metered from your plan. Without any key, the keyless tier answers the same shape for public pages. The keyless tier also crawls: POST /v1/keyless/crawl fetches up to 5 same-domain pages, one level deep, and returns them inline — no fleet dispatch, no persona, no residential egress. Each page spends the same daily allowance as a call to <code>/v1/keyless/scrape</code>, so the daily cap (not the per-request cap) is the real ceiling, and the response states both.

cloud.ts

const cloud = new CloudApi({ apiKey: process.env.WRIT_API_KEY }); // wt_…
const page = await cloud.scrape("https://example.com");
const site = await cloud.map("https://example.com", { search: "pricing", limit: 20 });
console.log(cloud.tier); // "metered" | "keyless"

What one page returns

The single-page call answers a flat body, not a job handle:

verbWhich operation answered.
url · titleThe page that was read and its title.
formatAlways "markdown" on this call.
markdownThe page body, cleaned.
countschars, raw_tokens_est and clean_tokens_est — what it would cost an AI model to read.
tier"metered" when a wt_ key authenticated the call.

Two failures worth handling: 422 scrape_unreachable when the page cannot be fetched, and 402 insufficient_credits when the page is past your plan and the wallet cannot cover it.

scope ▸ what gets fetched

Say what to crawl, and how far.

Only url is required. Everything else narrows the scope, changes how a page is read, or caps the work. Path filters are regexes matched against the path.

FieldWhat it does
urlRequired. The seed the crawl starts from.
nameA label for the job, so the list reads like your work and not like URLs.
executorregular | ai — default regular. The ai executor reasons about each page and weighs 5×.
extract_modemarkdown | schema — default markdown.
extract_schemaThe field shape to pull out when extract_mode is schema.
extract_promptPlain-English instruction for the ai executor.
render_modeauto | http | browser. A warm-browser render weighs 2×.
ocr_modeauto | off | force — see documents below. An OCR page weighs 2×.
persona_idCrawl signed in, using a saved login identity you own.
use_residentialPremium network path. Available on premium plans.
intentA plain-English goal. It derives the scope and ranks which URLs are worth visiting first.
seed_urls[]Extra starting points beyond url.
relevance_threshold0–1. How closely a page must match the intent to be kept.
include_paths[] · exclude_paths[]Path regexes. Include narrows, exclude subtracts.
max_depth0–20 links away from the seed.
page_budget1–50000, default 1000. The hard stop for this crawl.
max_concurrent_shards1–64. How wide the crawl runs.
shard_size1–200, default 25. Pages per unit of work.
delay_ms0–60000, default 250. Pause between requests.
respect_robotsDefault true.
same_domain · allow_subdomainsBoth default true.
content_spec{ preset, include_comments, exclude_selectors, include_selectors, keep } — what part of each page is kept.

Two ways to aim a crawl. Give it include_paths and max_depth and you have described the shape exactly. Give it intent instead and you have described the goal — the crawl derives a scope from it and orders the frontier by how close each URL looks, with relevance_threshold as the cut-off.

POST /api/crawl/preview answers with the scope a crawl would actually use — the effective include and exclude patterns, the depth, and a sample of URLs it would keep next to ones it would drop. It fetches nothing and costs nothing. Run it before a large budget.

Start a crawl

On the local agent the same job starts at 127.0.0.1:8131 with a local token, and the dataset it fills is read back through the data surface.

crawl.ts

const job = await client.crawl.start({
  url: "https://example.com",
  max_depth: 3,
  page_budget: 500,
});
const status = await client.crawl.get(job.id);
const table = await client.data.workflowData(job.data_workflow_id);

progress ▸ status

Watch it work. Stop it any time.

A crawl is a job, not a request: it routinely outlives any sane HTTP timeout, so you get a handle and poll it. GET /v1/crawl lists them (limit 1–500, default 50) under a crawls key; GET /v1/crawl/{id} reads one, or 404 if it is not yours.

statusWhat it means
queuedAccepted, waiting to begin.
mappingWorking out which URLs are in scope.
crawlingFetching pages.
stoppingCancellation acknowledged, finishing what is in flight.
completedTerminal. Everything in scope was visited or budgeted out.
failedTerminal. Read error for the reason.
cancelledTerminal. You asked it to stop.

The counters on a crawl

Every read of a crawl carries the same fields, so one poller covers every venue:

FieldWhat it holds
id · name · seed_urlIdentity and where it started.
include_paths · exclude_paths · max_depthThe scope, as the crawl resolved it.
same_domain · allow_subdomains · respect_robotsThe boundary rules in force.
extract_mode · extract_schemaWhat is pulled out of each page.
persona_idThe login identity used, if any.
delay_ms · max_concurrent · page_budgetThe pace and the ceiling.
workflow_id · data_workflow_idWhere the collected rows land.
pages_discovered · pages_done · pages_failed · pages_skippedThe four counters worth charting.
workers_active · current_depthHow wide and how deep it is right now.
status · error · cancel_requested · is_terminalWhere it stands, and whether it will move again.
created_at · updated_at · started_at · completed_atThe timeline.

A quirk worth knowing before you write the client: the boolean fields on a crawl come back as the integers 0 and 1, not JSON true and false. Test them as numbers, or coerce on the way in.

POST /v1/crawl/{id}/cancel always answers 200, whatever state the job was in, and returns the refreshed crawl plus cancel_requested_now — true when your call is the one that flipped it. Cancelling an already-finished crawl is not an error, so a retry is safe.

saved ▸ callable

Save a crawl, then call it like an API.

A definition is a crawl configuration with a name and a slug. It turns a one-off job into something a key can call: /v1/crawl/definitions on the local agent, /api/crawl/definitions on Writ Cloud. Both take a slug or an id as {ref}.

FieldWhat it does
nameUp to 200 characters.
slugUp to 120 characters. The name your calls use.
descriptionFree text, for whoever reads the list next.
default_max_age_secondsThe freshness window to apply when a call says nothing.
configThe crawl configuration to run. Same fields as starting a crawl.
from_crawl_idOr: copy the configuration from a crawl you already ran.

Send exactly one of config or from_crawl_id. Sending neither answers 400 — the definition would have nothing to run.

Running one

The run body is four fields, all optional:

max_age0 or more seconds. The freshness window for this call. Also accepted as ?max_age= or a Cache-Control max-age.
waitDefault false. True blocks the HTTP call until the crawl settles.
timeout5–300 seconds, default 120. Only meaningful with wait.
limit1–500, default 50. How many collected rows come back inline.

The freshness contract

This is the part to build against. The status code tells you what happened, and no answer is ever a dead end:

AnswerWhat happened
200 · cached: trueThe last run was inside the window. Its data comes back inline. Nothing was crawled and nothing was metered.
202A miss. A fresh crawl started; the body carries the crawl and its status_url. Poll it.
504wait: true ran past its timeout. The crawl is still running and the body still carries crawl_id and status_url — collect it, do not retry.
Cache-Control: no-cache · max_age: 0Always re-crawls, whatever the definition default says.

Every answer carries a _cache object — hit, age_seconds and source_crawl_id — so a client can log why it got what it got. And GET /v1/crawl/definitions/{ref}/data is a pure read of the last completed run at any age: it never crawls and never bills.

Save it, call it, read it

Three calls: create the definition, run it with a window, read what it already holds.

save-and-run.sh

# 1. Save the crawl — name + slug + the config it should always run.
curl -X POST https://api.usewrit.app/api/crawl/definitions \
  -H "Authorization: Bearer $WRIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Docs index",
    "slug": "docs-index",
    "default_max_age_seconds": 86400,
    "config": {
      "url": "https://example.com/docs",
      "include_paths": ["^/docs/"],
      "max_depth": 3,
      "page_budget": 500
    }
  }'

# 2. Call it. Fresh enough? You get the data. Stale? It re-crawls.
curl -X POST https://api.usewrit.app/api/crawl/definitions/docs-index/run \
  -H "Authorization: Bearer $WRIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"max_age": 86400, "wait": true, "timeout": 120, "limit": 50}'

documents ▸ ocr

PDFs, spreadsheets and scanned pages.

A site is rarely only HTML. PDFs, Word, Excel and PowerPoint files, images and scanned pages are read as part of the crawl, with OCR where the text is only pixels. One knob controls it:

ocr_modeWhat it does
autoOCR is used when a page or document has no readable text layer.
offNever run OCR. Text-layer documents still get read.
forceOCR every page, even when a text layer exists.

Where this applies: crawls that run on the Writ Cloud fleet, and self-hosted crawls. A cloud crawl that you route to your own linked machine does not do document extraction — that lane reads pages, not documents. Pick the venue accordingly. OCR pages meter at 2×.

price ▸ per page

A page is $0.0005. Some pages weigh more.

Crawling is pay-as-you-go per page. Inside your plan’s monthly crawl pages it costs nothing extra; past that it bills from the wallet at the same rate, and a wallet that cannot cover the page answers 402 insufficient_credits. Self-hosted deployments crawl on your own agents and carry no page charge from us.

How the page was readWeight
Plain HTTP page, or a document
Warm-browser render
OCR page
executor: "ai"

What each plan includes

Three separate numbers per plan. Read them as three, not one — they answer three different questions.

Plan Crawl pages a month Pages in one crawl Crawls at once
Free 1,000 1,000 1
Starter 15,000 10,000 2
Pro 75,000 25,000 3
Growth 400,000 50,000 6
Scale 1,000,000 50,000 12
Enterprise 2,000,000 50,000 24

These are different numbers. “Pages in one crawl” is the ceiling for a single job; “crawl pages a month” is what your plan includes across every job in the period. On Pro they are 25,000 and 75,000 — the same plan runs three full-size crawls a month before anything bills.

They also fail differently. A page_budget larger than your per-crawl cap is clamped down to the cap and the crawl runs — you are not rejected for asking. The two gates that can actually refuse are the concurrency limit (a crawl too many, while others are running) and the monthly allowance once the wallet cannot cover the overage.

Read the meter

GET /api/crawl/meta/usage answers with your current position, so a client can decide before it spends:

pages_included_per_monthThe plan allowance for the period.
pages_used_this_period · pages_remainingWhere you stand in it.
per_job_page_capThe clamp applied to a single crawl.
max_concurrent_crawlsHow many can run at the same time.
overage_price_micros_per_pageWhat a page costs past the allowance.
browser_page_units · ocr_page_unitsThe weight multipliers, so your estimate matches the bill.

usage.sh

curl https://api.usewrit.app/api/crawl/meta/usage \
  -H "Authorization: Bearer $WRIT_API_KEY"

keyless ▸ no account

No account? One page at a time.

The keyless tier reads public pages with no account and no key. A device is identified by a client id header, and the allowance is small on purpose — it exists so an SDK or the desktop app works before you sign up.

CallWhat it does
POST /v1/keyless/scrapeFull markdown for one public page. Costs 1 request and 1 page.
POST /v1/keyless/mapUp to 200 URLs from a site. Costs 1 request, 0 pages.
GET /v1/keyless/quotaWhat is left. Spends nothing.
POST /v1/keyless/crawlAlways 402 api_key_required — crawling a whole site needs an account.

The caps

  • 10 requests and 20 pages per day, per device.
  • 30 requests per day and 10 per minute, per IP address.
  • A map answers at most 200 URLs.
  • Over any of them: 429 keyless_rate_limited.

Every keyless call carries X-Writ-Client-Id — a stable device id. The SDKs and the desktop app set it for you; a call without it answers 400 client_id_required.

keys ▸ scopes

Three scopes, deliberately uneven.

Reading one page is a smaller thing than crawling a site, so it is a smaller scope. A key handed to a partner for single-page reads cannot start a crawl against your allowance.

ScopeWhat it grants
crawl:executeStart and cancel crawls; create, update and run saved crawls.
crawl:readList and read crawls, saved crawls and their collected data.
scrape:executeOne-page reads, map and preview. Separate, and lesser.

mcp ▸ tools

The same crawl, as MCP tools.

The desktop MCP server exposes the crawl surface as tools, so an AI client can run and re-run a crawl without you writing any HTTP:

ToolWhat it does
writ_crawl_siteurl, extract (markdown | schema), extract_schema, max_pages, max_depth, include[], exclude[], same_domain, allow_subdomains, content{}, persona, save_as, max_age.
writ_crawl_statusWhere a running crawl stands.
writ_saved_crawlsThe saved crawls you can call by name.
writ_run_saved_crawlcrawl, max_age, limit — the freshness contract, as a tool.
writ_saved_crawl_dataWhat a saved crawl already collected. Never crawls.
writ_scrape · writ_mapOne page, or the URL list.

Two behaviours worth knowing. Re-using a save_as name updates that saved crawl rather than creating a second one — so an AI client that keeps refining a crawl leaves you one definition, not twelve. And max_age only means anything together with save_as: without a saved crawl there is no previous run to reuse.

crawl tools

// Crawl a site and save it under a callable name in one turn.
writ_crawl_site {
  "url": "https://example.com/docs",
  "extract": "markdown",
  "max_pages": 500,
  "max_depth": 3,
  "include": ["^/docs/"],
  "exclude": ["^/docs/legacy/"],
  "same_domain": true,
  "allow_subdomains": false,
  "content": { "preset": "article", "exclude_selectors": ["nav", "footer"] },
  "save_as": "docs-index"
}

// Re-using a save_as name UPDATES that saved crawl — it does not duplicate it.
// max_age only matters together with save_as.
writ_run_saved_crawl { "crawl": "docs-index", "max_age": 86400, "limit": 50 }
writ_saved_crawl_data { "crawl": "docs-index" }
writ_saved_crawls {}
writ_crawl_status { "crawl_id": 8811 }
writ_map  { "url": "https://example.com" }
writ_scrape { "url": "https://example.com/pricing" }

faq

Crawl questions, answered.

What is the difference between the per-crawl page cap and the monthly allowance?
They answer different questions. The per-crawl cap is the most pages one job may visit — 25,000 on Pro. The monthly allowance is how many crawl pages your plan includes across every job in the billing period — 75,000 on Pro. A page_budget above the cap is clamped down and the crawl still runs; the monthly allowance is what starts billing the wallet once it is spent.
How do I avoid paying for a crawl I already ran?
Save the crawl as a definition and call it with max_age. If the last completed run finished inside that window you get 200 with cached: true and the data inline — nothing is crawled and nothing is metered. If you only ever want what is already there, call the definition data endpoint instead: it is a pure read at any age.
My wait:true call returned 504. Did I lose the pages?
No. A 504 here means the crawl outlived your timeout, not that it failed. The body still carries crawl_id and status_url, so poll that handle until it reaches a terminal status and collect the data. Retrying the run would start a second crawl and pay twice.
Why are the booleans on a crawl coming back as 0 and 1?
That is the documented shape: fields like same_domain, respect_robots, cancel_requested and is_terminal serialize as integers rather than JSON booleans. Compare them numerically, or coerce them once at your client boundary.
Are PDFs and scanned pages included?
Yes, on the Writ Cloud fleet and on self-hosted crawls: PDFs, Word, Excel and PowerPoint files, images and scanned pages are read, with OCR where there is no text layer, controlled by ocr_mode. A cloud crawl routed to your own linked machine does not do document extraction. OCR pages meter at 2×.
Can I crawl a whole site without an account?
No. The keyless tier covers one public page and a URL map; POST /v1/keyless/crawl always answers 402 api_key_required. Whole-site crawling needs an account, because it needs an allowance to bill against.

end ▸ start one

Preview the scope, then run it.

Preview costs nothing and shows exactly which URLs a crawl would keep. It is the cheapest way to be sure before a large page budget.