The Problem Every Company Website Has

It's 11:40 pm. A potential customer lands on your website while your whole team is asleep. They have a real project, a real budget, and one question: "Have you built HIPAA-compliant healthcare apps before?"

Usually, one of three things happens next:

  • Nobody answers. They fill in a contact form, wait, and sign with a competitor who replied first.
  • A scripted chatbot answers. It offers a menu of five buttons, none of which match the question.
  • A generic LLM chatbot answers. It sounds confident and invents a client, a certification, or a price your sales team now has to walk back.

What if I told you there's a fourth option? An AI agent that answers from the company's real knowledge, checks its own answer before sending it, and turns the conversation into a qualified lead with a sales brief on the team's phones.

I built it as a “digital twin” for Mobisoft Infotech, in about 700 lines of code. This post shows how to build an AI agent in Python like this one, with the code and the design choices that make it safe to put in front of real customers.

TL;DR for busy Leaders

The agent does four jobs a good sales assistant does, 24/7:

JobWhat The Agent DoesWhy The Business Cares
AnswerReplies to visitor questions using only the company's own knowledge base (a PDF)Visitors get accurate answers at any hour
Self-checkA second AI reviews every reply against the facts it retrieved, and blocks or rewrites anything unsupportedNo invented clients, prices or promises
CaptureCollects name, email and project needs, then saves the leadConversations become pipeline, not just traffic
BriefWrites a sales brief (who, what they need, best-fit services, first-call questions) and pushes it to sales reps' phonesSales walks into the first call prepared

Tech Stack At A Glance

  • Python 3.12
  • OpenAI API (chat, structured outputs, moderation)
  • BM25 keyword search
  • pypdf
  • Gradio for the UI
  • Pushover for mobile alerts

No vector database, no agent framework: one main.py.

How It Works: One Visitor Message, End To End

Each message passes cheap input checks, then an agent loop that can call tools, then a second model that reviews the draft before anyone sees it. Leads trigger a sales brief after the reply is sent. That final step turns a helpful assistant into an AI sales agent.

One Visitor Message, End To End

A rejected draft gets one rewrite with the reviewer’s feedback; if that fails too, the visitor gets a safe handoff to sales instead of a risky answer. If you’re evaluating AI agent development services, ask whether every agent includes a review step like this one before replies reach customers.

Step 1: Turn A PDF Into A Knowledge Base The Agent Can Search

The company's knowledge lives in one PDF: a company overview, one chapter per service, engagement models and contact details. Stuffing all of it into every prompt is slow and expensive, so the agent gets only a summary up front and fetches the details on demand.

First, the PDF is split into named sections. Every service chapter starts with a line like Service: Mobile App Development | Category: ..., which makes a clean split point:

SERVICE_LINE = re.compile(r"^Service: (.+?)\s+\|\s+Category: (.+)$")

def split_sections(pages):
    """Split the knowledge base into named sections: front matter, one per service, engagement models, contact."""
    front = "\n".join(pages[3:8])  # About This Document, Company Overview, Service Catalogue
    lines = "\n".join(pages[8:]).splitlines()
    sections = {}
    ...
    for i, line in enumerate(lines):
        match = SERVICE_LINE.match(line)
        if match:
            close()  # save the previous section
            current_name, current_category = match.group(1), match.group(2)
            current = [match.group(1), line]
        else:
            current.append(line)
    close()
    return front, sections

The front matter (overview + service catalogue) goes straight into the system prompt. The service chapters stay outside it, behind tools.

Why BM25 Instead Of A Vector Database?

For questions that span services ("Do you work with Flutter?", "Any HIPAA experience?"), the agent needs search. I chose BM25, the classic keyword-ranking algorithm behind many search engines, in about 15 lines:

def bm25(query_terms, terms, k1=1.5, b=0.75):
    length = sum(terms.values())
    score = 0.0
    for term in query_terms:
        if term in terms:
            idf = math.log(1 + (len(chunks) - doc_freq[term] + 0.5) / (doc_freq[term] + 0.5))
            tf = terms[term]
            score += idf * tf * (k1 + 1) / (tf + k1 * (1 - b + b * length / avg_len))
    return score

def search_knowledge_base(query, max_results=5):
    query_terms = tokenize(query)
    scored = sorted(((bm25(query_terms, terms), i) for i, terms in enumerate(chunk_terms)), reverse=True)
    results = [{"section": chunks[i]["section"], "text": chunks[i]["text"]}
               for score, i in scored[:max_results] if score > 0]
    return results or "No matching passages found in the knowledge base."

