AI EngineeringZero to ProductionHome·About·What’s new·Contact
Data & App Building · Chapter B4

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.

⏱️ ~55 min🖥️ Hands-on🎯 Beginner→Intermediate
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need an API key for whichever vendor you follow (toggle the Claude | OpenAI tabs on the code below), plus pip install streamlit gradio:Either way, keep the key in a .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.
The last mile — and it reuses everythingThese tools display the LLM logic you've built all course: the classifier (Ch 2), the RAG bot (Ch 3), the agent (Ch 4/L4), the charts (B2). They're the fastest way to demo or ship a small AI app. For production-grade backends you'll still want FastAPI (B3) — this chapter covers when each is the right call. Both frameworks evolve; learn the model, not exact widget names.

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:

Streamlit → a whole app/script your script widgets · state · layout you own all of it app grain Gradio → one function fn(inputs) Gradio builds the UI function grain Same job, different grain. Streamlit hands you a whole app you fill with widgets, state, and layout; Gradio wraps a ready-made interface around a single function. Pick by how much of the app you want to own.
🗺️ How to read this diagram

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.

StreamlitGradio
Feels likeA reactive data-app / dashboard scriptAn interface wrapped around a function
Best atMulti-widget apps, dashboards, internal toolsQuick model demos; ML-focused components
ModelRe-run the whole script on every interactionWire inputs → a function → outputs
Sweet spot"An app with several controls and views""A demo of this one function/model"
Rough rule of thumbReach for Gradio when you want to wrap one function/model in an interface fast (a chat box over your agent, a demo to share). Reach for Streamlit when you're building a small app with several inputs, tabs, and charts (an internal analytics tool, a RAG explorer). Both can do both — this is about which grain you're going with.

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."

interaction script re-runs line 1 … line Ntop to bottom fresh UI drawn local variables reset each run — persist with st.session_state Every click re-runs the whole script. That's why a plain variable resets each interaction — it's re-initialized every run. Anything that must survive (chat history, a loaded model, counters) goes in st.session_state. And expensive setup (loading data, building a retriever) gets a cache decorator so it doesn't repeat every rerun.
🗺️ How to read this diagram

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

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab B4.1

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})
▶ How this works

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.

  1. import streamlit as st and 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.
  2. The session_state guard. 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.
  3. 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.
  4. 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) and st.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.

Three ideas do all the workst.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.
Cache expensive setup — don't rebuild it every rerunBecause the script re-runs on every interaction, anything expensive (loading a dataframe, building a RAG retriever/index from Ch 3, creating a client pool) will re-run too — slow and wasteful. Wrap it in Streamlit's cache decorator so it runs once and is reused. Forgetting this is why beginner Streamlit apps feel sluggish: they rebuild the world on every keystroke.

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.

Gradio owns the app; you supply one function user message respond( message, history) you write this yield reply → chat Gradio passes history in · builds the whole UI · yield streams live One function, whole UI. Gradio calls your respond(message, history) for each turn, hands you the past conversation, and paints the entire chat interface — you never touch state or redraw.
🗺️ How to read this diagram

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 as history — no session_state, no manual storage.
  • The right box (yield reply → chat) shows the reply going back to the screen. Because you yield the growing text instead of return-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.

Lab B4.2

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
▶ How this works

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.

  1. def respond(message, history): is the one function you write. Gradio calls it for every user turn and hands you the past conversation in history automatically — you don't have to store it yourself the way Streamlit made you.
  2. The first two lines rebuild the message list the model expects: turn each past turn in history into a {"role", "content"} dict, then append the new message as the latest user turn.
  3. 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's stream.text_stream, or on OpenAI the response.output_text.delta events read via event.delta) adds each new chunk onto partial and then yield partial hands the growing text back. Yielding (instead of return) is what makes the answer stream live in the chat box — each yield updates the screen.
  4. 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.

Gradio manages the history for youNotice Gradio passes 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.

humanPDF · drag · click widget = doorwaybytes · float · Figure your PythonRAG · model · plot each widget translates a human action into a value your earlier-chapter code already handles A widget is a two-way door. A file uploader turns a PDF into 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.
🗺️ How to read this diagram

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 a float, and on the way back a Matplotlib Figure becomes 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 / componentPowers…
File uploadDrop a PDF/CSV → RAG (Ch 3) or Document Intelligence (P4)
Dataframe / table displayShow 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/outMultimodal apps (T2)
Sliders / selectsExpose model, effort (C1), or a temperature-like knob
Feedback buttons (👍/👎)Collect the quality signal for evals/monitoring (O4, I4)
This is the display layer for the whole courseFile-upload + RAG chat is the Document Intelligence project's UI. A dataframe + chart + question box is the Data Analyst project. A thumbs-up/down on each answer feeds the O4/I4 evaluation flywheel. These frameworks are how the LLM engineering you've learned becomes something a non-engineer can actually use.

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?

