Build

How to build an AI chatbot with Streamlit and Python.

Streamlit is the fastest way I know to put a working interface in front of a machine learning model, and its built-in chat components make an LLM chatbot almost embarrassingly short to write. In this guide we'll build a complete, streaming chat app — message history, typing-as-it-generates output, proper secrets handling — in under fifty lines of Python.

Setup

You need Python 3.10+ and two packages:

pip install streamlit anthropic

We'll use the Anthropic API as the model behind the chat (any provider with a Python SDK works the same way — and see the end of the post for running a fully local model instead). Create an API key in the Claude developer console and expose it to the app. For local development, an environment variable is fine:

# macOS / Linux
export ANTHROPIC_API_KEY="sk-ant-..."

# Windows (PowerShell)
$env:ANTHROPIC_API_KEY = "sk-ant-..."

For anything you deploy, use Streamlit's secrets mechanism instead: create a file called .streamlit/secrets.toml (and add it to .gitignore):

ANTHROPIC_API_KEY = "sk-ant-..."

The three Streamlit primitives you need

Streamlit's chat support rests on three pieces:

  • st.chat_input() — a chat text box pinned to the bottom of the page. Returns the submitted string, or None if nothing was submitted this run.
  • st.chat_message(role) — a message bubble container with an avatar, used as a context manager: with st.chat_message("user"): ...
  • st.session_state — a dictionary that survives across reruns. This one is essential: Streamlit re-executes your entire script from top to bottom on every interaction, so any state you don't stash in session_state is lost the moment the user does anything.

That rerun model is the only conceptually tricky part of Streamlit. Once you internalize "the script runs fresh every time, state lives in session_state," the rest is straightforward.

The complete app

Save this as app.py:

import os
import streamlit as st
import anthropic

st.title("💬 Chat")

SYSTEM_PROMPT = "You are a concise, helpful assistant."

# Read the key from Streamlit secrets if present, else the environment
api_key = st.secrets.get("ANTHROPIC_API_KEY", os.environ.get("ANTHROPIC_API_KEY"))
if not api_key:
    st.error("No API key found. Set ANTHROPIC_API_KEY in .streamlit/secrets.toml.")
    st.stop()

client = anthropic.Anthropic(api_key=api_key)

# 1. Initialize the conversation once per session
if "messages" not in st.session_state:
    st.session_state.messages = []

# 2. Re-render the full history on every rerun
for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

# 3. Handle new input
if prompt := st.chat_input("Ask me anything"):
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.markdown(prompt)

    # 4. Stream the model's reply token by token
    with st.chat_message("assistant"):
        with client.messages.stream(
            model="claude-opus-4-8",
            max_tokens=1024,
            system=SYSTEM_PROMPT,
            messages=st.session_state.messages,
        ) as stream:
            reply = st.write_stream(stream.text_stream)

    st.session_state.messages.append({"role": "assistant", "content": reply})

Run it:

streamlit run app.py

Your browser opens at http://localhost:8501 with a working chatbot: full conversation memory, and responses that stream in as the model generates them.

How it works, line by line

  • Step 1 creates the message list exactly once. On later reruns the if "messages" not in st.session_state guard is false, so history survives.
  • Step 2 redraws every stored message on each rerun. This is idiomatic Streamlit — you re-render the world from state rather than mutating a UI.
  • Step 3 uses the walrus operator to both read and test the chat input. The user's message is stored and rendered immediately, so it appears while the model is still thinking.
  • Step 4 is the nice part. The SDK's stream() helper yields text chunks via stream.text_stream, and Streamlit's st.write_stream() consumes any generator, rendering chunks as they arrive — and returns the concatenated full text when done, which we store back into history. The whole conversation is sent on every turn because LLM APIs are stateless; the model only "remembers" what you resend.

Useful upgrades

A system prompt selector and a clear button

Streamlit's sidebar makes small control panels trivial:

with st.sidebar:
    persona = st.selectbox("Persona", ["Concise assistant", "Socratic tutor", "Code reviewer"])
    if st.button("Clear conversation"):
        st.session_state.messages = []
        st.rerun()

Capping the history

Costs (and context windows) grow with conversation length, since the full history is resent each turn. A simple guard is to keep only the last N messages:

MAX_TURNS = 20  # keep the last 20 messages
history = st.session_state.messages[-MAX_TURNS:]

…and pass history instead of the full list to the API call.

Running a local model instead

If your data can't leave your machine, the same UI works with a locally hosted model. Install Ollama, pull a model (ollama pull llama3.1), install the ollama Python package, and replace step 4 with:

import ollama

with st.chat_message("assistant"):
    stream = ollama.chat(
        model="llama3.1",
        messages=[{"role": "system", "content": SYSTEM_PROMPT}]
                 + st.session_state.messages,
        stream=True,
    )
    reply = st.write_stream(chunk["message"]["content"] for chunk in stream)

No API key, no per-token cost, and your conversations stay local — the trade-off is that you need the hardware to run the model at a usable speed. (Local-first LLM interfaces are something of a theme of mine — see LLMLynx.)

Deploying

The lowest-friction option is Streamlit Community Cloud: push the repo to GitHub, connect it at share.streamlit.io, and add your API key in the app's Secrets settings (the same TOML format as the local file). For anything internal or production-grade, run the app in a container behind your usual reverse proxy — streamlit run app.py --server.port 8501 works the same anywhere, and pairs naturally with Docker.

Two production notes. First, never commit API keys — keep secrets.toml out of version control. Second, a public chatbot with your API key behind it is an open wallet: add authentication or rate limiting before sharing a link beyond people you trust.

Want a chatbot that knows your documents, your data, or your product? That's retrieval-augmented generation — and it's the kind of system I design and build end to end.

Get in touch
All posts