The reasons:

  • Company questions are keyword-heavy. Technology names, certifications and industries ("React Native", "HIPAA", "fintech") are exactly what keyword search is good at.
  • Zero infrastructure. No embedding model, no vector store, no extra bill. The index builds in memory at startup.
  • Easy to debug. When a result is wrong, you can see which words matched.

The tokenizer keeps characters like +, # and . so terms such as c++, c# and .net survive. Small detail, big difference for a software company's knowledge base.

For CTOs: Start with the simplest retrieval that fits your content. Move to embeddings or hybrid search when you can show keyword search is missing answers, not before.

Step 2: Give The Agent Tools, And Let It Decide When To Use Them

A chatbot replies. An agent decides what to do next: look something up, save a lead, log a gap, then answer. That decision-making comes from tool calling: you describe functions to the model, and it asks you to run them. For the business side of this distinction, read our AI chatbot development guide.

The agent has four tools:

ToolWhat It Does
get_service_detailsReturns the full chapter for one service
search_knowledge_baseBM25 search across the whole knowledge base
record_user_detailsSaves a lead (name, email, needs) to logs/leads.md
record_unknown_questionLogs questions the knowledge base can't answer

Each tool is a plain Python function plus a JSON schema. One small trick does a lot of work: the service name is an enum of the real section names, so the model can't ask for a service that doesn't exist.

get_service_details_json = {
    "name": "get_service_details",
    "description": "Get the full knowledge base chapter for one Mobisoft Infotech service "
                   "(offerings, process, technologies, differentiators, FAQs, official URL).",
    "parameters": {
        "type": "object",
        "properties": {
            "service_name": {"type": "string", "enum": list(sections)},
        },
        "required": ["service_name"],
        "additionalProperties": False,
    },
}

The Agent Loop

The heart of the agent is a short loop: call the model; if it asks for tools, run them, feed the results back, and call it again, until it gives a final answer.

MAX_TOOL_ROUNDS = 6

def run_agent(messages, knowledge, leads, activity):
    response = complete(messages)
    rounds = 0
    while response.choices[0].finish_reason == "tool_calls" and rounds < MAX_TOOL_ROUNDS:
        message = response.choices[0].message
        results = handle_tool_calls(message.tool_calls)
        messages.append(message)
        messages.extend(results)
        # Keep everything retrieved this turn: the evaluator checks the reply against it
        knowledge.extend(f"[{call.function.name}] {r['content']}"
                         for call, r in zip(message.tool_calls, results))
        response = complete(messages)
        rounds += 1
    return response.choices[0].message.content or ""

Three details worth stealing:

  • A hard cap on rounds (MAX_TOOL_ROUNDS = 6) so a confused model can't loop forever and burn tokens.
  • Every tool result is kept in knowledge. That list becomes the evidence the evaluator uses in Step 3.
  • Tools validate their inputs. record_user_details refuses to save a lead without a valid email and real project notes, and tells the model what to ask for instead:
def record_user_details(email, name="Name not provided", notes="not provided"):
    if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email.strip()):
        return "Not recorded: no valid email address yet. Ask the visitor for their email first."
    if not notes or notes.strip().lower() in ("not provided", "none", "n/a"):
        return ("Not recorded yet: ask the visitor which services they need and a short description "
                "of their project (and their name, if missing).")
    log_lead_markdown(email.strip(), name or "Name not provided", notes)
    return "Lead recorded: the visitor's details were saved for the sales team to follow up."

This is a pattern I'd recommend to any team building agents: make tool errors instructive. The tool's reply is the model's next prompt, so a clear "here's what's missing" message gets the conversation back on track.

record_unknown_question is the quiet hero for the business: every question the knowledge base couldn't answer lands in a log. That's a ready-made list of content gaps for marketing and the website.

AI sales agent automating customer interactions and lead qualification with LLM guardrails.

Step 3: The Agent That Checks Its Own Work

Anyone learning how to build an AI agent for customers should spend the most time here. This is the part that makes the agent safe to put on a real company website. There are guardrails on both sides of the model.

Input Guardrails: Stop Bad Messages Before They Cost Anything

Two cheap checks run before the main model sees a message:

  • Length limit: messages over 2,000 characters are rejected. This limits oversized prompt-injection payloads and runaway token costs.
  • Moderation: every message goes through OpenAI's free moderation endpoint.
