A March Madness pool that's run for 15+ years on paper and spreadsheets - someone manually entering and reconciling every score, a job that took hours after every round - rebuilt as a real, live web application. You draft individual players and coaches, not whole teams, and your score updates automatically as real games happen.
Every entry drafts a roster, not a team - and it's capped by seed tier, so you can't just draft every 1-seed: 6 players from seeds 1-4, 5 from seeds 5-8, 4 from seeds 9-12, 3 from seeds 13-16, plus 4 coaches. Scoring is just as simple: 1 point for every point your drafted players score in the tournament, 10 points for every win by one of your drafted coaches' teams. If two entries end up tied, it comes down to the tiebreaker - closest guess on the championship game's combined score, without going over.
The real diagram, not a simplified stand-in - every real node, restored from Tourney's own in-app documentation. One box runs identically on a dev laptop, in SIT, or in production; a second, separate box is FOMX.ai's own Spark, private hardware; everything else is a direct call out.
Nothing selected yet — click a box above.
Why Tourney routes to Claude directly for some calls and queues others to the FOMX.ai Spark is the same tradeoff every app on this platform makes - see Architecture Map's Model routing box. The mechanics of that queue - what a worker is and isn't trusted to do - are AI Worker Boundary's own story. Tourney's specific shape: Claude is called directly, over the open internet, the same as any other API call; the open-weight model works the opposite way - the app never calls out to the FOMX.ai Spark at all, it drops a job in the queue (a row in its own MySQL), and the Spark's worker comes and gets it on its own schedule.
The general five-step flow is Spark Worker Kit's own story; the one Tourney-specific wrinkle is in an agentic run: steps 2 and 4 each still happen once, but a third kind of call is sandwiched inside step 3 - every time the model wants data instead of finishing, the worker posts that one ask to the app and feeds the answer back to the model, up to 20 times per job. The Claude-direct agentic arm never touches the FOMX.ai Spark at all; its tool loop runs in-process, calling the same Python functions directly.
This is where I wanted to actually learn agentic AI, not just call an API - so I built it as a reusable experiment, not a one-off feature: pick a model, frontier or open-weight, and see the real cost/quality tradeoff for yourself, not a claim you have to take on faith. The agentic mode runs over a real MCP (Model Context Protocol) server the app exposes to itself - the model reaches back into the app through a genuine two-way tool-calling connection, not a one-shot prompt.
Every day, the app writes a short AI-generated update on how the standings changed - generated two different ways, on two different models, so the comparison is real rather than a marketing claim.
Both the prompt sent and every tool call made are watchable live, in the running app - not a claim you have to take on faith. Try it yourself, live → - the FOMX.ai Spark (open-weight) side is free to run; Claude asks for an access code first, email me for one.
Claude wrote noticeably better daily updates - a more natural, readable tone than the self-hosted model produced on the same prompt and the same data. On the agentic side, giving the model a menu of questions and letting it decide what to ask for, rather than handing it everything up front, was slower than the simple one-shot approach, but more reliable: it ended up better grounded in the actual data because the model chose what it needed instead of trusting a fixed payload. Which one actually wins depends on what you're optimizing for - better prose, or zero marginal cost - not a single verdict. For a job that runs every day of the season, the default is the self-hosted model: free per run beats noticeably better wording.
A side project that handles real names, emails, and phone numbers doesn't get to skip security because it's small.
One AWS server, three Docker containers, deliberately lean: a reverse proxy handling HTTPS, the Flask app itself, and its MySQL database. No server fleet, no managed services to wire together - a footprint sized for a free pool with a modest number of players, with an explicit upgrade path (split MySQL onto its own managed instance, move score-checking to a dedicated worker) if that ever stops being true.
The app is the hub, not the whole system - almost everything it does is a call out to somewhere else: browsers and admins talk to it directly; it pulls live scores and rosters from ESPN and betting odds from CollegeBasketballData.com; it sends account emails through Amazon SES; for AI, it either calls Claude directly or drops a job in its own database for a second machine to pick up.
That second machine is a physical NVIDIA DGX Spark - FOMX.ai's own GPU hardware, not a vendor's, running a self-hosted open-weight model (gpt-oss:20b, served by Ollama) plus an agentic loop that lets the model ask for its own data mid-run. How that connection is secured, and why the FOMX.ai Spark is never directly reachable, is the same worker-trust pattern every app on this platform shares - see AI Worker Boundary for the mechanics. SIT itself runs on that same physical FOMX.ai Spark, not a separate box reached over the network - there's only one Spark in the system, and SIT's deploy checkout lives directly on its disk.
Live scoring. A lightweight scheduler ticks every 60 seconds and asks whether an active tournament window is open and at least 5 minutes have passed since the last check - not a dedicated job-scheduling service, a pragmatic choice at this scale. Once a game finishes, ESPN's result is matched to the right teams, players, and picks, and every affected player's standing updates atomically: old numbers are cleared before new ones are written, never a partial overlay. Results are re-checked once an hour even after a game is marked final, as a safety net, and admins have a manual "run now" plus a one-click repair tool if ESPN's own data ever needs correcting.
Bracket projection. The feature players actually look at: a full bracket that colors in as the tournament unfolds. It does more than display results - for a game that hasn't been played yet, it still projects a winner, favoring whichever team the viewer drafted players from, so a bracket never looks half-empty. And it looks forward for risk, not just outcome: if a player has drafted players from two teams on a collision course to face each other, the bracket flags the earliest round that could happen. Both the confirmed-result override and the collision math are covered by automated tests, not just eyeballed.
The live app and the analytics side are deliberately two different databases. MySQL is the cash register - built to record one transaction correctly, fast, every time. A separate DuckDB warehouse is the back-office ledger - built to be queried and compared across everything that ever happened, without ever touching production. It's a galaxy schema: three dimensions (dim_player, dim_team, dim_year) and four fact tables sharing those same points, rebuilt from MySQL by a standalone ETL script whenever it needs refreshing, holding six completed tournament seasons (2018-2025, the in-progress season excluded on purpose, so every finding is based on confirmed outcomes).
dim_team's real key is the pair (espnTeamId, gameYear), not the team ID alone - seed and region are properties of a team's participation in one specific tournament, not the program itself. Skip the year in a join and you don't get a wrong value, you get a fan-out: a single fact row matched to every year a team ever made the field at once, silently multiplying that row with a different (mostly wrong) seed and region on each copy.
_ensure_coach_player() step wired into the games/players import, plus a one-off backfill for years already loaded.The warehouse exists to answer one question: out of season scoring average, seed, and opponent strength, what actually predicts tournament scoring? That takes three distinct steps, each doing a different job - reporting (a chart, available the moment data loads), statistics/ML (testing every candidate factor against real results), and AI narrative (explaining a confirmed finding in plain English) - deliberately in that order. AI is the last step, not the first: confidence in any answer comes from the data and the statistics underneath it; the model communicates a result, it doesn't discover one.
The first real report is already running against six completed seasons (2018-2025): total tournament points by player, by seed. Zach Edey (2024, 1-seed) leads at 177; Carsen Edwards (2019, 3-seed) at 139. Seeds 3, 4, and 8 all crack the top 10 alongside 1-seeds - an early signal that seed alone won't fully explain tournament scoring, which is exactly the kind of thing worth confirming statistically rather than eyeballing.
None of Tourney's process rules were designed up front - each was added after something went wrong, on a date that can be named. Pick one:
git checkout. That restores from the last commit - silently deleting another session's unsaved work.Rule now: Never revert with git. Test against a scratch copy. Commit early - committing is what protects your work from someone else's undo.
None of these incidents are hidden after the fact - all four are logged in the app's own engineering documentation the same way they're presented here, with the rule that came out of it standing next to the mistake, not softened into generic advice.
A reasoned call, not a gap. Almost nothing in the database is typed by hand - the one exception is what a player picks on the entry form. Everything else (the field, rosters, scores) is pulled from ESPN and could be regenerated by re-running that same fetch; the one thing that can't be regenerated, player picks, is also the smallest dataset, recoverable by re-collecting entries. At this scale - a free pool, no financial or contractual stakes - a backup-and-restore pipeline isn't worth building.
950+ tests, run against a real MySQL instance rather than mocks. Every push runs the same five-gate pipeline detailed on Engineering Platform; promotion to production is fast-forward-only and requires those checks to already be green.
This page is the overview. Six pages go further into one topic each - the same real diagrams, schemas, and process detail that used to live inside the app itself, now told to the audience it was actually written for.
Every real piece at once - the full AI-routing diagram, the environments pipeline, every shipped feature, the complete tech stack.
Read it →What a player actually experiences - the entry form, ESPN-verified links, accounts, and the AI commentary path.
Read it →Every player-facing route and the live operational schema behind it, plus the warehouse's grain/dimension/fact vocabulary.
Read it →Injection prevention, network isolation, password hashing, security headers, and secrets management - the real mechanisms.
Read it →The 11-stage pipeline, six AI roles under written contracts, a real worked example, and the 11 safeguards between a keystroke and a live server.
Read it →The actual GitHub Actions job sequence, container packaging, the no-rollback rationale, and the 7 real stored procedures.
Read it →Being upgraded on two fronts: deeper analytics beyond the existing warehouse reports, and support for running multiple independent pools instead of just this one - the two changes that would take it from a real app run for one group of friends to a viable product other pools could actually run on.