Problem
Sterkir pabbar is an online coaching business for dads in Iceland. The coach needed two things: a landing page that turns cold traffic into a free intro chat, and a paid members' area where members get a training plan, calorie and macro targets, and articles.
The members' area also has an assistant. A member can ask about their training, their nutrition or how to use the app, and get an answer grounded in their own assigned plan, their latest macro targets and the FAQ answers the coach writes. Anything outside that has to be refused. When I tested it, it hedged and then wrote a Python function anyway.
Constraints
- Iceland. Stripe doesn't support Iceland at all. The UI is Icelandic, and
toLocaleString("is-IS")can't be trusted in a browser. - Health numbers. The app calculates calorie targets for people I've never met. Every input that produced a number has to be kept, so "why did the app tell me 1,800 kcal in October" stays answerable.
- A client without GitHub. That ruled out every git-backed CMS. He gets an email invite and that's it.
- The client's email lives on the domain. Google Workspace MX records, and every CTA on the site points at that inbox.
- A fixed launch date of 1 October, and the coach's filming time was the real bottleneck.
- The assistant costs money per message, and the AI Gateway's budgets don't cover BYOK spend.
Architecture
sterkirpabbar.is
Astro landing page, zero JS
app.sterkirpabbar.is
React Router + Clerk
/api/assistant
AI SDK streamText, member-scoped tools
AI Gateway
BYOK to OpenAI
Neon + Sanity
Member rows, plans, FAQs
The landing page is static Astro. One site.ts module feeds both the visible copy and the JSON-LD, so the structured data can't drift from the page. Sanity content is fetched at build time into a committed JSON file, so a Sanity outage can't break a deploy. Publishing in the Studio fires a Vercel deploy hook, with drafts excluded so autosave doesn't trigger builds.
The members' app is a separate React Router app on a subdomain. There's no API service: loaders and actions are the backend. Onboarding, macro targets and plan assignments are append-only tables in Neon. Access is two nested layouts, not one check: a parent redirect beats the child loaders, so plan data is never sent to an unpaid member.
The assistant runs inside that app. Its tools take injected readers (assignedPlan, latestMacros) scoped to the signed-in member, so the eval harness runs the real tool mapping against a synthetic member without database rows. Off-topic questions get one fixed reply, OFF_TOPIC_REPLY, word for word. Emergencies are the one exception, and those point to 112. Route and evals share one generationSettings object so the evals can't drift from production. A daily per-member message limit and an ASSISTANT_ENABLED flag are both set through env vars.
What shipped
- Landing page. Designed, critiqued and reworked. I deleted placeholder testimonials, added a coach section ahead of pricing, and moved the first price further down the page. DNS moved to Vercel without breaking mail, and the site is on HTTPS.
- CMS. A Sanity singleton with Icelandic labels and character caps, validated in the Studio and again in the fetch script. The coach publishes on his own.
- Members' app. Clerk auth, an onboarding wizard (signed-cookie drafts, a health-flag acknowledgement step, Mifflin-St Jeor macros with a calorie floor and a protein ceiling), a weekly plan view, settings, an admin dashboard with payment-claim tracking, and preview-before-access for unpaid members.
- Assistant. Scope fix and an acceptance eval harness built from the coach's 60 handoff cases plus off-topic and in-scope cases. Exact match for refusals, an LLM judge from a different model family for the rest. Result: 57/63, zero critical, and every off-topic and safety case passed.
What I'd do differently
- Write the refusal on day one. A list of what the assistant helps with doesn't stop it doing other things. Without explicit off-topic handling the model treats being helpful as the default.
- Give the judge the same context as the assistant. Without the instructions and FAQ, it marked a real support email as made up. With them the score went from 55 to 57.
- Treat evals as content tests too. One case failed because a developer note had been left in an FAQ answer.
- Set the spend cap at the provider from the start. Gateway budgets ignore BYOK spend. Usage tracking also missed streams closed mid-answer, because an abort fires
onAbortand the route only handled the end and error callbacks. - Give production its own database branch. Dev and prod shared one Neon database, so local writes went to production data.
- Never trust a platform's green status. Vercel reported the domain verified while serving no certificate at all. Certificate issuance had failed before the nameservers moved and was never retried. Now I check the TLS handshake myself and force issuance right after a cutover.
- Design a success signal into fallbacks. A build that never fails also looked the same when it never reached Sanity.
- Drive the real UI earlier. Using the app in a browser found four defects that typecheck, lint and 40 passing tests had missed. One was a radio button at 1.05:1 contrast that nobody could see.