Quick UIs with Streamlit and Gradio
Build a streaming chat UI for internal testing in Streamlit (chat_message, chat_input, session_state) or Gradio (ChatInterface), and know when not to use them.
Words in this lessonStreamlit · Gradio · Session state · Streaming · API key
- Streamlit
- A Python library that turns a script into an interactive web page. Like a whiteboard that is wiped and redrawn from the top every time someone touches it.
- Gradio
- A Python library for building quick web demos of models, including ready-made chat windows. Like a ready-made tasting kiosk where you only plug in the recipe.
- Session state
- Data that a web app keeps for one user’s session between interactions. Like sticky notes on the edge of a whiteboard that survive each wipe.
- Streaming
- Sending an answer to the user piece by piece as it is generated, instead of all at once at the end. Like serving each dish as it leaves the kitchen instead of holding every plate until the whole order is ready.
- API key
- A long secret string that identifies your account when your code calls an API. Like the key to the shop's back door: anyone holding it gets in, and whatever they take goes on your tab.
On this page
- Two ways to set up a tasting table
- Arjun's first Streamlit attempt forgets everything
- Opening the hood: reruns and session state
- Following one conversation through three reruns
- The real Streamlit app
- The same assistant as a Gradio kiosk
- Choosing between them
- A better wiring: call your API, not the model
- Where it breaks
- Try it yourself
- Common mistakes
- Explaining it to Meera
- Recap
- Go deeper
Meera’s request sounded small: “A little chat page, just for us. I want to try questions myself, and the six store managers want to check things like allergen answers before customers see them. By Friday.”
Arjun’s first thought was to do it properly. He created a new React project, installed a component library, and spent Tuesday on a login screen, a chat bubble component and a stylesheet. By Wednesday evening he had a beautiful empty chat window that could not yet talk to anything, and two days left.
Priya looked at the folder of 600 installed packages. “Who is this page for?”
“Meera and six managers.”
“How long will they use it?”
“A few weeks, while we test the assistant. Then maybe it becomes part of the admin panel.”
“So you’re building a shopfront for a tasting table. Let me show you how to do this in forty lines of Python, and you can go back to the real frontend when there’s a real reason.”
Important
The idea in one sentence: For internal tools, demos and quick tests, Streamlit and Gradio give you a working chat interface in a few dozen lines of Python, as long as you understand each one’s model of state and never mistake them for a secure, public production frontend.
Two ways to set up a tasting table
“When the bakery tests a new recipe,” Priya said, “do they build a new shop?”
“No. They put a table out in the back room with plates and a feedback sheet.”
“That’s what these tools are. Streamlit and Gradio are Python libraries that draw a web page for you. You never write HTML, CSS or JavaScript. They are perfect for ‘let six people try this for three weeks’ and wrong for ‘thousands of customers on our website’.”
Both turn a Python script into a page in the browser, but they think differently:
- Streamlit is like a whiteboard that gets wiped and redrawn, top to bottom, every time anyone touches anything on it. You write a script that draws the page; Streamlit reruns the whole script on every interaction.
- Gradio is like a ready-made tasting kiosk. The chat window, the input box and the history are already built. You plug in one function, “given this message and the history, what is the reply?”, and Gradio does the rest.
Whiteboards are flexible: you can draw anything, in any layout. Kiosks are fast: you plug in one thing and you are done. You will build the same Crumb assistant in both.
Arjun’s first Streamlit attempt forgets everything
Streamlit is installed like any other package:
uv add streamlit anthropic python-dotenv
Arjun wrote his first version the way he would write any script:
# app_v1.py (this version has a bug)
import anthropic
import streamlit as st
from dotenv import load_dotenv
load_dotenv()
client = anthropic.Anthropic()
messages = [] # the conversation so far
st.title("Crumb Support Assistant")
question = st.chat_input("Ask about orders, allergens or delivery")
if question:
messages.append({"role": "user", "content": question})
response = client.messages.create(model="claude-opus-5-5", max_tokens=2000, messages=messages)
reply = next(b.text for b in response.content if b.type == "text")
messages.append({"role": "assistant", "content": reply})
for m in messages:
st.write(f"**{m['role']}:** {m['content']}")
He ran it with uv run streamlit run app_v1.py, and a browser tab opened. He asked “Do you have eggless cakes?” and got a good answer. Then he asked “Which of those are available in Baner?”
The assistant replied: “Could you tell me which items you’re referring to?” And the first question and answer had vanished from the screen.
Note
Pause and think. Using the whiteboard picture, explain why the app forgot the first question.
Show the answer
Every interaction reruns the whole script from the top. When Arjun sent the second question, Streamlit ran messages = [] again, so the list started empty. The model received only “Which of those are available in Baner?”, with no history, and the screen showed only the new pair. Ordinary Python variables are wiped with the whiteboard. Anything that must survive between interactions has to live somewhere the wipe does not touch.
Opening the hood: reruns and session state
Here is precisely what Streamlit does. When a browser opens the page, Streamlit runs your script from the first line to the last, and each st. call adds something to the page. When the user does anything (types in the chat box, clicks a button, moves a slider), Streamlit runs the entire script again, from the top, and redraws the page from the results. The widget that was touched returns its new value during that run: st.chat_input returns the typed text on the run triggered by pressing Enter, and None on every other run.
This model is wonderfully simple: your script is the page, read top to bottom. But it means nothing in a normal variable survives from one run to the next.
The fix is st.session_state: a dictionary-like object that belongs to one browser tab and survives reruns. Picture sticky notes on the edge of the whiteboard. The board is wiped every time; the notes stay. Each person who opens the page gets their own set of notes, so Meera and the Baner manager have separate conversations.
The second thing to keep out of the wipe is the API client. Creating a new client on every rerun works, but it throws away warm network connections each time (the delivery-rider problem from the last lesson). @st.cache_resource tells Streamlit: run this function once, and reuse its result across reruns and across users.
Following one conversation through three reruns
Here is the fixed app, traced through its first three runs. Watch the sticky note, st.session_state.messages.
Run 1: Meera opens the page.
messagesis not in session state yet, so it is set to[].- The loop over past messages draws nothing.
st.chat_input(...)draws an input box and returnsNone. Theifblock is skipped.- Sticky note after the run:
[].
Run 2: Meera types “Do you have eggless cakes?” and presses Enter.
messagesalready exists, so it is left alone:[].st.chat_inputreturns"Do you have eggless cakes?".- The question is appended and drawn in a user bubble.
- Claude is called with one message (about 60 input tokens including the system prompt) and streams back: “Yes! Our eggless range includes the chocolate truffle cake, the pineapple pastry and the Diwali dry-fruit cake…” It appears word by word.
- The full reply is appended.
- Sticky note:
[{user: "Do you have eggless..."}, {assistant: "Yes! Our eggless range..."}].
Run 3: Meera types “Which of those are available in Baner?”
- The two previous messages are redrawn from the sticky note (no model call needed to show them).
- The new question is appended; Claude receives all three messages, about 190 input tokens, so it knows what “those” means.
- Sticky note now holds four messages.
Notice the input tokens climbing: 60, then 190. That is the stateless API from Module 01 and the growing-history cost from this module’s token-limit lesson, showing up in a UI. A tasting table with six users is cheap, but the same trimming rules apply if conversations get long.
The real Streamlit app
# app.py
import anthropic
import streamlit as st
from dotenv import load_dotenv
load_dotenv()
SYSTEM = "You are Crumb & Co.'s friendly support assistant. Keep answers short and accurate."
@st.cache_resource # create the client once, reuse it on every rerun
def get_client() -> anthropic.Anthropic:
return anthropic.Anthropic()
client = get_client()
st.title("Crumb Support Assistant")
st.caption("Internal testing only. Answers are drafts, not promises to customers.")
# The sticky note: survives reruns, one per browser tab
if "messages" not in st.session_state:
st.session_state.messages = []
# Redraw the whole conversation so far
for msg in st.session_state.messages:
with st.chat_message(msg["role"]): # a chat bubble with a user or assistant icon
st.markdown(msg["content"])
# The input box; returns the text only on the run triggered by Enter
if prompt := st.chat_input("Ask about orders, allergens or delivery"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
def stream_reply():
with client.messages.stream(
model="claude-opus-5-5",
max_tokens=2000,
system=SYSTEM,
messages=st.session_state.messages,
) as stream:
yield from stream.text_stream # hand each piece of text to Streamlit
with st.chat_message("assistant"):
reply = st.write_stream(stream_reply()) # draws text as it arrives, returns the full string
st.session_state.messages.append({"role": "assistant", "content": reply})
Run it:
uv run streamlit run app.py
A browser tab opens at http://localhost:8501. The new pieces, line by line:
st.chat_message("user")andst.chat_message("assistant")draw chat bubbles. Anything inside thewithblock appears in that bubble.if prompt := st.chat_input(...)uses Python’s “walrus” operator (:=), which assigns and tests in one step: “if the user just sent something, call itprompt”.st.write_stream(...)takes a generator, draws each piece as it arrives (the streaming from the last lesson, now in a UI), and returns the complete text when done, which we save to session state.
That is the whole app: about 35 lines.
The same assistant as a Gradio kiosk
Gradio takes the opposite approach. Install it:
uv add gradio
Then write one function. Gradio calls it with the new message and the history so far, and shows whatever it returns. If the function yields instead of returning, Gradio streams.
# gradio_app.py
import anthropic
import gradio as gr
from dotenv import load_dotenv
load_dotenv()
client = anthropic.Anthropic() # Gradio runs this file once, so a module-level client is fine
SYSTEM = "You are Crumb & Co.'s friendly support assistant. Keep answers short and accurate."
def text_of(content) -> str:
"""Gradio history content may be a plain string or a list of parts; return plain text."""
if isinstance(content, str):
return content
return " ".join(part.get("text", "") for part in content if isinstance(part, dict))
def respond(message: str, history: list[dict]):
# history arrives as [{"role": "user", "content": ...}, {"role": "assistant", "content": ...}, ...]
messages = [{"role": m["role"], "content": text_of(m["content"])}
for m in history if m.get("role") in ("user", "assistant")]
messages.append({"role": "user", "content": message})
reply = ""
with client.messages.stream(model="claude-opus-5-5", max_tokens=2000,
system=SYSTEM, messages=messages) as stream:
for text in stream.text_stream:
reply += text
yield reply # yield the reply SO FAR; Gradio redraws the bubble each time
gr.ChatInterface(fn=respond, title="Crumb Support Assistant").launch()
Run it with uv run python gradio_app.py and open http://127.0.0.1:7860. You get a chat window, a text box, a send button, and buttons to retry or undo turns, none of which you wrote.
Trace the same two questions. On the first, history is [], so Claude receives one message. On the second, Gradio passes the first exchange as history, so Claude receives three. Same tokens, same answers as the Streamlit version. The difference is who holds the sticky note: in Streamlit you manage st.session_state yourself; in Gradio the ChatInterface keeps the history and hands it to you.
The text_of helper is there because Gradio’s message format has changed between major versions; recent versions use role-and-content dictionaries, and content can arrive as a plain string or as a list of parts. Older tutorials pass type="messages" to ChatInterface; that argument was needed on Gradio 5 and removed in later versions, where this format is the only one. Library APIs change quickly. If a name has moved, check the docs linked below.
Note
Pause and think. In the Gradio function, why does the code yield reply (everything so far) instead of yield text (just the new piece)?
Show the answer
Gradio replaces the assistant’s bubble with each yielded value; it does not append. Yielding only the new piece would show “We have”, then " three eggless", then " options", each replacing the last, so the user would see a flickering fragment. Yielding the accumulated reply makes the bubble grow naturally. Streamlit’s st.write_stream works the other way: you give it the pieces and it does the accumulating. Same goal, opposite contract, which is exactly the kind of detail worth checking in each library’s docs.
Choosing between them
| Streamlit | Gradio | |
|---|---|---|
| Mental model | Script reruns top to bottom on every interaction | You supply a function; the interface is prebuilt |
| Chat in | About 35 lines | About 25 lines |
| State | You manage st.session_state |
ChatInterface passes history to you |
| Layout freedom | High: sidebars, columns, charts, tables, file uploads | Medium: great presets, custom layouts with gr.Blocks |
| Best for | Internal dashboards and tools around a model | Quick model demos, especially on Hugging Face Spaces |
Arjun chose Streamlit for the managers’ tool, because Meera immediately asked for a sidebar to pick a store and a table of the day’s flagged answers, and Streamlit makes those easy. He kept the Gradio version for demos. Gradio is also the most common way to share model demos on Hugging Face Spaces, which you will tour in Module 11.
A better wiring: call your API, not the model
Look again at both apps: each talks to Claude directly. That means all the safety work from this module, the length caps, the error mapping, the timeouts, lives in your FastAPI service, while the UI quietly bypasses it.
The cleaner design is for the UI to call your own /chat/stream endpoint from the previous lesson. Then there is one place where prompts, limits and error handling live, and every interface, website, WhatsApp, or tasting table, gets the same behaviour.
import httpx
API_URL = "http://127.0.0.1:8000/chat/stream"
def stream_reply():
question = st.session_state.messages[-1]["content"] # the endpoint takes one message
with httpx.stream("POST", API_URL, json={"message": question}, timeout=60.0) as response:
response.raise_for_status()
yield from response.iter_text() # pieces of text as the API streams them
Drop this stream_reply into app.py in place of the direct version. The managers’ tool and the customer website now share one brain. (The endpoint as written takes a single message; extending it to accept the history is a good exercise, with the trimming rules from the token-limits lesson.)
Where it breaks
These tools are fast because they skip things a real product needs. Know which ones before you rely on them.
Caution
Gradio’s launch(share=True) and a Streamlit app deployed to a public host both put your chat page on the internet. Anyone with the link can use it, and every question they type is billed to your API key. Never share a page that calls a paid model without adding authentication in front of it, and never put a production API key into an app you share casually.
Other limits that bit Arjun:
- Refresh means amnesia. Session state lives in the browser tab’s session. Refresh the page and the conversation is gone. Fine for testing; for anything real, save conversations to a database.
- Every rerun re-executes everything. A slow line at the top of a Streamlit script (loading a big file, say) runs on every keystroke-and-Enter. Wrap expensive setup in
@st.cache_resourceor@st.cache_data. - Scaling and polish. Both handle a team comfortably. Neither is designed to be the public face of a shop with thousands of concurrent visitors, custom branding and accessibility requirements. That is when the React project earns its place.
Try it yourself
- Run
app_v1.py, ask two related questions, and confirm it forgets the first. - Run
app.pyand ask the same two questions. Expected: the second answer correctly refers to the eggless cakes from the first. - Add a sidebar with a store picker:
store = st.sidebar.selectbox("Store", ["Aundh", "Baner", "Koregaon Park"]), and addf"The customer is asking about the {store} store."to the system prompt. Expected: answers mention the selected store. - Add a “Clear chat” button in the sidebar that empties the history.
Show the answer for step 4
if st.sidebar.button("Clear chat"):
st.session_state.messages = []
st.rerun() # redraw immediately with the empty history
Put it above the loop that draws the messages, so the cleared state is what gets drawn.
Common mistakes
- Storing state in ordinary variables in Streamlit. They are reset on every rerun. Use
st.session_state. - Creating clients or loading models on every rerun. Wrap them in
@st.cache_resource. - Yielding only the new chunk to Gradio. Gradio replaces the message on each yield; yield the accumulated reply.
- Sharing a public link to an app with your API key behind it. Anyone can run up your bill. Add authentication or keep it internal.
- Treating a demo UI as the production frontend. Use these tools to learn what users need, then build the real thing when the need is proven.
Explaining it to Meera
On Thursday morning, a day early, Arjun sent Meera a link on the office network.
“It’s a tasting table,” he told her. “A simple page where you and the managers can ask the assistant anything and see exactly what customers would see. Pick your store in the sidebar. It remembers the conversation until you refresh or press Clear.”
“Did this take you all week?”
“The first two days, I was building a full website. This version took about forty lines, most of an afternoon. When we know what the managers actually use, we can build the proper version into the admin panel.”
“And can a customer stumble onto it?”
“No. It only works inside the office network, and it goes through the same safety checks as the website chat.”
That afternoon the Baner manager found a problem in ten minutes: asked “Is order CR-1043 out for delivery?”, the assistant cheerfully replied “Yes, it should arrive within the hour!” It had never looked at the order. It could not. It had guessed.
Recap
- Streamlit reruns your whole script on each interaction; keep conversation history in
st.session_stateand clients in@st.cache_resource. st.chat_message,st.chat_inputandst.write_streamgive you a streaming chat page in about 35 lines.- Gradio’s
ChatInterfaceprovides the whole chat UI; you supply a function of(message, history)that yields the growing reply. - Better wiring: point the UI at your own FastAPI endpoint, so limits and error handling live in one place.
- These are tasting tables, not shopfronts: never share them publicly with a paid key behind them.
When Arjun showed Priya the made-up delivery answer, she nodded slowly. “This is the most important bug you’ve found so far. The model is answering questions about the real world without being able to see the real world. Next module, we give it hands: function calling.”
Go deeper
- Streamlit documentation (chat elements, session state, caching): https://docs.streamlit.io
- Gradio documentation (ChatInterface): https://www.gradio.app/docs
- FastAPI documentation: https://fastapi.tiangolo.com
- HTTPX documentation (streaming responses): https://www.python-httpx.org