Runs onWrit CloudDesktopSelf-hosted
On this page
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.
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.
| Call | What it does | Cost |
|---|---|---|
POST /api/crawl/scrape | One page, returned as markdown with a character and token count. | 1 page |
POST /api/crawl/map | The URLs a site exposes, without fetching each one in full. | Metered |
POST /api/crawl | Start a crawl across the scope. Returns a job you poll. | Per page |
POST /v1/keyless/crawl | A few same-domain pages, one level deep, with NO account. Returns the pages inline, not a job. | Free, daily-capped |
POST /api/crawl/preview | The 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" cloud.py
cloud = Cloud(api_key=os.environ["WRIT_API_KEY"]) # wt_… — no daemon needed
page = cloud.scrape("https://example.com")
site = cloud.map("https://example.com", search="pricing", limit=20)
print(cloud.tier) # "metered" | "keyless" metered.sh
# Metered — wt_ API key, billed from your credit pool
curl -X POST https://api.usewrit.app/api/crawl/scrape \
-H "Authorization: Bearer $WRIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}' keyless.sh
# Keyless — no account, no key: a stable device id is the only identity.
# 429 keyless_rate_limited when the allowance is spent.
curl -X POST https://api.usewrit.app/v1/keyless/scrape \
-H "X-Writ-Client-Id: $WRIT_CLIENT_ID" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}'
curl https://api.usewrit.app/v1/keyless/quota -H "X-Writ-Client-Id: $WRIT_CLIENT_ID" What one page returns
The single-page call answers a flat body, not a job handle:
verb | Which operation answered. |
url · title | The page that was read and its title. |
format | Always "markdown" on this call. |
markdown | The page body, cleaned. |
counts | chars, 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.
| Field | What it does |
|---|---|
url | Required. The seed the crawl starts from. |
name | A label for the job, so the list reads like your work and not like URLs. |
executor | regular | ai — default regular. The ai executor reasons about each page and weighs 5×. |
extract_mode | markdown | schema — default markdown. |
extract_schema | The field shape to pull out when extract_mode is schema. |
extract_prompt | Plain-English instruction for the ai executor. |
render_mode | auto | http | browser. A warm-browser render weighs 2×. |
ocr_mode | auto | off | force — see documents below. An OCR page weighs 2×. |
persona_id | Crawl signed in, using a saved login identity you own. |
use_residential | Premium network path. Available on premium plans. |
intent | A plain-English goal. It derives the scope and ranks which URLs are worth visiting first. |
seed_urls[] | Extra starting points beyond url. |
relevance_threshold | 0–1. How closely a page must match the intent to be kept. |
include_paths[] · exclude_paths[] | Path regexes. Include narrows, exclude subtracts. |
max_depth | 0–20 links away from the seed. |
page_budget | 1–50000, default 1000. The hard stop for this crawl. |
max_concurrent_shards | 1–64. How wide the crawl runs. |
shard_size | 1–200, default 25. Pages per unit of work. |
delay_ms | 0–60000, default 250. Pause between requests. |
respect_robots | Default true. |
same_domain · allow_subdomains | Both 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); crawl.py
job = client.crawl.start("https://example.com", max_depth=3, page_budget=500)
job = client.crawl.get(job["id"])
table = client.data.workflow_data(job["data_workflow_id"]) crawl.go
job, _ := client.Crawl.Start(ctx, writ.CrawlStartParams{URL: "https://example.com"})
st, _ := client.Crawl.Get(ctx, job.ID) crawl.rs
use writ_client::CrawlStartParams;
let job = agent.crawl().start(CrawlStartParams {
url: "https://example.com".into(),
..Default::default()
}).await?;
let job = agent.crawl().get(job.id).await?; 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.
| status | What it means |
|---|---|
queued | Accepted, waiting to begin. |
mapping | Working out which URLs are in scope. |
crawling | Fetching pages. |
stopping | Cancellation acknowledged, finishing what is in flight. |
completed | Terminal. Everything in scope was visited or budgeted out. |
failed | Terminal. Read error for the reason. |
cancelled | Terminal. 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:
| Field | What it holds |
|---|---|
id · name · seed_url | Identity and where it started. |
include_paths · exclude_paths · max_depth | The scope, as the crawl resolved it. |
same_domain · allow_subdomains · respect_robots | The boundary rules in force. |
extract_mode · extract_schema | What is pulled out of each page. |
persona_id | The login identity used, if any. |
delay_ms · max_concurrent · page_budget | The pace and the ceiling. |
workflow_id · data_workflow_id | Where the collected rows land. |
pages_discovered · pages_done · pages_failed · pages_skipped | The four counters worth charting. |
workers_active · current_depth | How wide and how deep it is right now. |
status · error · cancel_requested · is_terminal | Where it stands, and whether it will move again. |
created_at · updated_at · started_at · completed_at | The 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}.
| Field | What it does |
|---|---|
name | Up to 200 characters. |
slug | Up to 120 characters. The name your calls use. |
description | Free text, for whoever reads the list next. |
default_max_age_seconds | The freshness window to apply when a call says nothing. |
config | The crawl configuration to run. Same fields as starting a crawl. |
from_crawl_id | Or: 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_age | 0 or more seconds. The freshness window for this call. Also accepted as ?max_age= or a Cache-Control max-age. |
wait | Default false. True blocks the HTTP call until the crawl settles. |
timeout | 5–300 seconds, default 120. Only meaningful with wait. |
limit | 1–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:
| Answer | What happened |
|---|---|
200 · cached: true | The last run was inside the window. Its data comes back inline. Nothing was crawled and nothing was metered. |
202 | A miss. A fresh crawl started; the body carries the crawl and its status_url. Poll it. |
504 | wait: 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: 0 | Always 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}' 200 — the window held
{
"cached": true,
"_cache": { "hit": true, "age_seconds": 3512, "source_crawl_id": 8811 },
"definition": { "name": "Docs index", "slug": "docs-index" },
"crawl": { "id": 8811, "status": "completed", "pages_done": 412 },
"status_url": "/api/crawl/8811",
"data": [ { "url": "https://example.com/docs/intro", "title": "Intro" } ]
}
// A miss answers 202 with the new crawl and its status_url instead.
// wait:true that overruns answers 504 — still carrying crawl_id and
// status_url, so the pages already paid for stay collectable. read-only.sh
# What the last completed run collected — any age, never crawls, never bills.
curl "https://api.usewrit.app/api/crawl/definitions/docs-index/data?limit=50" \
-H "Authorization: Bearer $WRIT_API_KEY"
# Force a fresh pass, whatever the definition's default window says.
curl -X POST https://api.usewrit.app/api/crawl/definitions/docs-index/run \
-H "Authorization: Bearer $WRIT_API_KEY" \
-H "Cache-Control: no-cache" 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_mode | What it does |
|---|---|
auto | OCR is used when a page or document has no readable text layer. |
off | Never run OCR. Text-layer documents still get read. |
force | OCR 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 read | Weight |
|---|---|
| Plain HTTP page, or a document | 1× |
| Warm-browser render | 2× |
| OCR page | 2× |
| executor: "ai" | 5× |
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_month | The plan allowance for the period. |
pages_used_this_period · pages_remaining | Where you stand in it. |
per_job_page_cap | The clamp applied to a single crawl. |
max_concurrent_crawls | How many can run at the same time. |
overage_price_micros_per_page | What a page costs past the allowance. |
browser_page_units · ocr_page_units | The 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" 200
{
"pages_included_per_month": 75000,
"pages_used_this_period": 12480,
"pages_remaining": 62520,
"per_job_page_cap": 25000,
"max_concurrent_crawls": 3,
"overage_price_micros_per_page": 500,
"browser_page_units": 2,
"ocr_page_units": 2
}
// -1 anywhere in this body means unlimited. 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.
| Call | What it does |
|---|---|
POST /v1/keyless/scrape | Full markdown for one public page. Costs 1 request and 1 page. |
POST /v1/keyless/map | Up to 200 URLs from a site. Costs 1 request, 0 pages. |
GET /v1/keyless/quota | What is left. Spends nothing. |
POST /v1/keyless/crawl | Always 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.
| Scope | What it grants |
|---|---|
crawl:execute | Start and cancel crawls; create, update and run saved crawls. |
crawl:read | List and read crawls, saved crawls and their collected data. |
scrape:execute | One-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:
| Tool | What it does |
|---|---|
writ_crawl_site | url, extract (markdown | schema), extract_schema, max_pages, max_depth, include[], exclude[], same_domain, allow_subdomains, content{}, persona, save_as, max_age. |
writ_crawl_status | Where a running crawl stands. |
writ_saved_crawls | The saved crawls you can call by name. |
writ_run_saved_crawl | crawl, max_age, limit — the freshness contract, as a tool. |
writ_saved_crawl_data | What a saved crawl already collected. Never crawls. |
writ_scrape · writ_map | One 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" } reference ▸ next
Where the crawl goes next
Every crawl endpoint, field by field.
→ Datasets and filesRead, search and export what a crawl collected.
→ MCPThe crawl tools in any MCP client.
→ ScribeDescribe the crawl in words and let it build the scope.
→ Where it runsCloud fleet, your own machine, or self-hosted.
→ Billing and usageHow pages, runtime and AI tokens are metered together.
→faq
Crawl questions, answered.
What is the difference between the per-crawl page cap and the monthly allowance?
How do I avoid paying for a crawl I already ran?
My wait:true call returned 504. Did I lose the pages?
Why are the booleans on a crawl coming back as 0 and 1?
Are PDFs and scanned pages included?
Can I crawl a whole site without an account?
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.