In Chapter 7, we built a terminal-based agent that works perfectly for developers who live in the command line. But here's the reality: not everyone on your team is comfortable with terminals. Your manager wants to check logs. Your product team needs insights. Your support engineers need quick answers.
A terminal interface creates a barrier. A web interface removes it.
This chapter is about making our AI logging agent accessible to everyone. We're going to build a chat interface using Streamlit that feels natural, looks professional, and requires no terminal knowledge.
I've shipped plenty of terminal tools in my career. They're fast to build and efficient to use. But every single time, someone asks: "Can I access this from my browser?"
Terminal tools are great for automation and scripting. But for interactive use, especially with AI, a chat interface makes sense. When you're having a conversation with an AI agent, you want to see the full conversation history, scroll back to earlier responses, and interact visually.
Here's what we gain with a web interface:
Accessibility: Anyone with a browser can use it. No SSH, no terminal setup, no command memorization.
Visual History: You see the entire conversation. In a terminal, once messages scroll off the screen, they're gone. In a web chat, everything persists.
Better UX: We can add sidebars, buttons, formatting, and visual feedback. The user experience becomes significantly better.
Shareability: Want to show a colleague? Just send them a URL. Want to demo to your manager? Open a browser. Want to deploy for your team? Host it once, everyone uses it.
Lower Friction: The barrier to entry drops from "know how to use a terminal" to "know how to type in a text box." That's the difference between a tool only developers use and a tool everyone uses.
We're taking the exact same agent we built previously and wrapping it in a Streamlit web interface. The core logic doesn't change. The AI capabilities don't change. We're just changing how users interact with it.
What changes:
- Terminal CLI becomes a web-based chat interface
- Manual conversation tracking becomes automatic session management
- Plain text becomes formatted, styled responses
- Local-only becomes shareable via URL
What stays the same:
- The agent logic
- The tools (read, list, search)
- The LLM integration
- The configuration
This is an important architectural principle: separate your business logic from your interface. We can swap interfaces without touching the core agent because we built clean layers in the previous chapter.
Before we look at the code, let's talk about Streamlit. It's a Python framework for building data apps and chat interfaces without writing HTML, CSS, or JavaScript.
You write Python. Streamlit handles the web stuff.
Here's a simple example:
import streamlit as st
st.title("Hello World")
name = st.text_input("What's your name?")
if name:
st.write(f"Hello, {name}!")That's it. Run it with streamlit run app.py and you have a web app. No Flask routes, no React components, no CSS files. Just Python.
For our chat interface, Streamlit provides:
st.chat_message()for displaying chat bubblesst.chat_input()for the input box at the bottomst.session_statefor maintaining state across interactions- Built-in styling that looks professional out of the box
This makes it perfect for AI chat applications. We can focus on the conversation logic instead of wrestling with frontend frameworks.
Our architecture we established remains intact. We're just adding a new layer on top:
User Browser
↓
Streamlit App (app.py)
↓
LogAnalyzerAgent
↓
[Config] [GeminiModel] [Tools] [Utils]
↓
Gemini API + Log Files
The agent doesn't know it's being called from Streamlit. It could be called from a CLI, an API, a Slack bot, or anything else. That's good design.
08/
├── app.py # Streamlit application
├── src/
│ ├── __init__.py
│ ├── config.py
│ ├── models/
│ │ └── gemini.py
│ ├── tools/
│ │ └── log_reader.py
│ ├── agents/ # Modified for external history
│ │ └── log_analyzer.py
│ └── utils/
│ └── response.py
├── logs/
├── requirements.txt # Added: streamlit>=1.30.0
└── Makefile
Most of the code is identical to the terminal version. We only need to modify the agent slightly and add the Streamlit app. This is what good architecture buys you—minimal changes when you add new features.
Let's build this piece by piece, starting with the main application file.
import streamlit as st
from langchain_core.messages import HumanMessage, AIMessage
import sys
from pathlib import Path
# Add src to path
sys.path.insert(0, str(Path(__file__).parent))
from src.agents import LogAnalyzerAgent
from src.config import Config
# Page configuration
st.set_page_config(
page_title="AI Log Analyzer",
page_icon="🔍",
layout="wide",
initial_sidebar_state="expanded"
)What's happening here?
We import Streamlit and the LangChain message types we'll need. The path manipulation lets us import from our src directory—same pattern as before.
st.set_page_config() must be the first Streamlit command. It sets the browser tab title, icon, and layout. The wide layout gives us more space for the chat interface, and we expand the sidebar by default so users see the helpful information immediately.
Streamlit reruns your entire script every time the user interacts with the page. That sounds inefficient, but Streamlit is smart about it. The key is st.session_state—a dictionary that persists across reruns.
def initialize_session_state():
"""Initialize Streamlit session state variables"""
if 'messages' not in st.session_state:
st.session_state.messages = []
if 'agent' not in st.session_state:
try:
Config.validate()
st.session_state.agent = LogAnalyzerAgent()
except ValueError as e:
st.error(f"Configuration error: {e}")
st.stop()What's happening here?
We check if messages exists in session state. If not, we initialize it as an empty list. This will store our chat history.
We do the same for the agent, but we only create it once. Creating the agent involves initializing the LLM, binding tools, and setting up prompts. We don't want to do that on every interaction. We create it once and reuse it.
If configuration validation fails (missing API key, for example), we show an error and stop execution. The user needs to fix their configuration before the app can work.
def display_sidebar():
"""Display sidebar with information and controls"""
with st.sidebar:
st.title("🔍 AI Log Analyzer")
st.markdown("---")
st.subheader("About")
st.markdown("""
An AI-powered log analysis tool that helps you:
- 📁 Read and analyze log files
- 🔎 Search for specific patterns
- 💡 Get intelligent insights
- 🗨️ Ask questions in natural language
""")
st.markdown("---")
st.subheader("Available Tools")
st.markdown("""
- **read_log_file**: Read a specific log file
- **list_log_files**: List all available logs
- **search_logs**: Search for patterns in logs
""")
st.markdown("---")
st.subheader("Example Questions")
st.markdown("""
- "What log files are available?"
- "Read the app.log file"
- "What errors are in error.log?"
- "Search for 'database' in app.log"
- "When did the connection fail?"
""")
st.markdown("---")
# Clear chat button
if st.button("🗑️ Clear Chat History", use_container_width=True):
st.session_state.messages = []
st.rerun()
# System info
st.markdown("---")
st.caption(f"Model: {Config.GEMINI_MODEL}")
st.caption(f"Temperature: {Config.TEMPERATURE}")
st.caption(f"Log Directory: {Config.LOG_DIRECTORY}")What's happening here?
The sidebar provides context and controls. Users can see what the agent can do, get example questions, and view the current configuration.
The "Clear Chat History" button is important. When clicked, we reset st.session_state.messages to an empty list and call st.rerun() to refresh the interface. This starts a new conversation.
We use st.markdown() for formatted text. Streamlit renders markdown, so we get nice formatting for free. The st.caption() at the bottom shows system information in smaller, lighter text.
def display_chat_messages():
"""Display all chat messages from history"""
for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.markdown(message["content"])This is beautifully simple. We loop through all messages in our session state and display each one using st.chat_message(). Streamlit handles the styling, the avatars, the layout—everything.
Messages have two keys: role (either "user" or "assistant") and content (the actual text). This matches the OpenAI chat format, which makes it easy to work with.
Here's where things get interesting. Streamlit uses one message format, LangChain uses another. We need to convert between them.
def convert_to_langchain_messages(messages):
"""Convert Streamlit messages to LangChain message format"""
langchain_messages = []
for msg in messages:
if msg["role"] == "user":
langchain_messages.append(HumanMessage(content=msg["content"]))
elif msg["role"] == "assistant":
langchain_messages.append(AIMessage(content=msg["content"]))
return langchain_messagesWhat's happening here?
Streamlit messages are dictionaries: {"role": "user", "content": "text"}.
LangChain messages are objects: HumanMessage(content="text") or AIMessage(content="text").
This function converts from Streamlit's format to LangChain's format. We need this because our agent expects LangChain message objects for its chat history.
def main():
"""Main application logic"""
# Initialize session state
initialize_session_state()
# Display sidebar
display_sidebar()
# Main content area
st.title("💬 Chat with AI Log Analyzer")
st.markdown("Ask me anything about your log files!")
# Display chat messages
display_chat_messages()
# Chat input
if prompt := st.chat_input("Ask about your logs..."):
# Add user message to chat
st.session_state.messages.append({"role": "user", "content": prompt})
# Display user message
with st.chat_message("user"):
st.markdown(prompt)
# Get agent response
with st.chat_message("assistant"):
with st.spinner("Analyzing..."):
# Convert chat history to LangChain format
chat_history = convert_to_langchain_messages(
st.session_state.messages[:-1] # Exclude the current message
)
# Get response from agent
response = st.session_state.agent.process_query(
user_input=prompt,
chat_history=chat_history
)
# Display response
st.markdown(response)
# Add assistant response to chat history
st.session_state.messages.append({"role": "assistant", "content": response})What's happening here?
The if prompt := st.chat_input("Ask about your logs...") line uses Python's walrus operator. It captures the user's input and checks if it exists in one line. When the user presses Enter, prompt contains their message.
We add the user's message to our session state immediately. This makes it appear in the chat history on the next rerun.
Then we display the user message using st.chat_message("user"). This creates the chat bubble on the right side (or left, depending on theme).
For the assistant's response, we do something clever. We use st.spinner("Analyzing...") to show a loading indicator while the agent thinks. Users know something is happening.
We convert the chat history from Streamlit format to LangChain format, excluding the current message (that's what [:-1] does). We don't want to include the message we're currently processing in the history—the agent should see the history before this message.
We call st.session_state.agent.process_query() with the user input and the converted history. The agent processes the query using its tools and LLM, then returns a response.
We display that response in an assistant chat bubble and add it to our session state so it persists.
We need to make one small change to our agent we built for the terminal. Instead of managing its own chat history internally, it needs to accept history as a parameter.
Here's the key difference in log_analyzer.py:
def process_query(self, user_input: str, chat_history: list = None) -> str:
"""
Process a user query and return the response.
Args:
user_input: User's question or command
chat_history: List of previous messages (HumanMessage, AIMessage)
Returns:
String containing the agent's response
"""
if chat_history is None:
chat_history = []
try:
# Format messages for the prompt
messages = self.prompt.format_messages(
chat_history=chat_history,
input=user_input
)
# Get response from LLM with tools
response = self.llm_with_tools.invoke(messages)
# Check if model wants to use tools
if hasattr(response, 'tool_calls') and response.tool_calls:
return self._handle_tool_calls(response, user_input, chat_history)
else:
# Direct response without tools
return extract_response_text(response)
except Exception as e:
error_msg = f"Error processing query: {str(e)}"
print(f"\n{error_msg}")
import traceback
traceback.print_exc()
return error_msgWhat's happening here?
The agent now accepts chat_history as a parameter instead of managing it with RunnableWithMessageHistory. This makes it stateless—the caller (Streamlit) manages the state.
We removed the internal chat history tracking. The agent processes one query, returns one response, and forgets everything. The Streamlit app maintains the conversation context in session state.
This is cleaner for web interfaces. Session state belongs in the web framework layer, not in the business logic layer. The agent shouldn't care how or where its history is stored.
To run the Streamlit app:
streamlit run app.pyStreamlit will start a local web server and open your browser to http://localhost:8501. You'll see the chat interface with the sidebar information, and you can start asking questions immediately.
The experience is smooth. Type a question, press Enter, see the response. The conversation history builds up visually. You can scroll back through previous messages. You can clear the history and start fresh. It feels like a real chat application.
Let me share what I've learned shipping both CLIs and web UIs for AI tools.
Discoverability: In a CLI, users need to remember commands. In this web UI, example questions are right there in the sidebar. New users know exactly what to try.
History Visibility: In a terminal, once a response scrolls off screen, it's gone unless you scroll up. Here, everything is visible. You can see the entire conversation at a glance.
Error Feedback: When something goes wrong in a CLI, users see a stack trace. Here, we can show a friendly error message in the chat. The experience is more polished.
Sharing: Want to show a colleague something the agent found? In a CLI, you copy and paste terminal output. Here, you just share your screen or send a screenshot. The visual format communicates better.
Adoption: This is the big one. I've built tools that were technically superior but nobody used because they required terminal knowledge. The web UI lowers the barrier to zero.
You might notice that our chat history lives in st.session_state, which means it disappears when the browser tab closes. That's intentional for this chapter.
For a production system, you'd want persistent storage. You could:
- Store conversations in a database (PostgreSQL, MongoDB)
- Use Redis for session storage
- Save to files on disk
- Integrate with authentication to track per-user conversations
But for development and small team use, session state works fine. It's simple, requires no additional infrastructure, and makes the code easier to understand.
We'll cover persistent memory in Chapter 9. For now, understand that the architecture supports it—we'd just swap out where we store messages from session state to a database.
Start the app and try these interactions:
Test 1: List files
You: What log files are available?
The agent should use the list_log_files tool and show you the three sample logs.
Test 2: Read a file
You: Read the app.log file
You should see the full contents with metadata about file size and line count.
Test 3: Memory
You: Read error.log
You: What was the first error in the previous file?
The agent should remember that "previous file" means error.log and answer correctly.
Test 4: Search
You: Search for 'database' in app.log
You should see all lines containing the word "database" with line numbers.
Test 5: Clear and restart
Click the "Clear Chat History" button in the sidebar. The conversation should reset. Ask a question that references "the previous file"—the agent should say it doesn't know what you're referring to because there is no previous context.
Once you have it working locally, you might want to share it with your team.
Local Network: Run with streamlit run app.py --server.address 0.0.0.0 and anyone on your network can access it via your IP address.
Streamlit Cloud: Push your code to GitHub, connect it to Streamlit Cloud, add your API key as a secret, and deploy. You get a public URL for free.
Docker: Package it as a Docker container and deploy anywhere that runs containers.
Behind Authentication: Put it behind your company's SSO or VPN if you're dealing with sensitive logs.
For our purposes, local or Streamlit Cloud deployment works great.
You've taken a working terminal application and made it accessible through a web browser. More importantly, you did it without changing the core agent logic. The separation between interface and business logic paid off.
Key concepts from this chapter:
Streamlit Session State: How to maintain state across interactions in a stateless web framework.
Message Format Conversion: How to translate between different message formats when integrating systems.
Stateless Agents: Why making your agent stateless and passing context from the outside makes it more flexible.
Progressive Enhancement: How to add a better interface without rewriting your core logic.
This is professional software engineering. You don't rebuild everything when you add a feature. You layer new capabilities on top of solid foundations.
In Chapter 9, we'll add decision-making to this agent. We'll teach it to classify errors by severity, route issues to the right teams, and make intelligent decisions about what requires immediate attention. The web interface we built here will make those capabilities much more accessible to non-technical users.
The foundation is solid. Now we build upward.