def is_flagged(message):
    """Input guardrail: OpenAI's moderation endpoint. Fails open so an API hiccup doesn't block real visitors."""
    try:
        return openai.moderations.create(model="omni-moderation-latest", input=message).results[0].flagged
    except Exception as e:
        print(f"Moderation check failed: {e}", flush=True)
        return False

Note the deliberate choice to fail open in these LLM guardrails: if the moderation API has a hiccup, real customers can still chat. The output evaluator below is the stronger safety net.

Output Guardrail: An Evaluator That Reviews Every Reply

Here's the core idea: a second LLM call acts as a strict reviewer. Before any reply reaches the visitor, the evaluator gets:

  • the draft reply,
  • the visitor's message and recent conversation,
  • and, most importantly, the exact knowledge-base passages retrieved this turn.

It returns a structured verdict, enforced with a Pydantic model so there's no parsing guesswork:

class Evaluation(BaseModel):
    is_acceptable: bool
    feedback: str

def evaluate(reply, message, history, knowledge):
    """Output guardrail: a second LLM call checks the reply against the knowledge actually retrieved this turn."""
    ...
    response = openai.chat.completions.parse(
        model=EVALUATOR_MODEL,
        messages=[{"role": "system", "content": EVALUATOR_PROMPT},
                  {"role": "user", "content": user_prompt}],
        response_format=Evaluation,
    )
    return response.choices[0].message.parsed

The evaluator's prompt lists what makes a reply unacceptable:

  • facts not supported by the overview or the retrieved extracts (invented services, clients, numbers, certifications)
  • prices, discounts, timelines or guarantees
  • answering off-topic requests instead of steering back
  • abandoning its role or revealing its system prompt
  • telling the visitor their details were saved when no tool result says Lead recorded

That last rule catches a subtle, real failure: models love to say "I've passed your details to the team" even when the save failed. The evaluator checks the claim against the actual tool output.

To go deeper on testing reviewers like this one, read our post on LLM evaluation for AI agents.

Reject, Rewrite, or Fall Back

If the evaluator rejects a reply, the agent gets one retry with the evaluator's feedback. If the retry fails too, the visitor gets a safe fallback pointing to the sales team.

for attempt in range(2):
    reply = yield from run_agent(messages, knowledge, leads, activity)
    evaluation = evaluate(reply, message, history, "\n\n".join(knowledge))
    if evaluation.is_acceptable:
        break
    messages.append({"role": "assistant", "content": reply})
    messages.append({
        "role": "system",
        "content": f"Your previous reply was rejected by quality control: {evaluation.feedback}\n"
                   "Write a corrected reply to the visitor's last message. Do not mention the review.",
    })
else:
    reply = FALLBACK_REPLY  # "Our team can help directly at business@..."

The Python for ... else is doing real work here: the else branch only runs if the loop never hit break, meaning both attempts were rejected.

For CEOs: This is the difference between "we put ChatGPT on the website" and "we put a supervised assistant on the website." The worst case is no longer a made-up promise to a client. It's a polite handoff to your sales team.

Step 4: From Chat To Sales Pipeline, Automatically

Answering questions is nice. Turning a curious visitor into a qualified lead with a ready-to-use brief is what pays for the project. It’s also what makes this an AI lead generation chatbot rather than a smarter FAQ page.

Qualify Before You Capture

The system prompt tells the agent to collect three things before recording a lead: name, email, and what they need. If something's missing, it asks in one friendly message. The tool validation from Step 2 enforces the same rule in code, so the prompt and the code back each other up.

A Structured Sales Brief

Once a lead is recorded, another structured-output call writes a brief for the sales team:

class SalesBrief(BaseModel):
    headline: str                   # "Jane (Acme Health) wants a HIPAA-compliant telehealth app"
    needs: str                      # 1-2 sentences for the first call
    recommended_services: list[str] # 1-3 best-fit services, exact names
    discovery_questions: list[str]  # 3 questions to ask on the call

The Model Picks, The Code Links

Here's my favorite design decision in the whole project. The model chooses which services fit, but it never writes the links. The code drops any service name that doesn't exist and pulls each official URL straight from the knowledge base:

def format_brief(brief, lead):
    # The model only picks services; names and URLs come from the knowledge base so links can't be made up
    services = [name for name in brief.recommended_services if name in sections]
    lines = [
        f"Contact: {lead.get('name', 'Name not provided')} <{lead['email']}>",
        f"Needs: {brief.needs}",
        "Services: " + (", ".join(services) or "to be decided on the call"),
        *(f"• {name}: {service_url(name)}" for name in services if service_url(name)),
        "Ask on the call:",
        *(f"• {question}" for question in brief.discovery_questions),
    ]
    return "\n".join(lines), services

The lesson generalizes: let the LLM make judgment calls, and let deterministic code handle facts like URLs, prices and IDs. Hallucinated links become impossible by construction, not by hoping the model behaves.

Straight To The Sales Team's Phones

The brief is saved to logs/briefs.md and pushed to the sales team's phones with Pushover, a simple notification API:

def push(title, message, url=None):
    """Send a Pushover notification. Returns False if Pushover isn't configured."""
    user, token = os.getenv("PUSHOVER_USER"), os.getenv("PUSHOVER_TOKEN")
    if not (user and token):
        return False
    payload = {"user": user, "token": token, "title": title[:250], "message": message[:PUSHOVER_MAX_CHARS]}
    if url:
        payload |= {"url": url, "url_title": "Service page"}
    requests.post(PUSHOVER_URL, data=payload, timeout=10).raise_for_status()
    return True

If Pushover isn't configured or fails, the brief is still on disk. Nothing is lost.

The brief also runs after the visitor's reply has been sent, so the visitor never waits on sales tooling.

For sales leaders: Your rep gets a phone notification with who the prospect is, what they want, which services fit (with links), and three questions to open the call. No more skimming a chat transcript before dialing.

If you’re planning AI chatbot development for your own site, design this handoff first. Your sales team will use it every day.

Step 5: Make The Agent's Thinking Visible

Agents are hard to trust when they're a black box. So next to the chat, the Gradio app shows a live Agent activity panel: every step, timestamped, as it happens.

A typical turn looks like this:

0.0s  🛡️ Moderation check…
0.4s  ✅ Moderation passed
0.4s  🧠 Thinking…
1.6s  🔍 Searching the knowledge base for "HIPAA healthcare compliance"
1.6s  ↳ found 5 passages
1.6s  🧠 Reading the results…
4.1s  🧑‍⚖️ Evaluator checking the reply against the retrieved knowledge…
5.3s  ✅ Reply approved
5.3s  🏁 Done

(Timings are illustrative; they depend on the model and network.)

The trick that makes it live is Python generators. The agent loop, the chat handler and the sales brief are all generators that yield after each step. Gradio streams every yield to the browser, so the panel updates while the model is still working:

class Activity:
    """The live 'agent brain' log shown next to the chat: one line per step, with the time since the turn began."""

    def __init__(self):
        self.start = time.monotonic()
        self.steps = []

    def add(self, text):
        self.steps.append(f"`{time.monotonic() - self.start:4.1f}s` {text}")


def respond(message, history):
    """Gradio handler: streams the chat and the activity panel together."""
    activity = Activity()
    ...
    for reply in chat(message, previous, activity):
        if reply is not None:
            history = history + [{"role": "assistant", "content": reply}]
        yield "", history, activity.markdown()

yield from run_agent(...) lets the inner loop stream its steps up through chat() and still return the final reply. No threads, no queues, no websockets to manage.

The panel helps three audiences at once:

  • Developers debug prompts and tools in real time.
  • CTOs see exactly where latency and cost go: retrieval, generation, or evaluation.
  • Executives and clients watching a demo see an assistant that looks things up and gets checked, not a box that makes things up.

Watch the Full Agent Demo

Production Readiness: The CTO's Checklist

Before an agent faces real customers, a CTO will ask whether it's safe, affordable and maintainable. Here's how the design answers each concern.

ConcernHow The Design Handles It
HallucinationAnswers grounded in retrieved passages; an evaluator checks each reply against them
Made-up links or dataFacts like URLs come from code, never from the model
Prompt injectionInput length cap, moderation, explicit security rules in the system prompt, evaluator checks for role-breaking
Runaway costCaps on tool rounds (6), history (20 messages), reply length (4,000 tokens) and input (2,000 chars)
Vendor flexibilityChat and evaluator models are environment variables (MODEL_NAME, EVALUATOR_MODEL)
Content updatesReplace the PDF and restart; no retraining, no re-embedding
PrivacyLeads live in local log files kept out of version control; briefs pushed via Pushover include the lead's name and email

The honest trade-off: the evaluator adds one extra LLM call per reply (two more on a retry). For a sales-facing assistant, I think that's cheap insurance. For high-volume support, you might run the evaluator on a smaller model or sample a share of replies.