Simple: UI does everything Streamlit/Gradio Claude Scaled: UI → backend UI FastAPI (B3) Claude Two architectures. For a demo or internal tool, the UI can call Claude directly — simplest. For anything shared, multi-client, or that needs real auth/scaling/monitoring, put the logic in a FastAPI backend (B3) and let the UI be a thin client. The backend can then also serve mobile, other services, or an agent — the UI is just one consumer.
🗺️ How to read this diagram

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/Gradio calls Claude directly. Fewest moving parts — perfect for a demo, a prototype, or an internal tool.
  • The right stack ("Scaled: UI → backend") adds a middle box: the UI calls FastAPI (B3), and that calls Claude. 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 toolMultiple clients (web + mobile + other services)
One or few trusted usersYou need real auth, rate limits, scaling (O3)
Logic is simpleHeavy/long agent runs, background jobs
Speed to ship matters mostYou 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.

strangertypes anything pretty UInot a wall Claude + key real controls: key server-side · auth · rate-limit · validate input without these, the UI is one layer from your key and your bill The UI is a coat of paint, not a lock. A stranger's text flows straight through the interface to the same model, key, and bill — so the real defenses (key server-side, auth, rate-limit, input validation) have to sit behind it, not in the widget.
🗺️ How to read this diagram

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.

ConcernDo this
API keyFrom env/secrets, never in the script or repo (T1, O3). The key lives server-side — never ship it to the browser
AuthA public Streamlit/Gradio app is open to the world — add auth, or keep it internal
Cost/abuseAn open chat app is an open tab on your bill — rate-limit, cap, monitor (O2/O4)
Prompt injectionUser input still flows to the model — the T1 threats don't disappear behind a nice UI
DeployContainer it (O3), or use the frameworks' hosting; put a backend behind it if it grows
A pretty UI doesn't change the threat modelIt's tempting to treat a Streamlit demo as "just a script," but the second it's shared it's a public LLM endpoint with all of T1's risks — plus a wide-open cost meter if there's no auth or rate limit. Keep the API key server-side, gate access, cap usage, and remember every input reaches the model. The UI is a face; the security is the same as B3/T1.

Common pitfalls expert

PitfallFix
Expecting variables to persist across reruns (Streamlit)Use st.session_state
Re-loading data/model every rerunCache expensive setup
Not streaming (long blank wait)st.write_stream / yield in Gradio
API key in the scriptEnv/secrets, server-side only (T1)
Public app with no auth/limitsAdd auth & rate limits, or keep internal (O3)
Cramming a production backend into the UI scriptMove logic to FastAPI (B3) when it scales
Assuming a UI removes T1 risksSame 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.

Exercise 1 · A minimal Streamlit pageBeginner

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.

Exercise 2 · A Streamlit chat app with historyIntermediate

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_state if 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_input to 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.

Exercise 3 · The same chat in GradioAdvanced

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).

Exercise 4 · The widgets that matter for AI appsExpert

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_uploader restricted to sensible types, acknowledging the loaded file
  • A st.slider exposing 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.

Exercise 5 · UI-only vs UI + FastAPI backendProfessional

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 POST to the FastAPI backend with plain requests (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.

Exercise 6 · Security & deployment basics for a shared appIndustry scenario

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
  1. Never hardcode secrets — read API keys from environment/secrets, never commit them.
  2. Add auth — these frameworks are open by default; put the app behind SSO/a proxy or a password before exposing it.
  3. Validate & limit input — cap upload size and request rate; treat user text as untrusted.
  4. Don’t leak internals — show users a friendly error, log the detail server-side.
  5. 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.
🏗️ Toward the capstone — module completeEvery project in the gallery gets its face here: a Streamlit/Gradio UI over the agent, with file upload for docs (P4), a table+chart for data (P5), and 👍/👎 feedback feeding evals (O4/I4). The AI DevOps Engineer's operator dashboard — where a human reviews proposed changes and approves risky ones (L5) — is exactly this: a UI over the FastAPI backend (B3) that fronts the agent. You've now completed Data & App Building: wrangle it (B1), chart it (B2), serve it (B3), show it (B4). See the project gallery →

Knowledge check check yourself

✓ Knowledge check

What is Streamlit's "rerun-on-interaction" model, and how do you keep values (like chat history) from being wiped each run?

Show answer
Every user interaction re-executes the whole script top-to-bottom, so ordinary variables are re-created and "forget" their values; anything that must survive goes in st.session_state, which Streamlit preserves between reruns.
✓ Knowledge check

How does Gradio's model differ from Streamlit's for building a chat app?

Show answer
Gradio wraps a UI around a single function: you write a 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.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in