In my previous article, I walked through building a LangGraph-based AI agent to automate a 15-minute customer service booking session.
The agent handles the entire booking process much like a real customer service representative. It is a LangGraph-based agent that orchestrates the following operations:
- Responds to customer queries and understands their needs.
- Calculates the service price and informs the customer.
- Handles the customer's acceptance or rejection.
- Proposes optimized time slots.
- Confirms and records the appointment.
In the first version of the agent, I did not focus much on the UI/UX. I built a simple Python CLI to test the agent's functionality. The customer service agent ran entirely in the terminal—functional for testing, but far from ideal for demonstrating a customer-facing booking experience.
The full source code is available on GitHub at customer-service-agent. Feel free to clone the repo and test it yourself.
In this article, we will build a clean, interactive Streamlit UI on top of the existing LangGraph agent.
A quick note on terminology: Throughout this article, I use agent and graph interchangeably. In LangGraph, the agent architecture is defined and executed as a compiled state graph object, so they essentially mean the same thing here.
User interface for the agent
From an implementation standpoint, Streamlit is not drastically different from a Python CLI. Both serve as a wrapper for the LangGraph agent. Streamlit is, of course, much more user-friendly and visually appealing.
The CLI interface collected input, invoked the graph, and printed the response. The Streamlit page will do the same, but it will also render structured information extracted from the graph state, such as current booking details, price quotes, and acceptance buttons.
The architecture remains the same; Streamlit simply presents the state to the user and sends user actions back to the agent.
Setting up the Streamlit page
Since we use Poetry for dependency management, we can install Streamlit with:
poetry add streamlitThis updates both pyproject.toml and poetry.lock. Next, create a new file, streamlit_app.py.
Start by importing the graph builder, models, and observability utilities:
from future import annotations
import os
from datetime import datetime
from typing import Any
from uuid import uuid4
import streamlit as st
from dotenv import load_dotenv
from langchain_core.messages import AIMessage, HumanMessage
from langchain_openai import ChatOpenAI
from customerserviceagent.graph import build_graph
from customerserviceagent.models import (
AgentState,
BookingDetails,
TimeOption,
)
from customerserviceagent.observability import (
createlangfusehandler,
flush_langfuse,
graph_config,
)
The agent graph does not include any Streamlit-specific logic. This separation is crucial because it allows you to run the graph from a CLI, an API, WhatsApp, or another frontend in the future.
The graph expects an AgentState to be initialized (e.g., graph = StateGraph(AgentState)), so we add the following in streamlit_app.py:
INITIAL_STATE: AgentState = {"messages": [],
"booking_details": BookingDetails(),
"calculated_price": None,
"time_options": [],
"selected_slot": None,
"status": "gathering_info",
}
After the first turn (i.e., the first customer message), LangGraph's checkpointer retains the state.
Now, let's design the core page logic. We'll set up the page configuration, handle session state, and build the chat interface.
st.setpageconfig(pagetitle="Customer Service Agent", pageicon="🤖")
Initialize session state
if "messages" not in st.session_state:
st.session_state.messages = []
if "graphstate" not in st.sessionstate:
st.sessionstate.graphstate = None
Sidebar for booking details and actions
with st.sidebar:
st.header("Booking Details")
if st.sessionstate.graphstate is not None:
details = st.sessionstate.graphstate.get("booking_details", {})
st.write(f"Service: {details.service or 'N/A'}")
st.write(f"Date: {details.date or 'N/A'}")
st.write(f"Time: {details.time or 'N/A'}")
st.write(f"Name: {details.customer_name or 'N/A'}")
st.write(f"Price: ${details.price or 'N/A'}")
if st.sessionstate.graphstate.get("time_options"):
st.subheader("Available Slots")
for slot in st.sessionstate.graphstate["time_options"]:
if st.button(f"{slot.date} {slot.time}", key=slot.id):
Send selection back to the agent
pass
Main chat area
st.title("Customer Service Agent")
Display chat history
for message in st.session_state.messages:
with st.chat_message(message.type):
st.write(message.content)
Chat input
if prompt := st.chat_input("How can I help you today?"):
Add user message
st.session_state.messages.append(HumanMessage(content=prompt))
with st.chat_message("user"):
st.write(prompt)
Invoke the agent
with st.chat_message("assistant"):
with st.spinner("Thinking..."):
Build and invoke the graph
graph = build_graph()
config = graph_config(uuid4().hex)
Prepare state
if st.sessionstate.graphstate is None:
currentstate = INITIALSTATE.copy()
currentstate["messages"] = st.sessionstate.messages
else:
currentstate = st.sessionstate.graph_state
currentstate["messages"] = st.sessionstate.messages
Run the agent
result = graph.invoke(current_state, config)
st.sessionstate.graphstate = result
Display assistant response
last_message = result["messages"][-1]
if isinstance(last_message, AIMessage):
st.write(last_message.content)
st.sessionstate.messages.append(lastmessage)
Update sidebar
st.rerun()
Handling user interactions
Streamlit's reactive model requires careful handling of user actions, especially when the agent expects a decision (e.g., acceptance or rejection of a price). In the sidebar, we render action buttons that correspond to the current state. When a button is clicked, we append a synthetic HumanMessage to the chat history and then invoke the graph again with the updated state.
For example, to handle price acceptance:
if st.sessionstate.graphstate.get("calculatedprice") and not st.sessionstate.graphstate.get("priceconfirmed"):col1, col2 = st.columns(2)
if col1.button("Accept Price"):
st.session_state.messages.append(HumanMessage(content="I accept the price."))
st.rerun()
if col2.button("Reject Price"):
st.session_state.messages.append(HumanMessage(content="I reject the price."))
st.rerun()
These buttons trigger the same graph invocation flow as a text input.
Why this approach matters in 2026
As AI agents become more prevalent in customer-facing applications, the gap between backend intelligence and frontend usability is narrowing. Streamlit remains a top choice for rapid prototyping and internal tools, but its integration pattern with agent frameworks like LangGraph has matured. By 2026, we see more standardized patterns for state synchronization, streaming responses, and embedding agents into production UIs. This example mirrors those trends: a clean separation of concerns, reusable agent logic, and a lightweight UI layer that can be swapped for a mobile app or web chat later.
In the next part, we'll add streaming outputs, persistent history, and deployment tips. Stay tuned!
