Essay

How SkudHealth is built.

A smart scale, a training log and a wearable, joined into one page — and a daily training note assembled from the result.

Three devices produce a lot of data and no answers. The scale knows body composition. Strava knows training. A wearable knows how the night went. None of them knows about the other two, and the question that actually matters — what should I do today — needs all three.

So this is the join: SkudHealth, a private dashboard at skudhealth.com showing what was burned against what was eaten, the scale trend underneath it, how the last few nights went, and a training plan assembled each morning and written by Claude. It began as a page in this garden and outgrew it — it is its own site now, with its own domain and repository, and this essay stayed behind. What follows is how it fits together, drawn rather than described wherever a picture is clearer. The page is behind a password and every figure on it is a real reading, so the mockups here are schematic — everything else is exactly what is running.

Sources
Withings · Strava · Google Health · Open-Meteo
A smart scale, a training log, a wearable read through Google Health, and hourly weather. Four APIs, four different ideas of what a date is.
Store
Aurora PostgreSQL 17 · Serverless v2
Scales to zero between syncs and is reached over the RDS Data API, so nothing attaches to a VPC and no NAT gateway exists.
Compute
Two Python 3.13 Lambdas
One sync on a six-hour schedule that writes; one API behind an HTTP API that reads. The sync is the only writer.
Intelligence
claude-opus-5 · claude-haiku-4-5
Two calls with different jobs and different budgets: one reasoning call a day for the training note, and a small naming call whenever a food is added to the catalogue.
Frontend
Next.js 16 · static export
Prerendered to HTML at build time, the same way this site is. The dashboard ships a sign-in form; every number arrives at runtime.
Secrets
SSM Parameter Store · SecureString
Nothing is committed or bundled. Every credential resolves from AWS at runtime, and the deploy fails if health data reaches the build output.

Two paths through one store. A scheduled job writes; a request-time function reads. They never contend, because the thing the browser reads is a file, not a query.

PROVIDERSSYNC · WRITESTORESERVE · READCLIENTWithingsStravaGoogle HealthOpen-MeteoEventBridgeevery 6hsync Lambdasingle writerclaude-opus-51 call/dayverbatimAuroraappend-only mirrorsrecomputestats.jsonS3 artifactprecomputednutritionAPI LambdaHTTP APIcookiebrowserstatic export
Solid: the read path a page load actually takes. Dashed: the one request that touches the database.

Every table is a mirror of somebody else’s API, and the rule is that the provider’s response is stored intact. A row keeps the whole payload in a jsonb column, and promotes to real columns only the values that are filtered or ordered on.

The reason is that today’s parsing decisions are guesses about tomorrow’s questions. A field nobody thought to extract is still there to extract later; a field discarded at ingest is gone, and getting it back means re-fetching history from an API that rate-limits and may no longer serve it.

What arrives

{
  "id": 14830921,
  "start_date": "…",
  "sport_type": "Run",
  "distance": 8047.2,
  "laps": [ … ],
  "…": "40 more fields"
}

What is stored

activity_idthe provider’s own id · the conflict target
started_atpayload.start_date · ordered on
sport_typepayload.sport_type · filtered on
payloadthe entire response · everything else, verbatim

insert … on conflict (activity_id) do nothing

A promoted column is an index, not a schema. Every question the promoted columns cannot answer is answered by reading back out of the payload — so a field nobody thought to promote is an ALTER TABLE later, never a re-fetch.

Inserts key on the provider’s own id with on conflict do nothing, which makes the sync idempotent: running it twice over the same range changes nothing. That collapses two jobs into one — backfilling history and syncing the last six hours are the same code with a different starting cursor, so there is one thing to get right instead of two.

One table deliberately breaks the rule. A finished activity and a scale reading never change, but a wearable revises last night for hours after it first appears as sleep stages settle. That table upserts, and its pull is a trailing window rather than a cursor, because insert-and-ignore would freeze whichever partial version happened to land first.

