Automated workout data extraction from VirtuaGym using Vercel's agent-browser. Extracts exercises, sets, reps, and weights — then generates structured JSON, text reports, and Instagram-ready images (1080x1350).
Works standalone via CLI, or integrated with Claude Channels, OpenClaw, and other AI agent frameworks.
How it all fits together: see ARCHITECTURE.md for the system design, data flow, and the rationale behind the phone-triggered (Cowork Dispatch → launchd) automation.
- Python 3.x
- Node.js / npm
- agent-browser (Vercel CLI)
Create the workout environment and install dependencies:
micromamba create -n workout python=3.12 -c conda-forge -y
micromamba run -n workout pip install -r requirements.txtWith Homebrew micromamba the env lives under the Cellar (e.g.
/opt/homebrew/Cellar/micromamba/<version>/envs/workout) — note this path moves on micromamba
upgrades. extract.sh resolves the env's python itself and honors a WORKOUT_PYTHON override if
yours lives elsewhere. To run scripts directly:
micromamba run -n workout python extract_workout.py lastnpm install -g agent-browser
agent-browser installAuthentication uses a dedicated, persistent Chrome profile driven over CDP (Chrome DevTools
Protocol). You sign in once via "Sign in with Google"; the live profile keeps its Google SSO
refreshed, so re-login is rare. (This replaces the old virtuagym-auth.json cookie snapshot, which
expired roughly every 30 days.)
python3 browser_session.py --login
# A visible Chrome window opens — choose "Sign in with Google" as your VirtuaGym accountThis creates a profile at ~/.virtuagym-chrome. All future runs launch that profile headless and
connect automatically — no browser window, no manual step.
Why a separate profile? Chrome 136+ refuses to enable remote debugging on your normal default profile, so automation requires its own
--user-data-dir. The profile lives outside the repo and is never committed.
Note: VirtuaGym does not offer a public API — web scraping via agent-browser is the only extraction method available without a business account.
Settings live in config.json (committed, no secrets). Each value resolves as
environment variable > config.json > built-in default, so you can override any of them per-run
with an env var without editing the file.
| Key | Default | Purpose |
|---|---|---|
CHROME_BIN |
/Applications/Google Chrome.app/Contents/MacOS/Google Chrome |
Chrome executable |
CHROME_PROFILE_DIR |
~/.virtuagym-chrome |
Dedicated automation profile |
CDP_PORT |
9222 |
Remote debugging port |
VIRTUAGYM_SIGNIN_URL / VIRTUAGYM_CALENDAR_URL |
thriveandconquer.virtuagym.com URLs | VirtuaGym endpoints |
VIRTUAGYM_GOOGLE_ACCOUNT |
troys2005@gmail.com |
Account shown in the sign-in prompt |
VIRTUAGYM_DROPBOX_DIR |
~/Dropbox/virtuagym |
Where extract.sh copies the IG image (skipped if no ~/Dropbox) |
If the session ever dies, runs detect it and automatically open a headed window for you to sign in again. You can also trigger the one-time login manually:
python3 browser_session.py --login# Extract today's workout (default)
python3 extract_workout.py
# Extract the most recent workout on the calendar
python3 extract_workout.py last
# Extract a specific date
python3 extract_workout.py 2026-03-21
# Other date formats
python3 extract_workout.py today
python3 extract_workout.py yesterday
python3 extract_workout.py "Mar 21"Cowork Dispatch runs in a sandboxed Linux VM — no launchctl, no macOS binaries, and
localhost:9222 is blocked — but it has a read/write mount of this repo. The bridge is launchd's
WatchPaths: the on-demand job watches .dispatch-trigger, so writing that file (which the sandbox
can do through the mount) fires extract.sh natively on the Mac, where Chrome lives.
phone → Dispatch (sandbox) → write .dispatch-trigger → launchd (Mac) → extract.sh → outputs + logs via mount
scripts/launchd.sh subcommands, by where they can run:
# Sandbox-safe (pure file I/O — works from Cowork Dispatch)
scripts/launchd.sh trigger [last|today|YYYY-MM-DD] # write .dispatch-trigger -> launchd fires
scripts/launchd.sh logs [N] # tail logs/extract.{out,err}.log
# Mac terminal only (need launchctl)
scripts/launchd.sh install # register the on-demand job (one-time; re-run after plist changes)
scripts/launchd.sh run-now # launchctl kickstart
scripts/launchd.sh status # load state / last exit code / pid
scripts/launchd.sh uninstallextract.sh re-renders logs/dashboard.html at the end of every run (scripts/status.py +
scripts/dashboard.sh, which themselves need launchctl so they only run on the Mac), so Dispatch
can read a current status dashboard straight from the mount.
Image delivery: on a successful run extract.sh copies the IG image to outputs/ (a
git-tracked folder, so it's visible in the Dispatch repo mount as outputs/latest_ig.png for the
agent to attach to the Outputs panel) and to ~/Dropbox/virtuagym/ (override with
VIRTUAGYM_DROPBOX_DIR). The Dropbox copy is the guaranteed channel — view it in the Dropbox
app/connector on the phone, independent of whether Dispatch can attach files. (outputs/ is tracked
via .gitkeep; the images inside it are gitignored.)
Trigger de-duplication: a single .dispatch-trigger write can emit several FSEvents, so
extract.sh --from-trigger fingerprints the trigger (mtime + content) and ignores duplicate fires; a
mkdir lock also prevents overlapping runs. One write → exactly one extraction.
Phone recipe: "In virtualgym-agent, run scripts/launchd.sh trigger, then scripts/launchd.sh logs until it prints Done!, then attach the file outputs/latest_ig.png so it appears in Outputs
(share the actual file, not just a description)." The explicit "attach the file" matters — Dispatch
only surfaces files the agent actively shares. If it still won't surface, grab the image from the
Dropbox virtuagym/ folder instead.
The script will:
- Load saved auth and open the VirtuaGym Activity Calendar
- Navigate to the target date
- Click through each exercise to read detailed set/rep/weight data
- Generate three output files in
data/andimages/
| File | Location | Description |
|---|---|---|
workout_YYYY-MM-DD.json |
data/ |
Structured workout data with exercises, sets, volume |
workout_YYYY-MM-DD_report.txt |
data/ |
Human-readable text summary |
workout_YYYY-MM-DD_ig.png |
images/ |
Instagram image (1080x1350, dark theme) |
virtualgym-agent/
├── README.md # This file
├── CLAUDE.md # Claude Code project context
├── ARCHITECTURE.md # System design, data flow, decision rationale
├── extract.sh # Single entry point (terminal + launchd); dedup, delivery
├── extract_workout.py # Main extraction pipeline (agent-browser)
├── browser_session.py # Persistent Chrome profile + CDP connect/login
├── generate_ig_workout.py # Pillow-based IG image generator
├── config.json # Browser/VirtuaGym settings (committed, no secrets)
├── requirements.txt # Python dependencies (pip)
├── scripts/ # launchd.sh, status.py, dashboard.{html,sh}
├── fonts/ # Poppins font files for image generation
├── data/ # JSON reports and text summaries (gitignored)
├── images/ # Generated Instagram images (gitignored)
├── outputs/ # Delivery folder for Dispatch (tracked dir, ignored contents)
└── logs/ # launchd job logs + dashboard.html (gitignored)
Two alternating programs, typically 2-3x/week:
-
AX-1 Squat/Pull: Dead bug, Scapular pull up (Rig), Side pivot (MRB), Horizontal row exorotation (EBs), Sumo squat stretch rotation > Squat (Barbell) > Plank jacks (Flowin), Assisted standing pull up wide grip, Split front squat L/R (Barbell), Squat to hammer curl (DBs), Wide back row (ST), Slam ball (MB)
-
AX-2 Press/Hinge: Neck pull (EB), Hand walk plyo pushup, Pallof press R/L (Pulley) > Goodmorning, Bench press wide grip (Barbell) > Knee raise side (Captains chair), Assisted dipping machine, Stiff legged deadlift (Barbell), Side raise seated (DBs), Hang clean press L/R (KB), Forward push (Sled)
Per VirtuaGym convention:
- Rep-based with weight:
reps x weight_lbsper set - Time-based with weight:
seconds x weight_lbsper set (seconds treated as reps) - No weight: volume = 0