---
title: Never refresh the gym app again — a slot watcher that proposes and books
series: Build It Yourself — briefs
brief: 01
license: CC BY 4.0
tested_on: OpenClaw 2026.6.11 · Claude Code (Aug 2026) · Node 22 · macOS
effort: one evening (3–4 h) for watch + propose; +2 h for the booking write path
---

# Never refresh the gym app again

## TL;DR
Your studio's booking app is bad and the good slots vanish in minutes. This brief builds
a watcher that logs into the app's API *as you*, notices newly opened classes at your
level, and sends you **one short WhatsApp proposal** when a slot matches the times you
actually go ("a spot opened Tuesday 18:00 — book it?"). A "yes" books it. It also knows
your monthly quota, stays quiet when you already train that week, and never spams. We
built it against Arbox (a common gym/studio platform in Israel); the design transfers to
any app with a JSON API behind it.

---

## What you get (a scene)

> *(Tuesday 10:00, WhatsApp, from your assistant)*
> **Assistant:** ❗ Hi — you have one more class left this month 🎾 a *Level 1* opened
> Thursday (21/08) at 18:00 with coach D. — want me to book it?
> **You:** yes
> **Assistant:** ✅ Booked: Thu 21/08 18:00 Level 1 (2/6 free after you). Calendar updated.
>
> *(Sunday 08:00)*
> **Assistant:** 🎾 Week ahead: Tue 18:00 (you + Alex), Thu 18:00 (you). 🆕 Just opened —
> the week of 31/08 is now bookable: Tue 18:00 ×3 free, Thu 19:00 ×2 free.

What you do NOT get: a message every time any slot opens (that lasted a week before it got
muted). The watcher's whole personality is *knowing when to shut up*.

---

## Prerequisites

- Your own member login for a studio app that has a JSON API (Arbox, Glofox, Mindbody,
  Wodify… — most mobile apps talk to one). Check with the browser dev-tools "Network"
  tab on the web version: if you see `application/json` requests, you're good.
- An always-on machine + a chat channel your scripts can send to, which can also hand your
  reply to an assistant that runs shell commands. We use OpenClaw on WhatsApp. **No such
  setup yet? Build brief 00 first** (`start-here-always-on-agent.md` — a Telegram bot and
  a small bridge, one evening, nothing to apply for). Everything below is written as
  "send me one line" / "a reply comes back", so any channel works.
- Node 20+ (or Python — the brief is language-agnostic; prompts say Node).
- Claude Code. Read the traps first.
- ⚠️ **Honesty note:** you'll use the app's *private* API with your *own* credentials, for
  your *own* bookings — that's what the app does on your phone. Don't build this for other
  people's accounts, don't hammer the API (we poll every 2 h), and expect an occasional
  tweak when the vendor changes an endpoint.

---

## Architecture (the 15-line version)

```
 every 2 h (cron/launchd) ─▶ watcher CLI ─▶ studio API (login cached 12 h)
                                │             ├─ schedule for the next 32 days
                                │             └─ rosters: who's booked in each class
                                ├─ hotslots: NEW openings at my level  ──▶ admin log (dedup by class id)
                                ├─ propose:  ONE slot matching my habits ─▶ WhatsApp me  ❗ + PROPOSAL_META → chat session
                                └─ quota / week-mute / same-day guards → silence
 "yes" in chat ─▶ assistant runs: watcher book --id <classId> --confirm ─▶ RESULT {status:"booked"}
 Sunday 08:00 ─▶ digest (mine + friend + newly opened week) ─▶ WhatsApp me
 nightly 02:00 ─▶ sync-calendar (booked classes → calendar events, tagged, idempotent)
```

1. **A tiny API client:** login → token (cache it 12 h in a file; re-login only on 401),
   profile (my name, my box/location ids), schedule between two dates. That's it.
2. **Rosters are the truth.** The class object lists who's booked (`booked_users[]`).
   "Is this me?" and "is my training buddy in it?" = name match against that list.
3. **State files:** `hot-seen.json` (openings already announced, by class id),
   `.last-proposal` (what was proposed today), `synced.json` (class id → calendar
   event id), `preferences.json` (my inferred habits: weekday + start time + count).
