The Hackathon Where I Built a Router Instead of a Tutor
The pitch nobody wanted
The problem statement at the hackathon was something like "build an AI tutor that helps students learn." Every team around me opened a chat UI, wired it to an LLM, and started arguing about system prompts.
I went a different way, and honestly I'm still not sure it was the smart move for a hackathon. I didn't build the tutor. I built the middleware.
The idea: a student says "I need help with derivatives," and something has to decide whether that means explain the concept, make me flashcards, or summarize my notes. Then it has to figure out the parameters each of those tools needs, call the right one, and hand back a clean answer. That "something" is what I built. The repo is AI Tutor Orchestrator, and if you've read my last two posts, this is the study-helper agent I've been poking at ever since.
What it actually does
Three tools. That's it.
- Note Maker for "summarize this" type requests
- Flashcard Generator for "quiz me" or "help me practice"
- Concept Explainer for "what is" and "explain"
Plus a fourth option called none, for when the student is just talking and no tool is needed. I forgot that case in the first version and the agent would try to make flashcards out of "thanks, that helped."
The whole thing is a FastAPI server with a LangGraph workflow inside it. A message comes in, the graph runs, a response goes out.
The part I'm actually proud of: parameter extraction
Picking a tool is the easy half. The hard half is that each tool wants different inputs. Flashcards need a count and a difficulty. Notes need a style. The explainer needs a depth. The student said none of that. They said "I need help with derivatives."
So the extractor has to infer. I gave it a student profile to work with: grade, learning style, how they're feeling, and a mastery level from 1 to 10. Then a small model (GPT-4o-mini, temperature 0.1 so it stays boring and predictable) reads the message, the last five turns of chat, and the profile, and fills in a Pydantic model.
# parameter_extractor.py
class ExtractedParameters(BaseModel):
tool_type: Literal["note_maker", "flashcard_generator", "concept_explainer", "none"]
topic: Optional[str] = None
difficulty: Optional[str] = None
count: Optional[int] = None
note_style: Optional[str] = None
depth: Optional[str] = None
confidence: float = 0.0
missing_parameters: list[str] = []The bit that made judges nod was the defaults. Instead of asking the student six questions, the code fills gaps from the profile:
def validate_and_fill_defaults(params, profile):
mastery = profile.mastery_level
if params.difficulty is None:
if mastery <= 3: params.difficulty = "easy"
elif mastery <= 6: params.difficulty = "medium"
else: params.difficulty = "hard"
if params.tool_type == "note_maker" and profile.learning_style == "visual":
params.note_style = params.note_style or "structured"
params.include_examples = True
if params.tool_type == "flashcard_generator" and params.count is None:
params.count = 5
return paramsNothing clever. But it's the difference between "help with derivatives" producing five medium flashcards with no follow-up questions, versus a chatbot going "Sure! How many flashcards would you like? What difficulty?" which is exactly what every other team demoed.
Why LangGraph, and what it cost me
I could've written this as an if-chain. For three tools, an if-chain is honestly fine. I used LangGraph because I wanted to learn it and hackathons are where you get to make that call without a code review.
The graph ended up looking like this:
analyze_message
↓
validate_parameters
↓
route_after_validation ─┬─→ execute_note_maker
├─→ execute_flashcard_generator
├─→ execute_concept_explainer
├─→ needs_clarification
├─→ handle_no_tool
└─→ handle_error
↓
format_response → END
Every path converges on format_response. That was the one genuinely good decision, because it meant the API always returns the same shape no matter what happened inside. The routing function is small:
def route_after_validation(state):
if state.get("error"):
return "handle_error"
if state.get("needs_clarification"):
return "needs_clarification"
tool = state["extracted"].tool_type
if tool == "none":
return "handle_no_tool"
return f"execute_{tool}"What it cost me: about three hours of fighting with state. LangGraph wants a TypedDict that every node reads and writes, and I kept adding keys mid-hackathon and forgetting to initialize them. Half my errors at 2 AM were KeyError on something I'd added twenty minutes earlier.
Would I use it again for three tools? Probably not. Would I use it for thirty? Yes, and that's the point. The README says the architecture is "scalable to 80+ tools" which is the kind of thing you write at a hackathon. But the shape is right. Adding a tool is one node and one line in the router.
Mock mode saved the demo
The three tools are supposed to be real backend services. At the hackathon they didn't exist yet, and the person building them was on a different team.
So I added a flag. With it on, every tool returns a canned response instantly. With it off, it hits real endpoints with timeouts and exponential backoff.
This sounds trivial. It's the reason I had a working demo. The extraction and routing, which was all the interesting stuff, could be shown end to end without waiting on anyone. When one real endpoint came online at the last minute, I flipped it for that one tool and left the others mocked.
If you're building any agent that calls things, add this on day one. Not day three.
What I'd change now
I've been living with this code for a few months and it's the reason my last two posts exist. Things I'd do differently:
The extractor sees the last five messages. I picked five because it felt right. It isn't. A student who said "I'm in class 11" seven messages ago is now in whatever grade the model guesses. I wrote a whole post about this exact failure before realizing I'd shipped it here first.
Confidence is a float nobody uses. The extractor returns a confidence score, and on a parse failure it returns zero with an error message. Great. Nothing downstream actually reads it. A low-confidence extraction should route to clarification. Right now it routes to whatever the model guessed.
The profile is an input, not a thing that learns. Mastery level 1 to 10 gets passed in and never updated. The student aces three flashcard rounds and the system still thinks they're a 3. The orchestrator has everything it needs to update that number. I just ran out of hackathon.
The honest ending
We didn't win. A team with a much prettier chat UI did, and their tutor was genuinely good at explaining things.
But I got the thing I actually wanted, which was a small, real system where every design decision I've written about since has a concrete place to live. When I say "trim your tool results" or "don't let the summary eat your constraints," I'm not saying it in the abstract. I'm saying it because this repo did the wrong thing first.
That's probably the best case for building the boring middleware at a hackathon. You don't win. You just end up with something worth writing about.