# Forma MVP implementation instructions: import an existing workout plan Status: implementation specification. Implement the MVP described below. Date: 2026-10-10 ## TL;DR - **Feasible, and it fits the current architecture well.** An imported plan maps onto the structures Forma already has: routines, routine exercises with per-set targets, custom exercises, and a `custom` plan rotated on the user's training days. **The MVP needs no database migration.** - **The only new infrastructure is one server-side function** that holds an Anthropic API key and calls Claude. Forma has no backend code today. It's a static Vite app on Netlify, with Supabase optionally providing auth and storage. - **The hard part is matching names, not reading the input.** The risk is mapping "Supino reto" or "Puxada frente" onto the right one of Forma's 1,325 **English-named** ExerciseDB exercises. For example, 23 exercises contain "bench press" and 114 contain "curl". The review screen is designed around that risk. - **MVP:** accept pasted text only in one input screen, then show a review screen that reuses the existing routine editor, followed by "Replace my plan". Do not implement file uploads, image inputs, PDF parsing, or document parsers. - **Model:** use Claude Haiku 5.5 (`claude-haiku-5-5`) for both evaluation and production. Do not test or call Opus or Sonnet. Hosting cost is expected to be negligible. --- ## 1. What exists today (findings from the codebase) ### Data model | Concept | Where | Shape | |---|---|---| | Exercise library | `public/data/exercises.json`, Supabase `exercises` | 1,325 ExerciseDB rows `[id, name, bodyParts, target, secondary, equipments, instructions]`, English names only. Normalised in `forma-core.js` `norm()`. Type is derived: bodyweight → `reps`, otherwise `weighted`. | | Custom exercises | `d.custom`, Supabase `custom_exercises` | `{ id: 'cx-', name (1–80 chars), type: weighted/reps/assisted/time/weight_time, muscles[], notes, youtube, archived }`. Registered into `EXMAP` so they behave like built-ins everywhere. | | Routine (= one workout day) | `d.routines`, Supabase `routines` + `routine_exercises` | `{ id, name, description, program, position, exercises: [{ id, exId, sets, reps, rest, weight, setVals: [{ reps, weight }] }] }` | | Plan (the split) | `d.profile.plan` (jsonb on `profiles`) | `{ key, name, routineIds[], sched: [{ from, days[], base }] }`. `routineIds` is the rotation order. `key: 'custom'` = user-built plan. | | Schedule | `profile.scheduleDays` (weekday numbers) + `plan.sched` segments | Date-driven. Each training day gets a slot, and `routineIds[slot % n]` is that day's workout. The `fixed`, `oldest` and `adaptive` modes all derive from this. | | Rest preference | `d.prefs[exId]` / `exercise_preferences` | A per-exercise global rest. **It overrides the routine's rest when a workout starts** (`mkWEx`: `d.prefs[exId] \|\| rest`). | What the model supports and doesn't support, given what plans usually contain: | Plan content | Supported? | Notes | |---|---|---| | Sets × reps (`4x10`) | Yes | `sets`, `reps` | | Pyramids (`12/10/8/6`) | Yes | `setVals[i].reps` | | Target weight (`20kg`) | Yes | per-set `weight` (stored in kg; convert lb) | | Rest (`90s`, `1'30`) | Yes | `rest`, 15–600 s in the editor | | Workout divisions (Treino A/B/C, Push/Pull/Legs) | Yes | one routine per division, with `plan.routineIds` as the rotation | | **Rep ranges (`8-12`)** | **No** | `repMin`/`repMax` were removed, and `normEx()` collapses them. The built-in programs use `floor((min+max)/2)`, so we should do the same and show "8–12 → 10" in review. | | **Timed sets (`plank 3x30s`)** | Partly | Built-in exercises are only `weighted` or `reps`. Plank is a `reps` exercise, so "30s" would become "30 reps". Options: create a custom timed exercise, or (later) add per-exercise type overrides. | | Supersets / bi-sets, drop sets, tempo, RIR/RPE, warm-up sets | No | Kept as text in the routine `description`, flagged in review | | Per-exercise notes ("pegada aberta") | No | Same as above (could append to the description) | | Cardio blocks ("esteira 20 min") | Partly | Possible as a custom `time` exercise, otherwise dropped with a warning | | Weekday labels ("Segunda: Peito") | Indirectly | Can set `scheduleDays`, and the sched `base` can be computed so the Monday routine lands on Mondays (see §3.4) | ### Flows - **Onboarding:** `welcome → onb (12 questions) → auth → rec → app` (`Forma.jsx` `onbSteps`, `finishOnb`). - **Recommendation screen (`vRec`):** a recommended program, with "Other options" listing alternatives plus **"Create my own plan: build it your own way, from scratch"** (`ownPlan()`). That last row is where an import option naturally goes. - **Plan creation:** `acceptPlan(key)` builds routines from a template, removes the old program routines, writes a new `plan`, and queues `routineDel` / `routine` / `profile` sync ops in a single `commit()`. `ownPlan()` does the same with an empty `custom` plan. **An import is the same operation with routines that come from the parser instead of a template.** - **Workouts tab:** a sheet with "Change plan" (`wkSwap`) and "Add routine" (`wkAdd` → `planDraft()`). An empty custom plan shows "Your plan is empty…". - **Routine editor (`vEdit`)** already covers everything the review step needs: rename, per-set reps and weight, add/remove sets, rest sheet, reorder by drag, and swap/add via the library (`openLib('swapRoutine')`), plus remove. - **Library (`vLib`, `Forma.search`)** gives word-match search with group and equipment filters, plus custom exercises. - **Sync:** an offline-first queue in localStorage that flushes to Supabase REST (`store.queue/flush`). Custom exercises, routines and the profile/plan each have a `PUSH` handler. ### Constraints that shape the design 1. **There's no server, and secrets can't go to the client.** The README is explicit: `VITE_` vars ship in the bundle. Calling an LLM needs a server-side function. 2. **There are two storage backends.** `local` (the default in `.env.example`) has no real auth, while `supabase` has JWT auth. Abuse control is much easier when import requires a Supabase account. 3. **UI and library are in English, but likely users write Portuguese.** The screenshot, the friend's feedback in the Lovable folder and gym culture all point that way. Matching therefore has to be cross-lingual. Showing the user's original wording next to the matched English name keeps the review screen legible. 4. **`template.jsx` is generated by Claude Design exports.** New screens should be designed in Claude Design, and the import logic should live in a persistent file (e.g. `src/plan-import.js`, like `exercise-aliases.js`) so an export doesn't overwrite it. 5. **Visual language:** Forma is light (cream `#FFFDF8` / ink `#16140F`, Bricolage Grotesque). The reference screenshot is dark. Use it for structure and interaction, not styling. --- ## 2. Implement this end-to-end user flow ```text Entry point ──► 1. Import input ──► 2. Reading… ──► 3. Review ──► 4. Confirm ──► Workouts tab (paste text) (progress, (days, matches, (replace plan, cancel) fixes, edit day) training days) ▲ │ └── error states ◄─┘ ``` ### Entry points (MVP) 1. **Recommendation screen → Other options:** add **"Import my plan: paste the workout plan you already follow"** next to "Create my own plan". This is the main path for new users. 2. **Workouts tab sheet:** add "Import a plan" next to "Change plan" and "Add routine". 3. **Empty custom plan state:** a secondary action, "Paste an existing workout plan". Phase 2: add an onboarding branch, "I already have a plan". It would skip the six questions that only drive the recommendation (experience, goal, setup, duration, priorities, avoid) but keep frequency/days, units, reminders and name. Today a user with a plan answers all 12 questions, sees a recommendation, then imports. That works, but it's slower than it needs to be. ### Step 1: Import input (one screen) - **Paste is the only input method:** provide a large multiline text area with a useful example placeholder. Users can paste from Notes, WhatsApp, a browser, or any other source. Support normal keyboard paste and the platform's native long-press paste interaction. - Do not provide file upload, camera, image, screenshot, PDF, Word, or Excel controls. Do not add clipboard-read permissions or a custom clipboard API requirement; a normal editable text area is sufficient. - **CTA "Import plan"** stays disabled until the text area contains non-whitespace text. Show a concise privacy note: *"We use AI to read your plan. Your plan text isn't stored."* - Offline: disable the CTA and say so. Everything else in Forma works offline, but this step can't. ### Step 2: Reading (processing state) - A full-screen progress view with staged copy ("Reading your plan…" → "Matching exercises…"), because 5–20 s may be normal for extraction and matching. Use streaming or staged updates rather than a bare spinner. - **Cancel** returns to step 1 with the input intact. - If the user leaves the app mid-request, keep the input. Re-running is cheap, so there's no need for background jobs in the MVP. ### Error and edge states | State | Message direction | Recovery | |---|---|---| | No workout found (blank or unrelated text) | "We couldn’t find exercises in this text." | Edit the text and try again | | Partial success | Review opens, with a banner listing lines that weren't understood | Add manually from library | | Rate limit reached | "You've imported a lot today. Try again tomorrow." | — | | Network / timeout / model refusal | Generic retry | Retry keeps input | | Not signed in (if local mode is excluded) | Explain that import needs a synced account | Sign up | ### Step 3: Review and correction (detailed in §5) ### Step 4: Confirm - If the user already has a plan: **Replace my plan** (default, same as `acceptPlan`; past workouts stay in history) or **Add these workouts to my plan** (phase 2). - **Training days:** if the number of detected workouts differs from `profile.frequency`, or the plan names weekdays, show the existing schedule-day picker prefilled with a suggestion, e.g. "Your plan has 4 workouts. Train Mon, Tue, Thu, Fri?" - Success → Workouts tab with the new plan, and the toast "Your plan is ready". --- ## 3. Implementation requirements ### 3.1 Pipeline ```text Client Supabase Edge Function Claude Haiku ────── ────────────────────── ──────────── trim + cap pasted text ──────────────► verify JWT, enforce quota ───► structured extraction validate + clamp output ◄─── (JSON schema) match names to library locally build import draft → review UI → commit() ``` ### 3.2 Input handling | Input | How Claude receives it | Requirements | |---|---|---| | Pasted text | A text block | Trim whitespace, reject empty input, cap at 20,000 characters on both client and server. Preserve line breaks and ordinary formatting. | Do not implement file parsing or binary input handling in this MVP. ### 3.3 Extraction: why an LLM, and what it returns A regex parser would handle `Nome 4x8-12 (90s)`. Real plans, though, mix formats ("3 séries de 12", "4x até a falha", "bi-set com…", tables, handwriting), mix languages and use gym slang. A single Claude call with **structured outputs** (`output_config.format` with a JSON schema) handles all of this and returns data, never prose. Proposed output schema (per import): ```jsonc { "plan_name": "Treino ABC", "days": [{ "label": "Treino A — Peito e tríceps", "weekday": null, // 0–6 if the source names one "exercises": [{ "source_text": "Supino reto 4x8-12 (90s)", "name_original": "Supino reto", "name_en": "barbell bench press", // canonical English, for matching "equipment": "barbell", // from Forma's equipment vocabulary, or null "muscles": ["Mid Chest"], // from Forma's MUSCLES list (used if we create a custom exercise) "sets": 4, "reps": [8, 12], // [n] | [min,max] | per-set list "per_set_reps": null, // e.g. [12,10,8,6] "timed_seconds": null, "rest_seconds": 90, "weight": null, "weight_unit": null, "notes": "drop set na última", // anything that doesn't fit "group": null // superset/bi-set id }] }], "unparsed_lines": ["Aquecimento 10 min bike"], "warnings": [] } ``` The system prompt teaches Brazilian and English gym shorthand ("4x12", "3x até falhar", "1'30", "bi-set", "drop", "cad.", "halt.", "pol."), tells the model never to invent values (use `null` when unknown), and to translate exercise names into the closest standard English name. ### 3.4 Exercise matching (the main risk) **Recommendation: the model normalises the name, and Forma matches it locally.** 1. **User's custom exercises first:** a case- and accent-insensitive match on `name_original` against the user's own custom exercises. This avoids duplicating "Supino máquina articulado" every time the user imports. 2. **Library match** on `name_en` + `equipment` against `Forma.all()`. Score words with a small synonym table (db→dumbbell, bb→barbell, "pulldown"="pull-down"), add an equipment bonus, and add a **popularity prior** from the 55 exercises the built-in programs already use (`X` / `POP` in `forma-core.js`). "Bench press" with no equipment should land on the standard barbell bench press, not "smith incline bench press". 3. **Confidence buckets:** - **Matched:** a clear winner. No badge. - **Check:** two or more close candidates, or a weak score. The best guess is pre-selected and the top 3 alternatives are offered. - **Not found:** no reasonable candidate. The default action is *create as my own exercise* (a custom exercise prefilled with the original name, type from timed/weighted, muscles from the model). The user can search the library instead, or skip. Why do this locally instead of sending the library to the model? - It's deterministic and unit-testable in `tests/` like `progress.test.js`. - It works with per-user custom exercises without sending them anywhere. - It's cheaper: the library is about 17.6k tokens per request. - **The fallback if the Phase 0 eval shows weak matches:** put the library (id | name | equipment) in a cached system prompt and let the model return candidate IDs, then validate those IDs client-side. This costs more per import (cache writes only amortise at steady volume) but is likely more accurate for unusual names. Also worth keeping: a **user correction memory**. When a user maps "Puxada alta" to `cable lat pulldown`, store that alias locally (later, per user in Supabase) and reuse it on their next import. It's a cheap win. ### 3.5 Mapping to Forma's model (no new concepts) - Each `days[]` entry → a routine `{ id: uid(), name: label, description: , program: null, position, exercises }`. - Each exercise → `{ id, exId, sets, reps, rest, weight, setVals }`, reusing `syncSets()` and `setSpecs()` semantics: - range `[8,12]` → `reps = floor((8+12)/2) = 10` (same as the built-in programs); review shows "8–12 → 10". - per-set list → `setVals`. - missing sets/reps → defaults of 3 × 10 (same as `libAddMany`), flagged in review. - missing rest → `d.prefs[exId] || 90`. - Clamp to the editor's bounds: sets 1–12, reps 1–99 (timed ≤ 3600 s), rest 15–600 s, weight ≥ 0. Convert lb to kg. - Unmatched → `d.custom.push({ id: 'cx-' + uid(), name, type, muscles, notes, … })`, the same as `saveCex()`. - Plan → `{ key: 'custom', name: plan_name || 'My plan', routineIds, sched: [{ from: today, days, base }], imported: true }`. **Use `key: 'custom'`, not a new key.** `effPlan()` and `vWorkouts()` special-case `'custom'` (it unlocks "Add routine", and an empty custom plan stays the effective plan). `plan` is jsonb, so the extra `imported` flag needs no migration. - **Weekday-labelled plans:** set `scheduleDays` from the detected weekdays, and pick `sched[0].base` so the first training day after `from` maps to the routine labelled for that weekday. `scheduledOn()` computes `slot = base + trainingCount(from, day)`, so this is pure arithmetic. In `fixed` mode the weekday labels then hold. In `adaptive`/`oldest` mode, order still follows completion, as it does today. - Commit exactly like `acceptPlan`: one `commit(d, [...custom ops, ...routineDel ops for replaced program routines, ...routine ops, { t: 'profile' }])`. Sync, offline queueing and conflict handling come along for free. - History: workouts keep `name` and `routine_id` becomes `SET NULL` on delete, so replacing a plan never loses history (the same guarantee "Change plan" relies on today). **Rest precedence:** `d.prefs[exId]` (a rest the user set during a past workout) overrides routine rest at workout start. Decided: the import clears `prefs` for imported exercises that have an explicit rest in the source (see §6.4). ### 3.6 Backend changes | Change | Required for MVP? | Detail | |---|---|---| | **Server function `import-plan`** | **Yes** | Holds `ANTHROPIC_API_KEY`, verifies the user, checks quota/size/MIME (magic bytes, not the extension), calls Claude with streaming, validates and clamps the JSON, returns it. Stores nothing. | | `plan_imports` table (metadata only) | Recommended | `user_id, created_at, source ('text'), status, error_code, model, input_tokens, output_tokens`. No content. Powers rate limiting and cost monitoring. RLS `own rows`, same pattern as the other tables. | | Changes to `routines`, `routine_exercises`, `custom_exercises`, `profiles` | **No** | Everything maps onto existing columns and `plan` jsonb. | | Rep-range columns, per-exercise notes, superset grouping | Later (phase 3) | Only if import shows these come up often. They're also improvements for manually built plans. | **Where to host the function:** - **Supabase Edge Function (recommended)** handles user JWTs natively, keeps the quota table in the same project, and allows long wall-clock limits (well over the 10–30 s an extraction can take). The catch is that it only serves users on the `supabase` backend. - **Netlify Function:** deploys with the site and works for `local`-mode users. However, local mode has no verifiable identity, so abuse control falls back to per-IP limits. Synchronous functions also have short default timeouts, so streaming or a background-function pattern is needed. Check current Netlify limits before choosing this. My recommendation: **import requires a Supabase-backed account.** A free AI feature that's anonymous and open to the internet is the main cost and abuse risk. ### 3.7 Model choice and cost Use **Claude Haiku 5.5** (`claude-haiku-5-5`) for the Phase 0 evaluation and every production import. Do not call or compare Opus or Sonnet. Use structured outputs with the defined JSON schema; keep the task focused on extraction and normalisation. Use Haiku's actual token usage from evaluation runs to calculate cost per import and project monthly spend. The feasibility estimate in the source analysis was under $10 per 1,000 imports for Haiku, but validate this against current API pricing and observed token usage before launch. Enforce per-user quotas (for example, 10 imports/day) to bound costs. ### 3.8 Security, privacy, limits - **Secrets:** the API key exists only in the function's environment, never in a `VITE_` variable. - **Limits (client-enforced and server-enforced):** pasted text ≤ 20,000 characters. Reject empty or oversized input. - **No input storage:** process pasted text in memory and discard it after the request. Log only metadata and token counts, never the plan text. - **Prompt injection:** a pasted document could contain instructions. Defences: - The model can only return the JSON schema. - The server validates types and clamps every number. - The client accepts only existing exercise IDs or new custom-exercise records. - Names are capped at 80 characters (the `custom_exercises` check constraint) and rendered as text (React escapes them). - The output never triggers any action without the user's review and confirmation. - **Disclosure / LGPD:** the input screen says the plan is read by AI and not stored. The privacy policy should list Anthropic as a processor. Under Anthropic's commercial terms, API inputs aren't used for training by default. Confirm the current retention terms when writing the policy text. --- ## 4. MVP scope and implementation phases ### Phase 0: Haiku spike and evaluation set (before building UI) - Collect **30–50 representative plan texts**: primarily Brazilian Portuguese text copied from WhatsApp or Notes, plus a small number of English examples. Do not collect or process image/PDF inputs for this MVP. - Write the extraction prompt and schema, run them, and score: days detected, sets/reps/rest correct, **top-1 exercise match rate**, and the share of exercises in each bucket (matched / check / not found). - Exit criteria (proposed): ≥ 95% correct sets/reps/rest on text, ≥ 85% top-1 matches, and ≤ 1 in 5 exercises needing user attention. Use this to decide **local matching vs. library-in-prompt** and **which model**. ### Phase 1: MVP - Input: **pasted text only**. - Entry points: recommendation screen, Workouts sheet, empty-plan state. - Processing, error and partial states. - Review screen + day editing via the existing editor + the "needs attention" resolver. - Confirm: **replace plan**, plus a training-days check. - Edge function, `plan_imports` table for quota and cost metrics, signed-in (Supabase) users only. - New screens designed in Claude Design. Logic lives in a persistent `src/plan-import.js`, and matching has unit tests in `tests/`. ### Phase 2 - File imports are outside this MVP and must not be implemented as part of this work. - Onboarding branch, "I already have a plan" (skips the six recommendation-only questions). - "Add to my current plan" mode; weekday-aware scheduling; per-user name-correction memory synced to Supabase. ### Phase 3 (only if the data justifies it) - Rep ranges and per-exercise notes in the data model. This would also benefit hand-built plans. - Supersets/bi-sets, timed variants of built-in exercises (plank, wall sit), cardio blocks. - Portuguese display names for library exercises. Today the user sees "Barbell bench press" for "Supino reto", and the review shows the original as a caption. - Share-to-Forma. The Web Share Target API works for an installed PWA on Android, but iOS doesn't support it. --- ## 5. Implement the review and correction experience The principle is to **never block the import, but never hide a guess.** Every uncertain decision is visible, and has a safe default and a one-tap fix. **Plan overview (top of the review screen)** - Editable plan name, e.g. "Treino ABC". - Summary: "3 workouts · 18 exercises · **3 to check**". Tapping "3 to check" jumps to the first one. - Banner for anything that wasn't understood: "2 lines weren't recognised" → expands to show the raw lines. **Day cards (one per routine, in rotation order)** - Name (editable inline), exercise count, estimated time (`estMin`). - Exercise rows reuse the routine screen's row design: GIF thumbnail, **matched library name**, spec ("4 × 10 · 1:30 rest"), and a caption with the original text ("from: Supino reto 4x8-12 (90s)") so the user can verify the translation at a glance. - Inline notes where something was approximated: "8–12 reps → 10", "No rest given → 1:30", "Bi-set noted in description". - Badges: **Check** (amber) and **New exercise** (for unmatched items that will be created as custom exercises). - Days can be reordered to change the rotation. Tapping a day opens the **existing routine editor** (`vEdit`) on a draft that belongs to the import, not yet to `d.routines`. That gives set/rep/weight editing, rest, reorder, swap from library, add and remove for free. The only change needed is that saving from this editor writes back into the import draft (e.g. an `importIdx` on the stack entry, checked in `saveDraft`). **Resolving a "Check" row (bottom sheet)** - Title: the original text. Three candidate cards with GIF, name and equipment, best guess pre-selected. - "Search the library" opens `vLib` in swap mode, pre-filled with `name_en`. - "Create as my own exercise" opens the custom-exercise form (`openCex`) prefilled with name, type and muscles. - "Remove from plan". - Whatever the user picks is remembered as an alias for future imports. **Confirm** - When items are still unreviewed, the CTA reads "Import plan" and shows a short summary sheet: "3 exercises will be added as your own exercises. 1 exercise uses our best guess." Options: *Import* / *Review first*. - Then the replace-plan dialog and the training-days check from step 4. Why this design: - It keeps the user in control without forcing them through every row: most rows are matched, and only exceptions ask for attention. - It reuses three existing, tested surfaces (routine editor, library picker, custom-exercise form) instead of building a second editor. --- ## 6. Decisions (agreed 2026-10-10) 1. **Who can use import:** signed-in Supabase users only. Host the function as a Supabase Edge Function and hide the entry points in local mode. 2. **Model:** use Claude Haiku 5.5 (`claude-haiku-5-5`) for evaluation and production. Do not call or compare Opus or Sonnet. 3. **Rep ranges:** use the midpoint, as the built-in programs do (`floor((min+max)/2)`), and show "8–12 → 10" in review. 4. **Rest precedence:** an imported plan's explicit rest wins. On commit, delete `d.prefs[exId]` for each imported exercise that has a rest in the source, and queue a `pref` sync op for each. When the source gives no rest, keep the existing pref (or 90 s). If the user later adjusts rest mid-workout, it becomes a pref again, which is the current behaviour for every plan. - Why this matters even though import follows onboarding: saved rests come from adjusting the rest timer during a workout (`setRest` and the rest sheet's `tSave` in `Forma.jsx`), not from onboarding. A brand-new user has none. An existing user who has trained on the recommended plan and then imports a trainer's plan from the Workouts tab will have some. 5. **Language:** keep the UI in English. The review screen shows the user's original wording as a caption under each matched English exercise name. 6. **MVP input:** pasted text only. Do not implement image, screenshot, PDF, Word, Excel, or other file inputs. ## 7. Phase 0 eval plan: validating Haiku Run every plan in the evaluation set through **Claude Haiku 5.5 only** with the same prompt and schema. **Steps** 1. **Collect and label.** Gather 30–50 representative pasted-text plans, primarily in Brazilian Portuguese, from sources such as WhatsApp and Notes, plus a few English examples. For each plan, hand-write the correct answer in the output schema, including the correct Forma exercise ID for each exercise (or "custom" when the library has no good match). Store the dataset in a git-ignored folder and remove personal information. 2. **Run.** A small Node script calls Haiku with structured outputs and runs each plan 2–3 times to measure consistency. Local matching runs on each output, so the whole pipeline is measured, not only extraction. 3. **Score automatically against the labels:** - workouts (days) detected correctly - sets, reps and rest correct per exercise - top-1 exercise match rate, and how often the correct exercise is in the top 3 - share of exercises landing in "check" or "not found" - invented values (model filled in something the source didn't say) - latency (p50 / p95) and cost per import, from the API's token usage 4. **Validate.** Confirm whether Haiku meets the exit criteria in §4. If it does not, improve the prompt, validation, matching, or review experience; do not switch to or evaluate Opus or Sonnet. **Eval cost:** estimate the evaluation cost from Haiku's current API pricing and observed token usage. The source analysis estimated under $1 for 50 plans × 3 runs; verify this estimate rather than treating it as a guaranteed price. The main cost is the time to collect and label the plans, not API spend. **Needs:** an Anthropic API key with access to Claude Haiku 5.5. Keep it in a local development environment for evaluation and in Supabase Edge Function secrets for production; never expose it in client-side code.