Aurora is Serverless v2 with a floor of zero capacity. It parks after roughly fifteen minutes idle and takes about fifteen seconds to wake. A half-ACU floor would remove that and cost around $44 a month to sit still; scaling to zero costs a few dollars.

So the wake cost is designed around rather than paid. The sync computes every number the dashboard shows and publishes one stats.json to S3, and the page fetches that file. The analysis is deterministic — recomputing it per request buys nothing.

stats.json from S3

40 msthe whole dashboard

Aurora, already warm

150 msnutrition read/write

Aurora, cold

15 sfirst load after ~15 min idle

Typical figures. A database with a floor of zero capacity is cheap precisely because it is allowed to be asleep, and somebody pays for that on the first request — so the read path is arranged so that somebody is almost never the page.

Nutrition is the single exception, because it is read/write: a meal logged now has to be visible now. That path does touch the database, and the API function’s timeout is deliberately set above a cold resume so the request can wait it out rather than die halfway through one.

The same idea appears twice more on this page, at different scales. Keep the raw thing so no decision made today can lose information wanted later; precompute everything expensive so the thing a person waits on is a file read. Scale readings and the training note are the same pattern.

All AWS, no platform-as-a-service, and nothing that runs when it is not being used. The whole thing costs a few dollars a month.

Aurora Serverless v2

The mirror. Postgres 17, floor of zero capacity, reached over the RDS Data API — an HTTP interface, so no Lambda attaches to a VPC and no NAT gateway is needed.

Lambda ×2

A sync function (512 MB, five-minute ceiling, reserved concurrency 1 so it is a single writer) and an API function (256 MB, 30-second timeout — long enough to outlast a database resume).

API Gateway · HTTP API

One custom domain, one default route. The rate limit lives on the stage, which is the only place that actually bounds cost.

EventBridge

The six-hour schedule that fires the sync. The only thing in the system with an opinion about time.

S3

The published stats artifact the dashboard reads, and a gzipped NDJSON dump of every table for backup.

CloudFront + Route 53

TLS and caching for skudhealth.com and its API subdomain. The static export sits behind it unchanged.

SSM Parameter Store

Every provider credential as a SecureString, read at runtime. The one token that rotates is written back before it is used for anything else.

Secrets Manager

The AWS-managed database password. Its ARN resolves from the cluster description at runtime, so rotation needs no config change.

CloudWatch + SNS

Error and throttle alarms to email. A sync that quietly stops is how months of data go missing unnoticed.

IAM + GitHub OIDC

Deploys authenticate by federated identity, so no AWS keys exist as repository secrets.

Integration work is rarely the API call. It is the handful of structural facts about someone else’s system that decide how the client has to be shaped.

Withings

Body composition — ten measure types per weigh-in, plus daily energy expenditure ingested from Apple HealthKit.

Shape · Paged, and the credential rotates

Every list endpoint returns a page and signals the rest with a cursor, so the reader loops until the cursor is empty. The refresh token is single-use: each refresh invalidates the one that bought it, so the client persists the new token before doing anything else, and the sync is capped at one concurrent execution so two refreshes can never race.

Strava

Every activity since mid-2024, with per-lap splits and Strava’s own best-effort detection for personal records.

Shape · The list is a summary, not the record

The activity list omits the description field, which is where strength sets live. Getting them means a second request per activity against a 100-request / 15-minute read limit, so that backfill is paced and resumable — it fetches only the rows whose detail is still null, and picks up where it stopped.

Google Health

Sleep, heart rate variability, resting heart rate. Replaces the Fitbit Web API, which is decommissioned in September 2026.

Shape · An aggregator, not a device

The account is fed by two wearables at once, and both report a figure for the same night. The source is therefore part of the row key rather than the date alone, and every reader pins to one device — otherwise a night one tracker was not worn scores a different sensor against the wrong baseline.

Joining them is one problem in particular. Every provider stamps its records differently, none of them in the calendar a person actually lives in, and a dashboard organised by day has to pick one.

Withingsunix int, or wall clock + IANA zone
StravaRFC-3339, UTC
Google Health{ year, month, day }
Open-Meteohourly, UTC

