Skip to content
Writ edited this page Aug 5, 2026 · 3 revisions

Writ — the operator's manual

Self-hosted Writ: record a browser session once, get a callable API, an MCP tool, or a monitor. This wiki is for running it. For what it is, start at the README.

Getting it running

  • Quickstartgen-env.sh, docker compose up, open localhost:8000. Two minutes, no domain needed.
  • Connecting agents — the coordinator runs no browsers itself, so nothing executes until one agent is connected. Start here if the app is up but a run never starts.
  • Production deployment — putting it on a public domain with HTTPS, upgrading it, and what to do when it breaks.

Reference

These live in the repo so they version with the code:

  • docs/DEPLOYMENT.md — every production setting, bring-your-own-proxy, rate limiting, backups.
  • .env.example — the annotated configuration reference. Every variable is documented where it is defined.
  • SECURITY.md — the security model and how to report a vulnerability.
  • CHANGELOG.md.

Calling a crawl like a workflow

A crawl row is one run: its settings live on the row and its id dies with that run. So a crawl could not be exposed as an API the way a workflow can — the URL would change every time you re-crawled — and "crawl that site again with the same settings" meant refilling the form.

A saved crawl fixes that. Save a crawl's settings once and you get a permanent endpoint plus a history, which is what makes freshness answerable:

# Save the settings a crawl actually ran with (the server reads the row, so nothing
# is lost — a crawl's status view does not echo every knob).
curl -X POST https://writ.example.com/api/crawl/definitions \
  -H "Authorization: Bearer wt_..." -H 'Content-Type: application/json' \
  -d '{"name":"Docs","from_crawl_id":41,"default_max_age_seconds":86400}'

# Then call it. Within the window you get the pages it already collected — instantly,
# with nothing crawled. Older than that, it re-crawls with those exact settings.
curl -X POST https://writ.example.com/api/crawl/definitions/docs/run \
  -H "Authorization: Bearer wt_..." -H 'Content-Type: application/json' \
  -d '{"max_age": 86400}'

Every answer says which you got: _cache.hit and _cache.age_seconds. A fresh crawl answers 202 with a crawl id to poll, because a whole-site crawl outlives an HTTP request; "wait": true blocks instead, and a 504 still carries the crawl id so the work stays collectable.

Two rules keep a reused answer trustworthy: only a completed run qualifies, and a run that fetched nothing never does — that is the shape a host that 403s every page produces, and it must not pin an empty answer behind a day-long window.

The key needs crawl:execute to run one and crawl:read to read its data. Mint one in Settings → API keys, or use the Call this crawl button on any crawl's page, which saves the settings and mints the key for you. Assistants get the same thing as MCP tools — writ_saved_crawls, writ_run_saved_crawl, writ_saved_crawl_data.

Calling a workflow by URL

Two routes run a workflow over HTTP, and they differ in what authenticates the call — pick by how the URL will be handed out.

Route Credential Use it for
POST /api/webhooks/hook/{token} The unguessable token is the credential, plus a mandatory HMAC signature (X-Writ-Signature + X-Writ-Timestamp) Third-party senders (Stripe, GitHub) that sign their payloads
POST /api/v1/webhooks/{custom_path} An API key with triggers:execute, scoped to the target workflow Your own code, where a readable URL matters more than an opaque one

A custom path is human-chosen (myapp/getUser), so it is guessable and cannot be its own credential — that route requires a key and fails closed without one. A signature is optional there, and still verified if you send one.

curl -X POST https://writ.example.com/api/v1/webhooks/myapp/getUser?wait=true \
  -H "Authorization: Bearer wt_..." -H 'Content-Type: application/json' \
  -d '{"user_id": 42}'

?wait=true returns the workflow's extracted data in the same call (&timeout= bounds it, 10–300s); without it you get the dispatch record and poll the run. Both routes are rate-limited to 30 calls/minute.

Set a custom path when creating the trigger, or read custom_webhook_path back from GET /api/webhooks/triggers. Recording an API-style workflow mints one endpoint per function automatically and returns them as endpoints on the create response.

The three commands

./scripts/gen-env.sh                              # fresh secrets in .env
docker compose up -d --build                      # local, loopback only
./scripts/deploy.sh writ.example.com you@here.com # public, with HTTPS

Getting help

Clone this wiki locally