Build an AI Degree Advisor
Build a full-stack AI chatbot that uses tool calling to answer degree-planning questions.
Introduction
30 Second Summary
You ask an AI chatbot what courses you need to graduate, and it gives you a confident answer. The problem is that half the course codes it mentioned don't even exist.
In this project, you will build a full-stack AI degree advisor that uses tool calling to look up real data before answering. Instead of guessing, your chatbot calls functions to check prerequisites, verify semester availability, and recommend courses from an actual 27-course catalog.
What You'll Build
You'll ask your advisor "What should I take next semester?" and watch it call tools in real time: checking your academic progress, evaluating prerequisite logic, and composing a grounded answer before your eyes in a live side panel.
By the end of this project, you'll have:
- A working tool-calling loop where you ask a degree question and watch your FastAPI backend call the AI model, execute data lookups against a 27-course catalog, feed results back, and repeat until a grounded answer emerges.
- A React chat interface with a live Tool Activity panel that streams each tool call and result via Server-Sent Events as the advisor thinks through your question in real time.
- Multi-turn conversation that remembers context, so follow-up questions like "What about CIS465?" work naturally without the backend storing any state.
- Secret Mission: Add production safeguards including max tool rounds, API error recovery, and basic token/cost tracking.
Are there any prerequisites?
You should be comfortable with basic Python (functions, dictionaries, the concept of async/await) and basic JavaScript (React components, state, fetch). Familiarity with REST APIs is helpful.
You'll also need Python 3.12 installed on your system. Step 1 walks you through installing it alongside any existing Python version.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Project and Secure the API Key
To build the degree advisor chatbot, you need two servers running side by side: a Python backend that talks to the AI model, and a React frontend the user interacts with.
The backend holds the API key for OpenRouter (the service that routes requests to the AI model). This key must never appear in your frontend code or browser network requests. In this step, you'll set up both project folders, install dependencies, and lock the key safely on the backend.
In this step, get ready to:
- Install Python 3.12 alongside your existing Python and create a backend virtual environment with FastAPI.
- Scaffold a React frontend with Vite and install its dependencies.
- Verify both dev servers start and confirm the API key is secured on the backend only.
Install Python 3.12 and create the backend
This project uses Python 3.12 specifically for dependency compatibility. You already have Python 3.14 installed. You do not need to uninstall it. Both versions live happily side by side.
- Check whether Python 3.12 is already on your system by running one of these commands in your terminal:
Windows
py -3.12 --version
macOS
python3.12 --version
✔️ I see Python 3.12.x
Python 3.12 is already installed. Skip ahead to the next instruction below.
ⓧ I see a different version or an error
You need to install Python 3.12. Follow the instructions for your platform:
- Go to python.org/downloads and download the Python 3.12 installer for your OS.
- On Windows: during installation, check Add Python to PATH and ensure the py launcher option is selected. These allow you to run py -3.12 from any terminal.
- On macOS: install via Homebrew by running:
brew install python@3.12
- After installation completes, re-run the version check command above to confirm you see 3.12.x in the output.
ⓧ Command not found
Python 3.12 is not installed yet. Follow the same installation steps in the tab above ("I see a different version or an error") to install it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and the backend subfolder by running these commands:
mkdir degree-advisor
cd degree-advisor
mkdir backend
- Confirm the folder was created by running:
ls
You should see backend listed in the output.
- Move into the backend folder and create a Python 3.12 virtual environment by running:
Windows
cd backend
py -3.12 -m venv venv
macOS
cd backend
python3.12 -m venv venv
What does this command do?
The -m venv venv part tells Python to create a virtual environment in a folder called venv. A virtual environment is an isolated copy of Python where you install project-specific packages without affecting your system.
By specifying py -3.12 (Windows) or python3.12 (macOS), you pin this environment to version 3.12 even though 3.14 is also installed.
- Activate the virtual environment:
Windows
venv\Scripts\activate
macOS
source venv/bin/activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
- Install the project dependencies by running:
pip install "fastapi[standard]" httpx python-dotenv
What are these packages?
- fastapi[standard] installs FastAPI plus Uvicorn (the server that runs it) and other defaults.
- httpx is an async HTTP client. The backend uses it to call the OpenRouter API without blocking.
- python-dotenv loads environment variables from a .env file so you never hardcode secrets in source code.
Seeing a pip error?
- Make sure your terminal shows (venv) at the start of the prompt. If it does not, re-run the activate command.
- If you see a permissions error on macOS, do NOT use sudo. Deactivate, delete the venv folder, and recreate it with python3.12 -m venv venv.
Help me debug this pip install error
- Create the .env file for your API key. In Devin (Windsurf), open the degree-advisor/backend folder and create a new file named .env with this content:
OPENROUTER_API_KEY=your-api-key-here
- Replace your-api-key-here with your actual OpenRouter API key. If you do not have one yet, grab one from openrouter.ai/keys.
Why does the API key live here?
The React app at localhost:5173 calls the FastAPI backend at localhost:8000. The backend reads the key from .env and attaches it to outgoing requests to OpenRouter. The browser never sees the key.
Even if someone opens the browser DevTools Network tab, they only see the request to localhost:8000. The outgoing request to OpenRouter happens server-side and is invisible to the browser.
- Create main.py in the backend folder with the following code:
import os
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
# Load environment variables from .env
load_dotenv()
app = FastAPI()
# Allow the React dev server to make requests to this backend
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Read the API key from the environment (never hardcode it)
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
What does this code do?
- load_dotenv() reads .env and injects its values into the environment so os.getenv() can find them.
- CORSMiddleware tells the browser it is safe for localhost:5173 (your React app) to call localhost:8000 (your backend). Without this, the browser blocks cross-origin requests.
- OPENROUTER_API_KEY stores the key in a variable the rest of the backend can use for outgoing API calls.
- Save main.py.
- Start the backend server by running:
uvicorn main:app --reload
You should see a line that says Uvicorn running on http://127.0.0.1:8000 in your terminal.
- Open http://127.0.0.1:8000/docs in your browser.
You should see the FastAPI interactive docs page. This confirms your backend is running correctly.
Server not starting?
- If you see a module-not-found error for fastapi, your venv is not activated. Re-run the activate command and try again.
- If port 8000 is already in use, check for other running servers and stop them.
Help me troubleshoot my FastAPI server
Scaffold the React frontend
The frontend will be a React app served by Vite. It runs on a separate port and calls your backend.
- Open a second terminal tab or window (keep the backend running in the first one).
- Navigate to your degree-advisor folder on the Desktop:
cd ~/Desktop/degree-advisor
- Scaffold the React frontend by running:
npm create vite@latest frontend -- --template react --no-immediate
What does this command do?
- npm create vite@latest runs the Vite project scaffolding tool.
- frontend is the folder name it creates.
- --template react selects the React JavaScript template.
- --no-immediate prevents Vite from auto-installing dependencies and starting a dev server. You will do that manually next.
- Move into the frontend folder and install dependencies:
cd frontend
npm install
- Start the Vite dev server by running:
npm run dev
You should see output including Local: http://localhost:5173/ in the terminal.
- Open http://localhost:5173 in your browser. You should see the Vite + React starter page with a spinning logo.
Vite not starting?
- Make sure you ran npm install before npm run dev. Without dependencies installed, the dev server cannot start.
- If you see a Node.js version error, confirm you have Node.js 20.19+ or 22.12+ by running node --version.
Help me fix my Vite dev server
Secure the .env and verify both servers
Before moving on, you need to make sure the .env file will never be committed to version control.
- In the degree-advisor root folder, create a file called .gitignore with this content:
.env
venv/
node_modules/
- Confirm your .env is inside the backend/ folder (not inside frontend/). The frontend code must have zero access to this file.
Before you run the final check, what do you expect to see when you visit the FastAPI docs URL?
- Verify both servers are still running:
- Backend: open http://127.0.0.1:8000/docs and confirm you see the FastAPI interactive docs page.
- Frontend: open http://localhost:5173 and confirm the Vite + React starter page loads.
Both servers are running. The FastAPI docs page shows your backend is live, and the React starter page shows Vite is serving the frontend. Your API key is secured in backend/.env, invisible to the browser.
✔️ Awesome, I've got everything!
Great. Double check you have saved both main.py and .env in the backend/ folder.
ⓧ I'd like to double check the full code
Here is the complete backend/main.py at this point:
import os
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
load_dotenv()
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
And backend/.env:
OPENROUTER_API_KEY=your-api-key-here
Your environment is ready. The backend can securely call the AI model and the frontend can talk to the backend. Next up, you'll build the chat endpoint and witness the hallucination problem firsthand.
Build the Chat Endpoint and See Hallucination
Your backend and frontend servers are running. Now it is time to give your backend something to do: answer degree-planning questions.
In this step, you will build a chat endpoint that forwards questions to DeepSeek V3 via OpenRouter. The catch is that the model has zero access to Meridian State University's actual catalog. When you ask it about courses, it will confidently invent course codes, prerequisites, and progress data. This hallucination problem is exactly what tool calling solves in the next step.
In this step, get ready to:
- Build a POST endpoint that calls OpenRouter's chat completions API.
- Send a degree-planning question and observe the model hallucinate.
- Understand why tool calling is needed to ground the model in real data.
Configure the API connection and system prompt
Every request to a chat model includes a list of messages. Each message has a role that tells the model who is speaking.
What are message roles?
There are three roles you will work with in this project:
- system sets the model's personality and rules. It runs before anything else and shapes every response.
- user is what the human typed. Each question from the learner gets this role.
- assistant is the model's own previous replies. Sending these back gives the model memory of the conversation.
The system prompt is the most powerful lever you have. A well-written system prompt can prevent hallucination, enforce rules, and focus the model on specific tasks.
- In main.py, find the existing import lines at the top. Add these two new imports right below the from fastapi.middleware.cors import CORSMiddleware line:
from pydantic import BaseModel
import httpx
- Below the OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") line, add the API URL, model name, and system prompt by pasting this code:
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
MODEL = "deepseek/deepseek-chat"
# System prompt defines the model's role and rules
SYSTEM_PROMPT = (
"You are a degree-planning advisor for Meridian State University. You help "
"students understand their degree requirements, check their academic progress, "
"determine which courses they can take (considering AND/OR prerequisites, "
"semester availability, and credit-load limits), and recommend courses.\n\n"
"You MUST use the available tools to look up information. Never invent or "
"guess course codes, prerequisites, semester availability, or student progress. "
"If you need information that requires a tool, call the appropriate tool. "
"If the student's question is unclear, ask a clarifying question before "
"calling tools."
)
What does this code do?
- OPENROUTER_URL is the endpoint your backend will POST to when it needs the LLM to respond.
- MODEL pins the model to deepseek/deepseek-chat (DeepSeek V3), which costs $0.20 per million input tokens.
- SYSTEM_PROMPT tells the model it is a degree advisor and that it must use tools instead of guessing. The model does not have tools yet, so it will ignore that instruction for now and hallucinate instead.
- Save main.py.
- Check the terminal where uvicorn is running. You should see a reload message with no errors.
Seeing an import error?
- Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
- Confirm you installed httpx earlier with the pip install command. If not, run pip install httpx now.
Still stuck? Help me debug an import error in my FastAPI project.
Build the chat endpoint
The endpoint receives a list of messages from the frontend, prepends the system prompt, forwards everything to OpenRouter, and returns the model's text response.
- At the bottom of main.py, add the request model and the chat endpoint by pasting this code:
class ChatRequest(BaseModel):
messages: list[dict]
@app.post("/api/chat")
async def chat(request: ChatRequest):
# Prepend system prompt so it shapes every response
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
*request.messages,
]
# Send the full message list to OpenRouter (non-streaming)
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": MODEL,
"messages": messages,
},
)
# Extract the assistant's text from the response
data = response.json()
content = data["choices"][0]["message"]["content"]
return {"content": content}
What does this code do?
- ChatRequest is a Pydantic model that validates incoming JSON. It expects a messages array where each item is a dictionary with role and content keys.
- The *request.messages syntax unpacks the user's messages into the list after the system prompt. This is how you build the full conversation history.
- httpx.AsyncClient makes the HTTP call without blocking FastAPI's event loop. The 60-second timeout gives the model enough time to think.
- The response follows the OpenAI-compatible format: the model's answer lives at data["choices"][0]["message"]["content"].
Why non-streaming calls to OpenRouter?
This project calls OpenRouter with stream: false (the default). Each request returns one complete JSON object with the full response.
The alternative is token-by-token streaming, where the model's answer arrives in fragments across many SSE chunks. When tool calling is involved, tool-call arguments arrive as partial JSON strings that must be reassembled before you can execute anything. That is significantly more complex to implement correctly.
By keeping upstream calls non-streaming, tool-call detection stays simple: just check whether message.tool_calls exists in the JSON. You will add your own SSE stream to the frontend later.
- Save main.py.
- Return to your browser and refresh http://127.0.0.1:8000/docs. You should see a new POST /api/chat endpoint listed in the interactive docs.
Don't see the new endpoint?
- Check the uvicorn terminal for syntax errors. A missing comma or unmatched parenthesis will prevent the reload.
- Make sure the @app.post("/api/chat") decorator is directly above the async def chat function with no blank line between them.
Need more help? Help me figure out why my FastAPI endpoint isn't showing up in /docs.
Test the endpoint and observe hallucination
Your endpoint is live. Time to ask the model a degree-planning question and see what it does without access to any real course data.
Before you send the request, think about this: the model has never seen Meridian State University's catalog. It does not know which courses exist, what their prerequisites are, or what Alex Chen has completed. What do you think it will do?
- In the FastAPI docs page, click on the POST /api/chat endpoint to expand it.
- Click the Try it out button in the top-right corner of the endpoint section.
- Replace the contents of the request body text area with this JSON:
{
"messages": [
{
"role": "user",
"content": "I'm Alex Chen in the CIS program at Meridian State. What courses should I take next semester?"
}
]
}
- Click Execute.
The response takes a few seconds. When it arrives, read the content field carefully.
The model responds with a confident, helpful-sounding answer. It lists specific course codes, describes their prerequisites, and may even reference Alex's progress. Every single detail is fabricated.
What should I see?
The model will invent plausible-sounding course names like CIS301, CIS450, or MATH310 that do not exist in the actual catalog. It will fabricate prerequisite chains and claim to know Alex's completed courses.
This is hallucination in action. The model cannot know that CIS365 requires (CIS240 AND (MATH261 OR STAT270)) because that detail exists only in the mock data you will create in the next step.
Why does the model hallucinate?
Large language models generate text by predicting what comes next based on patterns in their training data. They have no mechanism to look up live, specific data. When asked about something they do not have access to, they produce text that looks correct but is entirely made up.
The fix is tool calling. Instead of guessing, you give the model a menu of functions it can request. When it needs data (like a student's progress or a course's prerequisites), it asks your code to fetch it. The model gets real data back and uses that to compose a grounded answer.
Here is a question to think about before the next step: what do you think happens if you give the model a list of available tool definitions but do not handle its response when it tries to use one of them?
✔️ Awesome, I've got everything!
Great. Make sure you have saved main.py and confirmed the hallucination in your FastAPI docs.
ⓧ I'd like to double check the full code
Your main.py should contain these sections in order. Scroll up and confirm each one is present:
- Imports: os, dotenv, FastAPI, CORSMiddleware, BaseModel, and httpx.
- load_dotenv() call and app = FastAPI() with CORS middleware.
- Three constants: OPENROUTER_API_KEY, OPENROUTER_URL, and MODEL.
- SYSTEM_PROMPT multi-line string describing the Meridian State advisor role.
- ChatRequest class with a messages: list[dict] field.
- @app.post("/api/chat") endpoint that prepends the system prompt, calls OpenRouter, and returns {"content": content}.
You have seen the problem firsthand: an LLM without data access invents answers that sound authoritative but are completely wrong. In the next step, you will give the model tool schemas and a rich mock catalog so it can request real data instead of guessing.
Add Tool Schemas and Mock Catalog Data
In the last step, the model hallucinated course names and prerequisites because it had no access to real data. The fix is tool calling: you give the model a menu of functions it can request, and it responds with a structured "please call this function" message instead of guessing.
In this step, you will create the entire mock data layer and tool schema definitions. Then you will wire the schemas into the OpenRouter request and observe what happens when the model tries to call a tool but your code does not handle the response.
In this step, get ready to:
- Create a mock course catalog with AND/OR prerequisite logic, semester availability, and credit limits.
- Define tool schemas that tell the model what functions it can call and what arguments they accept.
- Observe the designed obstacle: the model returns tool_calls with null content instead of a text answer.
Understand tool schemas before writing code
A tool schema is a contract between your code and the LLM. It tells the model what functions exist, what arguments they accept, and what each function does.
The model does NOT execute functions itself. It reads the schema, decides which function would help answer the user's question, and returns a structured request saying "please call this function with these arguments."
What is tool calling?
Tool calling (also called function calling) is a way for an LLM to request real data instead of guessing. You provide a list of available tools in the API request. The model analyzes the user's question and decides which tools, if any, would help it answer accurately.
Each tool schema follows the OpenAI function-calling format: a type field set to "function", and a function object containing name, description, and parameters (a JSON Schema object defining what arguments the function accepts).
Create the course catalog and student data
The mock catalog is deliberately rich: 27 courses with AND/OR prerequisites, semester restrictions, and nested requirement buckets. This complexity ensures the model cannot guess answers and must rely on tool calls.
- Create a new file called tools.py inside your backend/ folder.
- Add the import and the first half of the catalog (CIS department courses) by pasting this code:
import json
# Meridian State University - Fictional Catalog
# 27 courses across 4 departments with AND/OR prerequisites,
# semester availability, credit limits, and nested requirement buckets.
CATALOG = {
"CIS110": {"name": "Foundations of Computing", "credits": 3, "semesters": ["fall", "spring"], "prerequisites": []},
"CIS160": {"name": "Discrete Mathematics for CS", "credits": 3, "semesters": ["fall", "spring"], "prerequisites": []},
"CIS240": {"name": "Computer Architecture", "credits": 3, "semesters": ["fall"], "prerequisites": [["CIS110"]]},
"CIS262": {"name": "Automata and Computation", "credits": 3, "semesters": ["spring"], "prerequisites": [["CIS160"]]},
"CIS320": {"name": "Algorithms", "credits": 3, "semesters": ["fall", "spring"], "prerequisites": [["CIS160"], ["CIS240", "CIS262"]]},
"CIS341": {"name": "Compilers", "credits": 3, "semesters": ["spring"], "prerequisites": [["CIS240"], ["CIS262"]]},
"CIS365": {"name": "Operating Systems", "credits": 4, "semesters": ["fall"], "prerequisites": [["CIS240"], ["MATH261", "STAT270"]]},
"CIS380": {"name": "Computer Networks", "credits": 3, "semesters": ["spring"], "prerequisites": [["CIS320"]]},
"CIS400": {"name": "Senior Project I", "credits": 3, "semesters": ["fall"], "prerequisites": [["CIS320"], ["CIS365", "CIS380"]]},
"CIS401": {"name": "Senior Project II", "credits": 3, "semesters": ["spring"], "prerequisites": [["CIS400"]]},
"CIS421": {"name": "Artificial Intelligence", "credits": 3, "semesters": ["fall"], "prerequisites": [["CIS320"], ["STAT270"]]},
"CIS455": {"name": "Cloud Computing", "credits": 3, "semesters": ["spring"], "prerequisites": [["CIS365"]]},
"CIS465": {"name": "Database Systems", "credits": 3, "semesters": ["fall", "spring"], "prerequisites": [["CIS320"]]},
"CIS471": {"name": "Machine Learning", "credits": 3, "semesters": ["spring"], "prerequisites": [["CIS421"], ["MATH261"]]},
- Directly below (still inside the CATALOG dictionary), add the remaining math, science, humanities, and business courses plus the closing brace:
"MATH151": {"name": "Calculus I", "credits": 4, "semesters": ["fall", "spring"], "prerequisites": []},
"MATH152": {"name": "Calculus II", "credits": 4, "semesters": ["fall", "spring"], "prerequisites": [["MATH151"]]},
"MATH261": {"name": "Linear Algebra", "credits": 3, "semesters": ["fall", "spring"], "prerequisites": [["MATH152"]]},
"MATH330": {"name": "Abstract Algebra", "credits": 3, "semesters": ["spring"], "prerequisites": [["MATH261"], ["CIS160"]]},
"STAT270": {"name": "Probability and Statistics", "credits": 3, "semesters": ["fall", "spring"], "prerequisites": [["MATH152"]]},
"STAT370": {"name": "Statistical Modeling", "credits": 3, "semesters": ["fall"], "prerequisites": [["STAT270"], ["MATH261"]]},
"PHYS150": {"name": "Physics I", "credits": 4, "semesters": ["fall", "spring"], "prerequisites": [["MATH151"]]},
"PHYS250": {"name": "Physics II", "credits": 4, "semesters": ["fall", "spring"], "prerequisites": [["PHYS150"], ["MATH152"]]},
"SCI200": {"name": "Science Elective Seminar", "credits": 2, "semesters": ["spring"], "prerequisites": [["PHYS150", "STAT270"]]},
"HUM101": {"name": "Technical Writing", "credits": 3, "semesters": ["fall", "spring"], "prerequisites": []},
"HUM201": {"name": "Ethics in Technology", "credits": 3, "semesters": ["fall"], "prerequisites": [["HUM101"]]},
"HUM301": {"name": "Senior Humanities Seminar", "credits": 3, "semesters": ["spring"], "prerequisites": [["HUM201"]]},
"BUS250": {"name": "Project Management", "credits": 3, "semesters": ["fall", "spring"], "prerequisites": [["HUM101"]]},
}
How do the prerequisites work?
Prerequisites use nested lists to represent AND/OR logic. The outer list means AND (all groups must be satisfied). Each inner list means OR (at least one course in the group must be completed).
For example, CIS365 has [["CIS240"], ["MATH261", "STAT270"]] which means: CIS240 AND (MATH261 OR STAT270). The student must have completed CIS240 plus at least one of MATH261 or STAT270.
- Below the CATALOG dictionary, add the first degree program (Computer Information Systems):
PROGRAMS = [
{
"id": "cis-bs",
"name": "Bachelor of Science in Computer Information Systems",
"total_credits": 120,
"max_credits_per_semester": 18,
"requirements": {
"core": ["CIS110", "CIS160", "CIS240", "CIS262", "CIS320", "CIS365", "CIS400", "CIS401"],
"math_science": ["MATH151", "MATH152", "MATH261", "STAT270", "PHYS150"],
"humanities": ["HUM101", "HUM201"],
"electives": {
"description": "Choose 3 from CIS 4xx courses",
"choose": 3,
"from": ["CIS380", "CIS421", "CIS455", "CIS465", "CIS471"],
},
},
},
- Directly below (still inside the PROGRAMS list), add the Data Science program and close the list:
{
"id": "ds-bs",
"name": "Bachelor of Science in Data Science",
"total_credits": 120,
"max_credits_per_semester": 18,
"requirements": {
"core": ["CIS110", "CIS160", "CIS320", "CIS421", "CIS465", "CIS471"],
"math_stats": ["MATH151", "MATH152", "MATH261", "STAT270", "STAT370"],
"science": ["PHYS150", "SCI200"],
"humanities": ["HUM101", "HUM201", "HUM301"],
"electives": {
"description": "Choose 2 from: CIS341, CIS365, CIS380, CIS455, MATH330, BUS250",
"choose": 2,
"from": ["CIS341", "CIS365", "CIS380", "CIS455", "MATH330", "BUS250"],
},
},
},
]
What are requirement buckets?
Each program organizes its courses into requirement buckets like core, math_science, and electives. The electives bucket uses a choose-N rule: the student picks a certain number from a pool of options. This structure makes the advisor's job non-trivial, since it must track which buckets still need courses.
- Below PROGRAMS, add the student record and the prerequisite-checking helper function:
STUDENT = {
"name": "Alex Chen",
"program": "cis-bs",
"completed_courses": [
"CIS110", "CIS160", "CIS240",
"MATH151", "MATH152", "MATH261",
"PHYS150", "HUM101",
],
"current_semester": "spring",
"next_semester": "fall",
}
def check_prerequisites(course_id: str, completed: list[str]) -> dict:
course = CATALOG.get(course_id)
if not course:
return {"met": False, "error": f"Course {course_id} not found"}
prereqs = course["prerequisites"]
if not prereqs:
return {"met": True, "details": "No prerequisites required"}
unmet_groups = []
for group in prereqs:
if not any(c in completed for c in group):
unmet_groups.append(group)
if unmet_groups:
return {
"met": False,
"unmet_groups": unmet_groups,
"explanation": " AND ".join(f"({' OR '.join(g)})" for g in unmet_groups),
}
return {"met": True, "details": "All prerequisite groups satisfied"}
What does check_prerequisites do?
This function evaluates the AND/OR prerequisite logic for any course. It loops through each prerequisite group (the outer AND) and checks if at least one course in that group (the inner OR) appears in the student's completed list.
If any group has no completed course, it returns the unmet groups with a human-readable explanation. The tool functions will use this helper to determine eligibility.
- Save tools.py.
- Verify the data loads correctly by running this in your backend terminal:
python -c "from tools import CATALOG, STUDENT; print(f'{len(CATALOG)} courses, student: {STUDENT[\"name\"]]}')"
You should see 27 courses, student: Alex Chen printed.
Seeing a ModuleNotFoundError or SyntaxError?
Make sure you are running the command from inside the backend/ folder (where tools.py lives). Also check that CATALOG has a closing } and PROGRAMS has a closing ].
help me fix an import error in tools.py
Define the tool schemas and dispatcher
Now you define the schemas that tell the model what functions it can call. Each schema describes a function's name, purpose, and accepted arguments using JSON Schema.
- Below check_prerequisites in tools.py, add the first two tool schemas (for listing programs and checking progress):
TOOL_SCHEMAS = [
{
"type": "function",
"function": {
"name": "get_programs",
"description": "Get available degree programs with their nested requirement buckets (core, math/science, humanities, electives with choose-N rules)",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "get_progress",
"description": "Get a student's academic progress: completed courses, remaining requirements by bucket, credits earned, current/next semester, and max credit load",
"parameters": {
"type": "object",
"properties": {
"student_name": {"type": "string", "description": "The name of the student"}
},
"required": ["student_name"],
},
},
},
What makes a good tool description?
The description field is what the model reads to decide whether to call a tool. Be specific about what data the function returns. Vague descriptions like "get data" lead to the model guessing instead of calling the right tool.
The parameters field uses JSON Schema to define accepted arguments. get_programs takes no arguments (empty properties), while get_progress requires a student_name string.
- Add the remaining two schemas (for checking eligibility and getting recommendations) directly below, still inside the TOOL_SCHEMAS list:
{
"type": "function",
"function": {
"name": "can_take",
"description": "Check if a student can take a specific course: evaluates AND/OR prerequisite logic, verifies the course is offered in the target semester, and checks credit-load capacity.",
"parameters": {
"type": "object",
"properties": {
"course_id": {"type": "string", "description": "The course ID to check (e.g., CIS365, STAT270)"},
"target_semester": {"type": "string", "enum": ["fall", "spring"], "description": "The semester to check availability for"},
},
"required": ["course_id"],
},
},
},
{
"type": "function",
"function": {
"name": "recommend",
"description": "Get personalized course recommendations for the next semester: filters by unmet prerequisites, semester availability, credit-load cap, and degree requirement priority",
"parameters": {
"type": "object",
"properties": {
"max_courses": {"type": "integer", "description": "Maximum number of courses to recommend (default 4)"},
"focus_bucket": {"type": "string", "description": "Optionally focus recommendations on a requirement bucket"},
},
"required": [],
},
},
},
]
Why does can_take only require course_id?
The target_semester parameter is optional. If the model omits it, the function defaults to the student's next semester. This gives the model flexibility to ask about a specific semester or use the default.
- Below the TOOL_SCHEMAS list, start the dispatcher function with its first handler for get_programs and the full get_progress handler:
def execute_tool(name: str, arguments: dict) -> dict:
if name == "get_programs":
return {"programs": PROGRAMS}
- Directly below, add the get_progress handler (still inside execute_tool):
elif name == "get_progress":
student = STUDENT
program = next((p for p in PROGRAMS if p["id"] == student["program"]), None)
if not program:
return {"error": "Program not found"}
completed = student["completed_courses"]
total_credits = sum(CATALOG[c]["credits"] for c in completed if c in CATALOG)
remaining = {}
for bucket_name, bucket_val in program["requirements"].items():
if isinstance(bucket_val, list):
left = [c for c in bucket_val if c not in completed]
if left:
remaining[bucket_name] = left
elif isinstance(bucket_val, dict):
taken_electives = [c for c in bucket_val["from"] if c in completed]
still_needed = bucket_val["choose"] - len(taken_electives)
if still_needed > 0:
available = [c for c in bucket_val["from"] if c not in completed]
remaining[bucket_name] = {"still_need": still_needed, "options": available, "description": bucket_val["description"]}
return {
"student": student["name"],
"program": program["name"],
"completed": completed,
"credits_completed": total_credits,
"credits_remaining": program["total_credits"] - total_credits,
"max_credits_per_semester": program["max_credits_per_semester"],
"current_semester": student["current_semester"],
"next_semester": student["next_semester"],
"remaining_by_bucket": remaining,
}
What does execute_tool do?
This is the dispatcher function. When the model returns a tool call request, your backend will call execute_tool with the function name and arguments. It routes to the correct handler and returns real data from the mock catalog.
The get_progress handler calculates which requirements are remaining by checking each bucket against the student's completed courses. It returns a complete progress snapshot the model can use to give accurate advice.
- Add the can_take handler below the get_progress block (still inside execute_tool):
elif name == "can_take":
course_id = arguments.get("course_id", "").upper()
target_semester = arguments.get("target_semester", STUDENT["next_semester"])
course = CATALOG.get(course_id)
if not course:
return {"error": f"Course {course_id} not found in catalog"}
completed = STUDENT["completed_courses"]
if course_id in completed:
return {"course_id": course_id, "course_name": course["name"], "can_take": False, "reason": "Already completed"}
semester_ok = target_semester in course["semesters"]
prereq_result = check_prerequisites(course_id, completed)
program = next((p for p in PROGRAMS if p["id"] == STUDENT["program"]), None)
max_credits = program["max_credits_per_semester"] if program else 18
can_enroll = semester_ok and prereq_result["met"]
result = {
"course_id": course_id,
"course_name": course["name"],
"credits": course["credits"],
"can_take": can_enroll,
"semester_available": semester_ok,
"offered_in": course["semesters"],
"target_semester": target_semester,
"prerequisites_met": prereq_result["met"],
"prerequisite_details": prereq_result,
"max_semester_credits": max_credits,
}
if not semester_ok:
result["blocking_reason"] = f"Not offered in {target_semester} (only {', '.join(course['semesters'])})"
elif not prereq_result["met"]:
result["blocking_reason"] = f"Missing prerequisites: need {prereq_result.get('explanation', 'unknown')}"
return result
What does can_take check?
This handler performs three checks: has the student already completed the course, is the course offered in the target semester, and are all AND/OR prerequisites satisfied. It returns a detailed breakdown so the model can explain exactly why a course is or is not available.
- Add the first part of the recommend handler, which gathers all needed courses by bucket:
elif name == "recommend":
max_courses = arguments.get("max_courses", 4)
focus_bucket = arguments.get("focus_bucket")
completed = STUDENT["completed_courses"]
target_semester = STUDENT["next_semester"]
program = next((p for p in PROGRAMS if p["id"] == STUDENT["program"]), None)
if not program:
return {"error": "Program not found"}
max_credits = program["max_credits_per_semester"]
needed = []
for bucket_name, bucket_val in program["requirements"].items():
if focus_bucket and bucket_name != focus_bucket:
continue
if isinstance(bucket_val, list):
for cid in bucket_val:
if cid not in completed:
needed.append((cid, bucket_name))
elif isinstance(bucket_val, dict):
taken = [c for c in bucket_val["from"] if c in completed]
if len(taken) < bucket_val["choose"]:
for cid in bucket_val["from"]:
if cid not in completed:
needed.append((cid, bucket_name))
- Directly below (continuing the recommend handler), add the filtering logic and the fallback else clause that finishes execute_tool:
recommendations = []
total_credits = 0
for course_id, bucket in needed:
course = CATALOG[course_id]
if target_semester not in course["semesters"]:
continue
prereq_result = check_prerequisites(course_id, completed)
if not prereq_result["met"]:
continue
if total_credits + course["credits"] > max_credits:
continue
total_credits += course["credits"]
recommendations.append({"course_id": course_id, "course_name": course["name"], "credits": course["credits"], "bucket": bucket, "reason": f"Prerequisites met, offered in {target_semester}, fills {bucket} requirement"})
if len(recommendations) >= max_courses:
break
return {"target_semester": target_semester, "max_credits": max_credits, "recommended_credits": total_credits, "recommendations": recommendations}
else:
return {"error": f"Unknown tool: {name}"}
How does recommend filter courses?
The recommend handler applies three filters in sequence: the course must be offered in the target semester, all prerequisites must be met, and adding it cannot exceed the 18-credit semester cap. It prioritizes courses by their requirement bucket order, filling core requirements first.
- Save tools.py.
- Verify the full module loads correctly by running:
python -c "from tools import TOOL_SCHEMAS, execute_tool; print(f'{len(TOOL_SCHEMAS)} tools defined')"
You should see 4 tools defined printed.
Seeing a syntax error when you import?
Check that the TOOL_SCHEMAS list has a closing ] and that each schema dictionary has matching braces. Also verify that execute_tool has consistent indentation for all the elif branches.
help me debug a syntax error in my tools.py file
Wire the tools into the API request and observe the obstacle
Now you connect the schemas to your existing endpoint. The model will see the available tools and choose to call one instead of generating text.
- In main.py, find the import section at the top of the file. Find this line:
import httpx
- Add this import directly below it:
from tools import TOOL_SCHEMAS, execute_tool
- Now find the json dictionary inside the client.post() call. It currently looks like this:
json={
"model": MODEL,
"messages": messages,
},
- Add tools and tool_choice so it now looks like this:
json={
"model": MODEL,
"messages": messages,
"tools": TOOL_SCHEMAS,
"tool_choice": "auto",
},
What does tool_choice auto mean?
Setting tool_choice to "auto" lets the model decide whether to call a tool or respond with text. If the user asks a simple greeting, the model responds directly. If the question requires data lookup, it returns a tool call request.
- Finally, find the three lines that extract and return the content:
data = response.json()
content = data["choices"][0]["message"]["content"]
return {"content": content}
- Replace those three lines with this (return the full message object so you can see tool_calls):
data = response.json()
return data["choices"][0]["message"]
- Save main.py. Uvicorn should auto-reload.
Before you test, predict: what do you think the response will look like now that the model has tool schemas available? Will it still return a text answer, or something different?
- Open the FastAPI docs at http://127.0.0.1:8000/docs in your browser.
- Expand the POST /api/chat endpoint and click Try it out.
- Paste this request body:
{
"messages": [
{"role": "user", "content": "I'm Alex Chen in the CIS program at Meridian State. What courses should I take next semester?"}
]
}
- Click Execute.
The response now contains a tool_calls array instead of a text answer. You will see something like:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_progress",
"arguments": "{\"student_name\": \"Alex Chen\"}"
}
}
]
}
Why is content null?
When the model decides to call a tool, it returns tool_calls instead of content. The model is saying: "I need this data before I can answer." It chose to call get_progress because it recognized from the schema that this function returns the student's academic progress.
Notice that arguments is a JSON string, not a parsed object. Your code will need to json.loads() it before passing the arguments to the actual function. The id field uniquely identifies this tool call so you can send the result back to the correct slot in the conversation.
This is the designed obstacle. The model wants to use tools, but your code just passes the raw response through without executing anything. The next step builds the loop that executes tools and feeds results back until the model produces a final answer.
Model still returning text instead of tool_calls?
If the model responds with hallucinated text instead of requesting a tool call, check two things. First, confirm that "tools": TOOL_SCHEMAS is included in the json dictionary of the client.post() call. Second, make sure your system prompt includes the instruction to use tools and never guess.
help me debug why my model isn't calling tools
✔️ Awesome, I've got everything!
Great. Double check you have saved both tools.py and main.py.
ⓧ I'd like to double check the full code
Your tools.py should contain these sections in order. Scroll up and confirm each one is present:
- import json and the header comments.
- CATALOG dictionary with all 27 course entries.
- PROGRAMS list with cis-bs and ds-bs programs.
- STUDENT dictionary for Alex Chen.
- check_prerequisites function.
- TOOL_SCHEMAS list with all four schemas (get_programs, get_progress, can_take, recommend).
- execute_tool function handling all four tool names plus the else clause.
Your main.py should match these sections:
import os
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import httpx
from tools import TOOL_SCHEMAS, execute_tool
load_dotenv()
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
MODEL = "deepseek/deepseek-chat"
SYSTEM_PROMPT = (
"You are a degree-planning advisor for Meridian State University. You help "
"students understand their degree requirements, check their academic progress, "
"determine which courses they can take (considering AND/OR prerequisites, "
"semester availability, and credit-load limits), and recommend courses.\n\n"
"You MUST use the available tools to look up information. Never invent or "
"guess course codes, prerequisites, semester availability, or student progress. "
"If you need information that requires a tool, call the appropriate tool. "
"If the student's question is unclear, ask a clarifying question before "
"calling tools."
)
class ChatRequest(BaseModel):
messages: list[dict]
@app.post("/api/chat")
async def chat(request: ChatRequest):
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
*request.messages,
]
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": MODEL,
"messages": messages,
"tools": TOOL_SCHEMAS,
"tool_choice": "auto",
},
)
data = response.json()
return data["choices"][0]["message"]
You have given the model a rich catalog of tools to work with. It responds by requesting data instead of guessing. Next, you will build the execution loop that actually runs those tools and feeds results back until the model composes a grounded final answer.
Build the Tool-Calling Loop
In the last step, the model returned tool_calls instead of answering your question. Your code passed that raw response through without doing anything with it.
This step builds the core architecture of an AI agent. You will write the loop that detects when the model wants to call a tool, executes the corresponding function, feeds the result back into the conversation, and calls the model again. The loop repeats until the model produces a final text answer instead of requesting more data.
In this step, get ready to:
- Build a while loop that orchestrates multiple tool-calling rounds.
- Execute tool calls and feed results back to the model as tool-role messages.
- Test multi-turn conversation with follow-up questions.
Restructure the endpoint into a tool-calling loop
The loop follows a simple pattern. Call the model with messages and tools. If the response contains tool_calls, execute each function, append the results to the message history, and call the model again. If the response contains content (no tool calls), that is the final answer.
Why does the message history grow with each round?
The model is stateless. It does not remember previous calls. To give it context, you must include the full conversation so far in every request. After one tool round, the messages array contains: system prompt, user question, assistant message (with tool_calls), and tool result. The model reads this entire chain to decide what to do next.
- In main.py, find this line near the top of your imports:
import os
- Add import json directly above it, so the first two lines look like this:
import json
import os
- Now scroll down to the chat function. Replace the entire function (from @app.post("/api/chat") to the end of the file) with the following code. Start with the loop structure:
@app.post("/api/chat")
async def chat(request: ChatRequest):
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
*request.messages,
]
round_count = 0
max_rounds = 5
async with httpx.AsyncClient(timeout=60.0) as client:
while round_count < max_rounds:
round_count += 1
response = await client.post(
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": MODEL,
"messages": messages,
"tools": TOOL_SCHEMAS,
"tool_choice": "auto",
},
)
What does this code do?
- round_count and max_rounds = 5 prevent infinite loops. If the model keeps requesting tools without ever producing a final answer, the loop stops after 5 rounds.
- The while loop wraps the existing API call so it can repeat when the model needs more data.
- The tools array is included in every request. OpenRouter requires it on each call so the model knows what tools are available.
- Directly below the code you just pasted (still inside the while loop), add the response handling and tool execution logic:
if response.status_code != 200:
return {"error": response.text}
data = response.json()
choice = data["choices"][0]
message = choice["message"]
if message.get("tool_calls"):
messages.append(message)
for tool_call in message["tool_calls"]:
func_name = tool_call["function"]["name"]
func_args = json.loads(tool_call["function"]["arguments"])
result = execute_tool(func_name, func_args)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call["id"],
"content": json.dumps(result),
}
)
else:
content = message.get("content", "")
return {"content": content}
return {"error": "Maximum tool rounds reached"}
What does the tool-call handling do?
- messages.append(message) adds the assistant's tool_calls message to the history. The model expects to see its own request before seeing the results.
- json.loads(tool_call["function"]["arguments"]) parses the arguments string into a Python dictionary. OpenRouter returns arguments as a JSON string, not as a dict.
- Each tool result is appended with role: "tool" and the matching tool_call_id. The ID links each result to the specific tool call that requested it.
- When the model responds with content instead of tool_calls, the loop exits and returns the final answer.
- The fallback return {"error": "Maximum tool rounds reached"} at the bottom fires only if the model never stops requesting tools.
- Save main.py.
- Your terminal running uvicorn should auto-reload. If you stopped the server earlier, restart it by running:
uvicorn main:app --reload
Confirm you see the reload message in the terminal with no import errors.
Seeing an indentation or syntax error?
The second code block must be indented at the same level as the existing code inside the while loop (three levels of indentation: function, async with, while). The final return {"error": "Maximum tool rounds reached"} sits outside the while loop but inside the function body (one indent level).
Also check that import json appears at the very top of the file. Without it, json.loads() will throw a NameError.
help me fix an indentation error in my tool-calling loop
Test with different tool-calling patterns
Before you test, think about what you expect to happen. When you ask a degree-planning question, do you think the model will call one tool or multiple tools across several rounds?
- Open the FastAPI docs UI at http://127.0.0.1:8000/docs.
- Expand the POST /api/chat endpoint and click Try it out.
- Paste this request body and click Execute:
{
"messages": [
{
"role": "user",
"content": "I'm Alex Chen in the CIS program. What courses can I take next semester?"
}
]
}
You should see a response with a content field containing a grounded answer. The model references actual course codes from the catalog (like CIS320, CIS365, STAT270) with correct prerequisite reasoning and semester availability.
What just happened behind the scenes?
The model likely called get_progress first to see what Alex has completed. Then it called can_take or recommend to check which courses are actually available. Each round added messages to the history until the model had enough data to compose a final answer.
Check your terminal output. You can see the uvicorn logs showing each request hit.
- Try a simpler question to see a single tool call in action. Replace the request body with:
{
"messages": [
{
"role": "user",
"content": "What degree programs are available?"
}
]
}
- Click Execute.
This should trigger a single get_programs call and return information about the CIS-BS and Data Science programs.
Getting null content or an empty response?
If the response has content: null or an error about tool rounds, the loop may not be appending messages correctly. Confirm that you have both messages.append(message) (the assistant's tool_calls) AND messages.append({...}) (each tool result) inside the loop. The model needs to see its own request followed by the results.
help me debug my tool-calling loop that returns null content
Verify multi-turn conversation
The endpoint already accepts a full messages array from the caller. This means multi-turn conversation works if the caller includes previous exchanges. The system prompt is prepended fresh on each request. Internal tool-round messages (the assistant's tool_calls and tool results) are ephemeral. Only the final assistant text content joins the conversation history for future requests.
- Test a follow-up question by including a prior exchange in the messages array. Paste this into the request body:
{
"messages": [
{
"role": "user",
"content": "I'm Alex Chen in the CIS program. Can I take CIS365 next semester?"
},
{
"role": "assistant",
"content": "Yes, you can take CIS365 next semester. You have CIS240 completed, and you also have MATH261 which satisfies the MATH261 OR STAT270 prerequisite group."
},
{
"role": "user",
"content": "What about CIS465?"
}
]
}
- Click Execute.
The model uses the prior context to understand that "What about CIS465?" means "Can I take CIS465 next semester?" It calls the can_take tool for CIS465 and answers based on the catalog data.
How does multi-turn work without server-side storage?
The backend is completely stateless. It stores nothing between requests. The frontend (which you will build in the next step) holds the full message history in React state. Each time the user sends a new message, the frontend POSTs the entire array of previous user/assistant pairs. The backend prepends the system prompt and runs the tool loop fresh.
Internal tool-round messages (the assistant tool_calls message and the tool results) live only for the duration of one request. They are not sent back to the frontend. Only the final text answer joins the conversation history for multi-turn.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and that your server reloaded without errors.
ⓧ I'd like to double check the full code
Here is the complete backend/main.py at this point. Scroll through and confirm each section matches yours.
import json
import os
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import httpx
from tools import TOOL_SCHEMAS, execute_tool
load_dotenv()
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
MODEL = "deepseek/deepseek-chat"
SYSTEM_PROMPT = (
"You are a degree-planning advisor for Meridian State University. You help "
"students understand their degree requirements, check their academic progress, "
"determine which courses they can take (considering AND/OR prerequisites, "
"semester availability, and credit-load limits), and recommend courses.\n\n"
"You MUST use the available tools to look up information. Never invent or "
"guess course codes, prerequisites, semester availability, or student progress. "
"If you need information that requires a tool, call the appropriate tool. "
"If the student's question is unclear, ask a clarifying question before "
"calling tools."
)
class ChatRequest(BaseModel):
messages: list[dict]
@app.post("/api/chat")
async def chat(request: ChatRequest):
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
*request.messages,
]
round_count = 0
max_rounds = 5
async with httpx.AsyncClient(timeout=60.0) as client:
while round_count < max_rounds:
round_count += 1
response = await client.post(
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": MODEL,
"messages": messages,
"tools": TOOL_SCHEMAS,
"tool_choice": "auto",
},
)
if response.status_code != 200:
return {"error": response.text}
data = response.json()
choice = data["choices"][0]
message = choice["message"]
if message.get("tool_calls"):
messages.append(message)
for tool_call in message["tool_calls"]:
func_name = tool_call["function"]["name"]
func_args = json.loads(tool_call["function"]["arguments"])
result = execute_tool(func_name, func_args)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call["id"],
"content": json.dumps(result),
}
)
else:
content = message.get("content", "")
return {"content": content}
return {"error": "Maximum tool rounds reached"}
Your advisor now uses real data instead of guessing. Next up, you will stream the tool-calling process to a React frontend in real time so users can watch the agent think.
Stream Tool Events to the React Frontend via SSE
Your backend can orchestrate multi-round tool calls and produce grounded answers. But from the user's perspective, they click Send and wait in silence for several seconds while the model calls tools behind the scenes.
In this step you will stream each tool-calling event to the React frontend in real time using Server-Sent Events. The user watches the advisor check progress, evaluate prerequisites, and gather recommendations before composing its final answer.
In this step, get ready to:
- Convert the backend endpoint from JSON responses to an SSE stream that yields tool_call, tool_result, text, and done events.
- Build a React chat interface that consumes the SSE stream with fetch and a ReadableStream reader.
- Test multi-turn conversation end-to-end with live tool activity visible in a side panel.
Convert the backend endpoint to stream SSE events
SSE is a protocol where the server keeps an HTTP connection open and pushes text events to the client. Each event has a specific wire format the browser understands.
What is the SSE wire format?
Each event is two lines followed by a blank line. The first line names the event type. The second line carries a JSON payload.
For example: event: tool_call on one line, then data: {"name": "get_progress", ...} on the next, then a blank line to signal the event is complete. The client reads line by line and dispatches based on the event name.
- In main.py, find the import block at the top of the file.
- Find this line:
from fastapi.middleware.cors import CORSMiddleware
- Add a new import directly below it so both lines appear together:
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
What does StreamingResponse do?
StreamingResponse accepts an async generator and streams each yielded string to the client as it becomes available. Combined with media_type="text/event-stream", it keeps the connection open so the frontend receives events in real time.
Now you will replace the entire chat endpoint function. The tool-calling loop stays the same, but instead of returning JSON at the end, it yields SSE events as each round completes.
- In main.py, delete everything from the @app.post("/api/chat") decorator down to the end of the file.
- Replace it with the following code that sets up the endpoint and the beginning of the async generator:
@app.post("/api/chat")
async def chat(request: ChatRequest):
async def event_stream():
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
*request.messages,
]
round_count = 0
max_rounds = 5
async with httpx.AsyncClient(timeout=60.0) as client:
while round_count < max_rounds:
round_count += 1
response = await client.post(
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": MODEL,
"messages": messages,
"tools": TOOL_SCHEMAS,
"tool_choice": "auto",
},
)
What changed so far?
The endpoint now defines an inner event_stream() async generator. Instead of returning values directly, this function will yield SSE-formatted strings. The tool-calling loop structure (while loop, round counter, API call) is unchanged from the previous step.
- Continue adding the error handling and response parsing directly below the code you just pasted:
if response.status_code != 200:
error_msg = response.text
yield f"event: error\ndata: {json.dumps({'message': error_msg})}\n\n"
yield "event: done\ndata: {}\n\n"
return
data = response.json()
choice = data["choices"][0]
message = choice["message"]
- Continue adding the tool-calls branch that yields SSE events for each tool call and result:
if message.get("tool_calls"):
messages.append(message)
for tool_call in message["tool_calls"]:
func_name = tool_call["function"]["name"]
func_args = json.loads(tool_call["function"]["arguments"])
yield f"event: tool_call\ndata: {json.dumps({'name': func_name, 'arguments': func_args})}\n\n"
result = execute_tool(func_name, func_args)
yield f"event: tool_result\ndata: {json.dumps({'name': func_name, 'result': result})}\n\n"
messages.append(
{
"role": "tool",
"tool_call_id": tool_call["id"],
"content": json.dumps(result),
}
)
What does this code do?
Each yield sends an SSE event to the frontend. When the model requests a tool call, the generator yields event: tool_call (so the UI can show what function is being called), executes the tool, then yields event: tool_result (so the UI can show what came back).
Errors from OpenRouter yield event: error followed by event: done and then return to close the generator.
- Finish the endpoint by adding the else branch, the max-rounds fallback, and the StreamingResponse return:
else:
content = message.get("content", "")
yield f"event: text\ndata: {json.dumps({'content': content})}\n\n"
yield "event: done\ndata: {}\n\n"
return
yield f"event: error\ndata: {json.dumps({'message': 'Maximum tool rounds reached'})}\n\n"
yield "event: done\ndata: {}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
What do these headers do?
Cache-Control: no-cache tells browsers and proxies not to cache the stream. X-Accel-Buffering: no prevents Nginx (if present) from buffering the response. Without these, events may arrive in batches instead of in real time.
When the model finally produces a text answer (no tool_calls), the generator yields event: text with the content and event: done to signal the stream is complete.
- Save main.py.
- Switch to the terminal where uvicorn is running. It should auto-reload and print a fresh startup message.
- Verify the SSE stream works by running this curl command in a new terminal:
curl -X POST http://127.0.0.1:8000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "What programs are available?"}]}'
You should see raw SSE text streaming in the terminal. Lines like event: tool_call and event: tool_result appear, followed by event: text with the final answer and event: done.
Not seeing any output from curl?
Make sure uvicorn reloaded successfully. Check its terminal for import errors. The most common issue is a missing StreamingResponse import or a stray return {"content": ...} left from the old code.
help me debug why my SSE endpoint returns no data
Why non-streaming upstream but SSE downstream?
The backend calls OpenRouter without streaming (each API call returns a complete JSON response). This makes tool-call detection trivial: just check message.tool_calls in the JSON.
The backend then emits its own SSE events to the React frontend as it progresses. Calling OpenRouter with stream: true would mean tool-call arguments arrive fragmented across many partial chunks that must be reassembled before execution. That is significantly more complex and belongs as an extension once you understand the foundational loop.
✔️ Awesome, I've got everything!
Great. Make sure main.py is saved and uvicorn is running cleanly.
ⓧ I'd like to double check the full code
Your main.py is over 30 lines, so confirm these sections are present in order:
- Imports including StreamingResponse and httpx.
- load_dotenv(), app setup, CORS middleware.
- Constants: OPENROUTER_API_KEY, OPENROUTER_URL, MODEL.
- SYSTEM_PROMPT multi-line string.
- ChatRequest Pydantic model.
- @app.post("/api/chat") with inner event_stream() generator containing the while loop, yield statements for each event type, and the return StreamingResponse(...) at the end.
Build the React chat interface with SSE parsing
The browser's built-in EventSource API only supports GET requests. Since you need to POST the full conversation history as JSON, the frontend uses fetch() with a ReadableStream reader to consume SSE events from the POST response.
- Open frontend/src/App.jsx in your editor.
- Select all the existing content and delete it.
- Paste the following code as the beginning of the file. This sets up state and starts the sendMessage function:
import { useState, useRef, useEffect } from "react";
import "./App.css";
function App() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [events, setEvents] = useState([]);
const messagesEndRef = useRef(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const sendMessage = async () => {
if (!input.trim() || isLoading) return;
const userMessage = { role: "user", content: input };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setInput("");
setIsLoading(true);
setEvents([]);
What does this code do?
The component stores the full conversation history in the messages array. Each time the user sends a message, it appends to this array and sends ALL of them to the backend. This is how multi-turn context works without server-side storage.
The events array stores tool activity (calls and results) for the side panel. It resets with each new message.
- Continue adding the SSE fetch and stream reader setup directly below:
try {
const response = await fetch("http://localhost:8000/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: updatedMessages }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
How does the buffer parsing work?
Network chunks do not align with SSE event boundaries. Data might arrive mid-line. The buffer accumulates text until a complete newline appears. lines.pop() removes the last element (which may be an incomplete line) and keeps it for the next iteration.
Complete lines are then checked: lines starting with event: set the event type, and lines starting with data: carry the JSON payload.
- Add the event dispatching logic that updates React state based on event type:
let eventType = "";
for (const line of lines) {
if (line.startsWith("event: ")) {
eventType = line.slice(7);
} else if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
if (eventType === "text") {
setMessages((prev) => [
...prev,
{ role: "assistant", content: data.content },
]);
} else if (eventType === "tool_call") {
setEvents((prev) => [
...prev,
{ type: "tool_call", ...data },
]);
} else if (eventType === "tool_result") {
setEvents((prev) => [
...prev,
{ type: "tool_result", ...data },
]);
} else if (eventType === "error") {
setMessages((prev) => [
...prev,
{ role: "assistant", content: `Error: ${data.message}` },
]);
}
}
}
}
What does each event type do?
- tool_call and tool_result add entries to the Tool Activity side panel so the user sees reasoning in progress.
- text appends the final assistant answer to the conversation history.
- error displays the error message as an assistant reply.
- Add the catch/finally blocks and keyboard handler:
} catch (error) {
setMessages((prev) => [
...prev,
{ role: "assistant", content: `Connection error: ${error.message}` },
]);
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
- Add the JSX return with the header, message list, and loading indicator:
return (
<div className="app">
<header>
<h1>Degree Advisor</h1>
<p>Meridian State University - AI-powered degree planning</p>
</header>
<div className="chat-container">
<div className="messages">
{messages.map((msg, i) => (
<div key={i} className={`message ${msg.role}`}>
<strong>{msg.role === "user" ? "You" : "Advisor"}:</strong>
<p>{msg.content}</p>
</div>
))}
{isLoading && (
<div className="message assistant loading">
<strong>Advisor:</strong>
<p>Thinking...</p>
</div>
)}
<div ref={messagesEndRef} />
</div>
- Add the events panel that shows tool activity in real time:
{events.length > 0 && (
<div className="events-panel">
<h3>Tool Activity</h3>
{events.map((event, i) => (
<div key={i} className={`event ${event.type}`}>
{event.type === "tool_call" && (
<p>
Calling: <code>{event.name}</code>(
{JSON.stringify(event.arguments)})
</p>
)}
{event.type === "tool_result" && (
<p>
Result from <code>{event.name}</code>:{" "}
<code>{JSON.stringify(event.result, null, 2)}</code>
</p>
)}
</div>
))}
</div>
)}
</div>
- Add the input area and export statement to complete the file:
<div className="input-area">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about your degree, courses, or progress..."
disabled={isLoading}
/>
<button onClick={sendMessage} disabled={isLoading || !input.trim()}>
Send
</button>
</div>
</div>
);
}
export default App;
- Save App.jsx.
- Switch to your browser at http://localhost:5173. You should see the Degree Advisor header, an empty chat area, and an input textarea at the bottom.
Seeing a blank page or console errors?
Open DevTools (F12) and check the Console tab. A common cause is a missing closing brace or parenthesis from pasting chunks separately. Make sure the file ends with export default App; and there are no unclosed brackets.
help me fix a React component that won't render after I pasted it in chunks
The UI renders but looks unstyled. Now replace the CSS to give it proper layout.
- Open frontend/src/App.css, select all content, and delete it.
- Paste the following base reset and layout styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.app {
max-width: 900px;
margin: 0 auto;
padding: 20px;
font-family: system-ui, -apple-system, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
}
header {
text-align: center;
margin-bottom: 20px;
}
header h1 {
font-size: 1.8rem;
color: #1a1a2e;
}
header p {
color: #666;
font-size: 0.9rem;
}
- Continue adding the chat container and messages wrapper styles:
.chat-container {
flex: 1;
overflow-y: auto;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.messages {
display: flex;
flex-direction: column;
gap: 12px;
}
- Add the message bubble styles for user and assistant:
.message {
padding: 12px 16px;
border-radius: 8px;
max-width: 80%;
}
.message.user {
background: #e3f2fd;
align-self: flex-end;
}
.message.assistant {
background: #f5f5f5;
align-self: flex-start;
}
- Add the message typography and loading state styles:
.message.loading {
opacity: 0.6;
font-style: italic;
}
.message strong {
display: block;
font-size: 0.75rem;
color: #666;
margin-bottom: 4px;
text-transform: uppercase;
}
.message p {
line-height: 1.5;
white-space: pre-wrap;
}
- Save App.css and refresh your browser.
- You should see a centered layout with a bordered chat container and message text styled with uppercase labels.
- Re-open App.css and add the events panel container styles below the previous code:
.events-panel {
background: #fafafa;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 12px;
margin-top: 8px;
}
.events-panel h3 {
font-size: 0.85rem;
color: #333;
margin-bottom: 8px;
}
.event {
font-size: 0.8rem;
padding: 6px 8px;
margin-bottom: 4px;
border-radius: 4px;
}
- Add the event type colors and code formatting styles:
.event.tool_call {
background: #fff3e0;
border-left: 3px solid #ff9800;
}
.event.tool_result {
background: #e8f5e9;
border-left: 3px solid #4caf50;
}
.event code {
background: rgba(0, 0, 0, 0.05);
padding: 2px 4px;
border-radius: 3px;
font-size: 0.75rem;
}
- Add the input area layout:
.input-area {
display: flex;
gap: 8px;
}
.input-area textarea {
flex: 1;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
resize: none;
min-height: 48px;
max-height: 120px;
}
.input-area textarea:focus {
outline: none;
border-color: #1976d2;
}
- Add the button styles to complete the CSS file:
.input-area button {
padding: 12px 24px;
background: #1976d2;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
}
.input-area button:hover:not(:disabled) {
background: #1565c0;
}
.input-area button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
- Save App.css.
- Return to your browser. The page should now show a centered layout with a bordered chat container, a text input area at the bottom, and a blue Send button.
Styles not appearing?
Check that App.jsx still has import "./App.css" at the top. Also confirm your Vite dev server is still running. If you stopped it, restart with npm run dev from the frontend folder.
help me debug why my Vite React app CSS is not loading
Test the full end-to-end flow
Both servers are running and the UI is styled. Time to see the full tool-calling flow in action.
- Click into the textarea at the bottom of the chat interface.
Before you type your question, think: what tool calls do you expect the advisor to make if you ask "What should I take next semester?" Do you think it will call one tool or multiple?
- Type the following into the textarea:
What should I take next semester?
- Press Enter or click Send.
Watch the Tool Activity panel appear below the chat. You should see tool calls appear one by one in real time. The advisor typically calls get_progress first, then can_take or recommend for specific courses.
After the tool rounds complete, the final answer renders in the chat as an assistant message. Now test multi-turn conversation.
- Type a follow-up question:
Can I take CIS365?
- Press Enter.
The advisor should answer in context of your prior exchange. It knows you are Alex Chen in the CIS program because the React state sent the full conversation history with this request.
Why does React send the full history each time?
The backend is stateless. It does not store conversation history between requests. React state holds the full array of user/assistant message pairs and POSTs all of them each time. The backend prepends the system prompt, runs the tool loop (which adds internal tool messages for that request only), then returns the final answer.
Internal tool-round messages (the assistant tool_calls message and each tool result) are ephemeral. They live only for the duration of that single request. Only the final assistant text content joins the history for multi-turn.
Getting a CORS error in the browser console?
Confirm that main.py has allow_origins=["http://localhost:5173"] in the CORS middleware. The Vite dev server runs on port 5173 and the backend must explicitly allow that origin.
help me fix a CORS error between my Vite frontend and FastAPI backend
Your AI degree advisor now shows its reasoning in real time. You can watch it check prerequisites, evaluate semester availability, and compose a grounded answer. Next up, the Secret Mission adds production safeguards so the system handles failures gracefully.
Secret mission
Production Hardening
Your working prototype has no safety rails. A runaway tool-calling loop could rack up unlimited API charges. Network errors crash silently with no recovery. And you have zero visibility into what each request costs. Time to add the defensive code a production system needs.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs unless you actively send messages through the app (which triggers pay-per-use OpenRouter API calls).
Resources you used:
- The project folder on your Desktop (containing backend/ and frontend/ directories, the Python virtual environment, and all source code).
- OpenRouter API usage billed to your OpenRouter account (typically $0.01-$0.05 for the learning session).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the advisor.
- Both servers stop when you close the terminal windows. No processes run in the background.
- No costs are incurred unless you open the app and send messages (each message triggers an OpenRouter API call).
- Your OpenRouter API key remains active. You can set a spending limit on it from your OpenRouter key settings page if you want a safety cap.
Pause - I'll come back to this later
Stop both dev servers to free up terminal windows and memory. You can restart anytime.
- In each terminal window running a server, press Ctrl+C to stop the process.
- To restart later, activate the virtual environment and run the backend:
cd ~/Desktop/backend
source venv/bin/activate
uvicorn main:app --reload
- Then start the frontend in a separate terminal:
cd ~/Desktop/frontend
npm run dev
- On Windows, replace source venv/bin/activate with venv\Scripts\activate.
Delete - I don't want to use this again
Remove all project files and optionally revoke your API key. This frees up disk space and closes the security surface of an active key.
- Stop both servers by pressing Ctrl+C in each terminal.
- Deactivate the Python virtual environment by running:
deactivate
- Delete the backend/ and frontend/ folders from your Desktop.
macOS
cd ~/Desktop
rm -rf backend frontend
Windows
cd ~\Desktop
rmdir /s /q backend
rmdir /s /q frontend
- If you created the OpenRouter API key solely for this project, revoke it from your OpenRouter key settings page. Select the key and delete it so it cannot be used again.
Nice Work!
Nice Work!
You just built a full-stack AI agent from scratch! Your degree advisor chatbot calls tools, evaluates complex prerequisite logic, and streams its reasoning process to the browser in real time.
You've learned how to:
- Build a FastAPI backend that calls DeepSeek V3 via OpenRouter with tool schemas in OpenAI-compatible format, experiencing the hallucination problem firsthand and understanding why tool calling exists.
- Implement the full tool-calling loop: detect tool_calls in the model response, execute mock functions, append tool-role messages to conversation history, call the model again, and repeat until a final grounded answer emerges.
- Stream tool-call status events, results, and final text to a React frontend via Server-Sent Events, managing multi-turn conversation history in React state so follow-up questions work without server-side storage.
- Secret Mission: Added max-rounds protection, error recovery with exponential backoff retry, and token/cost tracking for production readiness.
Ready to quiz yourself?