Headless TypeScript agent that watches course sources (CourSys course pages and forwarded emails), uses an LLM to extract concrete schedule items (assignments, midterms, lectures, office hours, ...) into a strict Zod schema, and syncs them into Google Calendar idempotently. PDFs and other handouts referenced from the source are downloaded into per-subject folders.
Designed to run from Task Scheduler (Windows) or cron (macOS / Linux) twice a day. Re-runs are safe - every calendar event ID is a deterministic function of (subject.id, itemId), every email is dedup'd by Gmail message ID, every site page is dedup'd by SHA-256 of normalized text, and every file is dedup'd by SHA-256 of its bytes.
For internals, read docs/auto-schedule-architecture.md.
- Node 20+ / TypeScript (strict)
puppeteer- CourSys scrapinggoogleapis- Gmail + Calendarai+@ai-sdk/openai- LLM call (Vercel AI SDK, OpenAI provider pointed at OpenRouter)zod- schema + structured-output validationbetter-sqlite3- state storepino+pino-pretty- multi-target structured loggingdotenv- config
npm installCopy .env.example to .env and fill in:
| Var | Required | What it is |
|---|---|---|
OPENROUTER_API_KEY |
https://openrouter.ai/ key (starts with sk-or-v1-) |
|
GOOGLE_OAUTH_CLIENT_ID |
"Desktop app" OAuth client from Google Cloud Console | |
GOOGLE_OAUTH_CLIENT_SECRET |
Paired with the client ID | |
GOOGLE_CALENDAR_ID |
optional | Defaults to primary |
NTFY_TOPIC |
one of | ntfy topic for auth-failure push |
PUSHOVER_TOKEN + PUSHOVER_USER |
one of | Pushover credentials, if not using ntfy |
AGENT_MODEL |
optional | OpenRouter model string (default openai/gpt-4o-mini) |
LOG_LEVEL |
optional | debug / info (default) / warn / error |
LOG_JSON |
optional | 1 to switch logs to raw JSON |
In Google Cloud Console, enable the Gmail API and Google Calendar API, configure the OAuth consent screen, add your gmail as a test user, and create a Desktop app OAuth client. Then:
npm run setup:googleOpens a browser, you grant the app Gmail-read + Calendar access, and the refresh token lands at data/auth/google.json.
npm run setup:coursysOpens a headful Chromium pointed at https://coursys.sfu.ca/. Log in via CAS, and once the tab is back on CourSys with a session cookie, the script saves cookies to data/auth/coursys.json and closes the browser. No password is ever stored. Sessions last ~7 days; if one expires the pipeline pushes a "re-auth coursys" notification + drops a calendar reminder, and you re-run this command.
Edit src/config/subjects.ts:
export const subjects: Subject[] = [
{
id: 'cmpt307',
name: 'CMPT 307',
professor: 'Valentine Kabanets',
destinationFolder: 'D:/Desktop/University/Summer 2026/CMPT 307',
sources: [
{ type: 'email', label: 'CMPT 307' },
{ type: 'site', url: 'https://coursys.sfu.ca/2026su-cmpt-307-d1/pages/' },
],
},
];idis used to build the calendar event ID - keep it short and stable.destinationFoldercan be relative (resolved from the repo root) or absolute.email.labelis a Gmail label. Set up a Gmail filter to apply this label to relevant emails (typically a forwarding rule from your school address).site.urlis any CourSys URL behind CAS auth.
npm run build
npm run runWatch the pretty-printed logs in your terminal. Every step is breadcrumbed: subject, source, agent call, calendar upsert, attachment download. Run it twice - the second run should report (no new items) for everything that was processed on the first run.
run.bat is a thin wrapper: it cds into the repo, sets LOG_FILE so the pipeline mirrors output to logs/cron-YYYY-MM-DD.log, finds node.exe even when Task Scheduler launches with a stripped PATH, and propagates the real exit code (0 ok / 1 fatal / 2 reauth) back to Task Scheduler.
Register the task (08:00 and 20:00 daily) - copy-paste into PowerShell from the repo root:
Unregister-ScheduledTask -TaskName auto-schedule -Confirm:$false -ErrorAction SilentlyContinue
$action = New-ScheduledTaskAction -Execute "$PWD\run.bat" -WorkingDirectory $PWD
$trigger1 = New-ScheduledTaskTrigger -Daily -At 8am
$trigger2 = New-ScheduledTaskTrigger -Daily -At 8pm
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 10)
Register-ScheduledTask -TaskName "auto-schedule" `
-Action $action -Trigger $trigger1,$trigger2 -Settings $settings `
-Description "Course -> Calendar sync (08:00 and 20:00 daily)"Verify and force a run:
Get-ScheduledTask -TaskName auto-schedule |
Select TaskName, State, @{n='NextRun';e={(Get-ScheduledTaskInfo $_).NextRunTime}}
Start-ScheduledTask -TaskName auto-schedulePause / remove:
Disable-ScheduledTask -TaskName auto-schedule
Unregister-ScheduledTask -TaskName auto-schedule -Confirm:$falsechmod +x run.sh, then crontab -e:
0 8,20 * * * /absolute/path/to/auto-schedule/run.shA local dashboard reads the same SQLite state the pipeline writes:
npm run serve # production build
npm run serve:dev # tsx, no build step neededOpen http://127.0.0.1:5174 - Schedule (week grid + today timeline + upcoming deadlines) and Subjects (index + per-subject detail with files, sources, pipeline status) views. Bound to 127.0.0.1 only; no auth. The "Sync now" button spawns a one-shot run of the pipeline as a subprocess.
The UI reads from calendar_items (populated whenever the pipeline upserts
a calendar event). On a fresh install that table is empty until the first
npm run run finishes.
| Command | What it does |
|---|---|
npm run run |
One sync pass over every subject |
npm run dev |
Same, via tsx (no build step) |
npm run serve |
Local web UI on http://127.0.0.1:5174 |
npm run serve:dev |
UI via tsx |
npm run build |
TypeScript -> dist/ |
npm run typecheck |
tsc --noEmit |
npm run setup:google |
One-time browser OAuth flow |
npm run setup:coursys |
One-time headful CourSys login |
node dist/index.js test:calendar |
Insert a "sanity check" event 1 hour from now; re-running updates instead of duplicating |
Two streams whenever a run executes:
- stdout - pretty-printed (colorized in a real TTY)
- file (
logs/cron-YYYY-MM-DD.log) - same lines, plain text, written by Node whenLOG_FILEis set by the wrappers
Agent failures get their own dump at logs/agent-errors/<timestamp>__<subject>__<slug>.json with the full responseBody, statusCode, and rawModelOutput - the first place to look when extraction misbehaves.
| Symptom | First thing to check |
|---|---|
agent extraction failed; errorKind: APICallError, statusCode: 400 |
logs/agent-errors/<...>.json -> responseBody. Usually "out of credits" or a schema-shape mismatch from a model that doesn't support strict structured outputs. Try AGENT_MODEL=anthropic/claude-haiku-4-5. |
errorKind: NoObjectGeneratedError, JSON truncated mid-string |
Model hit the token cap. Raise maxTokens in src/agent/extractor.ts (currently 2000). |
attachment download failed with http 404 |
Bad URL on the source side (e.g. a professor referenced a file they haven't uploaded yet). Harmless; will succeed once the file appears. |
Task Scheduler History shows Last Run Result: 0x80 |
run.bat exited because node wasn't on PATH. Add the node install dir to the PATH probe list at the top of run.bat. |
| Calendar event content didn't update | Did the source content change? If the input is identical, the agent emits identical events and the upsert is a no-op. Try LOG_LEVEL=debug and re-run. |
| Site fetcher logs "redirected to cas.sfu.ca" | CourSys session expired - run npm run setup:coursys again. |
auto-schedule/
+-- src/
| +-- agent/ # extractor, prompt, Zod schema
| +-- auth/ # Google OAuth + CourSys cookies
| +-- config/ # subjects.ts (user-edited)
| +-- notify/ # ntfy / Pushover + self-scheduled reauth event
| +-- sources/ # email + site fetchers + registry
| +-- state/ # SQLite wrapper
| +-- sync/ # calendar upsert, file downloader
| +-- index.ts # CLI dispatch
| +-- logger.ts # pino multi-target
| +-- pipeline.ts # orchestrator
+-- docs/
| +-- auto-schedule-architecture.md
+-- data/ # state.db + auth/ (gitignored)
+-- downloads/ # default destination root (gitignored)
+-- logs/ # cron-YYYY-MM-DD.log + agent-errors/ (gitignored)
+-- run.bat # Task Scheduler wrapper (Windows)
+-- run.sh # cron wrapper (macOS / Linux)