What To Measure Once It's Live

  • Lead capture rate: conversations that end in a recorded lead
  • Evaluator rejection rate, and what it rejects (a direct signal for prompt and knowledge-base fixes)
  • Fallback rate: how often both attempts fail
  • Unanswered questions per week, and how many you close by updating the knowledge base
  • Time from lead to first sales contact

Not sure which of these numbers should drive your roadmap? Our enterprise AI consulting team helps set those targets before launch.

Lessons Learned

  • Prompts ask, code enforces. Every important rule (valid email, real project notes, real service names, real URLs) is in the prompt and checked in code.
  • Instructive tool errors beat silent failures. A tool that says "ask for the email first" steers the model better than one that throws.
  • Give the reviewer the evidence. An evaluator that only sees the reply can judge tone. One that sees the retrieved passages can judge truth.
  • Separate judgment from facts. The LLM picks the services; the code supplies the links.
  • Keep the slow work off the user's path. The sales brief runs after the reply is delivered.
  • Start simple. BM25 and one Python file got this to a working, demo-ready agent. Add frameworks and vector stores when the problem demands them.

What's Next

  • Hybrid search (BM25 + embeddings) if the unanswered-questions log shows keyword search missing paraphrased questions
  • CRM integration so leads land in HubSpot or Salesforce instead of a Markdown file
  • An evaluation suite of real visitor questions, run on every prompt change to catch regressions
  • Analytics dashboard for the metrics above
  • Embeddable website widget instead of the standalone Gradio app

Which of these would your team need first? Most depend on your CRM, data and website stack. An experienced artificial intelligence development company can plan these upgrades around the systems you already run.

Conclusion: The Bar For Website AI Just Went Up

A chatbot that only talks is no longer enough. Visitors expect accurate answers, and businesses need those answers to be safe and to turn into revenue.

This agent shows you can get all three with a small, readable codebase: grounded answers from your own knowledge, a reviewer that checks every reply before it's sent, and a lead pipeline that hands sales a ready brief.

If you're a developer, take the patterns: tool validation, evidence-based evaluation, and "the model picks, the code links." If you're a CTO or CEO, ask one question of any AI you put in front of customers: who checks its answers before the customer sees them?

Want to try it? The full source is one main.py: swap in your own knowledge-base PDF (same section layout) and update the company-specific prompts, add an OpenAI key, and run python main.py.

You can download the agent code here.

AI chatbot for business automating customer support and sales lead generation.

Frequently Asked Questions

What if our company knowledge lives in many documents?

The agent can search several sources at once. Say service details sit in Confluence and case studies in SharePoint. We’d split both into named sections and tag each passage. That lets the AI chatbot for business cite where an answer came from.

Can Mobisoft Infotech connect the agent to our CRM?

Yes, we can swap the Markdown log for a HubSpot or Salesforce call. A valid email creates a contact with the sales brief attached. Your rep won’t dig through chat logs anymore. Leads from the AI lead generation chatbot then join your existing pipeline.

How do we test the agent before customers see it?

Collect 50 real questions from your sales inbox with correct answers. We’d rerun that set after every prompt change. Say a fix for “Do you work with Flutter?” breaks a HIPAA answer. You’ll catch it early, and the tests prove your LLM guardrails hold up.

What affects the monthly running cost of our agent?

Model calls per reply matter most, since the evaluator adds one to each. Three visitor questions mean at least six calls. You’ll save by moving the evaluator to a cheaper mini model. History and length caps also stop one long chat from inflating your bill.

Will Mobisoft Infotech hand over code our team can maintain?

Yes, you’ll receive the source and a runbook. We’d build an AI agent in Python with plain, readable functions. Updating content means swapping the knowledge-base PDF and restarting the app. Engineers handle only prompt edits and new tools.

This content is for informational purposes only and may include AI-assisted research or content generation. While we strive for accuracy, information may evolve over time. Readers are advised to independently verify critical information before making decisions.

Prashant Telangi

Prashant Telangi

Head of Technology, Mobile

Read more expand

Prashant Telangi brings over 15 years of experience in Mobile Technology, He is currently serving as Head of Technology, Mobile at Mobisoft Infotech. With a proven history in IT and services, he is a skilled, passionate developer specializing in Mobile Applications. His strong engineering background underscores his commitment to crafting innovative solutions in the ever-evolving tech landscape.