← Projects

Duke 2025

DukeDine

Co-founder · 3 engineers · live · 100+ users

Every Duke student technically has access to a database of macros for every dining hall item on campus. It’s called NetNutrition. It runs on CBORD, the same vendor that powers many American university dining systems, and it has had a public-facing search UI since the late 2000s.

I have never met a Duke student who uses it.

Search barely works. There’s no way to log meals. No history. No personalization. The page visibly hasn’t been touched in years. If you actually want to track what you ate at the Marketplace last Tuesday, you can either copy the macros by hand into MyFitnessPal, which doesn’t know what a Duke dining hall is, or you can give up. In my experience almost everyone does.

A friend pulled me aside at the end of last semester: what if there were a better way to track what we’re actually eating at Duke? He didn’t have a spec. He had a working theory: the data was already public, the social problem was real, and nobody had built the thing.

A friend, a grant, and a deadline

Three of us applied to Duke Colab for a small grant and got $1,000 to build it. The benchmark for success wasn’t a grade. It was real Duke students using it daily. We gave ourselves three months to ship.

I worked across the whole stack. I’ll write here about the parts I think were the most interesting: the data ingestion we had to invent because there was no API, the schema design I had to throw out and redo, and the AI layer that ended up doing more for daily friction than anything else we built.

The four endpoints CBORD doesn’t document

The first problem was that NetNutrition has no public API. Naively fetching the landing URL gives you an HTML page with no menu data in it, the menus are loaded by JavaScript after the user clicks something. So I opened Chrome devtools on NetNutrition with the Network tab filtered to XHR, and clicked through the UI manually. Pick a restaurant, watch the request fire, inspect the form data, inspect the response. After about a dozen clicks the pattern was obvious.

The interactive UI hides everything behind JavaScript onclick handlers like unitsSelectUnit(24), menuListSelectMenu(1057), and getItemNutritionLabel(267090803). Each handler submits a form-encoded POST and injects a chunk of HTML back into the page. Once I had the four endpoints mapped, the whole scraper was a couple of hundred lines of Python and BeautifulSoup against a single requests.Session.

The four are: POST /Unit/SelectUnitFromUnitsList (give it a unit ID, get back a JSON payload of “panels” containing the menu list as embedded HTML), POST /Menu/SelectMenu (give it a menu ID, get back the items as embedded HTML), POST /NutritionDetail/ShowItemNutritionLabel (give it an item ID and a unit ID, get back the FDA-style nutrition label as HTML), and POST /Unit/GetHoursOfOperationMarkup for hours. The only HTML scraping is the landing page, where restaurant IDs are encoded in the onclick attributes of restaurant links. After that it’s JSON-paneled POSTs all the way down.

The session is sticky on purpose. CBORD stores the selected unit on the server side, so menu calls only work after a unit-selection POST has been made first. The first time I tried hitting /Menu/SelectMenu cold, the server returned a blank response and I lost an afternoon to it. Once the session order was right, things worked.

Three schemas before I admitted what was wrong

Once the scraper was producing clean data, I had to figure out how to store it. My first instinct was straightforward relational normalization: restaurants → menus → categories → items. Foreign keys all the way down. Items live inside categories, categories inside menus, menus inside restaurants.

The trouble started immediately. Dining halls have one menu per day, a different breakfast, lunch, and dinner each day. Cafés have multiple static menus with no dates. Single-menu spots have categories directly under the restaurant, with no intermediate menu at all. I tried three different schemas: a nullable menu_id, a polymorphic association, and a single container table. Each one fit one restaurant shape and broke another.

After the third try I realized I was solving the wrong problem. I was forcing a rigid hierarchy onto data whose items were stable but whose organization was a per-restaurant presentation concern. The items existed at every restaurant. The way they got grouped didn’t.

So I pivoted. Items became first-class. The menu and category layout, which day, which section, which order, went into a single JSONB column on the restaurant. Items dedupe naturally; menus are just views; new restaurants with weird shapes plug in with no migration.

What I’d been modeling was the presentation layer, not the data layer.

Then it got used

We shipped at the end of month three. Students started logging meals.

And almost immediately the friction shifted. The complaint stopped being “I can’t find what I ate”; the menus were there, the macros were there. It became “tapping items takes too long.” Filling a meal still meant scrolling a category list and clicking each thing. Realistic Duke lunches have four or five items in them. Five taps wasn’t unreasonable, but five taps three times a day adds up to enough friction that people lapsed after a week.

I knew what to build next.

Type a sentence, get a meal

The Blue Devil meal agent is the most user-visible AI feature we shipped on nutrition. The user types a sentence — “turkey and rice from Farmstead, a kale salad with some dressing, and an apple” — or speaks it via voice-to-text, and the system returns a fully structured meal with macros grounded in real Duke menu rows wherever possible. No category scrolling. No quantity sliders.