The join key

a local calendar date

resolved through a named zone —
America/Chicago, never a fixed offset

A named zone knows when daylight saving applies; a hardcoded −5 is right for half the year and files a winter evening’s meal under tomorrow for the other half. Postgres current_date is UTC for the same reason, so the local date is bound as a parameter rather than computed in SQL.

The obvious thing to read is the wearable’s own readiness number. It does not exist as data. Readiness and sleep scores were computed inside the vendor’s app rather than stored as types, and neither survived the move to Google Health.

So it is derived from the three things that are stored, each scored against a 30-day personal median rather than a population range. An HRV of 41 ms means nothing on its own — healthy adults span roughly 20 to 200 ms — but the same number against a personal baseline does.

HRV

percent deviation

spread scales with level — 5 ms is a rough night at 30 and nothing at 120

Resting HR

absolute beats

the useful range is narrow, so 5 bpm means the same wherever it starts

Sleep

hours, banded

a five-hour night is a fact, not a deviation

baseline: 30-day medianback offcarry onpush

Three bands rather than a number, because the honest resolution of three noisy overnight measurements is back off, carry on, or push. One module owns the calculation and both consumers read it — the dashboard and the model’s context — so the verdict on the page and the verdict in the prompt cannot drift apart.

There are exactly two places a language model is called, and they are worth reading side by side, because almost everything that differs between them follows from one question: is anyone waiting?

A call nobody is waiting on can be slow, large and expensive, and should run where the data already is. A call inside a form on a phone has to be small, fast, cheap and incapable of breaking the form.

The training note

claude-opus-5

Job
Reason over a fortnight of training and say what to do next
Runs from
Inside the sync, on a schedule
Waiting for it
Nobody — the page reads the published result
Input
≈10,100 tokens of assembled context
Output
≈2,100 tokens, thinking billed as output
Repeat cost
Zero — gated on a fingerprint
Per call
≈$0.10
If it fails
Yesterday’s note stands; the sync still publishes

The emoji picker

claude-haiku-4-5

Job
Name one food with a pictograph
Runs from
The API function, inside a request
Waiting for it
A form on a phone
Input
≈350 tokens — a name, macros, a taken list
Output
≈60 tokens of JSON
Repeat cost
Not applicable — asked a few times a month
Per call
well under a cent
If it fails
Empty list; the manual emoji field stands
Model choice follows the job, not the budget. One is a reasoning task nobody is waiting on; the other is a naming task with a fixed output shape, sitting inside a request a person is watching.

Both calls are built the same way, and it is the pattern worth taking away from this page. A request is three pieces: two are prompt, and one is a contract on what comes back.

THE REQUESTTHE MODELWHAT COMES BACKsystem promptstatic · lives in codeuser messageassembled per runoutput schemaa contract, not a hopeClaudeone calltyped objectvalidated JSONrenderno regex anywhere in the path — changing a field is a schema edit and a component edit
Both model calls have this shape. What separates them is only how much goes in the middle box and how expensive the model in it is.

The split between the two prompts is the whole trick. Anything true on every run lives in the system prompt — the goals, the constraints, the vocabulary, the rules about what a good answer looks like. Anything that changes is assembled into the user message at run time, as plain labelled text. Tuning behaviour is then editing one block of English rather than touching the data path, and because the changing half is generated from tables rather than written by hand, the model never sees a stale fact.

The third piece matters as much. A request that carries a JSON schema gets back a typed object, not prose to parse. That removes the entire category of work where a component has to guess at the model’s formatting: there is no regex anywhere in either path, and changing what the UI shows is a schema edit plus a component edit. A schema also constrains the model usefully — asked for six emoji as an array of strings, it cannot answer with a paragraph explaining its choices.

