# Circuit timer — build a workout link You are reading this because someone asked you to build them a circuit / interval workout. This page is self-contained: the schema, the encoding, the presentation rules and the full reference encoder are all below. There is nothing to install and nothing else to fetch. The page at `https://mikemiller.uk/circuit` is an interval-workout timer. It reads the entire workout from one URL parameter, `c`, which is URL-safe base64 of the circuit JSON. Your job: turn the request into that JSON, encode it, and reply with the finished link. ## 1. Build the circuit JSON ```jsonc { "title": "Hip & glute circuit", // optional, shown as the heading "prepSeconds": 10, // optional, default 10; countdown before station 1 "rounds": 1, // optional, default 1; repeats the whole station list "restBetweenRounds": 0, // optional, default 0; replaces the last station's rest at a round boundary "settings": { // optional, all default true except autostart "voice": true, // spoken station names, halfway call, 3-2-1 countdown "videos": true, // show the YouTube demo for each station "beeps": true, // changeover tones and vibration "autostart": false // skip the setup card and start immediately }, "stations": [ // required, non-empty, in order { "name": "Squats", // required "work": 45, // required, seconds > 0 "rest": 15, // required, seconds >= 0 (rest AFTER this station) "videoId": "P-yaD24bUE8", // required, YouTube id (the ...watch?v= part) "group": "glute", // optional: warmup | glute | core | cooldown (badge colour) "color": "#0ba360", // optional, overrides the group colour "videoStart": 0 // optional, seconds into the demo video to start } ] } ``` Rules that match the page's behaviour: - **`videoId`** is the id only, never the full URL. `https://www.youtube.com/watch?v=P-yaD24bUE8` → `P-yaD24bUE8`. - **`rest`** is the pause *after* that station, and during it the page previews the *next* station's video. Set `rest: 0` on the final station. - **`group`** only picks a colour (warmup = blue, glute = green, core = purple, cooldown = amber). Omit for neutral grey, or set `color` directly. - **`rounds` > 1** repeats the station list. `restBetweenRounds`, if set, is used instead of the last station's own `rest` between rounds. - **`settings`** are yours to choose on the user's behalf. Leave them out unless the user asked for something specific ("no videos", "don't talk to me", "just start it"). A user can still change voice / videos / beeps on the page itself. Realistic values: 45s work / 15s rest for strength stations, 60s work / 10s rest for mobility and stretches, 90s for a final hold, `prepSeconds: 10`. A 20-minute session is roughly 16–20 stations at those timings. Warm up, work, cool down. A known-good station set to copy timings and real video ids from: ```json [ { "name": "Cat-cow", "work": 45, "rest": 10, "videoId": "xyNwxiuERXc", "group": "warmup" }, { "name": "90/90 hip rotations", "work": 60, "rest": 10, "videoId": "t4Zz6-aG8Iw", "group": "warmup" }, { "name": "Squats", "work": 45, "rest": 15, "videoId": "P-yaD24bUE8", "group": "glute" }, { "name": "Glute bridge", "work": 45, "rest": 15, "videoId": "nbjJjSa0cKo", "group": "glute" }, { "name": "Clamshells", "work": 45, "rest": 15, "videoId": "Cigb7cbcNxs", "group": "glute" }, { "name": "Step-ups", "work": 45, "rest": 15, "videoId": "vOiHvzj5XhA", "group": "glute" }, { "name": "Kettlebell swings", "work": 45, "rest": 15, "videoId": "1Qi0NQW89Oc", "group": "glute" }, { "name": "Kettlebell RDL", "work": 45, "rest": 15, "videoId": "2oSYzI6BUR8", "group": "glute" }, { "name": "Dead bug", "work": 45, "rest": 15, "videoId": "bxn9FBrt4-A", "group": "core" }, { "name": "Bird dog", "work": 45, "rest": 15, "videoId": "ZdAHe9_HeEw", "group": "core" }, { "name": "Push-ups", "work": 45, "rest": 15, "videoId": "WDIpL0pjun0", "group": "core" }, { "name": "Plank", "work": 45, "rest": 15, "videoId": "A2b2EmIg0dA", "group": "core" }, { "name": "Plank knee-to-elbow", "work": 45, "rest": 15, "videoId": "trxT-0hHF3o", "group": "core" }, { "name": "Scapular push-ups", "work": 45, "rest": 15, "videoId": "uN3HsCkcbSA", "group": "core" }, { "name": "Figure-4 stretch (both sides)", "work": 60, "rest": 10, "videoId": "ZuKvSYIVzGw", "group": "cooldown" }, { "name": "Couch stretch (both sides)", "work": 90, "rest": 0, "videoId": "wD_1Xv2xVEE", "group": "cooldown" } ] ``` Use real YouTube ids you are confident exist. If you are unsure of an id for an exercise, pick a different exercise you do know, or reuse one from the list above. A wrong id shows an unavailable video for that station. ## 2. Encode it `c` is base64 of the UTF-8 JSON, made URL-safe: `+` → `-`, `/` → `_`, and strip the `=` padding. Run one of these rather than encoding by hand: ```bash # Node node -e 'const c=require("fs").readFileSync(0,"utf8");process.stdout.write(Buffer.from(JSON.stringify(JSON.parse(c)),"utf8").toString("base64").replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,""))' < circuit.json ``` ```bash # Python python3 -c 'import base64,json,sys;print(base64.urlsafe_b64encode(json.dumps(json.load(sys.stdin),separators=(",",":")).encode()).decode().rstrip("="))' < circuit.json ``` If you have a shell and want the checks as well as the encoding, save the reference encoder from the appendix below and run it — it validates every field and refuses to print a link the page would fail to read. If you have no shell at all, encode with whatever tooling you do have, then verify by decoding the result back to JSON before you send it. ## 3. Reply with the link ``` https://mikemiller.uk/circuit?c= ``` The encoded parameter runs to hundreds of characters, so **never print the raw URL as visible text** — it swamps the reply and is unreadable on a phone. Present it as a short hyperlink with a one-line summary of the session: ```markdown [Start your workout](https://mikemiller.uk/circuit?c=) — 20 min, glutes and core, 16 stations. ``` - Label the link for the action ("Start your workout"), not the URL. - One link per reply. If you built variations, label each one distinctly ("[20 minute version](...)", "[Short version](...)"). - Don't echo the circuit JSON back unless asked; summarise it in a sentence. - If the surface you are replying into cannot render links, say what the workout is and put the URL in a code block rather than inline in a sentence. The page shows its own share panel with a QR code, so they can send it to a phone. Any of the settings can also be forced on the URL, which overrides whatever is in the encoded circuit — handy for a quick variation without re-encoding: ``` https://mikemiller.uk/circuit?c=&voice=0&videos=0&beeps=0&autostart=1 ``` Accepted values: `1`/`0`, `true`/`false`, `yes`/`no`, `on`/`off`. ## Using this again If the user wants this available in every session rather than pasted each time, point them at https://mikemiller.uk/circuit-ai, which walks through adding it to Claude or to a ChatGPT project. Otherwise they can just paste this page's URL — https://mikemiller.uk/circuit/skill.txt — into any new chat. ## Appendix: reference encoder (build-link.mjs) Optional. Save as `build-link.mjs` and run it with Node 18 or newer: ``` node build-link.mjs --file circuit.json ``` It fills in the top-level defaults, validates every station, and round-trips the encoded value back through the page's own decode path before printing, so a link it prints is a link the page can read. It exits non-zero on any problem — fix the reported field and re-run rather than publishing a rejected link. ```javascript #!/usr/bin/env node // build-link.mjs — turn a circuit definition into a shareable // https://mikemiller.uk/circuit link, using the exact encoding the page decodes. // // The page reads its circuit from the `c` query param: // b64 = c.replace(/-/g,'+').replace(/_/g,'/'); // re-padded to length % 4 // json = decodeURIComponent(escape(atob(b64))); // base64 of UTF-8 bytes // circuit = JSON.parse(json); // So encoding is: base64(UTF-8(JSON)) -> url-safe (+/ -> -_ , strip = padding). // // Usage: // node build-link.mjs '' # JSON as an argument // node build-link.mjs --file circuit.json # JSON from a file // echo '' | node build-link.mjs # JSON on stdin // // Flags: // --base Override the base URL (default https://mikemiller.uk/circuit) // --param Print only the encoded `c` value, not the full URL import { readFileSync } from "node:fs"; const GROUPS = ["warmup", "glute", "core", "cooldown"]; const SETTINGS = ["voice", "videos", "beeps", "autostart"]; function fail(msg) { process.stderr.write(`build-link: ${msg}\n`); process.exit(1); } function parseArgs(argv) { const out = { base: "https://mikemiller.uk/circuit", paramOnly: false, file: null, json: null }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--base") out.base = argv[++i]; else if (a === "--param") out.paramOnly = true; else if (a === "--file") out.file = argv[++i]; else if (a === "-h" || a === "--help") { printHelp(); process.exit(0); } else if (a.startsWith("-")) fail(`unknown flag: ${a}`); else if (out.json == null) out.json = a; else fail(`unexpected extra argument: ${a}`); } return out; } function printHelp() { process.stdout.write( "Usage: node build-link.mjs '' | --file | (stdin)\n" + "Flags: --base --param --help\n" ); } function readInput(args) { if (args.json != null) return args.json; if (args.file) return readFileSync(args.file, "utf8"); try { const stdin = readFileSync(0, "utf8"); if (stdin.trim()) return stdin; } catch { /* no stdin */ } fail("no circuit JSON provided (pass as an argument, --file, or on stdin)"); } function encode(circuit) { const json = JSON.stringify(circuit); return Buffer.from(json, "utf8") .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); } // Mirror the page's decoder exactly, so we verify what the browser will see. function decode(param) { let b64 = param.replace(/-/g, "+").replace(/_/g, "/"); while (b64.length % 4) b64 += "="; const json = Buffer.from(b64, "base64").toString("utf8"); return JSON.parse(json); } function normalize(input) { if (input == null || typeof input !== "object" || Array.isArray(input)) { fail("circuit must be a JSON object"); } const c = { ...input }; if (c.title != null && typeof c.title !== "string") fail("title must be a string"); c.prepSeconds = c.prepSeconds != null ? c.prepSeconds : 10; c.rounds = c.rounds && c.rounds >= 1 ? c.rounds : 1; c.restBetweenRounds = c.restBetweenRounds || 0; if (c.settings != null) c.settings = validateSettings(c.settings); if (!Array.isArray(c.stations) || c.stations.length === 0) { fail("circuit.stations must be a non-empty array"); } c.stations = c.stations.map((s, i) => validateStation(s, i)); return c; } function validateSettings(s) { if (typeof s !== "object" || Array.isArray(s)) fail("settings must be an object"); for (const [key, value] of Object.entries(s)) { if (!SETTINGS.includes(key)) fail(`settings.${key} is not a known setting (${SETTINGS.join(", ")})`); if (typeof value !== "boolean") fail(`settings.${key} must be true or false`); } return s; } function validateStation(s, i) { const where = `stations[${i}]`; if (s == null || typeof s !== "object") fail(`${where} must be an object`); if (typeof s.name !== "string" || !s.name.trim()) fail(`${where}.name is required`); if (typeof s.work !== "number" || s.work <= 0) fail(`${where}.work must be a positive number of seconds`); if (typeof s.rest !== "number" || s.rest < 0) fail(`${where}.rest must be a number of seconds (>= 0)`); if (typeof s.videoId !== "string" || !s.videoId.trim()) fail(`${where}.videoId (YouTube id) is required`); if (s.group != null && !GROUPS.includes(s.group)) { fail(`${where}.group must be one of ${GROUPS.join(", ")} (or omitted)`); } return s; } function deepEqual(a, b) { return JSON.stringify(a) === JSON.stringify(b); } const args = parseArgs(process.argv.slice(2)); let parsed; try { parsed = JSON.parse(readInput(args)); } catch (e) { fail(`input is not valid JSON: ${e.message}`); } const circuit = normalize(parsed); const param = encode(circuit); // Guarantee the browser will decode to exactly what we encoded. let roundTrip; try { roundTrip = decode(param); } catch (e) { fail(`round-trip decode failed: ${e.message}`); } if (!deepEqual(circuit, roundTrip)) { fail("round-trip mismatch — the encoded link would not decode identically in the page"); } const base = args.base.replace(/[?#].*$/, ""); const url = `${base}?c=${param}`; process.stdout.write((args.paramOnly ? param : url) + "\n"); ```