Building a Chatbot with UI in LangGraph & Streamlit
A terminal or Jupyter Notebook interface for an LLM application is insufficient for real-world deployment. To convert a raw LangGraph execution graph into an interactive product, we must layer a Graphical User Interface (UI) on top of our graph backend. This chapter covers how to decouple a conversational AI system into a dedicated LangGraph Back-End and a Streamlit Front-End, while handling state persistence across UI reruns using Streamlit's session_state and LangGraph's MemorySaver.
1. Architectural Motivation: Moving Beyond Notebook Interfaces
In basic agentic workflows, chatbots interact with users via Python's standard input() loop inside a terminal or Jupyter Notebook. While this works for early prototyping, it suffers from severe operational constraints:
- Lack of Accessibility: Non-technical end users cannot execute Python cells or interact with console outputs.
- Volatile Context: Closing the notebook kernel terminates state unless external persistence layers are explicitly integrated into a server model.
- Clunky Presentation: Formatted markdown, lists, code snippets, and custom user/bot avatars cannot be rendered natively inside raw text prompts.
To solve this, we decouple the system into a Front-End User Interface built with Streamlit and a Back-End State Graph built with LangGraph.
2. Decoupled System Architecture (Front-End vs. Back-End)
To build a clean, maintainable application, the repository is split into two distinct files:
Flow Breakdown
3. Core Building Blocks of Streamlit Chat Interfaces
Streamlit provides two specialized primitives for designing conversational UIs:
st.chat_message(name): A contextual block container that styles messages based on the author's role ("user"or"assistant"). It automatically renders distinct default avatars for human vs. AI roles.st.chat_input(placeholder): A fixed-bottom input bar that captures user keyboard input and triggers a script execution upon submission.
Streamlit UI Components Summary
Minimal Prototype: Rendering Static Chat Bubbles
import streamlit as st
# Render a human chat bubble
with st.chat_message("user"):
st.text("Hi")
# Render an assistant chat bubble
with st.chat_message("assistant"):
st.text("Hello! How can I assist you today?")
4. The Naive Copycat Bot & Streamlit's Execution Model
To understand how Streamlit handles state, consider a naive implementation: a "copycat" bot that simply echoes back whatever text the user types.
Streamlit's Execution Mental Model
Critical Concept: Streamlit applications do not run an infinite event loop like traditional desktop GUIs. Instead, the entire Python script re-executes from line 1 down to the end every time a user interacts with a widget (e.g., presses Enter in
st.chat_input).
Naive Implementation (Broken State)
import streamlit as st
user_input = st.chat_input("Type here...")
if user_input:
# Render user message
with st.chat_message("user"):
st.text(user_input)
# Echo response back as assistant
with st.chat_message("assistant"):
st.text(user_input)
Why the Naive Approach Fails
When a user submits "Hi", the script runs, renders "Hi" for both user and assistant, and terminates. When the user subsequently submits "My name is Nitish", the script re-runs from top to bottom. The previous "Hi" bubbles are not stored anywhere, so they are purged from the screen. Only the latest message ("My name is Nitish") displays.
5. Session State Deep-Dive: Persisting Chat Memory Across Reruns
To prevent previous chat turns from disappearing on script re-execution, we store conversation history in Streamlit's built-in memory dictionary: st.session_state.
Understanding st.session_state
st.session_state is a persistent dictionary tied to a specific browser session. Variables saved in st.session_state survive full script re-runs and are wiped only when the user reloads or closes the browser tab.
Session State Data Structure for Chat
We store conversation history as a list of dictionaries inside st.session_state["message_history"]:
st.session_state["message_history"] = [
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "Hello! How can I help?"}
]
Corrected Stateful Implementation (Copycat Bot)
import streamlit as st
# 1. Initialize session state key if it doesn't exist
if "message_history" not in st.session_state:
st.session_state["message_history"] = []
# 2. Re-draw all historical messages on every script re-run
for message in st.session_state["message_history"]:
with st.chat_message(message["role"]):
st.text(message["content"])
# 3. Capture new user input
user_input = st.chat_input("Type here...")
if user_input:
# Append user message to history & display
st.session_state["message_history"].append({"role": "user", "content": user_input})
with st.chat_message("user"):
st.text(user_input)
# Generate dummy copycat assistant response, append to history & display
assistant_response = user_input
st.session_state["message_history"].append({"role": "assistant", "content": assistant_response})
with st.chat_message("assistant"):
st.text(assistant_response)
6. The Back-End Implementation: LangGraph with MemorySaver
Now that the front-end rendering loop is stable, we establish our LangGraph back-end. This module defines the state structure, configures the LLM node, builds the state graph, and compiles it with an in-memory checkpointer (MemorySaver).
Back-End Code (langgraph_backend.py)
from typing import Annotated, TypedDict
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.messages import BaseMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import MemorySaver
load_dotenv()
# Define graph state with message reduction logic
class State(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
# Instantiate LLM
llm = ChatOpenAI(model="gpt-4o-mini")
# Define node logic
def chatbot_node(state: State):
response = llm.invoke(state["messages"])
return {"messages": [response]}
# Build StateGraph
builder = StateGraph(State)
builder.add_node("chat_node", chatbot_node)
builder.add_edge(START, "chat_node")
builder.add_edge("chat_node", END)
# Configure short-term checkpointer
checkpointer = MemorySaver()
# Compile the back-end graph
chatbot = builder.compile(checkpointer=checkpointer)
Note: The
add_messagesreducer function ensures that new incoming messages are appended to the graph's existing state list rather than overwriting the entire conversation array.
7. Connecting Front-End & Back-End: Thread-Aware Invocation
To wire the front-end UI to the compiled LangGraph object:
- Import Backend Graph Object: Import
chatbotintostreamlit_frontend.py. - Format Input as
HumanMessage: Wrap raw text fromst.chat_input()inside aHumanMessageinstance. - Provide Thread ID Config: Pass a valid
configurabledict containing athread_idtochatbot.invoke(). This enables LangGraph'sMemorySaverto look up past messages for that specific user session. - Extract AI Response: Extract the contents of the final
AIMessagereturned by the graph.
State Invocation Flow
8. Complete System Integration & End-to-End Walkthrough
Below is the complete production-style codebase connecting both components.
1. Back-End File (langgraph_backend.py)
from typing import Annotated, TypedDict
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.messages import BaseMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import MemorySaver
# Load API Keys
load_dotenv()
# 1. State Definition
class ChatState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
# 2. LLM Setup
llm = ChatOpenAI(model="gpt-4o-mini")
# 3. Chat Node
def chat_node(state: ChatState):
return {"messages": [llm.invoke(state["messages"])]}
# 4. Graph Construction
graph_builder = StateGraph(ChatState)
graph_builder.add_node("chat_node", chat_node)
graph_builder.add_edge(START, "chat_node")
graph_builder.add_edge("chat_node", END)
# 5. Checkpointer Setup & Compilation
memory_checkpointer = MemorySaver()
chatbot = graph_builder.compile(checkpointer=memory_checkpointer)
2. Front-End File (streamlit_frontend.py)
import streamlit as st
from langchain_core.messages import HumanMessage
from langgraph_backend import chatbot
# Configure thread ID for LangGraph memory saver
THREAD_CONFIG = {"configurable": {"thread_id": "session_thread_1"}}
# Page Title
st.title("LangGraph & Streamlit AI Chatbot")
# Step 1: Initialize Streamlit Session State for UI Persistence
if "message_history" not in st.session_state:
st.session_state["message_history"] = []
# Step 2: Render Existing Chat History from Session State
for message in st.session_state["message_history"]:
with st.chat_message(message["role"]):
st.text(message["content"])
# Step 3: Handle New User Input
user_input = st.chat_input("Type your message here...")
if user_input:
# A. Display User Message in UI & Save to Session State
st.session_state["message_history"].append({"role": "user", "content": user_input})
with st.chat_message("user"):
st.text(user_input)
# B. Invoke LangGraph Backend with HumanMessage and Thread Configuration
initial_state = {"messages": [HumanMessage(content=user_input)]}
response_state = chatbot.invoke(initial_state, config=THREAD_CONFIG)
# C. Extract AI Message from Graph Output
ai_response_text = response_state["messages"][-1].content
# D. Display AI Message in UI & Save to Session State
st.session_state["message_history"].append({"role": "assistant", "content": ai_response_text})
with st.chat_message("assistant"):
st.text(ai_response_text)
Execution Command
Run the application using the Streamlit CLI:
streamlit run streamlit_frontend.py
9. Summary and Key Takeaways
- Decoupled Architecture: Splitting application logic into a backend (
langgraph_backend.py) and frontend (streamlit_frontend.py) isolates AI graph state machine construction from UI rendering concerns. - Streamlit Execution Paradigm: Streamlit re-executes the entire script from top to bottom on every user input event. Without explicit state handling, local variables reset on every interaction.
- UI State Persistence via
st.session_state: To persist message bubbles across script reruns, historical turn objects must be appended to a list stored insidest.session_state. - Dual-Layer Memory Structure:
- Streamlit
st.session_state: Manages the visual text history displayed inside browser components. - LangGraph
MemorySaver: Manages the functional LLM conversation context using thread IDs across graph node executions.
- Streamlit
- Thread Configuration Requirement: When invoking a LangGraph instance compiled with
MemorySaver, passingconfig={"configurable": {"thread_id": "..."}}is mandatory for the graph to maintain stateful context across multiple turns. - Streamlit Primitive Mapping: Using
st.chat_message("user")andst.chat_message("assistant")automatically renders distinct icons and styles for human and AI speakers.