Thanks for being here. WebHarbor lives across two repositories on purpose:
webharbor(this repo, GitHub) — code: per-site Flask apps, control plane, scripts, Dockerfile.ChilleD/WebHarbor(Hugging Face dataset, https://huggingface.co/datasets/ChilleD/WebHarbor) — heavy assets:instance_seed/*.db,static/images/,static/external_cache/for every site.
A non-trivial change usually touches both. The workflow below makes that straightforward.
WebHarbor splits work across two roles on purpose:
- Contributor — builds the site: the Flask app, seed DB, assets, and the task list (
tasks.jsonl). A contributor'stasks.jsonlrows carry only the task definition:web_name, id, ques, web, upstream_url. The contributor does not write verifiers, rubrics, or any answer key. - Reviewer — validates the contribution: checks site quality (mechanical + functional), checks that each task is feasible (actually solvable by navigating the site, and not trivially answerable from an LLM's prior knowledge), and then writes the grading contract — a deterministic verifier per task plus a
judge_rubric, recorded back intotasks.jsonlasverifier_path+judge_rubric.
Workflows A and B below are the contributor's job. The Reviewer role section later in this file is the reviewer's job. The split keeps ground truth out of the agent-facing file (a contributor's tasks.jsonl never contains answers) and puts grading rigor where it belongs — with the person checking the work.
# fork github.com/webharbor/webharbor + huggingface.co/datasets/ChilleD/WebHarbor
git clone https://github.com/<you>/webharbor && cd webharbor
./scripts/fetch_assets.sh # pull current assets
./scripts/new_site.py mywebsite # OR edit an existing site
./scripts/build.sh && docker run -d --rm \
-p 8101:8101 -p 40000-40023:40000-40023 webharbor:dev
# iterate locally...
./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out
cd ../webharbor-static-pr
hf upload-large-folder <your-fork>/WebHarbor . --repo-type dataset
# open PR on HF first → grab the merge sha
cd ../webharbor
echo "revision: <hf-merge-sha>" > .assets-revision
git commit -am "feat(mywebsite): add site + bump assets to <sha>"
gh pr createWe aim for 100 sites; new ones are very welcome. A "site" is a self-contained Flask app under sites/<name>/ that mirrors a real website's behavior closely enough that an agent which works on the real site also works here.
websyn_start.sh lists SITES=(...) in port order; the new site goes on 40000 + index. Add it to:
websyn_start.sh— theSITES=( ... )arraycontrol_server.py— theSITES = [ ... ]list (must match exactly)Dockerfile—EXPOSE 8101 40000-Nif you push the upper bound
./scripts/new_site.py mywebsiteThis creates sites/mywebsite/ with the standard skeleton:
mywebsite/
├── app.py ← edit this
├── _health.py
├── requirements.txt ← only Flask by default
├── templates/index.html
├── static/{css,js,icons,images,external_cache}/
├── instance_seed/ ← drop your seed DB here as <name>.db
├── instance/ ← gitignored, recreated at boot
└── scraped_data/ ← gitignored, build-time only
The DB is the single source of runtime data. Everything an agent sees at request time should come from here. Anti-pattern: reading JSON files at request handler time.
A typical seed flow:
- Define SQLAlchemy models in
app.py(User, Product, Article, ...) - Write a
seed_data.pythat materializes a dataset into the DB. Make the function idempotent —if Foo.query.count() > 0: returnat the top. - Run once locally to produce
instance/<name>.db. - Copy it to
instance_seed/<name>.db. This is your seed.
Each route should:
- Return 200 on the happy path.
- Render non-empty content (no blank pages).
- Use links / forms / buttons that are reachable from
/. WebHarbor agents click their way around — orphan pages are a smell.
If your site has multiple categories / pages / topics, make sure the seed DB has enough rows in each that filters / pagination / search look plausible (≥ ~20 records per major filter).
./scripts/build.sh
docker run -d --rm --name wh-test \
-p 8101:8101 -p 40000-400NN:40000-400NN webharbor:dev
# the new site should be on port 40000+i
curl -so /dev/null -w "%{http_code}\n" http://localhost:400NN/
curl -X POST http://localhost:8101/reset/mywebsite
# make sure /reset/mywebsite keeps the DB byte-identical to the seed
docker exec wh-test md5sum \
/opt/WebSyn/mywebsite/instance/<name>.db \
/opt/WebSyn/mywebsite/instance_seed/<name>.db
# both md5s MUST match — see "Idempotent seeding" belowAdd one JSON line per task to sites/<site>/tasks.jsonl. The contributor writes ONLY these keys:
{"web_name": "My Site", "id": "My Site--0", "ques": "...", "web": "http://localhost:4000N/", "upstream_url": "https://realsite.com/"}idis"<SiteName>--<N>"(0-indexed, matches theweb_name).quesis the natural-language task the agent must perform by navigating the mirror.webis the mirror base URL (the in-container port40000 + index).upstream_urlis the real site being mirrored.- For login tasks, put the demo account credentials directly in
ques(e.g."Log in with the demo account (email: alice.j@test.com, password: TestPass123!), then ...") so an autonomous agent can log in.
Do NOT add verifier_path, judge_rubric, or any answer key — those are the reviewer's to add (see "Reviewer role"). Ground truth never lives in this file because the agent reads it.
Design tasks to be feasible and meaningful: each must be solvable by navigating the site, and ideally should require reading a page-specific fact an LLM can't recall from memory (exact dates/IDs, on-page wording, a specific row) rather than general knowledge. The reviewer will reject tasks that are trivially answerable from prior knowledge or ill-posed for autonomous evaluation.
The HF dataset stores one <site>.tar.gz per site (avoids the small-file
stall on hf download for 4000+ images). extract_assets.sh packs your
site into a single tarball; upload just that one file.
./scripts/extract_assets.sh ../wh-static-pr/ mywebsite
cd ../wh-static-pr
hf upload mywebsite.tar.gz <your-fork>/WebHarbor mywebsite.tar.gz --repo-type dataset
# Then open a PR on https://huggingface.co/datasets/ChilleD/WebHarbor
# After it's merged, copy the merge commit sha.
cd ../webharbor
# bump the pin
sed -i "s/^revision:.*/revision: <hf-merge-sha>/" .assets-revision
git add .
git commit -m "feat(mywebsite): add new site
Adds Flask app, templates, and seed DB for <real-site-name>.
Assets uploaded to HF as mywebsite.tar.gz; .assets-revision bumped to <sha>."
gh pr create --title "feat(mywebsite): add new site"GitHub PR description should include:
- Real site mirrored + URL
- Number of seeded rows per major model
- Link to the HF PR (the asset side)
- Output of
curl -X POST .../reset/mywebsiteshowingready: true
Common case: you replaced 50 product images, or refreshed an instance_seed DB.
git checkout -b update-amazon-imgs
# put new files in sites/amazon/static/images/ or sites/amazon/instance_seed/
./scripts/build.sh && docker run ... # smoke test
./scripts/extract_assets.sh ../wh-static-pr/ amazon # pack only amazon
cd ../wh-static-pr
hf upload amazon.tar.gz <your-fork>/WebHarbor amazon.tar.gz --repo-type dataset
# (single-file upload keeps the PR scoped to one site)Open the HF PR; once merged, bump .assets-revision in this repo and open the GitHub PR. CI on the GitHub PR will fail-closed if the pinned revision isn't reachable.
The reviewer picks up a contributor's PR (site + tasks.jsonl with the basic keys) and does two things: (A) validate site quality and task feasibility, then (B) add the grading contract (verifier + verifier_path + judge_rubric). Only after both pass should the PR merge.
Build the image from the branch and run it on alt ports (see AGENTS.md "Pre-PR checks" for the exact commands). Then:
- Mechanical — every site returns 200;
/healthall alive;POST /reset/<site>wipes runtime writes and restores the DB byte-identical to the seed (md5(instance) == md5(instance_seed));reset-allcompletes in ~1s. - Functional — drive the site's routes (auth, search, list/detail, any stateful action) and confirm each renders correct, non-empty content. The contributor's tasks must be genuinely completable on these pages.
- Task feasibility — for each task in
tasks.jsonl, confirm it is solvable by navigating the site and is not trivially answerable from an LLM's prior knowledge. Drive a few tasks end-to-end (manually or withagent_demo/agent.py). Reject — and send back to the contributor — tasks that:- can be answered without ever opening the site (e.g. a common dictionary definition),
- are ill-posed for autonomous evaluation (e.g. "ask me which one" presupposing a human in the loop),
- have no stable, verifiable answer (e.g. a "today" value that rotates by run date),
- or are mechanically trivial / non-deterministic in a way that defeats grading (e.g. a quiz score the agent can't be graded on).
The merriam_webster review is the reference example of this step: several original tasks were rejected as knowledge-shortcuts / human-in-the-loop / date-dependent and re-anchored onto page-specific facts.
For each task the reviewer accepts, the reviewer writes the grading artifacts and records them in tasks.jsonl:
- A deterministic verifier — one Python script per task, placed under the site's own
sites/<site>/verify/directory (so each site is self-contained; verifiers never live underagent_demo/). It emits a binary PASS/FAIL from the run signature(initial_state, after_state, trajectory, agent final output). Deterministic-first (navigation / regex / token / SQLite after-state); LLM only as an anchored utility. The ground truth is HARDCODED inside the verifier — never intasks.jsonl(the agent reads that file; an answer key there leaks answers). Seesites/merriam_webster/verify/verify_lib.pyfor the shared utilities (load_run,navigated_to,llm_text_match,llm_screenshot_shows, SQLite helpers, theJudgeharness) andsites/merriam_webster/verify/verify_*.pyfor one-per-task examples. verifier_pathin the task row — the relative path (from repo root) to that verifier, e.g.sites/merriam_webster/verify/verify_0.py.judge_rubricin the task row — a short English block of "FACT CHECKPOINTS" the LLM judge verifies (which pages the agent MUST have opened, which facts/answers MUST appear, that an empty answer is a FAIL). The rubric states the rules, not the answers, so it's safe for the agent to see.
After the reviewer's pass, tasks.jsonl has these keys: web_name, id, ques, web, upstream_url, verifier_path, judge_rubric. There is no answer key. Sites whose tasks predate this contract may omit verifier_path and judge_rubric (both optional); agent.py and eval_judge.py handle their absence gracefully.
The reviewer confirms the grading contract is sound before merge:
- A no-op run (agent opens the homepage, does nothing, empty answer, clean DB) makes every verifier return FAIL (exit 1) — no false positives.
- A PASS case (drive a task to completion correctly) makes its verifier pass; a shortcut case (correct answer but no on-site navigation) and a wrong-answer case both FAIL — proving the verifier can't be fooled.
- For stateful tasks, a state-mismatch case (agent self-reports success but the DB is unchanged) FAILs on the DB check.
- The LLM judge appends a rubric-specific system-prompt block (and emits
rubric_checkpoints) ONLY for tasks with a non-emptyjudge_rubric; tasks without one get the plain base prompt.
Why two graders: an LLM-as-judge alone is gullible — a plausible-but-wrong answer, or a correct answer recalled from memory with no page visit, can pass. The deterministic verifier catches both (wrong-answer via ground-truth match; knowledge-shortcut via the navigation check). The rubric makes the LLM judge stricter and more consistent. The verifier is the primary grader; the rubric-driven LLM judge is secondary/lenient. Both are invoked through the single agent_demo/eval_judge.py entry point (--verifier True for the verifier, default for the LLM judge). See sites/merriam_webster/verify/README.md and the merriam_webster site for a worked example (20 tasks, 20 verifiers + verify_lib.py).
All three tools read the same env vars (CLI flags override):
| env var | meaning |
|---|---|
OPENAI_API_KEY |
bearer token for the OpenAI-compatible endpoint |
OPENAI_BASE_URL |
base URL of that endpoint |
JUDGE_MODEL |
model id used by BOTH the agent, the LLM judge, and the verifier LLM utilities |
Run an agent task and grade it (reviewer validation loop):
export OPENAI_API_KEY=... OPENAI_BASE_URL=http://api.openai.com/v1 JUDGE_MODEL=GPT-5
# agent (writes trajectory.json + screenshots/, carries judge_rubric in)
uv run python agent_demo/agent.py --tasks_file sites/<site>/tasks.jsonl \
--task_id "<site>--N" --url http://localhost:40000+i/ --out_dir runs/x
# deterministic verifier (primary grader) — run via eval_judge's verifier mode
uv run python agent_demo/eval_judge.py --run_dir runs/x --verifier True
# LLM judge (secondary, rubric-driven) — default mode
uv run python agent_demo/eval_judge.py --run_dir runs/xNote: the tools use simpleArgParser, so boolean flags take a value (--no_llm True, --headless False), not a bare flag.
These exist because we got bitten:
Every seed_database() (and any seed_*() helpers called at module import time inside with app.app_context():) must early-return when the DB is already populated. The pattern is:
def seed_database():
if Partner.query.count() > 0:
return
# ... rest of seedPer-row gates are not enough: the bare act of opening a SQLAlchemy session and committing zero changes still bumps SQLite metadata, which breaks /reset/<site> byte-identity. See feedback_seed_stabilization in the project history for the war story.
If you have multiple seed phases (seed_database, seed_benchmark_users, seed_extras), gate each of them. After a fresh seed, re-running the boot path should be a no-op. Test with:
docker exec wh-test md5sum /opt/WebSyn/<site>/instance{,_seed}/<site>.db
# must match
docker restart wh-test && sleep 5
docker exec wh-test md5sum /opt/WebSyn/<site>/instance{,_seed}/<site>.db
# must STILL matchAnything an HTTP handler reads should come from SQLite, not from a JSON file under scraped_data/. We ran into this with bbc_news: gallery data lived in scraped_data/article_galleries.json, the request handler read it on every page view, and the JSON was redundant with the DB.
If you have intermediate scrape data, that goes in scraped_data/ (gitignored, dockerignored). Once you've written a seed_* function that turns it into DB rows, the JSON is build-time only.
Sites must not import from one another. The image launches each as an independent process; sharing breaks isolation and makes /reset/<site> non-atomic from the perspective of other sites.
Each site sets SECRET_KEY to a deterministic dev value. Acceptable for a benchmark image (resets blow away sessions anyway). If a contrib ever needs real secrets, raise it in an issue first.