fraser-gate — the Experience Merge-Gate for AI coding agents
Point FRASER at a build and it generates ICP personas from your product,
drives a real browser as each one, and hands back grounded, machine-readable usability findings —
what real users felt, where they hesitated, misread, or lost trust — then re-runs the identical cast
to gate your fix pass | fail. FRASER is the loop's brain and eyes: it finds where real users break
and verifies the fix helped. It never writes or merges your code; your agent does that — after
validating each finding.
Why this exists: a build can pass its own typecheck + unit tests and still have a user drop off. That gap — experience ≠ correctness — is what the gate catches.
This package is a thin, dependency-light client (an MCP server + a stdlib CLI) that talks to the hosted FRASER API. The real work runs on FRASER's servers.
Setup (one paste)
Add this to your agent's .mcp.json (Claude Code, Cursor, Windsurf, Copilot, …). uvx fetches and runs
fraser-gate on demand — nothing to install first:
{
"mcpServers": {
"fraser": {
"command": "uvx",
"args": ["--from", "fraser-gate@latest", "fraser-gate-mcp"],
"env": {
"FRASER_API_KEY": "<your token — see below>",
"FRASER_API_URL": "https://fraser.lythe.ai"
}
}
}
}
The @latest makes uvx re-resolve to the newest published version each launch, so you auto-update —
no reinstall when we ship a fix. (Drop @latest to pin to whatever uvx first cached; for a
reproducible CI run, pin an exact version like fraser-gate==0.4.0.) If you're on an older config, the
server tells you when an update is available.
Don't have uvx? It ships with uv (curl -LsSf https://astral.sh/uv/install.sh | sh,
or pip install uv). Prefer pip? pipx install fraser-gate (or uv tool install fraser-gate) puts
fraser-gate-mcp and fraser-gate on your PATH; then set "command": "fraser-gate-mcp" with no args
(update those with pipx upgrade fraser-gate / uv tool upgrade fraser-gate).
Get your token
Sign up at fraser.lythe.ai, then mint a long-lived MCP token from the API (two calls — works anywhere, no dashboard needed):
# 1) log in → a short-lived session token (or POST /api/auth/signup to make an account)
SESSION=$(curl -s -X POST https://fraser.lythe.ai/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"your-password"}' \
| python -c "import sys,json;print(json.load(sys.stdin)['token'])")
# 2) exchange it for the long-lived MCP token you paste into .mcp.json
curl -s -X POST https://fraser.lythe.ai/api/auth/mcp-token -H "Authorization: Bearer $SESSION"
# → {"token":"eyJ… ← this is FRASER_API_KEY", "base_url":"https://fraser.lythe.ai"}
Signed in with Google (no password)? Grab the session token from your browser after logging in (it's the bearer token the web app sends), then run step 2. The minted token is a normal session JWT with a long TTL — revoke it by rotating your account, same as any session.
The loop
your agent builds + deploys ─▶ fraser_start_review(target_url) → {run_id, project_id, flow_id}
│ (async, minutes — poll)
fraser_get_status(run_id) ─▶ complete
fraser_get_findings(run_id)
│ → findings[] {persona, sentiment, what_they_wish, evidence, screenshot, finding_id} + guidance + fix_prompt
your agent validates + fixes ◀────┘ (FRASER never touches code)
▼
fraser_validate_fix(project_id, flow_id, baseline_run_id, new_url)
fraser_get_validation(run_id, baseline_run_id)
│ → {gate: pass|fail, per-finding {fixed|still_present|regressed}, new_findings}
gate=fail ⇒ don't merge · gate=pass ⇒ ship
Each finding carries a stable finding_id (content-addressed, survives re-wording) so the gate maps
fix→verify on the same defect across runs — no false "fixed" when the model just renames a bug.
The findings envelope also carries an experience funnel — the per-ICP verdict: {goal, tested, funnel{achieved, partial, blocked, gave_up, completion_rate}, cast[per persona: segment, reached_goal, depth, emotion, takeaway], drop_off[who missed the goal and where]}. Prioritize by it: fix the biggest
drop-off first, not the loudest finding. (Real run vs. linear.app: 5 ICPs, 40% reached the goal, 3
dropped — 2 of them at /signup, matching a high-severity signup finding.)
Run it end-to-end (autonomous)
Every tool response carries a next_step string, so an agent drives the whole loop without a playbook:
fraser_start_review(target_url)— no credentials needed up front: if the app is gated, FRASER gives each tester a disposable inbox and signs up on its own (the image-captcha + emailed code are handled for it).- Poll
fraser_get_statusuntil terminal. If it returns ablockedobject, FRASER couldn't get in on its own —blocked.asksays what's needed; ask the user for a test login and re-runfraser_start_reviewwithauth_notes(or, for abot_wall, retry withbypass_bot_protection). fraser_get_findings— validate each finding against the product yourself, make the smallest change that resolves it, and redeploy the preview.fraser_validate_fix→fraser_get_validation— re-runs the identical cast and gates the fix:gate: pass⇒ ship it — commit and push tomain, or open a PR tomain, per your repo's usual flow.gate: fail⇒ don't merge; fix thestill_present/regressedfindings and validate again.
Tools (6)
| Tool | Use |
|---|---|
fraser_start_review(target_url, mode?, objective?, steps?, success_criteria?, personas?, context?, num_testers?, auth_notes?) |
Start a usability review. Async — returns a run_id. Steer the flow (objective/steps) or the cast (free-text personas). |
fraser_get_status(run_id) |
Poll until terminal (complete/failed/timed_out). Runs take minutes. |
fraser_get_findings(run_id) |
Persona-grounded usability findings + the per-ICP experience funnel (goal-completion + drop-off) + validate-first guidance + fix_prompt. Validate each before changing code; fix the biggest drop-off first. |
fraser_validate_fix(project_id, flow_id, baseline_run_id, target_url) |
Re-run the identical cast on the fix. Async. |
fraser_get_validation(run_id, baseline_run_id) |
The pass/fail gate verdict. |
fraser_cancel(run_id) |
Stop a run. |
It takes minutes. FRASER generates personas from your app, then drives a real browser as each one.
Start the review, do other work, and poll fraser_get_status every few seconds.
Knobs (defaults are good — omit everything and you get the grounded ICP cast on open exploration):
mode—visual(persona usability review, default — this is the product) orbattle(adversarial defect sweep).personas— free-text descriptions to test AS specific people (["a 55-year-old first-time user on an old Android", "a skeptical enterprise buyer"]); omit for the grounded ICP cast.objective/steps/success_criteria— pin a specific flow instead of open exploration.context— one line on what the product is (anchors personas for a new/thin app).auth_notes— app login creds if the core experience is behind a login. Ask the user for a test account and pass them here; secret, never echoed or logged.num_testers—0= the full grounded cast.
localhost just works
Point a review at http://localhost:3000 and the MCP opens an ephemeral public tunnel automatically
(via cloudflared, downloaded once) so the hosted engine
can reach your machine. You set nothing up; the tunnel is verified reachable before the run starts
(cloudflared advertises the URL a few seconds before it actually routes — we wait), and it closes when
the run finishes.
One requirement — serve the app single-origin. The tunnel exposes exactly the one port you point
at. If your page makes browser-side calls to a second localhost port (a separate frontend/API split,
common with docker-compose), those calls break through the tunnel — the remote browser resolves that
port on its machine, not yours. Put everything behind one port (a dev proxy so /api is same-origin)
or hand FRASER a deployed preview URL. A run that couldn't reach the target comes back blocked with
reason: "target_unreachable" (not a credentials problem — see below).
CLI (same loop, for CI without MCP)
The fraser CLI is stdlib-only — a CI job needs nothing but Python and this package:
export FRASER_API_KEY=<token> FRASER_API_URL=https://fraser.lythe.ai
fraser review https://myapp-pr42.vercel.app --wait # prints findings JSON
fraser validate <project_id> <flow_id> <baseline_run_id> <new_url> --wait # exits non-zero on a FAIL gate
fraser validate --wait exits 3 on a fail gate, so a CI step can block the merge on it directly.
Local development of this package
git clone <repo> && cd gate_client
pip install -e .
python -m fraser_gate.mcp_server # stdio MCP server
python -m fraser_gate.cli --help # the CLI
Publishing a new version: see PUBLISHING.md.
MIT licensed. FRASER is a product of Lythe.
Release files for fraser-gate 0.7.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| fraser_gate-0.7.0.tar.gz | 18.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| fraser_gate-0.7.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 40.1 kB
Release files / fraser_gate-0.7.0.tar.gz
| Download URL | fraser_gate-0.7.0.tar.gz |
|---|---|
| Size | 18.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2f7e4dbcdd84df0e6d0ba3b08e0be61d8638a8b23a84e4078fcfbf5383e3645f
|
|
BLAKE2b-256 checksum How to use checksums |
4a405cf407b3203dacf90667705d0ad24825e6b68556ef7d3baea49b12d16806
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.13
|
Release files / fraser_gate-0.7.0-py3-none-any.whl
| Download URL | fraser_gate-0.7.0-py3-none-any.whl |
|---|---|
| Size | 21.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
16c38fa09d47757c960fc43315fc8f3e845a1360521071c94957cfdd338fba3e
|
|
BLAKE2b-256 checksum How to use checksums |
4299dcea92835b9a4765a6f92915a8e3899cfb01bf448ceb8851cffe8d583677
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.13
|