Building AI Agents: A Step-by-Step Guide to Creating a Business Analysis Agent
🚀 So AI Agents are everywhere right now... From chatbots to copilots and personal assistants, AI agents are becoming the new interface between humans and complex systems.
But what actually is an AI Agent? And why is everyone suddenly talking about them?
At its core, an AI Agent is a system that can perceive, reason, and act in an environment to achieve a goal. Think of it like a mini digital brain, not just running code, but actually making decisions on the fly. So what’s the hype all about?
Well, unlike traditional AI models that just give you an answer and stop there, agents are interactive and autonomous. They can:
🧠 Think step-by-step to solve complex problems.
🛠️ Use tools (APIs, search engines, databases, calculators, etc.)
🧩 Break a task into sub-tasks and assign them to other agents.
📈 Learn or adapt their behavior based on what happens.
They're not just responding, they’re strategizing. In a way, they blur the line between software and an assistant. Instead of “here’s the output,” it’s more like “I’ll figure it out for you.” And that's why they're blowing up right now.
With frameworks like LangChain, it's becoming easier than ever to build agents that do serious work: customer support, research, automation, data analysis... even other agents 👀
In this blog, I’ll break down how I built a custom AI Agent using Python, LangChain, and Streamlit. I’ll explain each part of the code step-by-step, so even if you’re new to agents (I am too 😅), you’ll still get the full picture. Let’s dive into it 👇
Here’s a quick look at the tech stack behind the Business Analysis Agent:
📝 Note: You don’t have to use these exact tools for your system; feel free to experiment with different models or APIs based on your needs.
💻 Core Imports
Here are the main libraries and components used to build the system:
import streamlit as st
from langchain_community.utilities import SQLDatabase
from langchain_community.tools.sql_database.tool import QuerySQLDatabaseTool
from langchain import hub
from langchain_tavily import TavilySearch
from langchain_core.tools import Tool
from typing import Dict, Any
from typing import TypedDict
from typing import Annotated
import os
import pdfplumber
import tempfile
# RAG components
from langchain_community.document_loaders import PyPDFLoader
from langchain_community.vectorstores import FAISS
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain.chains import RetrievalQA
We'll walk through what each of these does later when we connect the pieces in the full system.
🧠 How the System Works
At the heart of this setup is a single AI agent with access to three powerful tools:
The system uses an LLM (Gemini 2 Flash) to automatically decide which tool is needed based on the user’s question.
📝 Note: The RAG tool is independent, it's only activated if the user uploads files and explicitly checks a box to indicate they want answers based on that content.
🗃️ SQL Tool
query_prompt_template = hub.pull("langchain-ai/sql-query-system-prompt")
class QueryOutput(TypedDict):
query: Annotated[str, ..., "Syntactically valid SQL query."]
For the SQL Tool, I started by pulling a predefined SQL system prompt from LangChain’s prompt hub. This basically explains to the LLM what it is and what its task is, guiding it to generate syntactically correct SQL queries based on the user’s question and the database schema.
Then I defined the expected output format from the LLM, in this case, just a single valid SQL query as a string. This ensures the response can be directly executed on the connected database without breaking anything.
📝 Note: You don’t have to use this exact prompt, you can write your own custom prompt that fits your schema or business logic better.
def write_query(question):
prompt_obj = query_prompt_template.invoke({
"dialect": db.dialect,
"top_k": 10,
"table_info": db.get_table_info(),
"input": question,
})
prompt_text = "\n".join([msg.content for msg in prompt_obj.messages])
structured_llm = llm.with_structured_output(QueryOutput)
result = structured_llm.invoke(prompt_text)
return result["query"]
Next, I created a function called write_query() that takes the user’s natural language question and feeds it into the system prompt we got earlier.
Here’s what it does step-by-step:
def execute_query(query):
execute_query_tool = QuerySQLDatabaseTool(db=db)
return execute_query_tool.invoke(query)
After generating the SQL query, I needed a way to actually run it on the database, so I used LangChain’s built-in QuerySQLDatabaseTool.
I initialized the tool with my connected db, passed in the query string we got earlier, and just invoked it to get the results directly.
def generate_answer(question, query, result):
# Convert result to string if it isn't already
result_str = str(result) if not isinstance(result, str) else result
prompt = (
"Given the following user question, corresponding SQL query, "
"and SQL result, answer the user question in business-friendly language.\n\n"
f"Question: {question}\n"
f"SQL Query: {query}\n"
f"SQL Result: {result_str}"
)
try:
response = llm.invoke(prompt)
return response.content if hasattr(response, 'content') else str(response)
except Exception as e:
return f"Could not generate answer: {str(e)}"
Now that I had the raw SQL result, I didn’t want to just show users a SQL output, I wanted to translate it into a clean, business-friendly answer.
So I created a function called generate_answer() that takes the user's question, the generated SQL query, and the SQL result, then wraps all that into a prompt telling the LLM: “Here’s the question, the query, and the result now explain this like you’re talking to a business person.”
The LLM responds with a nicely worded answer, and I return that. If anything breaks, it just gives a fallback message.
def process_question(question):
try:
query = write_query(question)
result = execute_query(query)
answer = generate_answer(question, query, result)
return query, result, answer
except Exception as e:
raise Exception(f"Error processing question: {str(e)}")
To wrap everything together, I created a function called process_question(). This is the main function that handles the entire pipeline:
If anything fails in between, the whole thing is caught in a try-except block and raises a useful error message.
def sql_ans(input: str) -> Dict[str, Any]:
try:
query, result, answer = process_question(input)
return {
"answer": answer,
"query": query,
"result": result
}
except Exception as e:
return {"error": str(e)}
And finally, I wrapped the whole SQL flow into one neat tool function called sql_ans().
This is the actual function the agent uses when it decides to call the SQL tool. You just pass the user's question, and it returns:
If anything fails, it returns a clean error message instead of crashing the app.
sql_tool = Tool.from_function(
name="sql_tool",
description="EXCLUSIVELY for MZ Neural Company's internal database queries.",
func=sql_ans
)
To plug the SQL logic into the agent, I created a tool using LangChain’s Tool.from_function().
I named it sql_tool and added a clear description so the agent knows exactly when to use it (only for querying MZ Neural Company’s internal database).
The function it wraps is sql_ans() that we just did, which handles the full question → query → result → answer pipeline. So now, whenever a user asks something related to the internal data, the agent knows this is the tool to call.
Recommended by LinkedIn
🌐 Web Search Tool
search_tool = TavilySearch(
max_results=5,
topic="news",
tavily_api_key='***'
)
This tool uses Tavily to perform real-time web searches, specifically focused on news-related content. It’s set to return up to 5 relevant results per query. This is especially useful when the agent needs to gather competitor insights or trending market info that can’t be found in a static database.
You can tweak the topic, max_results, or even use a different web search provider depending on your needs.
📄 RAG Tool (Retrieval-Augmented Generation)
So now, we’ve built a Business Analysis Agent that already does two solid things:
✅ It can answer internal data questions using the SQL Tool
✅ It can pull fresh info about competitors from the web using the Search Tool
But business isn’t just about live data and online insights, there’s always internal documentation: reports, proposals, meeting notes, project evaluations… all locked away in PDFs.
So I added a third layer, a RAG (Retrieval-Augmented Generation) component. This lets users upload their own files (like PDFs), and if they check the "RAG" option, the agent will search through those documents to answer their question, no scrolling, no digging, just answers.
def load_pdf(file):
with pdfplumber.open(file) as pdf:
text = ""
for page in pdf.pages:
text += page.extract_text() or ""
return text
Before we can search or reason over the document, we need to convert it into plain text. This function handles that cleanly by extracting all the readable text content from the user-uploaded PDF file, so that the text can be split, embedded, and stored in a vector DB to make it searchable with natural language.
def initialize_rag(uploaded_files):
if not uploaded_files:
return None
# Save files temporarily
temp_dir = tempfile.mkdtemp()
file_paths = []
for file in uploaded_files:
path = os.path.join(temp_dir, file.name)
with open(path, "wb") as f:
f.write(file.getbuffer())
file_paths.append(path)
# Process files
documents = []
for path in file_paths:
loader = PyPDFLoader(path)
documents.extend(loader.load())
os.remove(path) # Clean up
os.rmdir(temp_dir)
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
splits = text_splitter.split_documents(documents)
embeddings = HuggingFaceEmbeddings(model_name="all-mpnet-base-v2")
vectorstore = FAISS.from_documents(splits, embeddings)
qa_chain = RetrievalQA.from_chain_type(
llm,
retriever=vectorstore.as_retriever(),
chain_type="stuff"
)
return qa_chain
The function initialize_rag(uploaded_files) sets up the RAG system. Here's how it works:
By enabling RAG, the system can now answer specific questions directly from the content of the uploaded PDFs that cannot be answered using the company’s database or web search. Now, a manager can ask about the current progress of a project they're working on, or any other documented details. As long as the information is in the uploaded documents, the system will retrieve the relevant content and generate an answer.
def determine_tool(question: str) -> str:
"""Determine which tool to use or return 'direct' for simple responses."""
prompt = f"""Analyze this user input and decide how to respond:
User Input: "{question}"
Options:
1. 'sql' - For questions about MZ Neural's internal data (sales, projects, etc.)
2. 'web' - For general/competitor info needing web search
3. 'direct' - For greetings/simple questions that don't need tools
Respond with ONLY one of these words: sql, web, direct"""
response = llm.invoke(prompt)
tool = response.content.strip().lower() if hasattr(response, 'content') else str(response).strip().lower()
return tool if tool in ['sql', 'web', 'direct'] else 'web'
The LLM processes the prompt and decides which tool (if any) to use. The result is then returned as a string, and if the response doesn't match the expected values ('sql', 'web', 'direct'), the default choice will be 'web'.
🖥️ Streamlit App
Our Business Analysis Agent is almost ready, just one thing left: a user interface! To keep things simple, fast, and elegant, I used Streamlit to build a clean, professional UI that lets users interact directly with the system.
We begin the Streamlit UI by configuring the page and giving our app a proper title.
st.set_page_config(page_title="AI Business Analyst", layout="wide")
st.title("🧠 Business Analysis Agent")
# Initialize session state
if 'qa_chain' not in st.session_state:
st.session_state.qa_chain = None
if 'processed_files' not in st.session_state:
st.session_state.processed_files = []
Next, we set up session state to handling things like uploaded files and the RAG pipeline across user interactions, and ensuring that the necessary variables (qa_chain and processed_files) are available throughout the session, even if the user refreshes the page.
# Sidebar
with st.sidebar:
st.header("📂 Document Management")
uploaded_files = st.file_uploader(
"Upload PDFs for RAG",
accept_multiple_files=True,
type=['pdf']
)
if st.button("Process Documents"):
if uploaded_files:
with st.spinner("Processing documents..."):
st.session_state.qa_chain = initialize_rag(uploaded_files)
st.session_state.processed_files = [f.name for f in uploaded_files]
st.success("Documents processed!")
else:
st.warning("Please upload files first")
if st.session_state.processed_files:
st.subheader("📑 Processed Documents")
for file in st.session_state.processed_files:
st.write(f"- {file}")
This sidebar section provides the user interface for managing the RAG feature. It allows users to upload multiple PDF files and process them so the system can answer questions based on their content. Once the "Process Documents" button is clicked, it runs the initialize_rag() function to build the RAG pipeline and stores the processed file names in the session state. It also displays the list of processed documents to inform the user which files are currently active in the system.
Now for the main content, the app consists of 2 tabs. The first tab, "💬 Query Interface," is where users can enter their questions and receive responses. It includes an input field for the query, a submit button, and options for performing searches within uploaded documents (RAG).
The second tab, "ℹ️ System Info," displays key details about the app's system configuration, including the specific LLM (in this case, "gemini-2.0-flash"), embeddings used for semantic search ("all-mpnet-base-v2"), the vector store being utilized (FAISS), the database type ("MySQL"), the web search tool used (Tavily), and the routing mechanism for automatic tool selection to handle user queries.
First TAB
# Main content
tab1, tab2 = st.tabs(["💬 Query Interface", "ℹ️ System Info"])
with tab1:
col1, col2 = st.columns([4, 1])
with col1:
user_input = st.text_input(
"Enter your question:",
placeholder="Ask about your data or competitor news...",
label_visibility="collapsed"
)
with col2:
submit_btn = st.button("Submit", type="primary", use_container_width=True)
# Add RAG checkbox above the results
use_rag = st.checkbox("Search in uploaded documents",
disabled=not st.session_state.qa_chain,
help="Check to search through your uploaded PDFs")
if submit_btn and user_input:
with st.spinner("Processing your request..."):
try:
# First check if user wants to use RAG
if use_rag and st.session_state.qa_chain:
result = st.session_state.qa_chain.invoke({"query": user_input})
st.success("Answer from documents:")
st.write(result["result"])
with st.expander("Relevant Passages"):
retriever = st.session_state.qa_chain.retriever
docs = retriever.invoke(user_input)
for i, doc in enumerate(docs):
st.markdown(f"**Passage {i+1}:**")
st.info(doc.page_content)
st.divider()
# If not using RAG, use automatic routing
else:
if use_rag and not st.session_state.qa_chain:
st.warning("No documents available - using automatic search")
selected_tool = determine_tool(user_input)
if selected_tool == "direct":
response = llm.invoke(f"Respond to this user message appropriately: {user_input}")
st.success(response.content if hasattr(response, 'content') else str(response))
elif selected_tool == "sql":
response = sql_tool.invoke(user_input)
if "answer" in response:
st.success("Answer:")
st.write(response["answer"])
else:
st.warning("No answer was generated")
with st.expander("Technical Details"):
if "query" in response:
st.write("**SQL Query:**")
st.code(response["query"], language="sql")
if "result" in response:
st.write("**Results:**")
st.write(response["result"])
if "error" in response:
st.error(f"Error: {response['error']}")
st.caption(f"🔍 Detected query type: {selected_tool.upper()} response")
if selected_tool == "web":
search_results = search_tool.invoke(user_input)
# Prepare sources for the report
sources = [f"{i+1}. [{res['title']}]({res['url']})"
for i, res in enumerate(search_results["results"])]
# Create a prompt for the LLM to generate a business report
report_prompt = f"""Create a short professional business analysis report based on these competitor news.
User Query: {user_input}
Search Results:
{chr(10).join([f"## {res['title']}{chr(10)}{res.get('content', 'No content available')}"
for res in search_results["results"]])}
Instructions:
1. Start with "## (suitable header)"
2. Provide a short 2-3 paragraph executive summary
3. Include key findings with bullet points
4. include strategic insights if and only if applicable (always assume that my company is in the same industry as the competitor am searching about), examples:
- Spoted Strategic Moves? New product launches → Time to analyze: should I respond? Improve? Counter?
- Catched a Weaknesses Early (like bad reviews or layoffs)? These are opportunity windows — to steal market share, talent, or customers.
- Inspire Ideas, competitor moves might trigger ideas for features or tweaks that we never thought of.
5. Use professional but concise business language (don't yap alot)
6. Highlight any numbers/statistics found
7. Format in clean Markdown"""
with st.spinner("Generating professional report..."):
try:
# Generate the report
report = llm.invoke(report_prompt)
report_content = report.content if hasattr(report, 'content') else str(report)
# Add sources to the report
full_report = f"{report_content}\n\n### Sources\n" + "\n".join(sources)
# Display the report
st.markdown(full_report, unsafe_allow_html=True)
except Exception as e:
st.error(f"Failed to generate report: {str(e)}")
# Fallback to simple display
for result in search_results["results"]:
st.markdown(f"### [{result['title']}]({result['url']})")
st.write(result.get('content', 'No content available'))
st.divider()
st.caption(f"🔍 Detected query type: {selected_tool.upper()} response")
except Exception as e:
st.error(f"Error processing request: {str(e)}")
st.exception(e)
The tab has 2 columns the left column (col1) contains a text input field where users can enter their question. the right column (col2) holds a button to submit the query.
Second Tab
with tab2:
st.subheader("System Configuration")
st.json({
"LLM": "gemini-2.0-flash",
"Embeddings": "all-mpnet-base-v2",
"Vector Store": "FAISS",
"Database": f"MySQL ({db_name})",
"Web Search": "Tavily",
"Routing": "Automatic tool selection"
})
This is the second tab in the app, which is used for displaying the system configuration.
Demonstration 🎬
🗃️ SQL Tool
🌐 Web Search Tool
📄 RAG Tool (Retrieval-Augmented Generation)
And there you have it! We've just walked through building a Business Analyst (BA) Agent that lets the user interact with their database, generate clean, professional reports with strategic insights about their competitors, and even explore uploaded documents for in-depth analysis. By leveraging tools like LangChain and Streamlit, we've crafted a robust system that's ready to take on real-world challenges with precision and efficiency. I hope this guide helps you understand how these technologies can elevate your work. Don’t hesitate to experiment with them and unlock new possibilities in your own projects!
Stay curious, keep innovating, and happy coding! 🚀
GDG Lead & Honors Data Science Student at Ajman University
2wGreat work Mohammad, very interesting!👏🏻👏🏻👏🏻
IT Solution Architect @ Maison Consulting & Solutions | Implementing Cloud Solutions | D365 | M365 | Azure IAAS | Dynamics LCS | System Engineering AI | Azure Devops Engineering | CI-CD | D365Fo | CRM | IT Operations..
3wAppreciate 👍
IT Networking & Security student at Ajman University
1moThanks for sharing, Mohammad
Educator, Life Coach, & Author.
1moVery interesting 👌
Super cool! LangChain, Streamlit, and RAG open up real use cases. Exciting times with agentic platforms and multi-LLM setups becoming more accessible!