One call to claude-opus-5 a day, made from inside the sync rather than from the page. That placement is the first design decision: the note lands in the same stats.json the browser already fetches, so the dashboard renders it with no extra request, no API key anywhere near the client, and no database wake.

  1. 01

    System prompt

    Static across days

    The standing rules: what this athlete is training for, a movement-pattern map so the advice can say "the posterior chain has not been trained in five days" rather than naming one lift, the equipment’s actual ceilings, and a closed list of the exercises in this programme. Closed on purpose — left open, a model reaches for the obvious answer rather than the one available in the gym.

    • training goals
    • pattern map
    • equipment limits
    • closed exercise list
    • tone rules
  2. 02

    Context

    Rebuilt from Aurora on every run

    A single user message assembled by querying the mirrors: the trailing fortnight of activity with per-lap splits and the notes attached to each session, a rolled-up week summary, today’s conditions, the recovery verdict, and an explicit statement of how much of today has already been spent. That last one is computed rather than inferred, because a page whose whole job is "what am I doing next" cannot be one bad inference away from prescribing a session that already happened.

    • 14 days of activity
    • lap splits
    • week summary
    • today so far
    • recovery verdict
    • conditions
  3. 03

    Response

    Constrained by a JSON schema

    The request carries an output schema, so what comes back is a typed object rather than prose to parse: a one-line status, the next two days in order, and the detail behind each. The React component renders it field by field.

    • status line
    • day one
    • day two
    • per-day detail
    • week rollup

Regeneration is gated on a fingerprint

Before the call is made, the assembled context, the system prompt and the output schema are hashed together. If the hash matches the one stored beside the existing note, the call is skipped and that note stands. Identical inputs cost nothing, which is what makes a six-hourly schedule affordable.

What goes into the hash is a design decision, not an implementation detail. The prompt is inside it on purpose, so editing the advice reaches the page on the next sync rather than waiting for tomorrow. Ambient data is deliberately outside it: weather drifts continuously, and hashing it would unlock a billed regeneration every few hours for a temperature change that altered nothing about the training. The rule that falls out is anything the athlete enters belongs in the fingerprint; anything the world merely reports does not.

What it costs, exactly

Roughly 10,100 input and 2,100 output tokens, against claude-opus-5 at $5 and $25 per million: about ten cents a call, call it $6 a month at two a day. Reasoning bills as output, so the effort setting moves that figure faster than anything else; this one runs at medium.

The token counts are stored alongside each note, so the running cost is a query rather than a guess — which is worth doing on any metered call, because an estimate made while building is almost never the number a month later.

The call is also wrapped so a failure can never fail the sync. The published artifact is the thing the dashboard cannot do without, and yesterday’s advice beats no publish at all.

The food catalogue is a grid, and a grid is read by shape long before it is read by name — which makes the emoji beside each food the thing that actually makes it findable. The problem is that typing one means leaving the keyboard to hunt through a system picker, so in practice most foods ended up with none.

The obvious fix is a keyword table: match chicken, return a drumstick. It fails immediately, and how it fails is the interesting part.

The name is not the food

milk

150 kcal · 8P / 12C / 8F

🥛

milkshake

700 kcal · 12P / 95C / 28F

🥤

One word apart, and the macros are what separate them. A lookup table keyed on the name gets both wrong or both the same.

A name nothing contains

Fieldgood bar

200 kcal · 20P / 22C / 8F

🍫

A table would need every brand in it, spelled the way it was typed. The macros and the shape of the name are enough to place the category.

Two rows, one glyph

sparkling water

taken: 🥤 🥛 🍎

💧

Every emoji already on the day’s log travels with the request, so the model steers around them. The grid is read by shape before it is read by name.

Illustrative foods, not logged ones. Each case is a question a keyword table cannot answer and a two-sentence prompt can.

So the food’s name, brand, serving and macros are handed to claude-haiku-4-5 with a short system prompt and a schema asking for six candidates, best first. Haiku rather than the training note’s Opus, because this is a naming task with a fixed output shape and no reasoning to do, sitting inside a request somebody is watching — the same reason the training note is not answered by the cheap model.

