
In Part 1 you learned what happens when an AI app answers a question: a list of messages goes to a model over an API, and text comes back. Now you will turn that idea into a working product: StudyBuddy, a chat app that explains topics, quizzes you and summarises notes, with streaming answers and sensible error messages. Every block of code is explained, so you understand what you ship.
What you will build
- A chat interface with message bubbles and an input box, built with Streamlit
- Conversation memory using
st.session_state, so StudyBuddy remembers the current chat - Streaming responses that appear word by word
- A sidebar with a mode selector (Explain, Quiz me, Summarise), a temperature slider and a Clear chat button
- History trimming to keep token usage under control
- Friendly error messages for a missing key, rate limits and network failures
What you need
- Python 3.10 or newer, a terminal and a code editor such as VS Code
- An API key from any provider with an OpenAI compatible endpoint (Google Gemini, Groq and others), or a local Ollama server
- About 60 to 90 minutes of focused time
Why Streamlit and the OpenAI SDK
Streamlit lets you write the entire interface in Python, with ready made chat components. It reruns your script from top to bottom every time the user interacts. That rerun model is the most important thing to understand about Streamlit, and we will come back to it.
For the model we use the official openai Python package, but not because you must use OpenAI. Many providers, including Google Gemini, Groq and a local Ollama server, expose OpenAI compatible endpoints, so the same client code works everywhere: you only change the base URL, key and model name. That matters when a free tier runs out in the middle of a project.
Keep your code provider agnostic. Your app should not care whose model it talks to. Configuration decides that, not code.
Project structure and setup
Create a folder for the project with this layout:
studybuddy/
├── app.py # the whole Streamlit app
├── requirements.txt # Python packages
├── .env # your secret key (never commit this)
├── .gitignore # tells git to ignore .env and the venv
└── .venv/ # virtual environment (created below)
Open a terminal inside the folder and create a virtual environment, so this project's packages do not clash with anything else on your machine:
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS / Linux
source .venv/bin/activate
requirements.txt
streamlit>=1.31
openai>=1.0
python-dotenv>=1.0
streamlit gives you the UI, openai is the API client and python-dotenv loads your key from the .env file. The minimum Streamlit version matters because st.write_stream arrived in version 1.31. Install everything with:
pip install -r requirements.txt
.env and .gitignore
Your API key is a password. It must never appear in your code or on GitHub. Put it in .env:
# Pick ONE provider block. Check each provider's docs for the current base URL and models.
# Google Gemini (OpenAI compatible endpoint)
LLM_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
LLM_API_KEY=paste-your-key-here
LLM_MODEL=your-model-name
# Groq
# LLM_BASE_URL=https://api.groq.com/openai/v1
# LLM_API_KEY=paste-your-key-here
# LLM_MODEL=your-model-name
# Local Ollama (no real key needed, but the SDK wants a non empty value)
# LLM_BASE_URL=http://localhost:11434/v1
# LLM_API_KEY=ollama
# LLM_MODEL=your-model-name
Replace your-model-name with a current model from your provider's model list. Model names change often, so always copy the exact name from the docs rather than from an old tutorial. Then create .gitignore:
.env
.venv/
__pycache__/
Do this before your first git commit. Leaked keys get scraped from public repositories quickly, and you will be the one paying for whatever runs on them.
The complete app.py, block by block
Below is the full app.py, split into eight numbered blocks. Paste them into one file in this exact order and you have the complete, working app. After each block you will find what it does and why.
Block 1: imports and configuration
import os
import streamlit as st
from dotenv import load_dotenv
from openai import (
OpenAI,
APIConnectionError,
APIStatusError,
AuthenticationError,
NotFoundError,
RateLimitError,
)
# 1. Configuration
load_dotenv()
BASE_URL = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1")
API_KEY = os.getenv("LLM_API_KEY")
MODEL = os.getenv("LLM_MODEL", "your-model-name")
MAX_HISTORY_MESSAGES = 12 # last 6 user + 6 assistant turns
load_dotenv() loads .env into the environment so os.getenv can read it. We import specific SDK exceptions now to catch them later. MODEL lives in exactly one place: switching providers means editing .env, not code. MAX_HISTORY_MESSAGES is our token budget control, covered below.
Block 2: the StudyBuddy system prompt and modes
SYSTEM_PROMPT = """You are StudyBuddy, a patient study assistant for Indian college students.
Rules:
- Explain concepts in simple English, step by step, with one small example.
- Use short paragraphs and bullet points. Use Markdown for code and formulas.
- If you are not sure about a fact, say so clearly instead of guessing.
- Stay on study topics. Politely decline requests to write graded assignments
word for word; offer to explain the concept instead.
"""
MODE_INSTRUCTIONS = {
"Explain": "Explain the topic the student asks about, then check understanding with one short question.",
"Quiz me": "Ask the student ONE question at a time on their topic. Wait for their answer, then tell them if it is right and why, before asking the next question.",
"Summarise": "Summarise the text or topic the student gives into 5 to 8 crisp bullet points, then list 3 key terms with one line definitions.",
}
The system prompt is StudyBuddy's rulebook. It is sent first in every request, and the model treats it as standing instructions. Good system prompts are specific: who the assistant is, who the user is, the output format, and what to do when unsure. Asking it to admit uncertainty reduces confident wrong answers, the most damaging failure for a study tool.
Instead of three separate prompts, we keep one base prompt and append a short instruction for the selected mode. The Quiz me instruction says "ONE question at a time" in capitals on purpose; otherwise many models dump ten questions with answers attached.
Block 3: page setup
# 2. Page setup
st.set_page_config(page_title="StudyBuddy", page_icon=":books:")
st.title("StudyBuddy")
st.caption("Your AI study assistant for college notes")
st.set_page_config sets the browser tab title and icon. It should be the first Streamlit command your script runs. The title and caption render at the top of the page.
Block 4: helper functions
# 3. Helper functions
@st.cache_resource
def get_client(base_url: str, api_key: str) -> OpenAI:
return OpenAI(base_url=base_url, api_key=api_key, timeout=60, max_retries=2)
def build_messages(history: list, mode: str, max_messages: int) -> list:
"""System prompt + mode instruction + the most recent chat turns."""
system_text = f"{SYSTEM_PROMPT}\nCurrent mode: {mode}. {MODE_INSTRUCTIONS[mode]}"
recent = history[-max_messages:]
# Some providers expect the first non system message to come from the user.
while recent and recent[0]["role"] != "user":
recent = recent[1:]
return [{"role": "system", "content": system_text}] + recent
def stream_reply(client: OpenAI, messages: list, temperature: float):
"""Yield text pieces from the model as they arrive."""
stream = client.chat.completions.create(
model=MODEL,
messages=messages,
temperature=temperature,
stream=True,
)
for chunk in stream:
if not chunk.choices:
continue
piece = chunk.choices[0].delta.content
if piece:
yield piece
def friendly_error(err: Exception) -> str:
if isinstance(err, AuthenticationError):
return "Your API key was rejected. Check LLM_API_KEY in your .env file."
if isinstance(err, RateLimitError):
return "The provider says you are sending requests too fast or your free quota is used up. Wait a minute and try again."
if isinstance(err, NotFoundError):
return f"The model '{MODEL}' was not found. Check LLM_MODEL against your provider's model list."
if isinstance(err, APIConnectionError):
return "Could not reach the AI provider. Check your internet connection and LLM_BASE_URL."
if isinstance(err, APIStatusError):
return f"The provider returned an error (status {err.status_code}). Try again in a moment."
return "Something unexpected went wrong. Check the terminal for details."
get_client creates the API client. Because Streamlit reruns the script on every click, @st.cache_resource makes sure the client is created once and reused. The 60 second timeout stops a stuck request from hanging forever, and max_retries=2 lets the SDK retry short network blips itself.
build_messages assembles exactly what the model sees: the system prompt with the current mode, then recent conversation. The slice history[-max_messages:] keeps only the last few messages. The while loop handles a subtle gotcha: after slicing, the list might start with an assistant message, and some providers reject a conversation that does not begin with a user turn.
stream_reply is a generator. With stream=True, the API sends the answer in small chunks, each carrying a delta with a few new characters, and we yield each piece as it arrives. The two if checks skip chunks with no choices (such as a final usage chunk) or empty content, preventing crashes and stray None values.
friendly_error turns exceptions into messages a student can act on. Several of these classes inherit from APIStatusError, so the specific checks must come first.
Block 5: session state
# 4. Session state
if "messages" not in st.session_state:
st.session_state.messages = []
def clear_chat():
st.session_state.messages = []
This is where the rerun model bites beginners. Every interaction runs app.py from the top, so a normal list would be recreated empty and StudyBuddy would forget everything. st.session_state survives reruns while the browser tab is open. Each item uses {"role": ..., "content": ...}, exactly the format the API expects. clear_chat resets the list and becomes a button callback.
Block 6: the sidebar
# 5. Sidebar
with st.sidebar:
st.header("Settings")
mode = st.radio("Mode", list(MODE_INSTRUCTIONS.keys()))
temperature = st.slider(
"Temperature", min_value=0.0, max_value=1.0, value=0.4, step=0.1,
help="Lower is more focused and repeatable. Higher is more creative.",
)
st.button("Clear chat", on_click=clear_chat)
st.caption(f"Model: {MODEL}")
Anything inside with st.sidebar: renders in the left panel. The radio options come from MODE_INSTRUCTIONS, so a fourth mode later needs only one new dictionary entry.
Temperature controls randomness. Lower values give more consistent explanations; higher values give more varied quiz questions. The 0 to 1 range is safe across providers.
The Clear chat button uses on_click=clear_chat rather than if st.button(...). A callback runs before the script reruns, so the chat redraws already empty instead of flashing old messages for one run.
Block 7: stop early if the key is missing
# 6. Stop early if the key is missing
if not API_KEY:
st.error("No API key found. Add LLM_API_KEY to your .env file and restart the app.")
st.stop()
client = get_client(BASE_URL, API_KEY)
The most common first run failure is a missing key, usually .env in the wrong folder or a typo in the variable name. Instead of a long traceback, we show a clear message and st.stop() ends this run cleanly.
Block 8: render history and handle new messages
# 7. Replay the conversation so far
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
# 8. Handle a new message
if prompt := st.chat_input("Ask about a topic, paste notes, or say 'quiz me'"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
messages = build_messages(st.session_state.messages, mode, MAX_HISTORY_MESSAGES)
with st.chat_message("assistant"):
try:
reply = st.write_stream(stream_reply(client, messages, temperature))
except Exception as err:
print(f"[StudyBuddy] {type(err).__name__}: {err}")
st.error(friendly_error(err))
st.session_state.messages.pop() # forget the failed question
else:
st.session_state.messages.append({"role": "assistant", "content": reply})
The first loop redraws every saved message on each rerun. st.chat_message gives you the familiar bubbles with avatars, and st.markdown renders the model's lists and code blocks properly.
st.chat_input draws the input box pinned to the bottom of the page. It returns the typed text on the run right after the user presses Enter, and None otherwise. The walrus operator := assigns and checks in one line.
Inside the assistant bubble, st.write_stream takes our generator, writes each piece as it arrives, and returns the full text when the stream ends. We save that return value to history. Forgetting to save it is a classic bug: the answer appears, then vanishes on the next interaction.
The try / except / else structure is deliberate. On failure we print the real error to the terminal, show the friendly version to the user, and remove the unanswered question so history stays clean for a retry. The else branch runs only when streaming succeeded.
Run the app:
streamlit run app.py
Your browser opens the app on a local address. Try "Explain recursion with a simple example", then switch to Quiz me and type "Quiz me on recursion".
Controlling tokens with history limits
A model has no memory between calls; the history you send is its memory. So a long chat gets slower and more expensive with every turn, and eventually hits the model's context window limit.
MAX_HISTORY_MESSAGES = 12 keeps the last six exchanges, plenty for a student focused on one topic at a time. The trade off:
| Setting | Effect |
|---|---|
| Lower limit | Cheaper, faster requests, but StudyBuddy forgets earlier context sooner |
| Higher limit | Better continuity in long sessions, but more tokens per request and quicker rate limits |
Notice that the full history stays in st.session_state, so the user still sees the whole conversation on screen. We only trim what goes to the model.
The screen and the model do not need to see the same history. Show the user everything, send the model only what it needs.
In Part 3 you will meet a smarter alternative: instead of sending more chat history, you retrieve only the relevant chunks of your notes.
Error handling that respects your users
With free tiers, errors are part of normal use. What each one usually means:
- Missing key:
.envnot found, wrong variable name, or Streamlit not restarted after editing.env. - AuthenticationError: the key is wrong, revoked, or belongs to a different provider than your base URL.
- RateLimitError: too many requests in a short window, or the free quota is used up. Limits vary and change, so check your provider's rate limits page.
- NotFoundError: a typo in the model name, or a retired model.
- APIConnectionError: no internet, a firewall on college WiFi, a wrong base URL, or Ollama not running.
Friendly message for the user, raw error in the terminal for you: that is the right split.
Testing tips
You do not need a big test suite yet, but a few habits will save you hours.
Test the failure paths on purpose
Rename .env temporarily, break one character of the key, and turn off WiFi. Confirm each shows its friendly message. Better you see these than a recruiter during a demo.
Use Streamlit's built in test runner
Streamlit includes AppTest, which runs your app without a browser. Save this as test_app.py:
from streamlit.testing.v1 import AppTest
def test_missing_key_shows_error(monkeypatch):
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.setattr("dotenv.load_dotenv", lambda *a, **k: False)
at = AppTest.from_file("app.py").run()
assert "No API key found" in at.error[0].value
def test_sidebar_has_three_modes(monkeypatch):
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.setattr("dotenv.load_dotenv", lambda *a, **k: False)
at = AppTest.from_file("app.py").run()
assert at.sidebar.radio[0].options == ["Explain", "Quiz me", "Summarise"]
Install pytest and run pytest. These tests need no API key, so they cost nothing and run in seconds. The monkeypatch lines make sure your real .env is ignored during the test.
Prompt testing checklist
Rerun a fixed set of prompts whenever you change the system prompt: a clear concept question ("Explain normalisation in DBMS"), a vague one ("explain this"), a Quiz me request to confirm it asks one question and waits, a long paragraph in Summarise mode, and a "write my assignment" request to confirm it keeps its rules.
Common gotchas and next steps
- Edited
.envbut nothing changed: stop the server with Ctrl+C and start it again. Environment variables load at startup. - Ollama connection errors: confirm the Ollama server is running and you have pulled the model named in
LLM_MODEL. - Messy Quiz me answers: lower the temperature and make the mode instruction more explicit.
Then personalise it: add an "Exam answer" mode, a button to download the chat, or examples tuned to your branch. On your resume, describe the decisions you made (streaming, history trimming, error handling) rather than listing tools. Our AI resume review can help you phrase project bullets, and our courses go deeper into Python fundamentals.
Recap and next part
You built a working AI chat app in plain Python: a clean project with secret handling, a StudyBuddy system prompt with switchable modes, memory with st.session_state, streaming with st.write_stream, sidebar controls, history trimming and friendly error handling. Commit it to GitHub now, with .env safely ignored.
One big limitation remains: StudyBuddy has never seen your actual notes. In Part 3: Add your own data with RAG, you will load your PDFs, split them into chunks, create embeddings and let StudyBuddy answer from your own material. Part 4 then covers tools, function calling and agents, and Part 5 covers deploying, securing and showcasing your app. Find the full series on our blog.