4. **Guards, in order:** monthly quota reached → quiet; a week where I already share a
   class with my buddy → mute that week; same-day slot → skip; day I'm already booked →
   skip; already proposed today → skip.
5. **The LLM does one job:** understand "yes" and run the book command with the id it
   was handed. Everything else is a dumb script on a dumb scheduler.
6. **Booking is a separate, gated command:** preview by default, `--confirm` for real,
   pre-flight checks, proof by re-reading the roster, one machine-readable `RESULT` line.

---

## Build steps — paste each block into Claude Code, in order

### Step 1 — discover the API (30 min, you + dev-tools, no code yet)

Open the studio's web app, log in, open dev-tools → Network → filter `Fetch/XHR`. Do four
things and note the requests: (a) log in, (b) open the schedule for a week, (c) open a class
you're booked in, (d) open a class with free spots. Copy each request as cURL. You want:
the login endpoint + the token header names, the schedule endpoint + its body, and the
class object shape (id, date, time, category/name, coach, `free`, `max`, the roster).
For Arbox these are `POST /api/v2/user/login`, `POST /api/v2/schedule/betweenDates`, and
the token travels in `accesstoken`/`refreshToken` headers with `whitelabel` +
`referername: app` + `version` headers (a public reference: github.com/saar120/arbox-automation-v2).
Don't hunt for those header *values* in documentation — the `whitelabel` string and your
box/location ids are tenant-specific: copy `whitelabel` straight out of any captured
request's headers, and read the box ids from the login response (the profile payload lists
the boxes your membership belongs to; your watcher should call that at startup rather than
hardcode them). Do NOT guess endpoints — trap #6.

### Step 2 — the client + read commands

```text
Create ./gym-watcher/watcher.js (Node, no deps beyond fetch/fs). Config from .env:
API_BASE, EMAIL, PASSWORD, plus any static headers the API needs (I'll paste my captured
cURL below). Implement:
- class Api { login({force}), req(endpoint, opts), getProfile(), boxIds(), getSchedule(fromISO,toISO) }
  Cache the token in session.json with savedAt; reuse for 12 h; on 401/403 or a
  "token/expired" message re-login ONCE and retry the request.
- helpers: isTennis(c) via CATEGORY_KEYWORDS substring on the class category (default: all),
  isMyLevel(c) via LEVEL substring, isFutureActive(c), bookedNames(c) from the roster,
  nameIn(c, name), isBookedByMe(c) = nameIn(c, profile.full_name)  // NOT any "user_booked" flag
- commands: login-test (prints my name + box), available [--days 7] (bookable at my level,
  sorted by free desc then time), mine [--days 14], friend [--days 14] (classes FRIEND_NAME
  is booked in + can I join), newweek (the furthest 7-day block in a 32-day fetch = the week
  the box just opened; detect the edge dynamically, never hardcode "4 weeks"), digest
  (WhatsApp-friendly: mine / with friend / open at my level / newly opened week).
Here is my captured cURL for login and schedule: <paste>
```
**Check:** `node watcher.js login-test` prints your name; `mine` shows the class you know
you're in; `available` shows only your level.

### Step 3 — hotslots + propose (the part people want)

```text
Add to watcher.js:
- hotslots [--min 2]: NEW openings at my level with >= min free that I'm not in, over 32
  days. Dedup via hot-seen.json keyed by class id (announce each class ONCE — a slot that
  fills and reopens is not re-announced; prune past dates). Week-mute: if I and FRIEND_NAME
  already share a booked class in a Sun–Sat week, suppress that week's openings (do NOT
  mark them seen, so cancelling the shared class re-arms them). Silent when nothing new.
- month-count: prints MONTH_BOOKED: N for the current calendar month in the box timezone,
  date-filtered locally (the API is loose at range boundaries).
- prefs [--history-days 120] [--min-count 2] [--write]: from my past bookings (my name in
  rosters over the last N days) infer preferred (weekday, HH:MM, count) patterns; --write
  saves preferences.json with generated_at.
- propose [--days 14]: candidates = open, my level, future, free>0, not me, not on a day
  I'm already booked, not today; keep those matching a preferred pattern; drop ones already
  proposed today (.last-proposal marker keyed by class id, pruned by class date); sort by
  pattern count desc then soonest; print exactly ONE:
    PROPOSAL: <one friendly sentence: "you have a class left this month 🎾 a <level> opened
    <weekday> (<dd/mm>) at <HH:MM> with <coach> — want me to book it?">
    PROPOSAL_META: {"classId":…,"date":…,"time":…,"coach":…,"level":…,"free":…}
  Silent otherwise (exit 0, no output).
```
**Check:** run `hotslots` twice — second run prints nothing. Run `prefs --write`, open
`preferences.json`, sanity-check it against your real habits. Run `propose` — one line or
nothing.