THE FORMTHE REQUESTTHE CALLTHE PICKERname, then bluror “suggest” tappedPOST /suggest-emojione small JSON bodyclaude-haiku-4-5system prompt + schemasix candidatesfiltered, dedupedname · brand · servingcalories · proteincarbohydrate · fattaken[] — every glyph in useempty listnever an exceptionany failurethe manual emoji fieldstands; the food still savesa suggestion is a convenience — it is never allowed to be the reason a food cannot be saved
The suggestion is asked for unprompted when a food is named for the first time, and only on request when an existing one is edited — so opening a food to correct a macro does not spend a call on an emoji that was already chosen.

The prompt is four rules, and every one of them earns its place

Best first. The ordering is part of the contract, so the first candidate can be treated as the answer and the rest as alternatives.

Prefer the specific over the generic. A glass of milk is a glass of milk, not a plate. Falling back to a generic food glyph is allowed only when nothing specific exists — otherwise every third food becomes the same shape and the grid stops working.

Use the macros as a check on the name. High protein and no carbohydrate is meat, fish or a shake; an unfamiliar name with 40 g of carbohydrate and little fat is closer to rice or bread than to a steak. This is the rule that makes the feature work at all, because the names worth suggesting for are exactly the ones nothing recognises.

Avoid what is taken. Every glyph already in use travels with the request, so suggestions steer around them.

A convenience is never allowed to break the thing it decorates

The whole route is written so it cannot raise. A refusal, a malformed response, a missing credential, a timeout — all of them return an empty list, and the form keeps its ordinary emoji field. The failure mode of the feature is that the feature is not there.

That is not defensiveness for its own sake. An unhandled exception in a request handler becomes a gateway error, and a response the function never produced carries no CORS headers — so the browser refuses to read it and reports a network failure rather than a status. A convenience that can present as “the page is broken” is not a convenience.

The output is filtered on the way back, too. A model asked for pictographs occasionally answers with a word, and a picker button reading burrito is worse than one fewer candidate — so anything containing a letter or a digit is dropped as prose rather than shown. Trusting a schema for structure and still checking the values is the correct amount of trust.

Emoji are only one of three kinds of mark on the page, and choosing between them is a real decision rather than a matter of taste.

A Unicode emoji is a codepoint — the viewer’s own operating system draws it. It costs nothing, needs no build step, and a character exists for practically anything a person might eat, which is why the food catalogue uses them: the set of foods is open-ended and user-authored, so no curated collection could ever cover it. The price is that it renders differently on every platform and cannot be recoloured to match a palette.

An SVG icon inlined from an open set wins wherever the vocabulary is closed and small. Consistency of register matters more than breadth once a handful of marks sit together in one control — mixing a flat monochrome glyph beside a full-colour emoji reads as two different families of object rather than as siblings. If one member of a picker becomes an SVG, all of them should.

Either way the file is pasted into the source and shipped, never fetched. This site makes no external requests, and an icon that arrives over the network is both a request and a layout shift.

No attribution required

MIT · Apache 2.0 · ISC · CC0 — paste and go

  • Material Symbols15,600Apache 2.0
  • Fluent UI System19,800MIT
  • Phosphor9,100MIT
  • Tabler6,200MIT
  • Lucide1,800ISC
  • Noto Emoji3,700Apache 2.0
  • Fluent Emoji3,100MIT
  • Health Icons2,000MIT
  • Simple Icons3,500CC0

Attribution required

CC BY — a visible credit line is part of the cost

  • Font Awesome 62,000CC BY 4.0
  • Twemoji4,000CC BY 4.0
  • Solar7,600CC BY 4.0
  • Streamline3,000CC BY 4.0
  • Game Icons4,100CC BY 3.0

Share-alike

CC BY-SA — edits inherit the licence

  • OpenMoji4,500CC BY-SA 4.0
  • Typicons340CC BY-SA 4.0
Icon counts rounded. Every set here is free and open; the licence column is the one that usually decides, because an attribution obligation follows the mark into whatever it is pasted into.

The catalogue above is the useful part to keep somewhere findable, because the deciding constraint is almost never the drawing — it is the licence. MIT, Apache 2.0, ISC and CC0 sets can be pasted and forgotten. CC BY obliges a visible credit wherever the mark ends up. CC BY-SA makes any edited version share-alike, which is a real constraint on a mark that will be recoloured or trimmed.

