Runs onWrit CloudDesktopSelf-hosted
On this page
API reference
The whole surface, endpoint by endpoint.
Writ answers in two places: writ-agentd, the agent daemon on your own machine at http://127.0.0.1:8131, and Writ Cloud at https://api.usewrit.app. Same JSON grammar and Bearer header on both — different token families, and only one of the two is metered.
surfaces ▸ local + cloud
Two surfaces.
The local daemon is the software the desktop app (or the self-hosted agent) keeps running: loopback-only and free. Writ Cloud is the hosted surface a wt_ key unlocks. The SDKs discover the first and can carry the second.
| Surface | Base URL | Auth |
|---|---|---|
| Local agent (writ-agentd) | http://127.0.0.1:8131 | Bearer wlt_ · wlk_ · wlo_ |
| Writ Cloud | https://api.usewrit.app | Bearer wt_ · X-Writ-Client-Id header (keyless) |
Use 127.0.0.1, not localhost — the daemon checks Host and Origin against DNS rebinding. An HTTPS twin listens on https://127.0.0.1:8132 with a per-install CA at ~/.writ/tls/ca.pem, and WRIT_PORT overrides the port.
A published workflow is its own door: POST /v1/{slug}/{path} on Writ Cloud, authenticated with a csk_ consumer key you mint for its callers, documented on managed endpoints. The MCP server (JSON-RPC at /mcp) and signed webhook deliveries have their own pages too: MCP server, webhooks.
auth ▸ five prefixes
Token families.
One header everywhere: Authorization: Bearer …. What changes is the token family — each prefix is confined to its surface. Rotation and scope detail live in authentication.
| Token | Surface | Role |
|---|---|---|
wlt_ | Local | Runtime token — the full surface. The only family that can mint scoped keys. |
wlk_ | Local | Scoped key minted via POST /v1/keys; scopes are a CSV of read|run|admin. |
wlo_ | Local | OAuth 2.1 token carrying the run scope. |
wt_ | Cloud | API key for metered cloud calls on api.usewrit.app. |
X-Writ-Client-Id | Cloud | Not a token — a device-id header for the keyless routes and their fixed allowance. |
Minting a wlk_ key requires the wlt_ runtime token — so a leaked scoped key can never widen itself:
const key = await client.keys.create({ name: "ci-runner", scopes: "read,run" }); key = client.keys.create("ci-runner", scopes="read,run") key, err := client.Keys.Create(ctx, "ci-runner", "read,run") let key = agent.keys().create("ci-runner", Some("read,run")).await?; # Minting keys requires the full-access runtime token (wlt_)
curl -X POST http://127.0.0.1:8131/v1/keys \
-H "Authorization: Bearer $WRIT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "ci-runner", "scopes": "read,run"}' errors ▸ stable codes
One error shape.
Errors are small JSON objects: {"error": "…", "code": "…"} — a human sentence and a stable code. The codes:
| Code | HTTP | Meaning |
|---|---|---|
bad_request | 400 | Malformed JSON, a missing field, or a parameter outside its allowed range. |
unauthorized | 401 | No Bearer token, or one the daemon does not recognize. |
captcha_required | 402 | The operation hit a verification step that needs a human to finish. |
forbidden | 403 | The token is valid but its scopes do not cover this operation. |
not_found | 404 | No resource at that id or path. |
device_capacity | 409 | The device is at its capacity limit for this resource. |
vault_locked | 423 | The encrypted vault is locked; unlock it and retry. |
too_many_requests | 429 | Too many requests in a short window; space them out and retry. |
internal | 500 | Unexpected failure inside the daemon. |
A few 4xx paths answer text/plain rather than JSON. When you read error bodies, tolerate non-JSON.
runs ▸ the contract
Run semantics.
The one contract to understand before wiring anything:
- Async by default.
POST /v1/workflows/{id}/runanswers as soon as the run is dispatched, with the run id. - Or block for the result. Add
?wait=true— the call holds until the run settles.timeoutis in seconds, clamped to 1–3600, default 120. - A failed run is a result, not an error. You get the run back with
status: "failed"; keep error handling for transport and auth problems. - Two id shapes. The run feed returns composite ids like
workflow-3; every/v1/runs/{id}/*call takes the numeric row id.
reference ▸ 98 operations
The local surface.
Every operation of the local daemon, exactly as the OpenAPI description states it — 98 operations in 16 groups, all under /v1 on loopback, all Bearer-authenticated.
Every call is this shape — one Bearer header, JSON in, JSON out:
monitor.sh
# Local agent daemon — loopback, wlt_/wlk_ token (use 127.0.0.1, not localhost)
curl -X POST http://127.0.0.1:8131/v1/monitors \
-H "Authorization: Bearer $WRIT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/pricing"}' List envelopes vary by design. Most lists answer {"data": [...], "count": n}; /v1/runs adds "total"; monitors, selectors, extractors, automations and /v1/changes/recent answer bare arrays.
Agent
| Method | Path | Summary |
|---|---|---|
GET | /v1/agent | Lightweight agent status |
GET | /v1/health | Deep health probe |
Workflows
The run semantics above apply to POST /v1/workflows/{id}/run. The session pair manages the browserless HTTP-lane session a workflow can hold.
| Method | Path | Summary |
|---|---|---|
GET | /v1/workflows | List workflows |
POST | /v1/workflows | Create a workflow |
GET | /v1/workflows/{id} | Get one workflow |
PATCH | /v1/workflows/{id} | Update a workflow |
DELETE | /v1/workflows/{id} | Delete a workflow |
POST | /v1/workflows/{id}/run | Run a workflow (async by default, or wait for the result) |
POST | /v1/workflows/{id}/cancel | Cancel the newest live run of a workflow |
GET | /v1/workflows/{id}/session | Browserless HTTP-lane session status |
DELETE | /v1/workflows/{id}/session | Clear the persisted session |
Runs
GET /v1/runs/{id}/events streams live progress over SSE. Cancelling a run that has already settled answers 409 — with the run itself as the body.
| Method | Path | Summary |
|---|---|---|
GET | /v1/runs | List runs (enriched feed) |
GET | /v1/runs/{id} | Get one run |
GET | /v1/runs/{id}/results | Raw run result payload |
GET | /v1/runs/{id}/data | Extracted data of one run |
GET | /v1/runs/{id}/events | Live run event stream (SSE) |
POST | /v1/runs/{id}/cancel | Cancel a live run by run id |
Monitors
| Method | Path | Summary |
|---|---|---|
GET | /v1/monitors | List monitors |
POST | /v1/monitors | Create a monitor |
GET | /v1/monitors/capacity | Device check-capacity meter |
GET | /v1/monitors/{id} | Get one monitor |
PATCH | /v1/monitors/{id} | Update a monitor |
DELETE | /v1/monitors/{id} | Delete a monitor |
POST | /v1/monitors/{id}/run | Run a monitor check now |
GET | /v1/monitors/{id}/changes | Change + uptime history of a monitor |
GET | /v1/changes/recent | Recent changes across all monitors |
Selectors
| Method | Path | Summary |
|---|---|---|
GET | /v1/monitors/{id}/selectors | List a monitor's selectors |
POST | /v1/monitors/{id}/selectors | Add a selector to a monitor |
GET | /v1/monitors/{id}/selectors/{selector_id} | Get one selector |
PATCH | /v1/monitors/{id}/selectors/{selector_id} | Update a selector |
DELETE | /v1/monitors/{id}/selectors/{selector_id} | Delete a selector |
POST | /v1/monitors/{id}/selectors/{selector_id}/toggle | Toggle a selector's enabled flag |
POST | /v1/monitors/{id}/selectors/{selector_id}/test | Probe a selector against the live page |
POST | /v1/monitors/{id}/selectors/{selector_id}/set-baseline | Capture the selector's baseline |
POST | /v1/monitors/{id}/selectors/{selector_id}/clear-baseline | Clear the stored baseline |
Extractors
Note the toggle: PATCH, not POST as on selectors.
| Method | Path | Summary |
|---|---|---|
GET | /v1/selectors/{selector_id}/extractors | List a selector's extractors |
POST | /v1/extractors | Create an extractor |
GET | /v1/extractors/{extractor_id} | Get one extractor |
PATCH | /v1/extractors/{extractor_id} | Update an extractor |
DELETE | /v1/extractors/{extractor_id} | Delete an extractor |
PATCH | /v1/extractors/{extractor_id}/toggle | Toggle an extractor's enabled flag |
POST | /v1/extractors/{extractor_id}/test | Test a saved extractor |
Automations
| Method | Path | Summary |
|---|---|---|
GET | /v1/automations | List automations |
POST | /v1/automations | Create an automation |
GET | /v1/automations/{id} | Get one automation |
PATCH | /v1/automations/{id} | Update an automation |
DELETE | /v1/automations/{id} | Delete an automation |
POST | /v1/automations/{id}/enable | Enable / disable an automation |
POST | /v1/automations/{id}/run | Fire an automation now |
Personas
| Method | Path | Summary |
|---|---|---|
GET | /v1/personas | List personas |
POST | /v1/personas | Create a persona |
GET | /v1/personas/{id} | Get one persona |
PATCH | /v1/personas/{id} | Update a persona |
DELETE | /v1/personas/{id} | Delete a persona |
GET | /v1/personas/{id}/runs | Recent runs that acted as this persona |
POST | /v1/personas/validate-totp | Validate a TOTP seed |
POST | /v1/personas/{id}/test-2fa | Smoke-test the persona's 2FA |
Secrets
Metadata only — no endpoint ever returns a secret value.
| Method | Path | Summary |
|---|---|---|
GET | /v1/secrets | List secrets (metadata only) |
POST | /v1/secrets | Create a secret |
GET | /v1/secrets/{key} | Get one secret's metadata |
DELETE | /v1/secrets/{key} | Delete a secret |
Vault
| Method | Path | Summary |
|---|---|---|
GET | /v1/vault/status | App-lock status |
POST | /v1/vault/lock | Lock the vault now |
POST | /v1/vault/unlock | Unlock the vault |
Files
| Method | Path | Summary |
|---|---|---|
GET | /v1/files | List file handles |
POST | /v1/files | Upload a file (multipart) |
POST | /v1/files/from-data | Export workflow data into a file |
GET | /v1/files/{id} | Get one file handle |
DELETE | /v1/files/{id} | Delete a file |
GET | /v1/files/{id}/content | Download file bytes |
Data
| Method | Path | Summary |
|---|---|---|
GET | /v1/data | Data-explorer workflow picker |
GET | /v1/workflows/{id}/data | Aggregated extracted-data table |
DELETE | /v1/workflows/{id}/data | Delete extracted-data rows |
GET | /v1/workflows/{id}/data/runs | Data snapshot index |
GET | /v1/workflows/{id}/data/facets | Per-column facets |
GET | /v1/workflows/{id}/data/export | Export the extracted-data table |
Datasets
?format=json|csv|markdown|html — any non-json format answers rendered text instead of a JSON body.
| Method | Path | Summary |
|---|---|---|
GET | /v1/datasets | The unified dataset catalogue |
GET | /v1/datasets/search | Global full-text search across every dataset |
GET | /v1/datasets/{id} | Dataset metadata + inferred schema |
GET | /v1/datasets/{id}/records | Page through a dataset's records |
GET | /v1/datasets/{id}/export | Download a dataset's full records |
GET | /v1/datasets/{id}/search | Full-text search within one dataset |
Crawl
Definitions are saved, callable crawls. POST /v1/crawl/definitions/{ref}/run accepts max_age — a recent-enough previous crawl is reused instead of fetched again.
| Method | Path | Summary |
|---|---|---|
GET | /v1/crawl | List crawls |
POST | /v1/crawl | Start a crawl |
GET | /v1/crawl/{id} | Get one crawl |
POST | /v1/crawl/{id}/cancel | Request cancellation of a crawl |
GET | /v1/crawl/definitions | List saved crawls |
POST | /v1/crawl/definitions | Save a crawl configuration |
GET | /v1/crawl/definitions/{ref} | Get one saved crawl |
PATCH | /v1/crawl/definitions/{ref} | Update a saved crawl |
DELETE | /v1/crawl/definitions/{ref} | Delete a saved crawl |
POST | /v1/crawl/definitions/{ref}/run | Run a saved crawl (with optional freshness reuse) |
GET | /v1/crawl/definitions/{ref}/data | Read what a saved crawl already collected |
Keys
Minting requires the wlt_ runtime token.
| Method | Path | Summary |
|---|---|---|
GET | /v1/keys | List API keys |
POST | /v1/keys | Mint a scoped API key |
GET | /v1/keys/{id} | Get one key record |
DELETE | /v1/keys/{id} | Delete a key record |
WebSocket tickets
| Method | Path | Summary |
|---|---|---|
POST | /v1/ws-ticket | Mint a single-use WebSocket ticket |
cloud ▸ metered + keyless
The cloud surface.
Writ Cloud is the hosted, metered surface at https://api.usewrit.app. The spec declares four REST operations there: one-page Scrape with a wt_ key, plus a keyless tier identified only by a device id. Everything else on the cloud host — published endpoints, MCP, webhooks — is documented on its own page.
| Method | Path | Summary |
|---|---|---|
POST | /api/v1/website-to-api | Turn a website into an API |
GET | /api/v1/website-to-api/{id} | Poll a website-to-API build |
POST | /api/crawl/scrape | Scrape one page (metered) |
POST | /api/crawl | Start a whole-site crawl |
GET | /api/crawl/{id} | Poll a crawl |
GET | /api/targets | List monitors |
POST | /api/targets | Create a monitor |
GET | /api/targets/{id} | Get a monitor |
PATCH | /api/targets/{id} | Update a monitor |
DELETE | /api/targets/{id} | Delete a monitor |
PATCH | /api/targets/{id}/toggle | Pause or resume a monitor |
POST | /api/targets/{id}/run | Check a monitor now |
GET | /api/targets/{id}/changes | A monitor's change history |
GET | /api/targets/changes/recent | Recent changes across all monitors |
POST | /v1/keyless/crawl | Crawl a few pages (keyless) |
POST | /v1/keyless/scrape | Scrape one page (keyless) |
POST | /v1/keyless/map | Map a site's URLs (keyless) |
GET | /v1/keyless/quota | Remaining keyless allowance |
Keyless answers 429 keyless_rate_limited when the allowance is spent; metered answers 402 insufficient_credits when the credit pool is empty.
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" faq
API questions, answered.
Is this the API the SDKs call?
How do I wait for a run to finish?
Why did this list return a bare array?
Which token goes where?
Does calling the local API cost anything?
end ▸ ship
Point something at it.
The quickstart takes you from account to first call in minutes; the SDKs wrap this whole page in typed clients.