### Step 4 — the scheduler + WhatsApp + chat context

```text
Write hotslots.sh (bash, PATH prepended with the runtime bin dirs, absolute paths):
1. BOOKED=$(node watcher.js month-count) ; if BOOKED >= QUOTA → QUIET=1 (still log to my
   admin channel with "quota N/Q — not notified"; fail OPEN if month-count errors).
2. HOT=$(node watcher.js hotslots) → non-empty: send to my ADMIN channel only (log), and
   append to hot-pending.txt unless QUIET.
3. if not QUIET: OUT=$(node watcher.js propose); if PROPOSAL: line → send "❗ <PROPOSAL>"
   to ME via the assistant CLI's message-send; send PROPOSAL + META to admin; then push a
   context note into MY chat session (assistant CLI "send to session") saying:
   "[CONTEXT] a booking proposal was just sent: <PROPOSAL> META=<json>. If she answers
   yes → run `node watcher.js book --id <classId> --confirm` and report the RESULT line."
4. Once a day (the 16:00 run): if hot-pending.txt non-empty, send ONE FYI summary of the
   non-preferred openings ("for info, no reply needed"), truncate it.
Schedule hotslots.sh every 2 h 08–22 local via cron/launchd (NOT via an LLM turn).
Also weekly-digest.sh (Sun 08:00 → `digest` → me) and reminder.sh (20:00 daily →
tomorrow's booked classes → me; silent if none). Errors → admin channel, never to me.
Give every job a success artifact (state file mtime / a "sent" log line) that a monitor
can check — "it ran" is not "it delivered".
```
**Check:** wait for a real opening or temporarily lower `--min`; you get exactly one ❗
proposal; the assistant, asked "what did you just propose?", knows.

### Step 5 — the booking write path (gated)

```text
Add to watcher.js, from a booking-config.json I fill from captured requests (endpoint +
body template for book/cancel + a late-cancel check + lateCancelHours):
- book (--id <classId> | --date YYYY-MM-DD --time HH:MM) [--confirm]
- cancel (--id … | --date --time) [--confirm] [--confirm-late]
Rules: resolve the class; if the date/time matches >1 class → hard stop "ambiguous, use
--id". Pre-flight: already booked → status already-booked; not future/active → not-active;
free == 0 → full (no waitlist in v1). Without --confirm → print "WOULD book …" and exit
(status dry-run). With --confirm → POST; then RE-FETCH the schedule and prove my name is
in the roster before saying booked; otherwise status "unverified". Cancel inside the
late window requires --confirm-late so a penalty is never silent. Always end with ONE
line: RESULT {"action","status","classId","class",...} — callers trust status, not prose.
Update SKILL.md for the assistant: book ONLY on my explicit request or my clear "yes" to a
proposal that named the exact class; restate the class before a live call otherwise; run
with --confirm; report from the RESULT line; never claim booked without status "booked".
```
**Check:** `book --id X` (no confirm) prints WOULD; `book --id X --confirm` on a class
you actually want → RESULT booked → it's in the app. Cancel it the same way.

### Step 6 — calendar mirror (optional, 30 min)

```text
sync-calendar [--days 30] [--dry-run]: mirror my booked classes into my calendar via my
calendar CLI/API. synced.json maps class id → event id; re-runs update changed classes and
delete cancelled ones; tag events "[gym:<id>]" in the description. Nightly at 02:00; ping
admin only when something changed or failed.
```

---

## The traps we hit (read these twice)

**1. The "am I booked?" flag lied.** The class object had a `user_booked` field — always
null in our tenant — and an `is_user` field that means "is a real account" (true for
everyone), not "is me". *Fix:* the roster is the truth: match your own profile name against
`booked_users[].full_name`. The same trick tells you whether your friend is in.

