An AI session recommender for a 283-session conference agenda, shipped as one file
A healthcare leadership association publishes a five-day agenda and no way to choose inside it. We built a recommender that turns role and interests into eight non-overlapping sessions with a stated reason for each, as a single HTML page with no login, no database and no outbound requests.
- case study
- automation
A national association publishes its annual conference agenda: 283 rows across five days, seven tracks, up to thirteen sessions running against each other at the busiest moment. Every one of those rows is on the website. None of them are ranked, filtered, or matched to the person reading.
The association already knew this. Tracks exist, and beyond them the program is displayed in full for attendees to read through. The content is not missing. The decision layer is.
What does a discovery layer actually have to do?
Three things, in order: decide which rows are even recommendable, score the rest against a person, and refuse to hand anyone a schedule that is physically impossible to attend.
That first step is where most of the work went. Of 283 published rows, 158 survive into the recommendation pool. The rest are removed for reasons, not by guesswork:
| Removed | Rows | Why |
|---|---|---|
| Pre and post-congress days | 6 | Five are separately ticketed boot camps sold outside the main program |
| Restricted audience | 9 | C-suite or Fellow only. One row carries no audience badge at all, so the filter matches badge or title |
| Student sessions | 2 | Wrong audience for every persona in the tool |
| Not an education session | 108 | Alumni receptions, chapter mixers, meals, closed board meetings |
158 + 108 + 9 + 6 + 2 = 283. Those counts are produced by the exclusion code on every row, not typed into a slide.
Proof this mattered: the six excluded off-day rows are weighted, on the published agenda, at four times anything in the main program. Left in, they would have ranked first for most attendees, sending them to a session that is not part of the ticket they bought. Restricted rows are a gate rather than a ban: select the C-suite role and they become eligible again, and the tool says so on screen.
Why does the AI run at build time and not in the browser?
Because a live model call is the one component that can be slow, refuse, or invent something in front of an audience. So none of it happens live.
At build time, Claude tags every session in the pool against a closed 12-slug vocabulary. Not free-form labels: a fixed list, so the scoring maths downstream cannot drift. The browser then does arithmetic only. There is no model in the runtime path at all.
The same decision shapes how the tool explains itself. Every reason string is assembled from the scoring components that actually fired, so each sentence traces back to a number rather than being generated prose. When an early build let the reasons come from a template, the result was 960 sentences with 108 distinct forms, all opening with the same word, and one persona receiving eight identical endings. That was caught by a review pass and rewritten to lead with the track and name the actual role.
Why is the whole thing a single file?
Because the client’s publishing process has a fast lane and a slow lane, and the difference between them is weeks.
An embed or a script goes to the internal web team. Anything classed as development goes out to an external agency, with a cost and a queue. So the deliverable is one self-contained HTML page: no backend, no login, no database, no cookies, no local storage and zero outbound requests. Double-click it with the wifi off and it works, byte for byte identical to the hosted copy.
Proof this is enforced rather than claimed: the build fails outright if any external URL appears in the output, or a fetch, XMLHttpRequest or WebSocket call. The typeface is vendored and inlined as base64 rather than pulled from a font CDN, because the page is not allowed to make even that one request. Bundle weight lands at 273 KB.
For a digital marketing manager asked to put someone else’s tool on their site, that page answers the entire objection list at once: nothing to run, nothing to patch, nothing that can go down during the event.
How do you stop it recommending two sessions at once?
Interval overlap with a 15-minute changeover buffer, not slot matching.
Comparing start times would be the obvious shortcut and it is the wrong rule, because it silently breaks the moment a program contains sessions of different lengths. Measured on the shipped 158-session pool: 13 concurrent sessions at the busiest moment, 771 overlapping pairs.
And here is the honest part. On this particular agenda, every one of those 771 pairs starts at the same minute, and the smallest real gap between sessions is exactly 15 minutes, so the buffer currently rejects nothing that raw overlap would have kept. It is a margin that does not bind on this data. We say that rather than selling the buffer as load-bearing, because next year’s program is a different file.
The feature we deleted because the client corrected us
The first build carried a scoring dimension lifted straight from a numeric field on the published agenda, plus a goal setting that ranked sessions by it. It read the agenda correctly. It described the wrong thing.
The number was real. What we assumed it meant for an attendee was not, and no amount of reading the published program would have told us, because the difference lives in how the association actually administers the event. One sentence from the client and every assumption behind that feature collapsed.
So the running total, its animation, the goal control and the weighted scoring were all removed the same day, along with the calendar export, which the client asked to drop so attendees stay on one source of truth. What replaced them is a test: the suite fails if that total returns to the result, or if any of its vocabulary reaches a label, a reason or a note. The underlying field still exists in the data, used for exactly one internal purpose, separating an education session from a banquet. It is never scored, summed or shown.
The print view went the other way. The client asked for it by name, and nothing guarded it, so it now has its own seven tests reading the template and the stylesheet rather than the built bundle, so a regression fails before a build ever runs. Each of those guards was verified by deliberately breaking it and confirming the suite caught it.
What we did not build
No login. No integration with their systems. No attendee data. No chat. No matchmaking. No admin panel.
Each of those absences is what keeps the page in the fast lane and out of a privacy conversation. There is no personal data surface to discuss, because there is no personal data.
Three things worth stealing
- Decide what is recommendable before you decide what is good. The exclusion pass did more for output quality than any scoring tweak, and it is the part nobody demos.
- Move the model to build time whenever you can. Tagging is a batch job pretending to be a runtime feature. Batch it, and the live product cannot fail in a way you did not test.
- Let the client correct the model of their own world. Ours was wrong in a way no amount of reading the published program would have revealed, and the fix was to delete work, not add it.
Book a 20-minute walkthrough if you run an event program with more sessions than anyone can read.