Tourney

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.

How the pool actually works

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 problem, the decision, the result - and what it proves

Problem
Paper for years, then an unsynced Google Forms/Sheets/Looker patchwork - either way, someone had to enter and reconcile every number by hand, a job that took hours after every round, before anyone saw a real score.
Decision
Build a real, integrated application - validated entry forms, automatic scoring from live results, real reporting - instead of another manual patchwork. Automate the scoring; keep the draft, the tiebreaker and the money human.
What I built
A Flask/MySQL web app that scores itself from live ESPN results, a seed-tier-capped roster draft, a separate DuckDB analytics warehouse, and two AI daily-recap systems - a frontier model over a real MCP tool interface and a self-hosted model on FOMX.ai's own GPU - compared honestly and both kept.
Result
In production on AWS with 40+ real participants through the March 2026 NCAA tournament. 950+ tests run against a real MySQL database in CI. Try it live →
Where AI stops
The AI writes the daily recap. It never touches a score - scoring is deterministic code fed by live results, and the AI is the last step, not the first.
What it proves
Business problem → requirements → automation → application → live production system, with the same environments, gates and audit trail an enterprise release would get.

Architecture

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.

Click any box below to find out more about what it does.
Browser players + admin Claude API via the Anthropic API Tourney's own box dev laptop, SIT, or production nginx reverse proxy · :80 / :443 gunicorn running the Flask app — public · admin · scores · history · daily web container · python:3.11-slim calls ESPN, CollegeBasketballData, SES, and Claude directly — see right MySQL 8.0 teams · players · picks entries · scores · settings Job queue (same MySQL) scoring scheduler thread guarded — only 1 runs per host (a lock file, so a second gunicorn worker steps aside) its own always-on loop — not one HTTP request FOMX.ai Spark private hardware Chat worker spark_worker.py — chat jobs Open-weight model via Ollama, loopback only on the Spark gpt-oss:20b External data services Amazon SES email, via boto3 CollegeBasketballData.com betting odds & round dates ESPN public APIs site.api.espn.com Analytics warehouse DuckDB datawarehouse/etl.py standalone script · not scheduled, not in Docker scikit-learn dev-side, optional Google Looker Studio admin-triggered odds import direct call the FOMX.ai Spark's worker hitting the app's API over mTLS — never the queue directly, detail below manually run, never wired into live scoring offline copy for analysis feature profiles, trained model reporting — live request path    ‧‧‧ worker crosses the trust boundary, mTLS (Spark Worker Kit's own pattern)    ― ― manual / offline, never part of live scoring Security tools used checked on push, or at admin login Tools ruff — lint bandit — SAST pip-audit — dependency CVEs scan_secrets.py — committed secrets Admin TOTP — MFA (pyotp)
Restored from Tourney's own in-app engineering documentation, node for node - including the animated line showing the FOMX.ai Spark's worker presenting its certificate, and the app's response coming back on its own separate line. Click any box for what it does.

Nothing selected yet — click a box above.

What it's built with

Python / Flask MySQL SQLAlchemy Docker Nginx HTMX Amazon EC2 AWS SES DuckDB (analytics warehouse) GitHub Actions

The AI path, step by step

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.

Tourney’s own box Flask app Job queue a row in the same MySQL FOMX.ai Spark - private hardware Chat worker gpt-oss:20b via Ollama, never leaves this box 1 5 2 3 4 solid = direct call dashed = the worker polling, never the app calling in
Every dashed arrow starts from the worker, never the app - the FOMX.ai Spark accepts no incoming connections, so the only way anything reaches it is by asking, 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.

Separate from the live app entirely: a second, offline pipeline pulls a conformed copy of six completed seasons (2018-2025) into a DuckDB analytics warehouse, kept apart from production so analysis never touches live scoring.

AI - two systems, compared honestly

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.

Simple
The app looks up the standings itself and hands the model everything it needs in one message. The model just writes.
Agentic
The model gets no data upfront - it's given a short menu of questions it's allowed to ask (standings, recent results, biggest movers) and decides for itself what it needs before writing.
Claude
Hosted by Anthropic, called through a real agentic tool-calling loop - the prompt deliberately contains no data.
Self-hosted
An open-weight model running on FOMX.ai's dedicated NVIDIA DGX Spark - the company's physical GPU hardware, an agentic loop built from scratch, zero API cost.

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.

What I learned

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.

Built to be safe, not just built to work

A side project that handles real names, emails, and phone numbers doesn't get to skip security because it's small.

What it's actually built from

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.

The two algorithms that actually matter

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 data model - and a real bug it explains

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.

A real, live bug this design explains - and fixed. A coach's row in the source MySQL table reuses that team's own ID as a borrowed identifier, rather than an independent person being referenced. Checked directly: 756 coach rows existed with zero matching row in the players table, because nothing ever inserted a coach entity upstream. Any report inner-joining the scoring fact table to the player dimension silently dropped all 756 of them - not an error, just quietly incomplete numbers. Fixed by a dedicated _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.

A real incident, and the rule it produced

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:

What happened - A session testing its own work edited a shared file, then undid it with 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 deliberate tradeoff: no database backup

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.

Testing and safeguards

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.

Go deeper

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.

Big Picture

Every real piece at once - the full AI-routing diagram, the environments pipeline, every shipped feature, the complete tech stack.

Read it →
Product

What a player actually experiences - the entry form, ESPN-verified links, accounts, and the AI commentary path.

Read it →
Data & Schema

Every player-facing route and the live operational schema behind it, plus the warehouse's grain/dimension/fact vocabulary.

Read it →
Security

Injection prevention, network isolation, password hashing, security headers, and secrets management - the real mechanisms.

Read it →
Process

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 →
Deploy

The actual GitHub Actions job sequence, container packaging, the no-rollback rationale, and the 7 real stored procedures.

Read it →

What's next

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.