Unattended spring-registration prep for one college student, driven by a persistent cloud agent.
It reads public registrar pages, researches candidate sections, flags courses that are not offered every term, assembles ranked schedules, and watches seat availability every 15 to 30 minutes from the day the schedule publishes through the end of add/drop. When a seat opens at 2 a.m., the phone buzzes.
It is general purpose. Everything school-specific lives in a YAML file and a pluggable adapter, so a second university is a configuration change rather than a fork.
- It never registers, adds, drops, waitlists, or submits any form. It alerts and prepares. Every alert ends with a line saying so.
- It holds no credentials. No university single sign-on, ever. If a school's schedule of classes cannot be read logged out, that school is unsupported and the tool says so instead of asking for a password.
- It never publishes the student. The degree audit, transcript, and parsed
requirements live in
/workspace/scheduler/private/, outside the repository clone, and never appear in a log, a digest, a commit, or a chat message.
An unattended watcher that mistakes a login redirect for "no seats available"
is worse than no watcher at all. So every fetch ends in exactly one of three
outcomes, decided by scripts/parse_seats.py and never by a language model:
| Outcome | Means |
|---|---|
DATA |
The page was real and the parser found sections. |
EMPTY |
The page said, in so many words, zero results. |
FAILED |
Anything else. |
FAILED covers an unmatched selector, a response under 2 KB, a non-200 status,
a redirect off the registrar's host, a title containing sign in / login / error
/ unavailable, and a CAPTCHA. seats.json is written only on DATA; a
FAILED run changes nothing but the health record.
Silence is not evidence of no change. If nothing has produced DATA in three
hours, the student gets a message saying the watch is blind and when the last
real page was parsed, repeating every six hours until it recovers.
git clone https://github.com/<owner>/GrokBot-Scheduler.git
cd GrokBot-Scheduler
export SCHEDULER_HOME=~/scheduler SCHEDULER_REPO=$PWD
bash scripts/setup.sh # idempotent; safe to re-run
cp schools/virginia-tech.yml config/school.yml # or config/school.example.yml
$EDITOR config/school.yml # add calendar dates and a watch list
python3 scripts/fetch_seats.py --routine dry_run --dry-run
python3 scripts/health.py
python3 scripts/digest.pyconfig/school.yml is gitignored. It is the only file that knows which student
this instance works for.
- Take the routine's lock. A healthy lock from another run means exit
SKIPPED_OVERLAPimmediately. - Group the watch list by course, so six sections of two courses cost four page loads rather than twelve.
- Fetch, three seconds apart, at most twelve pages per run, one worker. Save
every page to
cache/. - Screen each response, then parse it.
DATA,EMPTY, orFAILED. - Compare against the last observation. Alert only on a transition —
opened,filled, andnearing_fullwhere the school publishes seat counts. The same transition does not re-fire until the section has passed through a different one. - Update
health.json, write one JSONL log and one row inruns.csv, release the lock.
State writes go through scripts/statefile.py: temp file, fsync, rename, with
the previous version kept in state/history/. scripts/restore.py rolls one
back. On 429 or 503 the backoff ladder is 30, 60, 120, 240 minutes. The cadence
floor lives in the config file, not in a chat message, and asking the Bot to
check faster does not move it.
Most schools need no Python. Copy config/school.example.yml, run
scripts/detect_adapter.py against the schedule search page, and fill in the
registrar.generic block — request shape, the pattern that isolates the results
table, and the column order.
Then prove the selectors against a page you actually saved:
python3 scripts/parse_seats.py --probe --term 202601 --subject CS --number 3114 \
--page cache/all.html --page cache/open.html--probe passes only when seven required fields parse on at least three
sections. Nothing gets scheduled against selectors that have not passed it.
A registrar that needs logic rather than configuration gets its own module in
adapters/, roughly a hundred lines: describe the requests, parse the table,
return one of the three outcomes.
The reference implementation. VT's Timetable of Classes is a Banner-era CGI at
selfservice.banner.vt.edu, readable without a login.
- Current terms:
HZSKVTSC.P_ProcRequest - Past terms:
HZSKVTSC.P_DispHistorical— the same CGI withhistory=Y, listing about 112 terms. Rotation analysis reads it; seat watch never does. - VT publishes a section's capacity but not its remaining seats. Rather than
estimate, the adapter reads availability as set membership: the
open_only=onfilter returns only sections with room, so a CRN present in the all-sections result and absent from the open-only result is full. Alerts fire onopenedandfilled;nearing_fullreportsunavailable_at_this_school. - Instructor
N/Ameans the assignment is not yet published. That isUNKNOWN, not "staff".
The fixtures in tests/fixtures/vt_timetable/ were saved from the live site on
2026-09-06. If VT changes its markup, the tests fail — which is the point.
Every line in every artifact is OBSERVED (read on a page, with the page and
the time), INFERRED (derived, with the source), or UNKNOWN. Course profiles
carry a confidence of HIGH, MEDIUM, or LOW.
Grade distributions come only from the registry in data/grade_sources.yml. A
school with no entry gets the literal string unavailable_at_this_school.
Spring 2020 through Spring 2021 is excluded, numbers are reported as a delta
from that term's department mean, and fewer than 20 students is flagged
small_sample.
bot/role.md the agent's role, pasted verbatim
bot/routines.md one paste-ready block per scheduled routine
config/ school.example.yml; school.yml is gitignored
schools/ contributed configs: public URLs and selectors only
adapters/ base contract, vt_timetable, generic
scripts/ fetch, parse, alert, health, digest, state, locks
data/ grade_sources.yml
tests/ fixtures saved from live sites, and the tests over them
logs/ scrubbed weekly copies of run history
On the agent's computer: /workspace/scheduler/{repo,private,cache,state,logs,digests}.
python3 -m pytest tests -qThey run offline against saved pages. scripts/setup.sh refuses to finish if
they fail, so a broken parser cannot be scheduled.
Working: the seat-watch pipeline end to end — adapters, the three-outcome parser, fetching, alerting, health and blind detection, locking, atomic state with rollback, run logging and scrubbing, the weekly digest, publication watch, and adapter detection.
Not yet built: intake of a degree audit, candidate research, grade lookup,
rotation analysis, and schedule assembly (R1, R3, R4, R5, R7 in
bot/routines.md). Their contracts are written; the code is next.
MIT