Building HeatSync Part 3: Making It Work
So I had the idea and a clear scope: upload a PDF, enter a swimmer name, get calendar events in under 30 seconds. Time to build.
My goal for the first milestone was simple: get the core flow working end-to-end. Upload a PDF, enter a name, see the events. Ugly UI acceptable. Polish comes later.
Tech Stack Decisions
I made these choices fast and didn’t second-guess them:
| Layer | Choice | Why |
|---|---|---|
| Frontend | SvelteKit | Lightweight, reactive, I know it well |
| Backend | Hono + Bun | ~14KB framework, native TypeScript, fast |
| AI | OpenAI SDK | Works with any OpenAI-compatible endpoint |
| PDF Processing | mupdf | Server-side rendering, handles any PDF |
| Calendar | ics library | Generates valid .ics files |
| Styling | TailwindCSS v4 | Rapid prototyping, responsive out of the box |
Nothing exotic here. I picked tools I’m comfortable with so I could focus on the actual problem, not learning new frameworks.
The Model Selection Circus
Here’s where things got interesting.
I knew from my ChatGPT experiment that gpt-5.2 could extract events from heat sheet PDFs. But gpt-5.2 is expensive. For a free tool, cost matters.
So I tested every model I could get my hands on:
- All AI Builder Space models
- OpenAI’s gpt-5-mini
- OpenAI’s gpt-5-nano
- gpt-5 (the full model)
The results were disappointing.
Most models either:
- Couldn’t read the PDF properly (hallucinated events that didn’t exist)
- Returned malformed JSON
- Missed events entirely
- Confused swimmers with similar names
The only models that consistently produced correct results were gpt-5 and gpt-5.2.
gpt-5 worked but was painfully slow (sometimes 30+ seconds per extraction). That kills the “under 30 seconds” OKR immediately.
So I ended up right back at gpt-5.2. Went in a circle and landed where I started.
Lesson learned: test your assumptions early. I spent half a day on model experiments before accepting the obvious answer.
The Core Architecture
I landed on a simple architecture:
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ SvelteKit UI │───▶│ Hono Backend │───▶│ OpenAI API │
│ (Upload Form) │ │ (PDF + AI) │ │ (gpt-5.2) │
└──────────────────┘ └──────────────────┘ └──────────────────┘Why a backend at all? Two reasons:
- API key security: Can’t expose OpenAI keys to the client
- PDF processing: Server-side mupdf is more reliable than client-side PDF.js, especially on mobile
The backend does all the heavy lifting: receives the PDF, optionally renders it to images (for non-GPT models), calls the AI, parses the response, returns structured data.
The Extraction Prompt
Getting the AI prompt right took several iterations. The final version includes:
| Instruction | Purpose |
|---|---|
| Name normalization | Handle both “First Last” and “Last, First” formats |
| Exact name matching | Return the name exactly as it appears in the PDF |
| Page thoroughness | Scan ALL pages before returning (no early termination) |
| Session date calculation | Derive date from meet date range + weekday indicator |
| Heat time extraction | Get explicit times or estimate from previous heats |
| temperature: 0 | Deterministic output for consistency |
The prompt also explicitly warns about swimmers with similar names (but more on that in Part 6 when I cover accuracy improvements).
First Working Demo
Two days after starting, I had a working demo:
- Upload a PDF ✓
- Enter a swimmer name ✓
- See extracted events ✓
- Download .ics file ✓
Was it pretty? No. Was it fast? Not really. Did it work? Yes.
That’s the milestone. Get it working, then make it good.
What I Used to Build It
This project was built almost entirely with Claude Code. I described what I wanted, reviewed the changes, iterated. The commit history tells the story: dozens of small, focused changes over a couple of days.
I’m not going to pretend I typed every line of code myself. That’s not the point anymore. The point is knowing what to build and being able to guide the tool effectively.
This is Part 3 of a series on building HeatSync. ← Part 2: Defining the MVP | Part 4: Removing Friction →