My first design instinct was wrong. I’d already built a single-shot Claude vision call for nutrition labels (picture in, structured macros out), and I assumed I could reuse the same shape. But that pattern works for nutrition labels because nutrition labels are nutrition. A meal description isn’t. “Turkey from Farmstead” doesn’t carry its macros in the words; the macros live in our items table. A single-shot LLM call would have produced confident-looking guesses from the model’s training memory, not the macros Duke actually publishes.

So I built it as a tool-use loop instead. The original implementation (mealAgent.js) has three internal tools: search_food_db(query, restaurant?), request_clarification, and record_meal. The prompt design that took the longest was splitting query from restaurant — Duke item names never include the restaurant, so search_food_db("turkey from Farmstead") kills the match. For multi-dish meals the model emits parallel searches in one turn; the handler runs them with Promise.all so Bedrock’s tool_result matching invariant holds.

Behind the agent sits a deterministic post-validator. Before any meal payload leaves the server, three non-LLM checks run: 4-4-9 macro arithmetic, db_match integrity (every Duke food ID verified against the items table; invalid IDs downgraded to estimated), and meal-level total recomputation from corrected dish rows. The model is the orchestrator; the menu rows are the source of truth.

How it works today. All user AI goes through one universal chat (POST /api/chat, SSE). Meal logging in chat uses MCP meals.* tools with an escalation ladder: search the tenant menu first, then USDA, then web lookup, then estimate — assemble a confirmable proposal with meals.draft_meal, log with meals.log_meal after the user confirms. The Add Food modal still calls the original mealAgent sub-agent via REST (POST /api/food/agent-meal); chat wraps the same sub-agent for photos via meals.propose_meal. Same grounding philosophy, two entry paths — granular MCP tools for text, monolithic sub-agent where vision needs a dedicated loop.

The other half

About halfway through the project, DukeDine grew a second half. The nutrition side has clean relational data: menus, items, macros, logs. The strength training side doesn’t. When you finish a lift you don’t fill out a form. You jot something into your notes app: bench 225x5x3, last set felt heavy, 6hrs sleep, 200mg pre. That’s prose with structure buried inside.

We built Iron Logic — Claude agents that turn brain-dumps into per-set workout data, run RAG over your reflections, and answer training questions with cited dates. Originally each capability had its own REST endpoint (Coach, Analyst, Synthesis, ingest, dump). That matched how we built it, not how users ask questions.

In May 2026 we consolidated to one assistant backed by an MCP tool registry — roughly 100 tools across 11 namespaces (gym, meals, signals, reflect, routines, glossary, activity, viz, system, admin). The chat orchestrator advertises only the tools relevant to the tab you’re on (gym tab gets gym.* + reflect.*; log tab gets meals.*) and exposes system.load_tools so the model can pull in other namespaces mid-turn when you ask a cross-domain question. The old per-agent routes are gone; the agent modules (Parser, Synthesis, Coach, Analyst) remain as backing services that MCP tools call into.

The adversarial pattern in parser and vision flows is unchanged: commit tool or request_clarification, never ambiguous prose. What changed is the surface — one chat, one registry, shared invoker — instead of six endpoints the frontend had to dispatch between.

”Works locally” and “works in production” are different products

Until DukeDine I had never deployed a full-stack app. I had run npm start locally and called it shipping. By the end of the V1 era I was running multi-stage Docker builds, docker-compose on a single EC2 instance, nginx behind Cloudflare DNS with SSL, GitHub Actions auto-pushing path-filtered images to Docker Hub, and a deploy CLI that cross-checks running container SHA against the pulled tag.

The deploy CLI’s trick is small but it has saved me at least four times. After it pulls the new image and recreates the container, it verifies the running container’s image SHA via docker inspect. “Deploy succeeded” actually has to mean it deployed. I have been burned exactly once by the silent variant of this failure, where the container restarted on the previous image and the script printed green and went home. That was the morning I wrote the SHA check.

(Production has since moved to ECS Fargate as part of the MealMarkOS platform pivot — the deploy verification lesson carried forward.)

Where it landed

DukeDine has 100+ active Duke users and is still growing. Both halves are live. We took it from zero to production in three months. The codebase evolved into MealMarkOS — a multi-tenant platform where Duke is the first tenant — but the scraping pipeline, item-centric schema, MCP tool registry, and universal assistant all trace back to this project.

What’s next

We’re nowhere near finished. Next on the list:

Long term, the vision is one wellness platform for any university student trying to eat and train intentionally — not just at Duke.

What it taught me

A handful of things I expect to carry into everything I build next: how real systems break in production and why deploys have to verify themselves, how to design a schema when the data refuses to fit a clean hierarchy, how to put an LLM in front of structured data without making the LLM the source of truth, how to make a probabilistic system feel trustworthy by denying it the option of ambiguous output, and how one MCP tool surface beats a zoo of purpose-built agent endpoints when users ask whole-person questions.

DukeDine showed me the difference between writing code and building systems, which turns out to be most of the work.