One practical rule governs the choice more than any other: judge every candidate at the size it will really be drawn. An outline icon that reads beautifully at 96 px silts up into a blot at 14 px, and detail that survives the shrink is the only criterion that matters. A hand-drawn shape from three rectangles regularly beats a far better drawing whose proportions say the wrong thing — a stubby can reads as a soda no matter what its label says.

One rule shapes the analysis more than any other. A consumer scale’s body-fat reading scatters more day to day than the thing it is measuring changes in a week: around 0.34 percentage points of noise against a real weekly movement several times smaller. Fit a slope over a short window and the confidence interval swamps the signal.

1 week

±0.88 pp/week — cannot detect the signal even in principle

2 weeks

±0.31 pp/week — still not enough

3 weeks

±0.17 pp/week — marginal

95% interval on the fitted slopethe rate of change being looked for, 0.15 pp/week

A bar that overshoots the line is a window whose arithmetic cannot see the thing being measured.

So every trend carries its own error bars, and a window whose interval is wider than its slope is annotated rather than drawn as a confident line. It is a small amount of arithmetic, and it is the difference between a dashboard and a machine for confirming whatever you already believed this morning.

A static export — no server, every route prerendered to HTML at build time. The dashboard is prerendered too, and it contains no data: it ships a sign-in form, and every number arrives from the authenticated API at runtime. The landing page's “log in” is therefore just a link to it. The whole site is noindex and disallowed in robots.txt, because there is nothing on any of it for a search engine to hold.

Authentication is standard library only — PBKDF2 for the password, HMAC for the session token — so the function deploys as a plain zip with nothing to vendor. The token rides in an HttpOnly, Secure, SameSite=Lax cookie. The API lives on a subdomain of the site it serves on purpose: that makes the cookie first-party, so SameSite=Lax is sufficient. A separate domain would need SameSite=None and get blocked by tracking prevention. Moving the dashboard to its own domain therefore meant moving the API with it, to api.skudhealth.com — a second site was not enough on its own.

The client retries GETs and only GETs. A 4xx is an answer and will not change on a second ask; a 5xx or a network error is worth retrying. Writes are never retried — the food log carries no idempotency key, so a retried POST would record the same meal twice.

The refresh button is asynchronous for a reason. The sync runs to five minutes and the API function times out in thirty seconds, so it cannot wait for a result. Pressing it fires the sync, and the page then polls the stats endpoint watching for the generated-at timestamp to change.

Coach

Today

Tomorrow

Today

Body composition

Schematic, not a screenshot. The page is behind a password and every number on it is a real reading, so none of them appear here.

Two GitHub Actions workflows deploy on a push, both authenticating by OIDC, so no AWS keys exist as repository secrets. One builds the site and invalidates the CDN; the other packages both functions. They are scoped by path, so a change to a markdown file deploys nothing.

The interesting parts are the assertions. The site workflow fails the deploy if any health data appears in the build output — the one mistake here that could not be fixed by a follow-up commit. Packaging imports each function’s entry point against only the files staged for its zip, so a module the handler needs but the file list forgot fails the build instead of the first invocation. And the backend workflow smoke-tests the live API afterwards: an unauthenticated request must return 401, and the CORS preflight must advertise every method the routes use.

All three failures are invisible until a browser or a runtime hits them, which is exactly why they are assertions rather than intentions.

Two deployables, four providers, one precomputed file and two model calls with very different budgets, in roughly 5,500 lines of Python, built in Claude Code.

If there is one thing to take from it, it is that most of these decisions are the same decision asked at different scales. Keep the raw thing. Precompute the expensive thing. Put a contract on anything whose shape you do not control. And let the part that can fail be the part nobody needs.

SkudHealth keeps its own repository, which is private. This site’s source is public at github.com/KyleSkudlarek/kyleskudlarek.com. The data is in neither, and never touches either.