Releases: usewrit/writ
Release list
Writ coordinator v1.0.4
Crawls can now be read by AI, personas can record their own sign-in, and a crawl whose login silently failed is refused instead of harvesting login screens.
This release includes four schema migrations (0019–0022). They are applied automatically on start by the container entrypoint, so the upgrade is unchanged:
git pull && docker compose up -d --buildIf you run the coordinator with a custom command: that bypasses entrypoint.sh, apply them yourself with alembic upgrade head.
Added
AI reader for crawls (executor=ai). Every fetched page can be read against a plain-language instruction and come back as structured records instead of raw markdown — for data with no clean CSS selector. It runs on your own provider, so nothing is metered or billed.
executor is independent of render_mode: an AI crawl of a JavaScript-rendered site is executor=ai + render_mode=browser, a bulk pass over static pages is executor=ai + render_mode=http. Failure is contained rather than silent — a page whose read fails keeps its markdown, and a crawl started with the AI reader on while no provider is configured is refused up front instead of fetching a whole site to produce exactly what a regular crawl would have.
Let AI sign a persona in, and keep the recording. A persona could previously only get a login workflow if you recorded one by hand or attached one you already had. The coordinator can now ask a connected fleet agent to sign in with the persona's credentials, record the flow, turn it into a workflow and wire it onto the persona — which is then replayed on every session expiry. The coordinator runs no AI of its own; the agent carries its own keys.
Changed
MCP run_<name> tools are now opt-in per workflow. The /mcp server used to mint one tool for every saved workflow. MCP clients inject every advertised tool schema into model context on each request and several enforce hard tool caps, so an instance with many workflows either burned thousands of tokens per turn or had its tool list truncated at the client. Pin the ones you want exposed; everything stays reachable through writ_run_workflow regardless. Existing workflows default to off.
The extracted-data endpoints serve from bounded reads. Every request used to load the full payload of the whole scan window and flatten it in Python just to answer "the newest 50 rows". On a crawl dataset that was tens of megabytes of JSON parsed per request, and the Data page fires several concurrently on open and then polls.
Fixed
A crawl could bank a site's sign-in page as content, 1069 times. Every check applied to a warm session inspected its shape — does it carry cookies, is one HttpOnly, does an auth-looking name appear. Plenty of sites hand an anonymous visitor a perfectly well-formed HttpOnly session cookie, so a login that silently failed produced a session that passed every test there was. The crawl then ran to completion capturing the logged-out page, and nothing in the pipeline could tell.
Sessions are now probed behaviourally — the site itself is asked whether the session is signed in — and a crawl whose persona turns out to be signed out is refused up front rather than producing a corpus of login screens.
Inline extraction returned page furniture instead of the article. The paths that extract without a fleet agent used a regex block-tag stripper whose "first N lines" are a page's nav and header. Those paths now mirror the agent's own extraction ladder server-side.
transfer_imports could fail to be created at all. created_by_user_id was declared String(36) while users.id is a UUID column, and the model placed a foreign key across that mismatch. A strict engine refuses such a constraint outright, so Base.metadata.create_all died on this table and took the rest of the schema with it.
Verification
Tagged only after scripts/release-e2e.sh passed against this commit: docker compose up → create the owner → install the published writ-agent release through the same one-liner the UI prints, checksum verified → record a workflow on a real browser → replay it → call it over REST and over MCP.
706 tests pass, all 22 migrations apply to a single head, and dependency audits are clean on both sides — npm audit 0, pip-audit 0, bandit clean.
You can run the same gate yourself with ./scripts/release-e2e.sh.
The tag is signed — git verify-tag v1.0.4.
Full changelog: CHANGELOG.md · v1.0.3…v1.0.4
Writ coordinator v1.0.3
Personas can now sign themselves in, so an authenticated crawl recovers from an expired session unattended.
This release includes a schema migration (0017_persona_login_workflow). It is applied automatically on start by the container entrypoint, so the upgrade is unchanged:
git pull && docker compose up -d --buildIf you run the coordinator with a custom command: that bypasses entrypoint.sh, apply it yourself with alembic upgrade head.
Added
A persona can be linked to a login workflow, and sign itself in. Until now a persona's warm session could only ever be captured from something that had already logged in — a persona created with credentials alone had no way to establish one, so authenticated crawls using it were rejected indefinitely. Record the sign-in once, attach it to the persona, and the coordinator re-runs it on demand and whenever the session expires. Credentials and 2FA are folded in from the persona, and the captured session is written back to it when the run completes.
Concurrent shards are serialised: one caller takes a lock and performs the sign-in while the rest wait for that result and reuse it. Without this, a crawl starting N shards against an expired session would drive N simultaneous logins at the same account — which is exactly the pattern that trips a site's abuse defences and gets the account locked.
A crawl workflow is rejected as a login workflow. It is orchestrator machinery rather than a recorded sign-in, and pointing a persona at one would make a crawl that needs a login re-enter the very path that asked for it.
Fixed
The language picker always showed "English", whatever language the app was actually displaying. i18next only sets resolvedLanguage for a language it has a dictionary for, and English ships none — it is the source strings. So resolvedLanguage || 'en' read as English even while the UI rendered in French, and the <select> sat on the wrong option until you re-picked the language you were already using. A new activeLanguage() returns the true active base code and is now the single source for both pickers and for date formatting.
A freshly loaded dictionary did not repaint mounted components. The re-emit after a lazy dictionary load compared the exact language tag, but the browser detector supplies regional tags — fr-FR on a French machine — so the match failed and already-mounted components kept their English strings until some unrelated render refreshed them. The comparison is now on the normalised base code.
Linking a login workflow to a persona made the schema unsortable. automation_workflows.default_persona_id already referenced personas, so the new personas.login_workflow_id closed a foreign-key cycle between the two tables and SQLAlchemy could no longer order them — breaking metadata.create_all / drop_all, which SQLAlchemy warns "may raise an error in a future release". The constraint is now emitted with use_alter so it takes no part in the sort. Existing installs were never affected: the migration adds a column to a table that already exists and never sorts the schema as a whole.
Security
React Router upgraded to 7.18.2, clearing three advisories against the 6.x line:
- CVE-2026-53669 — open redirect via backslash in
<Link>anduseNavigate - CVE-2026-53668 — open redirect leading to XSS
- CVE-2026-53666 — arbitrary constructor injection via
deserializeErrors()during SSR hydration
react-router-dom 6.x had no patch available for the second of these, so the major upgrade was the only route to a clean tree.
Verification
Tagged only after scripts/release-e2e.sh passed against this commit: docker compose up → create the owner → install the published writ-agent release through the same one-liner the UI prints, checksum verified → record a workflow on a real browser → replay it → call it over REST and over MCP.
Dependency audits are clean on both sides — npm audit 0, pip-audit 0 — and bandit reports nothing at medium severity and confidence.
You can run the same gate yourself with ./scripts/release-e2e.sh.
The tag is signed — git verify-tag v1.0.3.
Full changelog: CHANGELOG.md · v1.0.2…v1.0.3
Writ coordinator v1.0.2
Attaching a file while recording shipped in v1.0.1, but a file picked during a recording did not survive to replay. These four defects were the gap between the feature existing and the feature working.
No schema or configuration changes — upgrade by pulling and rebuilding.
git pull && docker compose up -d --buildFixed
A recorded upload failed at replay with "no file is bound" — even though you had picked a file. A saved step carries its binding in config when the editor wrote it and in options when the recorder did. The run's file map read only config, so every upload bound while recording was simply absent from it. Both shapes are canonical for a saved workflow — the UI already tolerates the step.x / config.x / options.x spread — so both are read now, with config winning as the explicit later edit.
An upload step with a file already pinned to it was invisible in the run dialog. A pinned file was treated as "not a run-time slot" and excluded entirely, so a workflow that ran perfectly well offered no way to swap the file for a single run without editing the workflow itself. Every upload step is now listed, and a pinned file comes through as that slot's default — the run works untouched, and the default is there to override.
Answering the recorder's file chooser could cancel itself. FilePicker calls onSelect and then onClose, while answering is asynchronous — it mints a signed URL first. Both handlers therefore ran against the same React render, and the close handler sent skip before the answer had gone out, so the file you chose was discarded and the page's dialog was dismissed empty. The prompt is now claimed through a ref that updates synchronously: whichever handler takes it first wins, and the other finds nothing and does nothing. State could not fix this — a state update is not visible to a handler in the same render.
MCP callers could not tell which files a workflow needs. Workflow tools now describe their files parameter per workflow: each slot, its label, and the filename of any pinned default, so a model can see what the call will use if it passes nothing, and which slot it must supply when a step ships no file. The workflow-agnostic runner keeps a generic {slot: file_id} schema. No file_id is ever exposed — only slot names, labels and the default's filename.
Security
The automation builder's hand-off draft now re-validates where it sends you. Leaving the builder to create a workflow stashes the draft in sessionStorage along with a returnTo route, and finishing interpolates that value straight into a navigate() target. It was checked only for being a string.
It is written as location.pathname, so it is same-origin by construction — but it then survives in storage and is re-read rather than re-derived, so anything able to write that key chose where "back to the builder" landed, including an absolute URL or the /\evil.com form that gets past React Router's own check (CVE-2026-53669). It is now resolved against this origin on the way out, via the same safeInternalPath guard the login redirect uses, and falls back to /automations/new when it does not point here. Validating on read rather than on write means the guarantee holds however the value got there.
Defence in depth rather than a fix for a live hole: writing that key requires script execution on the page already. Legitimate builder routes — including query and fragment — are unaffected.
Verification
Tagged only after scripts/release-e2e.sh passed against this commit: docker compose up → create the owner → install the published writ-agent release through the same one-liner the UI prints, checksum verified → record a workflow on a real browser → replay it → call it over REST and over MCP.
You can run the same gate yourself with ./scripts/release-e2e.sh.
The tag is signed — git verify-tag v1.0.2.
Full changelog: CHANGELOG.md · v1.0.1…v1.0.2
Writ coordinator v1.0.1
A feature and fix release. No schema or configuration changes — upgrade by pulling and rebuilding.
git pull && docker compose up -d --buildAdded
Attach a file while recording. When a page opens its file chooser mid-recording, the recorder now asks which stored file to hand it instead of dismissing the dialog empty. Pick one and recording continues; the step is saved bound to that file, so every replay uploads it again with no model in the loop.
The agent never receives your credentials. It is handed a short-lived signed URL for that one file (GET /files/{id}/signed-url, TTL from FILE_SIGNED_URL_TTL_SECONDS), minted only after ownership is checked server-side — an agent cannot authenticate as you, and so cannot read /files/{id}/content itself. Declining is always available and never blocks the page: the chooser is answered either way, and the step is simply recorded unbound rather than leaving the browser stuck on an open dialog.
Changed
A goal-directed crawl now spends its page budget best-first instead of breadth-first. crawl_targeting gained a lexical relevance score — token overlap between the goal and a link's URL path and anchor text, with discounted credit for stem and plural variants, a boost for include-path hits, and a mild depth penalty. It runs on the coordinator with no AI, no network call and no embeddings, so it costs nothing per link. The frontier is ranked before admission rather than filtered after, which is what makes the budget go to pages the goal actually names.
With no goal supplied the score reduces to shallow-first, reproducing the previous breadth-first sweep exactly — untargeted crawls behave as before. Links that match the goal not at all are still followed, because the page you want is often two hub pages away, but they draw from a bounded allowance so they cannot starve the on-topic links found later.
/map harvests a real candidate pool before ranking.
Fixed
Every image in a crawled article rendered as a broken tile. Two defects stacked: the markdown preview had no raw-HTML support, so an <img> tag arrived as literal text; and once it did render, the app's Content-Security-Policy (img-src 'self' data: blob:) refused the remote host anyway.
Markdown  and raw-HTML <img> now take one path, so the two cannot diverge. An image is embedded only when its URL is one the CSP actually permits — inline data:/blob: bytes, or this origin — and anything else degrades to its alt text, the caption the page's author wrote, which is real information rather than a broken tile. The CSP is deliberately not widened to arbitrary remote hosts: rendering a crawled page must not become a way to make your browser fetch from whatever that page names.
The onboarding tour's buttons could sit outside its own card, and a first-time operator could reach the last step and be unable to click Done. The dot strip is now a single progress track that flexes to nothing, the three actions travel as one non-shrinking group, and the panel is sized for the longest language rather than for English. The track carries role="progressbar", so progress is announced rather than being decorative markup a screen reader skipped.
Two invisible elements caused by an invalid utility class. Both asked for bg-border-border-strong; the token is border-strong, so the correct utility is bg-border-strong. Tailwind emits nothing for a class it cannot resolve and warns about nothing at build time, so both elements rendered with no background and simply were not visible.
Verification
Tagged only after scripts/release-e2e.sh passed against this commit: docker compose up → create the owner → install the published writ-agent release through the same one-liner the UI prints, checksum verified → record a workflow on a real browser → replay it → call it over REST and over MCP.
You can run the same gate yourself with ./scripts/release-e2e.sh.
The tag is signed — git verify-tag v1.0.1.
Full changelog: CHANGELOG.md · v1.0.0…v1.0.1
Writ coordinator v1.0.0
The first tagged release of the self-hosted Writ coordinator.
The version matches writ-agent v1.0.0
and writ-mcp 1.0.0. The coordinator,
the agent it drives and the connector that talks to it are one product and are
versioned together, so "which agent goes with which coordinator" is never a
question you have to research.
Getting started
git clone https://github.com/usewrit/writ.git && cd writ
./scripts/gen-env.sh
docker compose up -d --buildOpen http://localhost:8000, create the owner account, then connect an agent from
Fleet — the page prints a one-line installer that fetches the published
writ-agent release for your platform and verifies its SHA-256 before running it.
Full setup, deployment and operations documentation is in the
wiki.
What is in it
A single-owner coordinator you run yourself: browser workflows recorded once and
replayed with no model in the loop, site crawling with PDF/office/OCR extraction,
change monitoring, a REST API over everything, and a 31-tool MCP server so an AI
client can drive it directly. SQLite on disk, secrets encrypted at rest, no
outbound telemetry.
See CHANGELOG.md for the complete list.
How this release was verified
Beyond per-component CI, every release is gated by scripts/release-e2e.sh,
which walks the whole promise across repository boundaries:
docker compose up → coordinator + doc-extract healthy → create the owner →
mint an API key → install the published agent release through the same
one-liner the UI prints, checksum verified → agent connects to the fleet →
record a workflow on a real browser over MCP → replay it → call it over REST →
call it over MCP.
You can run it yourself: ./scripts/release-e2e.sh.
License
AGPL-3.0. GET /api/about reports the running version and where its source
lives, as §13 requires; operators of a modified build point WRIT_SOURCE_URL
at their own fork.