Building AI-Native Applications with Streamlit and Gradio
A FastAPI backend (B3) has no face. Streamlit and Gradio let you put a real UI on your LLM app in pure Python — no HTML, CSS, or JavaScript. This chapter builds a streaming chat app, covers the state model that trips everyone up, and shows when a quick UI is enough versus when you need the B3 backend.
pip install streamlit gradio:- Claude: an Anthropic API key (
ANTHROPIC_API_KEY) from console.anthropic.com +pip install anthropic - OpenAI: an OpenAI API key (
OPENAI_API_KEY) from platform.openai.com +pip install openai
.env file (never in code) and let the zero-argument client pick it up from the environment.Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.Learning objectives
- Explain what Streamlit and Gradio are for and how they differ.
- Build a Streamlit app and understand its rerun-on-interaction model.
- Manage state and build a streaming chat UI.
- Build the same app in Gradio and know when each tool fits.
- Decide UI-only vs UI + FastAPI backend, and mind the security basics.
What they are — Python UIs, no frontend advanced
Picture the moment your LLM code finally works — and nobody can see it. You've built a classifier, a RAG bot, an agent loop. It runs beautifully in your terminal, prints to stdout, returns clean JSON. Then your manager, or a friend, or a potential user asks the fair question: "Can I try it?" And you freeze, because the honest answer is "sure — install Python, clone this repo, set an env var, and run this script from a shell." That gap — between working logic and something a human can actually touch — is the entire reason this chapter exists. It's called the last mile, and historically it was the expensive mile: it meant learning HTML, CSS, JavaScript, a frontend framework, and how to wire all of that back to your Python. Streamlit and Gradio delete that mile.
Here's the mental shift, and it's a big one: treat these tools as a translation layer, not a web framework. You keep writing the Python you already know — variables, functions, loops — and the library watches what you produce and paints a matching web interface. Write st.text_input("Name") and a text box appears; write st.write(df) and a scrollable table appears. You are never authoring a webpage; you are describing an interface in Python and letting the tool be the frontend engineer you didn't have to hire. The analogy that lands for most people: it's the difference between writing a stage play — you script who says what, and the theatre supplies the lights, the set, the sound — versus building the theatre yourself, brick by brick.
The common beginner mistake is treating one of these like a real frontend: reaching for custom CSS, fighting the layout, trying to control every pixel. That way lies frustration, because these tools trade fine-grained control for speed on purpose. They are opinionated — they pick sensible defaults so you don't have to make a hundred small decisions. So the moment you catch yourself battling the framework to get a pixel "just right," read it as a signal, not a bug: you've probably outgrown the tool and want a real frontend (or the FastAPI backend of §UI-only vs backend). Used for what they're built for — getting working Python in front of humans fast — they feel close to magic. The two differ mainly in grain, which the table below makes concrete: Streamlit thinks in whole apps, Gradio thinks in single functions.
Streamlit and Gradio both turn a Python script into a web app. You write Python; they render widgets (text boxes, buttons, chat, charts, file uploaders) and run a local/hosted server. No HTML/CSS/JS. The difference is emphasis:
Read it left vs right: both tools turn Python into a web app, but they hand you a different-sized unit to work with.
- The left box (Streamlit) is one big block — your whole script. You place the widgets, manage the state, and arrange the layout yourself, so you "own all of it." That's the app grain.
- The right side (Gradio) is two small boxes: you supply just a
fn(inputs), and the arrow shows Gradio building the entire interface around it for you. That's the function grain. - The dashed divider marks the philosophical split: whole-app vs single-function. Neither is "more powerful" — they trade control for speed differently.
In short: Streamlit = you own an app; Gradio = you write a function and get an app. Choose by which grain fits the job.
| Streamlit | Gradio | |
|---|---|---|
| Feels like | A reactive data-app / dashboard script | An interface wrapped around a function |
| Best at | Multi-widget apps, dashboards, internal tools | Quick model demos; ML-focused components |
| Model | Re-run the whole script on every interaction | Wire inputs → a function → outputs |
| Sweet spot | "An app with several controls and views" | "A demo of this one function/model" |
The Streamlit mental model: rerun-on-interaction advanced
This one idea is the difference between Streamlit feeling magical and feeling haunted. Almost every "why is my app doing that?!" story from a Streamlit beginner traces back to a single misunderstanding, and if you internalize it now you'll skip a week of confusion. So let's put the stakes plainly: a variable you set at the top of your script will look, to your eyes, like it should stick around — and it won't. Your counter resets to zero. Your chat forgets the last message. Your loaded model reloads on every keystroke. None of that is a bug in your logic. It's the execution model, and once you see it, it all becomes obvious and predictable.
Here's the analogy that makes it click. Most UI frameworks you may have seen work like a whiteboard: you draw the screen once, then reach in and erase-and-redraw only the one number that changed. Streamlit does the opposite — it works like a photocopier. Every time the user touches anything, Streamlit throws away the whole page and re-runs your entire script from line 1 to the last line to produce a brand-new copy. There is no "update just this widget." There is only "run the whole thing again and show me the fresh result." Your script isn't a program that sets up a UI once and then listens; your script is the description of the page, re-executed in full on every click, keystroke, and slider drag.
Why would anyone design it that way? Because it's gloriously simple to reason about: your UI is always just a pure function of the current widget values, top to bottom, with no tangle of callbacks and event handlers to trace. The price you pay for that simplicity is exactly the trap above — ordinary variables can't remember anything, because they're born fresh each run and die at the bottom. Streamlit's answer is a special dictionary, st.session_state, that it deliberately preserves between reruns. Anything that must survive — chat history, a counter, a loaded model, a user's choices — lives there; everything else is happily rebuilt each pass. (There's a sibling trap too: expensive setup like loading a dataframe or building a retriever also re-runs every time unless you cache it — the callout after the lab covers that.) Keep the photocopier picture in your head as you read the diagram: interaction in, whole-script-rerun in the middle, fresh screen out.
The one thing that confuses every Streamlit beginner: your entire script re-runs top-to-bottom on every interaction. Click a button, type in a box — the whole file executes again. Understand this and Streamlit is simple; miss it and state behaves "randomly."
st.session_state. And expensive setup (loading data, building a retriever) gets a cache decorator so it doesn't repeat every rerun.
This picture explains the one rule that makes Streamlit click: every time you touch the UI, Streamlit runs your whole Python file again from the top. There is no clever "only update this button" — it re-executes line 1 to line N, then draws a fresh screen.
- The left box (interaction) is any user action — a click, typing in a box, moving a slider. That is the trigger.
- The middle box (script re-runs, line 1 … line N, top to bottom) is the surprising part: your entire script executes again, start to finish, on every single interaction.
- The right box (fresh UI drawn) is the result — Streamlit paints a brand-new screen from whatever that fresh run produced.
- The red caption line is the catch: because the script starts over, an ordinary variable is re-created from scratch each run, so it "forgets." Anything that must survive (chat history, a counter, a loaded model) has to be stored in
st.session_state, which Streamlit keeps between runs.
In short: Read it as a loop you can't see: interaction → whole script runs again → new screen. If a value seems to reset "randomly," it's just a plain variable being re-born on the next rerun — move it into st.session_state.
Lab B4.1 · A Streamlit chat app advanced
A Streamlit streaming chat app — in Claude or OpenAI
The Streamlit shape — session_state for history, the redraw loop, and st.write_stream for live tokens — is identical for both vendors. Only the client and the streaming call change: Claude uses client.messages.stream(...) and stream.text_stream; OpenAI uses client.responses.stream(...), filtering the event stream for response.output_text.delta events. Toggle the tab to switch SDK.
shellpip install streamlit anthropic
streamlit run app.py
app.pyimport streamlit as st
from anthropic import Anthropic
client = Anthropic()
st.title("Chat with Claude")
# history must survive reruns → session_state
if "messages" not in st.session_state:
st.session_state.messages = []
# re-draw the whole conversation every run
for m in st.session_state.messages:
st.chat_message(m["role"]).write(m["content"])
if prompt := st.chat_input("Ask something"):
st.session_state.messages.append({"role":"user","content": prompt})
st.chat_message("user").write(prompt)
with st.chat_message("assistant"):
with client.messages.stream(model="claude-opus-4-8", max_tokens=1024,
messages=st.session_state.messages) as stream:
text = st.write_stream(stream.text_stream) # streams tokens live
st.session_state.messages.append({"role":"assistant","content": text})
shellpip install streamlit openai
streamlit run app.py
app.pyimport streamlit as st
from openai import OpenAI
client = OpenAI()
st.title("Chat with GPT")
# history must survive reruns → session_state
if "messages" not in st.session_state:
st.session_state.messages = []
# re-draw the whole conversation every run
for m in st.session_state.messages:
st.chat_message(m["role"]).write(m["content"])
if prompt := st.chat_input("Ask something"):
st.session_state.messages.append({"role":"user","content": prompt})
st.chat_message("user").write(prompt)
with st.chat_message("assistant"):
with client.responses.stream(model="gpt-5.5", max_output_tokens=1024,
input=st.session_state.messages) as stream:
deltas = (e.delta for e in stream
if e.type == "response.output_text.delta")
text = st.write_stream(deltas) # streams tokens live
st.session_state.messages.append({"role":"assistant","content": text})
This is a complete streaming chat app in about 15 lines. Keep the rerun rule in mind: this whole file runs top-to-bottom every time you send a message, so the code is written to rebuild the screen each run while remembering the conversation in between.
import streamlit as stand the client (Anthropic()for Claude,OpenAI()for OpenAI — toggle the tab above) set up the two tools: Streamlit draws the UI, and the client talks to the model (it reads your API key from the environment).st.title(...)just puts a heading on the page.- The
session_stateguard.if "messages" not in st.session_state:runs only the first time — it creates an empty list to hold the chat. On later reruns the list already exists, so it is kept instead of wiped. This is how the app remembers past turns despite the whole script re-running. - Redraw the whole conversation. The
for m in st.session_state.messages:loop re-prints every past message each run — because the screen is rebuilt from scratch every time, you must paint the full history yourself. - Handle a new message.
if prompt := st.chat_input(...)is true only when the user actually typed something. When they do, the app appends their message to the history, shows it, then opens the stream (client.messages.stream(...)on Claude,client.responses.stream(...)on OpenAI) andst.write_stream(...)prints the model's reply token by token, live. Finally it appends the finished reply to the history so it survives the next rerun.
What the output means: A working chat window: your messages and Claude's answers stack up the page, and each new answer types out live instead of appearing all at once. The conversation stays put even though the script re-runs on every message.
Try this: Delete the if "messages" not in st.session_state: guard and set st.session_state.messages = [] directly — now the history wipes on every message and Claude forgets everything. That's the whole point of session_state.
st.session_state holds the message history across reruns (the C2 stateless-API pattern, in the UI). Redraw the loop — because the script re-runs, you re-render the whole conversation each time. st.write_stream consumes the Claude token stream (C2) straight into the UI. That's a real streaming chat app in ~15 lines.Lab B4.2 · The same, in Gradio expert
You just wrote a Streamlit chat and had to hand-manage its memory — the session_state guard, the redraw loop, appending each turn. Now watch the same app disappear into a single function. That contrast is the point of this section: it's not "here's another tool," it's "here's the opposite philosophy, so you can feel which grain fits which job." Getting this comparison in your bones is what lets you answer the interview question "Streamlit or Gradio?" with a reason instead of a shrug.
The mental model flips completely. In Streamlit you own the app — the script, the state, the redraw — and the framework just renders whatever you produce. In Gradio, the framework owns the app and you just supply one function: give it a respond(message, history), and Gradio hands you the past conversation, builds the entire chat interface (input box, message bubbles, streaming, a share link), and calls your function whenever the user says something. The analogy: Streamlit is renting an empty apartment and furnishing every room yourself; Gradio is checking into a hotel room that's already made up — you bring your toothbrush (the function) and nothing else. That's why the whole app below is one function plus gr.ChatInterface(respond).launch().
The common mistake here is expecting Gradio to behave like Streamlit — hunting for the equivalent of session_state, or trying to manually redraw the history. Don't; there's nothing to manage. Gradio passes the history into your function as an argument, so your job shrinks to "given this message and this history, what do I say back?" And streaming comes free from a Python feature you already know: instead of return-ing the finished reply, you yield the answer as it grows, and each yield updates the chat box live. The trade-off is the mirror of the tool's strength — you get a polished chat UI in one line, but you give up Streamlit's freedom to arrange arbitrary widgets, tabs, and layouts across a custom page. Rule of thumb to carry out of this lab: Gradio when the app is the function (a model demo, a chat over your agent); Streamlit when the function is just one part of a bigger app.
Gradio's model is different: you define a function and wrap a chat interface around it. Less boilerplate for a pure "wrap my model" demo.
respond(message, history) for each turn, hands you the past conversation, and paints the entire chat interface — you never touch state or redraw.
Follow the arrows left to right — this is one round-trip of a Gradio chat turn.
- The left box (user message) is what the user types; Gradio captures it for you.
- The middle amber box (
respond(message, history)) is the only code you write. Gradio calls it and passes the past conversation in ashistory— nosession_state, no manual storage. - The right box (
yield reply → chat) shows the reply going back to the screen. Because youyieldthe growing text instead ofreturn-ing it, each chunk updates the chat box live. - The blue caption line names everything Gradio does for you: passes history, builds the whole UI, and turns each yield into streaming.
In short: You write one function; Gradio supplies the history and the entire interface. Contrast this with the Streamlit lab where you owned the state and the redraw.
The same chat app in Gradio — in Claude or OpenAI
Gradio's respond(message, history) + yield-the-growing-string pattern is identical for both vendors — only the streaming call changes. Claude uses client.messages.stream(...) over stream.text_stream; OpenAI uses client.responses.stream(...), filtering the event stream for response.output_text.delta events. Toggle the tab to switch SDK.
gradio_app.pyimport gradio as gr
from anthropic import Anthropic
client = Anthropic()
def respond(message, history): # Gradio passes history for you
msgs = [{"role": h["role"], "content": h["content"]} for h in history]
msgs.append({"role":"user","content": message})
with client.messages.stream(model="claude-opus-4-8", max_tokens=1024,
messages=msgs) as stream:
partial = ""
for t in stream.text_stream:
partial += t
yield partial # yield to stream into the chat UI
gr.ChatInterface(respond).launch() # a full chat UI from one function
gradio_app.pyimport gradio as gr
from openai import OpenAI
client = OpenAI()
def respond(message, history): # Gradio passes history for you
msgs = [{"role": h["role"], "content": h["content"]} for h in history]
msgs.append({"role":"user","content": message})
with client.responses.stream(model="gpt-5.5", max_output_tokens=1024,
input=msgs) as stream:
partial = ""
for event in stream:
if event.type == "response.output_text.delta":
partial += event.delta
yield partial # yield to stream into the chat UI
gr.ChatInterface(respond).launch() # a full chat UI from one function
Same chat app, Gradio style. The mindset flips: instead of a script that re-runs and manages its own state, you write one function that takes a message and returns a reply, and Gradio wraps a whole chat UI around it. Notice there is no session_state here at all.
def respond(message, history):is the one function you write. Gradio calls it for every user turn and hands you the past conversation inhistoryautomatically — you don't have to store it yourself the way Streamlit made you.- The first two lines rebuild the message list the model expects: turn each past turn in
historyinto a{"role", "content"}dict, then append the newmessageas the latest user turn. - The stream call (
client.messages.stream(...)on Claude,client.responses.stream(...)on OpenAI) asks the model for a streamed reply. The loop over the chunks (Claude'sstream.text_stream, or on OpenAI theresponse.output_text.deltaevents read viaevent.delta) adds each new chunk ontopartialand thenyield partialhands the growing text back. Yielding (instead ofreturn) is what makes the answer stream live in the chat box — each yield updates the screen. gr.ChatInterface(respond).launch()is the payoff: one line turns your function into a full, shareable chat web app — input box, message bubbles, and streaming, all provided for you.
What the output means: A chat UI that behaves just like the Streamlit one, but you wrote far less: no state bookkeeping, no manual redraw. Gradio supplies the history and the interface; you only supplied the "what to say back" function.
Try this: Compare the two labs side by side. Streamlit = a re-running app where you own the state; Gradio = one function wrapped in a ready-made UI. That difference is the whole "which tool when" decision in this chapter.
history into your function — you don't manage session state yourself as in Streamlit. And a generator that yields the growing string gives you streaming for free. gr.ChatInterface(fn) is the fastest path from "I have a function that talks to Claude" to "here's a shareable chat app." This is exactly how model demos on sharing platforms are built.Beyond chat: the widgets that matter for AI apps expert
A chat box is where everyone starts, and it's also where a lot of AI apps get stuck. It's tempting to believe every problem is a conversation — but ask yourself what your users actually need to do. An analyst doesn't want to chat about a spreadsheet; she wants to drop the file in and see a chart. A reviewer doesn't want to type "the answer was good"; he wants to click 👍. The stakes of this section are quiet but real: the difference between a toy and a tool is usually not a smarter prompt — it's the right handful of widgets around the model, so a human can get their real work in and out.
Think of a widget as a doorway in and out of your Python. Each one is a two-way translation: a file uploader turns a PDF on someone's desktop into a bytes object your RAG code can chunk; a slider turns a mouse drag into a float you pass as temperature; a chart component turns the Matplotlib Figure you built in B2 back into pixels the user sees. You already wrote the logic in earlier chapters — the classifier, the retriever, the Pandas transforms, the plots. Widgets are simply the doors that let that logic meet a human, and picking the right doors is a design skill worth more than it looks.
Here's the mindset that makes the table below click: read each row as "an earlier chapter, now with a face." File upload isn't a new feature — it's the front door of your Ch 3 RAG pipeline or the P4 Document Intelligence project. The dataframe display is your B1 Pandas result, made visible. The chart component is your B2 figure, rendered. The 👍/👎 buttons are the missing input to your O4/I4 evaluation loop — the moment a real user's judgment becomes data you can measure and improve against. A subtle mistake worth flagging: don't over-widget. Every extra control is another thing to explain, validate, and secure; the best AI UIs expose the two or three knobs that matter (usually: bring-your-own-input, one model parameter, and a way to give feedback) and hide the rest. Match the door to the job, and you're building tools, not toys.
Chat is the headline, but both frameworks give you the pieces to build real AI tools — connecting straight back to earlier modules.
bytes, a slider turns a drag into a float, a chart component turns a Figure back into pixels — each one connects a human to logic you already wrote.
Read it as a flow from a person on the left to your code on the right, with the widget in the middle doing the translating.
- The left box (human) is the real-world action — dropping a PDF, dragging a slider, clicking a button.
- The middle box (widget = doorway) is the framework's job: it converts that action into a plain Python value — a PDF becomes
bytes, a drag becomes afloat, and on the way back a MatplotlibFigurebecomes pixels. - The right box (your Python) is the logic you built in earlier chapters — the RAG pipeline, the model call, the B2 plot. Nothing new; the widget just feeds it.
- The arrows point both ways in spirit: input flows human → code, and results flow code → human through the same kind of door.
In short: Widgets don't add logic — they're the doors that let the logic you already have meet a human. Pick the two or three doors your users actually need and hide the rest.
| Widget / component | Powers… |
|---|---|
| File upload | Drop a PDF/CSV → RAG (Ch 3) or Document Intelligence (P4) |
| Dataframe / table display | Show a Pandas result (B1) — the Data Analyst UI (P5) |
Chart display (st.pyplot / Gradio Plot) | Render the Figures you returned in B2 |
| Image / audio in/out | Multimodal apps (T2) |
| Sliders / selects | Expose model, effort (C1), or a temperature-like knob |
| Feedback buttons (👍/👎) | Collect the quality signal for evals/monitoring (O4, I4) |
UI-only vs UI + FastAPI backend expert
Here's a decision that quietly separates a weekend demo from a system you can stand behind. The very thing that makes Streamlit and Gradio wonderful — you write one script, it talks to Claude, done — is also a trap you can fall into without noticing. It works so well for the demo that you keep bolting features onto that one script: a bit of auth here, a rate limit there, a background job, a second kind of client… until one file is trying to be a frontend, a backend, and a security perimeter all at once. The stakes are concrete: get this call wrong in the "too simple" direction and you ship your API key to the world; get it wrong in the "too complex" direction and you spend a day building a backend for an app three people will ever use.
The clean way to think about it is to separate two jobs that the demo accidentally fuses. One job is presentation — draw the boxes, take the input, show the answer. The other is logic — hold the secret key, call the model, enforce who's allowed and how often, run the long agent loops. A Streamlit/Gradio app is a superb face. Whether it should also be the brain is the whole question. The analogy: a small café can have one person take your order, cook it, and run the till — perfectly efficient at that scale. A restaurant chain cannot; it splits the front-of-house (the UI) from the kitchen (the backend) so the kitchen can serve many locations, enforce standards, and be swapped or scaled without retraining every waiter. The diagram below is exactly those two arrangements.
So how do you actually decide? Lean on who and how many. If it's you, or a few trusted colleagues, and the logic is simple, let the UI call Claude directly — the left stack — and enjoy the speed. Reach for the FastAPI backend (B3) — the right stack — the instant any one of these is true: more than one kind of client will use it (web and mobile and another service), you need real auth, rate limits, or scaling (O3), the work is heavy or long-running, or you simply want the core logic reusable and independently testable rather than trapped inside a UI script. A useful gut-check: can this thing embarrass me or bill me? The moment it's reachable by people you don't personally trust, the backend stops being optional — because a UI-only app puts your key and your cost meter one layer from the open internet, which is precisely what the security section is about. The tables spell out both sides row by row.
Key architectural call: is a Streamlit/Gradio app enough, or do you need a B3 FastAPI backend behind it?
This diagram lays out the one architecture decision of the chapter: should your UI talk to Claude directly, or should it go through a separate backend first? The two stacks show the same app at two stages of growth.
- The left stack ("Simple: UI does everything") is two boxes:
Streamlit/GradiocallsClaudedirectly. Fewest moving parts — perfect for a demo, a prototype, or an internal tool. - The right stack ("Scaled: UI → backend") adds a middle box: the
UIcallsFastAPI (B3), and that callsClaude. The UI becomes a thin "face" and all the real logic lives in the backend. - The arrows show who calls whom — always top-to-bottom, one layer to the next. The extra layer on the right is the whole point: it's where you'd add auth, rate limits, scaling, and monitoring.
- Why bother with the right one? Because a backend can serve many clients at once (web, mobile, other services, an agent), not just this one UI — so the logic is reusable and testable on its own.
In short: Start on the left (UI → Claude) for anything small or private. Move to the right (UI → FastAPI → Claude) the moment the app is shared, needs real auth/limits, or has more than one kind of client.
| UI-only is fine when… | Add a FastAPI backend (B3) when… |
|---|---|
| Demo, prototype, internal tool | Multiple clients (web + mobile + other services) |
| One or few trusted users | You need real auth, rate limits, scaling (O3) |
| Logic is simple | Heavy/long agent runs, background jobs |
| Speed to ship matters most | You want the logic reusable & independently testable |
Security & deployment basics expert
Let's be blunt about the failure mode this section exists to prevent: a public URL, no auth, your API key inside, and a surprise four-figure bill on Monday. It happens more than anyone admits, and it happens precisely because these tools are so friendly. Streamlit and Gradio give you a one-line "share" or "launch" that turns your laptop script into a link anyone can open — and that link feels so casual, so demo-y, that your brain files it under "just a script" instead of "a public LLM endpoint I am financially responsible for." The stakes are real money and real data. So the mental reframe to adopt before you ever hit share is simple and a little uncomfortable: the instant it's reachable by someone else, it's production, and every rule from T1 and O3 applies — the pretty UI changes nothing.
Why is that reframe so easy to skip? Because a nice interface feels like a boundary. A polished chat box and a friendly title trick you into thinking there's a wall between the user and the model. There isn't. The text a stranger types flows to the same LLM, spends the same tokens on the same API key, and carries the same prompt-injection risk it would through a raw curl. The UI is a coat of paint on a door, not a lock. Think of it like a shop: a beautiful storefront window doesn't secure the till — you still need a lock on the door (auth), a limit on how much any one person can take (rate limits), and the cash kept out of the customer's reach (the key server-side, never shipped to the browser).
The good news is that the defenses are a short, unglamorous checklist, and doing them is mostly discipline, not cleverness. Keep the API key in the environment or a secrets store — never a literal in the script or committed to the repo, because git history is forever and scanners crawl public repos within minutes. Put access behind auth or keep the app internal; assume "unlisted URL" is not privacy. Cap and monitor usage so a runaway loop or an abuser can't drain your budget (O2/O4). Treat every input as untrusted and remember the T1 threats didn't vanish behind the widget. And when it grows past a toy, containerize it and, per the previous section, move the real logic behind a FastAPI backend where these controls belong anyway. Read the table below as your pre-share checklist — the danger callout after it is the one-paragraph version to tattoo on your memory.
The moment your app is reachable by others, the T1/O3 rules apply — and these frameworks make it dangerously easy to skip them.
Follow the top arrow first — it's the danger path — then look at the band underneath for the fix.
- The top row traces a stranger's input straight through the pretty UI (marked "not a wall") to Claude and your API key. The friendly interface stops nothing — the same tokens are spent on the same key.
- The amber middle box is the trap: a polished chat box feels like a boundary but isn't one.
- The lower band lists the controls that are the actual wall — keeping the key server-side, adding auth, rate-limiting, and validating input.
- The red caption line is the stakes: skip the band and your UI sits one thin layer away from your secret key and your cost meter.
In short: The instant the app is shared it's a public LLM endpoint. Treat the UI as decoration and put the real security (from T1/O3) in the layer behind it.
| Concern | Do this |
|---|---|
| API key | From env/secrets, never in the script or repo (T1, O3). The key lives server-side — never ship it to the browser |
| Auth | A public Streamlit/Gradio app is open to the world — add auth, or keep it internal |
| Cost/abuse | An open chat app is an open tab on your bill — rate-limit, cap, monitor (O2/O4) |
| Prompt injection | User input still flows to the model — the T1 threats don't disappear behind a nice UI |
| Deploy | Container it (O3), or use the frameworks' hosting; put a backend behind it if it grows |
Common pitfalls expert
| Pitfall | Fix |
|---|---|
| Expecting variables to persist across reruns (Streamlit) | Use st.session_state |
| Re-loading data/model every rerun | Cache expensive setup |
| Not streaming (long blank wait) | st.write_stream / yield in Gradio |
| API key in the script | Env/secrets, server-side only (T1) |
| Public app with no auth/limits | Add auth & rate limits, or keep internal (O3) |
| Cramming a production backend into the UI script | Move logic to FastAPI (B3) when it scales |
| Assuming a UI removes T1 risks | Same threat model; guard input & cost |
Exercises expert
Exercise B4.1 — Streaming chat, both ways
Context: Building the same streaming chat in both frameworks over one Claude call is the fastest way to feel their different models — and to form an opinion on which you'd grow into a real app.
Your task: Build the streaming chat both ways — a Streamlit version and a Gradio version — over the same Claude call, noting where each manages history and streaming.
Requirements:
- Implement the chat once in Streamlit and once in Gradio over the same underlying Claude call
- Both stream the response as it's generated
- Note where each framework manages conversation history
- Reflect on which was faster to build and which you'd extend into a bigger app
💡 Hint: Contrast the mechanics — Streamlit persists history in st.session_state and re-runs, while Gradio hands you the history in the callback.
Exercise B4.2 — A real mini-app
Context: Uploading a CSV, showing it, asking a question, and rendering both an answer and a chart is the projects' UI pattern in miniature — a mini Data Analyst. Caching the load is what keeps it from re-parsing on every question.
Your task: Build a Streamlit app that uploads a CSV, shows it as a table, lets the user ask a question, and displays both a Claude answer and a chart — caching the CSV load.
Requirements:
- Accept a CSV upload and display it as a table
- Cache the CSV load so it isn't re-parsed on every interaction
- Let the user ask a question and show a Claude-generated answer
- Render a chart, using a function that returns a Figure with
st.pyplot - The result is a mini Data Analyst app
💡 Hint: Caching matters most here — wrap the load so reruns reuse it — and reuse the return-a-Figure habit from the viz lesson to render with st.pyplot.
Show what to look for
The caching matters most here — without it, every question re-parses the CSV. And returning a Figure from a function (B2) is what lets you render the chart with st.pyplot. This app is the projects' UI pattern in miniature.
Exercise B4.3 — UI-only or backend?
Context: The UI-only-versus-backend call isn't one answer — it shifts with who uses the app. Reasoning it through for a prototype, an internal tool, and a public product builds the judgment the module is really teaching.
Your task: For three scenarios — a solo prototype, an internal team tool, and a public product — decide UI-only vs UI + FastAPI backend and justify each, and for the public one list the protections you'd add before launch.
Requirements:
- Give a decision (UI-only or UI + backend) for each of the three scenarios
- Justify each choice by who uses it and what it needs (shared logic, secrets, auth, scale)
- For the public product, list the threat-model / deployment protections to add before launch
- Tie the reasoning back to the same split the FastAPI lesson makes
💡 Hint: Lean UI-only for the prototype, lean toward a backend as the audience widens, and for the public case name the auth, rate-limit, and input-validation guards from the security rung.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Streamlit's whole model is one idea: it re-runs your script top-to-bottom on every interaction, so the UI is just a function of the current widget state. There's no callback wiring to learn first.
Your task: Write a minimal Streamlit app with a title, a text input, and output that echoes the input back, and explain the rerun-on-interaction model in one line.
Requirements:
- Show a title and a text-input widget
- Echo the entered value back as output
- No callback wiring — you read the widget value and write output directly
- State in one line that the whole script re-runs on every interaction
- Note the run command (
streamlit run app.py)
💡 Hint: Read the widget's return value and conditionally write output; Streamlit re-executes the script whenever that input changes.
Show solution
Rerun model: Streamlit re-runs your whole script top-to-bottom on every interaction, so the UI is just a function of the current widget state. Needs streamlit + a browser:
import streamlit as st
st.title("Echo app")
name = st.text_input("Your name")
if name:
st.write(f"Hello, {name}!")
# run: streamlit run app.py
There’s no callback wiring — you read the widget’s value and write output, and Streamlit re-executes the script whenever the input changes.
Context: Because the script re-runs on every interaction, ordinary variables reset each time — so a chat would forget its history on the next keystroke. st.session_state is what persists data across reruns.
Your task: Build a Streamlit chat UI that remembers the conversation across reruns, and explain why st.session_state is required given the rerun model.
Requirements:
- Initialize a message list in
st.session_stateif it isn't present - Render the existing history on each run with
st.chat_message - Append the user's turn and the assistant reply to session state
- Explain that ordinary variables reset each rerun, so history must live in
st.session_state - Use
st.chat_inputto collect the next message
💡 Hint: Guard the history list with an if "messages" not in st.session_state check so it survives reruns, then append each turn to it.
Show solution
Why session_state: because the script re-runs every interaction, ordinary variables reset each
time. st.session_state persists data (the message history) across reruns.
Needs streamlit + a browser:
import streamlit as st
st.title("Chat")
if "messages" not in st.session_state:
st.session_state.messages = [] # survives reruns
for m in st.session_state.messages:
st.chat_message(m["role"]).write(m["content"])
if prompt := st.chat_input("Say something"):
st.session_state.messages.append({"role": "user", "content": prompt})
reply = f"You said: {prompt}" # replace with an LLM call
st.session_state.messages.append({"role": "assistant", "content": reply})
st.chat_message("assistant").write(reply)
Without session_state the history would vanish on the next keystroke — that’s the classic
Streamlit beginner bug.
Context: Gradio takes the opposite approach to Streamlit: instead of re-running a script, it's callback-based — you write a (message, history) -> reply function and Gradio wires the UI to it and manages history for you.
Your task: Implement an equivalent chat with Gradio's ChatInterface and contrast Gradio's function/callback model with Streamlit's script-rerun model.
Requirements:
- Write a
respond(message, history)function that returns the reply - Wrap it in
gr.ChatInterface(fn=respond, ...)and launch it - Note that Gradio manages the conversation history for you
- Contrast the models: Streamlit re-runs the script; Gradio wires the UI to your function
- State the trade-off — Gradio for a quick shareable demo, Streamlit for a custom-layout app
💡 Hint: The whole app is one function plus ChatInterface; let Gradio own the history argument rather than tracking it yourself.
Show solution
Contrast: Streamlit re-runs the script; Gradio is callback/function-based — you write a
function (message, history) -> reply and Gradio wires the UI to it. Needs gradio + a
browser:
import gradio as gr
def respond(message, history):
# history is a list of prior (user, assistant) turns, managed by Gradio
return f"You said: {message}" # replace with an LLM call
demo = gr.ChatInterface(fn=respond, title="Chat")
demo.launch()
Gradio manages history for you and is quick for a shareable demo; Streamlit’s script-rerun model gives you more control over arbitrary page layout. Pick by whether you want a demo (Gradio) or an app (Streamlit).
Context: Beyond the chat box, a real AI app needs a few specific controls: a way to bring your own document, a knob for a model parameter, and a clean way to reset state. These three cover most of what turns a toy into a tool.
Your task: Show three Streamlit widgets an AI app actually needs — a file uploader, a slider for a parameter like temperature, and a stop/clear control — wired to sensible behavior.
Requirements:
- A
st.file_uploaderrestricted to sensible types, acknowledging the loaded file - A
st.sliderexposing a model parameter such as temperature - A clear/reset button that wipes the conversation from
st.session_state - Call
st.rerun()after clearing to force a fresh run - Each widget maps to a real use — upload for bring-your-own-doc, slider for a parameter, button to reset
💡 Hint: The uploader turns chat into a document assistant, the slider surfaces a knob, and the button plus st.rerun() is how you reset state cleanly.
Show solution
Needs streamlit + a browser. Uploader (bring-your-own-doc RAG), a parameter slider, and a clear-history button:
import streamlit as st
uploaded = st.file_uploader("Upload a document", type=["txt", "pdf"])
if uploaded:
st.success(f"Loaded {uploaded.name} ({uploaded.size} bytes)")
temperature = st.slider("Creativity (temperature)", 0.0, 1.0, 0.2, 0.05)
st.caption(f"Using temperature = {temperature}")
if st.button("Clear conversation"):
st.session_state.pop("messages", None)
st.rerun() # force a fresh run after clearing
The uploader turns a chat into a document assistant; the slider exposes a model parameter to the user; the
button + st.rerun() resets state cleanly.
Context: A Streamlit app can call the model directly, and that's fine for a demo or internal tool. But once you need shared logic, secrets off the client, auth, or rate limiting, a FastAPI backend belongs in front — the same split the da3 lesson makes.
Your task: Decide when the UI should call the model directly versus call a FastAPI backend, and show the wiring for the backend case — a Streamlit front end making a plain HTTP call.
Requirements:
- State the decision: UI-only is fine for a demo/internal tool; add a backend for shared logic, secret-keeping, auth, or rate limiting
- Show the Streamlit UI collecting the prompt
- Have it
POSTto the FastAPI backend with plainrequests(with a timeout) - Check the response status and render the returned answer
- Note that the backend holds the API key and business logic, letting other clients reuse it
💡 Hint: Keep the UI thin — collect input, requests.post to /ask, display the JSON answer — and let the backend own the key and the real logic.
Show solution
Decision: a UI-only app (Streamlit calls the model directly) is fine for a demo or an internal tool. Put a FastAPI backend in front when you need: shared logic across multiple UIs, to keep API keys off the client, real auth, rate limiting, or to reuse the same service from non-UI callers.
Needs streamlit to run the UI; the HTTP call is plain requests:
import streamlit as st
import requests
prompt = st.chat_input("Ask")
if prompt:
# the backend holds the API key and the real logic:
r = requests.post("http://localhost:8000/ask", json={"prompt": prompt}, timeout=30)
r.raise_for_status()
st.chat_message("assistant").write(r.json()["answer"])
The split keeps secrets and business logic server-side and lets a web UI, a CLI, or another service share one backend — the same reasoning as the da3 FastAPI lesson.
Context: Before a Streamlit or Gradio app leaves your laptop, a short list of must-dos stands between you and a leaked key or an open door — and the single highest-value habit is never hardcoding a secret.
Your task: List the security and deployment must-dos before sharing a Streamlit/Gradio app beyond your machine, and show the one code habit that prevents the most common leak — reading secrets from the environment.
Requirements:
- Cover the essentials: never hardcode secrets, add auth (these frameworks are open by default), validate and rate-limit input, hide internal errors, deploy behind HTTPS with pinned deps
- Read the API key from
os.environ, never a literal in source - Refuse to start (raise) when the key is missing rather than failing later
- Never print or log the secret's value
- Explain that reading from the environment is what keeps keys out of git history
💡 Hint: Pull the key with os.environ.get(...) and raise if it's absent; the value never appears in the source, so it can't be committed.
Show solution
- Never hardcode secrets — read API keys from environment/secrets, never commit them.
- Add auth — these frameworks are open by default; put the app behind SSO/a proxy or a password before exposing it.
- Validate & limit input — cap upload size and request rate; treat user text as untrusted.
- Don’t leak internals — show users a friendly error, log the detail server-side.
- Deploy behind HTTPS and pin dependencies.
The single highest-value habit — reading secrets from the environment — is plain Python and runs clean:
import os
api_key = os.environ.get("MODEL_API_KEY")
if not api_key:
raise RuntimeError("MODEL_API_KEY is not set; refusing to start")
print("key loaded from environment:", bool(api_key)) # True, value never printed
Reading from os.environ (never a literal in the source) is what keeps keys out of your git
history — the most common way demo apps leak credentials.
✓ Checkpoint — you can move on when you can…
- Say what Streamlit and Gradio are for and how they differ.
- Explain Streamlit's rerun model and use
session_state+ caching. - Build a streaming chat app in both frameworks.
- Decide UI-only vs UI + FastAPI backend for a scenario.
- Apply the API-key, auth, cost, and injection basics to a shared app.
Knowledge check check yourself
What is Streamlit's "rerun-on-interaction" model, and how do you keep values (like chat history) from being wiped each run?
Show answer
st.session_state, which Streamlit preserves between reruns.How does Gradio's model differ from Streamlit's for building a chat app?
Show answer
respond(message, history) function and Gradio passes the history and supplies the whole interface — no session_state or manual redraw — whereas Streamlit re-runs a script where you own the state.