**2. "Tennis" matched nothing.** Categories at our box are `Level N · Court X` — the sport
isn't in the name. *Fix:* keyword-filter on what the category *actually* says (`Level`),
and treat empty keywords as "everything". Look at real data before writing filters.

**3. Two lanes logged in at the same second.** My watcher and my friend's watcher (same
box, both every 2 h on the hour) hit `/login` together and one got a stale token. *Fix:*
cache the token 12 h and only re-login on 401; offset the second lane by 3 minutes.

**4. Raw openings = spam.** Version 1 pinged every opening at my level. Dozens a week,
most at times I never go. What survived: (a) dedupe once per class id, (b) **week-mute**
when I already share a class with my buddy that week, (c) **monthly quota guard** (5
lessons/plan — don't tease with slots I can't book), (d) **habits, not availability** —
propose only slots matching my inferred weekday+time patterns, ONE per run, (e) the rest
in one 16:00 FYI digest. The feature is the silence.

**5. "Same-day" proposals were useless.** A slot opening at 10:00 for 11:00 today isn't a
proposal, it's noise. Skip today; skip days already booked.

**6. Guessed endpoints break things.** The booking POST was captured from the web app's own
JS bundle and a read-only inspection of a booked class — not inferred. Templates with
`${token}` placeholders live in `booking-config.json`; the code never hardcodes body shapes.

**7. "Booked ✅" without proof.** An LLM will happily say "booked!" because the POST
returned 200. *Fix:* re-fetch, prove the name is in the roster, and emit a `RESULT` JSON
line the assistant must quote from. Prose is not status.

**8. Confirm-first is a rule, not a vibe.** Booking is a real-world transaction. The
assistant books only on an explicit ask, or on a clear "yes" to a proposal that *named the
exact class*; anything vaguer → restate and re-ask. Cancels inside the penalty window need
a second flag. Put this in the assistant's skill doc in bold.

**9. The month boundary was fuzzy.** The API's date range was loose at edges, so the quota
count drifted at month start. *Fix:* fetch wide, filter dates locally in the box timezone.

**10. Cron green, nobody notified.** My friend's lane went silent for weeks while every run
reported success — the script exited 0 with nothing to say. *Fix:* every job names the
artifact that proves it delivered (a state-file touch, a "sent" line), and a monitor
alerts on staleness, not on exit codes.

**11. The "newly opened week" is a moving edge.** The box publishes ~4 weeks out; each
Sunday a new far week appears. Hardcoding "4 weeks" drifts. *Fix:* fetch 32 days, take the
furthest 7-day block that exists.

**12. Don't schedule with an LLM.** Running the watcher *inside* an agent turn every 2 h
costs money and can deadlock when the script calls back into the same runtime. Dumb
scheduler for the loop; the LLM only where judgment lives (the "yes").

---

## You know it works when

- [ ] `hotslots` announces an opening once and never again for that class id.
- [ ] A slot at your habitual time opens → exactly one ❗ proposal, with the class named.
- [ ] Reply "yes" → the assistant runs the book command → `RESULT status:"booked"` → the
      class shows in the app; the calendar has it by morning.
- [ ] Book your last quota class → the lane goes quiet for the month; admin log continues.
- [ ] Book a class with your buddy → no more pings for that week; cancel it → pings return.
- [ ] Sunday digest arrives with a "🆕 just opened" section on the week that just appeared.
- [ ] Kill the network for a run → error to admin, nothing to you, and the monitor sees it.

## Extend it

- **Two-person mode:** a `--for <friend>` perspective flag that swaps me/friend, language,
  and the dedupe state file — so the friend gets her own pings from her own login (offset
  by 3 min; see trap #3). Outbound to a non-owner goes through whatever persona talks to
  outsiders.
- **Waitlist:** if the app supports it, "full" → offer to waitlist instead of stopping.
- **Speak it:** a `--speak` output shape (no emoji/bullets) so a smart speaker can read the
  Sunday digest.
- **Re-derive habits** every month or two (`prefs --write`) — patterns shift with seasons.

## Changelog

- 2026-08-16 — first public version. Tested on the versions in the header.

---

*Part of **Build It Yourself** — free briefs on building a personal AI Chief of Staff, one
capability at a time: https://chief.natalicot.com/briefs · License CC BY 4.0. Free, forever.*
