How to Build an AI Agent for Your Website: Complete Guide with n8n, Knowledge Base, and Frontend (2025)

7 min read

As a data engineer specializing in AI automation, I’ve built multiple AI agents for websites. This comprehensive guide is part of my coverage of AI agents and LLM integration — showing you exactly how to build a production-ready AI agent with n8n, a vector knowledge base, and a custom frontend. Learn more about workflow automation with n8n and explore my complete data engineering portfolio.

I’ve been getting a lot of questions about building AI agents for websites lately, and for good reason: AI agents that can answer questions based on your own content are becoming essential for modern websites. Instead of static FAQ pages or expensive chat support, you can deploy an intelligent agent that understands your content and helps users 24/7.

In this guide, I’ll show you how to build a complete AI agent system from scratch:

  1. n8n backend for workflow orchestration and LLM integration
  2. Vector knowledge base (using Qdrant) to store and retrieve your content
  3. Custom frontend (React/Astro) for the chat interface
  4. Production deployment strategies

What you’ll build:

  • An AI agent that answers questions using your website’s content
  • Semantic search over your knowledge base
  • Beautiful chat interface integrated into your site
  • Full control over data and costs

Let’s get started.

Architecture Overview

Here’s the complete system architecture:

┌─────────────────┐
│   Frontend      │  (React/Astro - Chat UI)
│   (Your Site)   │
└────────┬────────┘
         │ HTTP POST

┌─────────────────┐
│   n8n Webhook   │  (Receives questions)
└────────┬────────┘

         ├─► ┌──────────────┐
         │   │   Qdrant     │  (Vector search in knowledge base)
         │   │  (Knowledge  │
         │   │    Base)     │
         │   └──────┬───────┘
         │          │ Relevant context
         │          ▼
         │   ┌──────────────┐
         │   │   LLM API    │  (GPT-4/Claude + context)
         │   │ (OpenAI/     │
         │   │  Claude)     │
         │   └──────┬───────┘
         │          │ AI response
         └──────────┘


         ┌──────────────┐
         │   Response   │  (Back to frontend)
         └──────────────┘

Prerequisites

Before we start, you’ll need:

  • n8n instance (self-hosted or cloud) - See my n8n setup guide
  • LLM API access (OpenAI, Azure OpenAI, or Claude)
  • Qdrant (vector database) - See Qdrant in my toolstack
  • Node.js/Python for data processing
  • Frontend framework (React, Astro, or vanilla JavaScript)

Estimated cost:

  • n8n: Free (self-hosted) or $20/month (cloud)
  • Qdrant: Free (self-hosted) or ~$10/month (small instance)
  • LLM API: ~$5-20/month (depending on usage)
  • Total: $15-40/month (vs $200+/month for SaaS alternatives)

Part 1: Building the Knowledge Base

First, we need to create a searchable knowledge base from your website content.

Step 1: Prepare Your Content

Gather all content you want the agent to know about:

  • FAQ pages
  • Documentation
  • Blog posts
  • Product descriptions
  • Support articles

Save them as markdown or text files. For this example, I’ll use a simple structure:

knowledge-base/
├── faq.md
├── product-info.md
├── documentation.md
└── support-articles/
    ├── getting-started.md
    └── troubleshooting.md

Step 2: Set Up Qdrant

Option A: Self-hosted (Recommended for privacy)

docker run -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage:z \
  qdrant/qdrant

Option B: Qdrant Cloud

  1. Sign up at qdrant.tech
  2. Create a cluster
  3. Get your API key and endpoint

Step 3: Create Embeddings and Store in Qdrant

We need to convert your text into vectors (embeddings) and store them in Qdrant. Here’s a Python script:

import os
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from openai import OpenAI
import hashlib
import json

# Initialize clients
qdrant_client = QdrantClient(
    url="http://localhost:6333",  # Or your Qdrant Cloud URL
    api_key=os.getenv("QDRANT_API_KEY")  # Optional for self-hosted
)

openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

# Create collection
collection_name = "website_knowledge_base"

try:
    qdrant_client.create_collection(
        collection_name=collection_name,
        vectors_config=VectorParams(size=1536, distance=Distance.COSINE),  # OpenAI ada-002 uses 1536 dimensions
    )
except Exception as e:
    print(f"Collection might already exist: {e}")

def get_embedding(text):
    """Get embedding from OpenAI"""
    response = openai_client.embeddings.create(
        model="text-embedding-ada-002",
        input=text
    )
    return response.data[0].embedding

def chunk_text(text, chunk_size=500, overlap=50):
    """Split text into overlapping chunks for better context"""
    words = text.split()
    chunks = []

    for i in range(0, len(words), chunk_size - overlap):
        chunk = ' '.join(words[i:i + chunk_size])
        chunks.append(chunk)

    return chunks

def process_document(file_path, metadata=None):
    """Process a single document and add to Qdrant"""
    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()

    # Split into chunks
    chunks = chunk_text(content)

    points = []
    for i, chunk in enumerate(chunks):
        # Create unique ID
        chunk_id = hashlib.md5(f"{file_path}_{i}".encode()).hexdigest()

        # Get embedding
        embedding = get_embedding(chunk)

        # Create point
        point = PointStruct(
            id=chunk_id,
            vector=embedding,
            payload={
                "text": chunk,
                "source": file_path,
                "chunk_index": i,
                "metadata": metadata or {}
            }
        )
        points.append(point)

    # Upload to Qdrant
    qdrant_client.upsert(
        collection_name=collection_name,
        points=points
    )

    print(f"Processed {file_path}: {len(chunks)} chunks")

# Process all documents
knowledge_base_dir = "./knowledge-base"

for root, dirs, files in os.walk(knowledge_base_dir):
    for file in files:
        if file.endswith('.md'):
            file_path = os.path.join(root, file)
            process_document(file_path, metadata={"type": "markdown"})

print("Knowledge base ready!")

Save this as setup_knowledge_base.py and run:

pip install qdrant-client openai
python setup_knowledge_base.py

Step 4: Test the Knowledge Base

Verify it works:

from qdrant_client import QdrantClient
from openai import OpenAI

qdrant_client = QdrantClient(url="http://localhost:6333")
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

# Search query
query = "How do I reset my password?"

# Get query embedding
query_embedding = openai_client.embeddings.create(
    model="text-embedding-ada-002",
    input=query
).data[0].embedding

# Search Qdrant
results = qdrant_client.search(
    collection_name="website_knowledge_base",
    query_vector=query_embedding,
    limit=3
)

for result in results:
    print(f"Score: {result.score}")
    print(f"Text: {result.payload['text'][:200]}...")
    print(f"Source: {result.payload['source']}")
    print("---")

Perfect! Now we have a searchable knowledge base. Let’s build the n8n workflow.

Part 2: Building the n8n Workflow

The n8n workflow handles:

  1. Receiving questions from the frontend
  2. Searching the knowledge base
  3. Calling the LLM with context
  4. Returning the response

Step 1: Create the Workflow

In n8n, create a new workflow with these nodes:

Webhook → Function (Process Request) → HTTP Request (Qdrant Search)
→ Function (Prepare Context) → OpenAI/Claude → Function (Format Response) → Respond to Webhook

Step 2: Webhook Node

Settings:

  • HTTP Method: POST
  • Path: /ai-agent
  • Response Mode: “Last Node”
  • Authentication: Optional (recommended for production)

Step 3: Function Node - Process Request

This node extracts the question and session info:

// Extract request data
const question = $input.item.json.body.question;
const sessionId = $input.item.json.body.sessionId || `session_${Date.now()}`;
const conversationHistory = $input.item.json.body.conversationHistory || [];

return {
  json: {
    question: question,
    sessionId: sessionId,
    conversationHistory: conversationHistory
  }
};

Step 4: HTTP Request Node - Search Qdrant

Settings:

  • Method: POST
  • URL: http://localhost:6333/collections/website_knowledge_base/points/search
    • Or your Qdrant Cloud URL
  • Authentication: Header Auth (if using Qdrant Cloud)
    • Header: api-key
    • Value: Your Qdrant API key
  • Body (JSON):
{
  "vector": [],
  "limit": 3,
  "with_payload": true
}

Important: We’ll get the vector embedding in the next step using a nested workflow or Function node.

Actually, let’s use a better approach: Create an intermediate Function node to get the embedding first:

Step 4a: Function Node - Get Query Embedding

const question = $input.item.json.question;

// Call OpenAI embeddings API
const response = await $http.request({
  method: 'POST',
  url: 'https://api.openai.com/v1/embeddings',
  headers: {
    Authorization: `Bearer ${$env.OPENAI_API_KEY}`,
    'Content-Type': 'application/json'
  },
  body: {
    model: 'text-embedding-ada-002',
    input: question
  }
});

const embedding = response.data[0].embedding;

return {
  json: {
    question: question,
    embedding: embedding,
    sessionId: $input.item.json.sessionId,
    conversationHistory: $input.item.json.conversationHistory
  }
};

Step 4b: HTTP Request Node - Search Qdrant

Settings:

  • Method: POST
  • URL: http://localhost:6333/collections/website_knowledge_base/points/search
  • Body (JSON):
{
  "vector": "={{ $json.embedding }}",
  "limit": 3,
  "with_payload": true,
  "score_threshold": 0.7
}

Step 5: Function Node - Prepare Context

This node prepares the context for the LLM:

const question = $input.item.json.question;
const searchResults = $input.item.json.result.result; // Qdrant returns results here
const conversationHistory = $input.item.json.conversationHistory || [];

// Extract relevant context from search results
const context = searchResults.map((result) => result.payload.text).join('\n\n---\n\n');

// Build conversation context
const systemPrompt = `You are a helpful AI assistant for our website. Answer questions based on the provided context from our knowledge base.

Context from knowledge base:
${context}

Guidelines:
- Answer based on the context provided
- If the answer isn't in the context, say "I don't have information about that in my knowledge base. Please contact support."
- Be concise and friendly
- If asked about something not in the context, acknowledge it gracefully

Previous conversation:
${conversationHistory.map((msg) => `${msg.role}: ${msg.content}`).join('\n')}`;

return {
  json: {
    question: question,
    systemPrompt: systemPrompt,
    conversationHistory: conversationHistory,
    sessionId: $input.item.json.sessionId
  }
};

Step 6: OpenAI/Claude Node

Settings:

  • Model: GPT-4 or GPT-3.5-turbo (or Claude)
  • System Message: ={{ $json.systemPrompt }}
  • Messages:
    • User message: ={{ $json.question }}

Or use Claude:

// For Claude, use HTTP Request node:
{
  "model": "claude-3-5-sonnet-20241022",
  "max_tokens": 1024,
  "system": $json.systemPrompt,
  "messages": [
    ...$json.conversationHistory,
    {
      "role": "user",
      "content": $json.question
    }
  ]
}

Step 7: Function Node - Format Response

const aiResponse = $input.item.json.choices[0].message.content;
const sessionId = $input.item.json.sessionId || $input.first().json.sessionId;

return {
  json: {
    response: aiResponse,
    sessionId: sessionId,
    timestamp: new Date().toISOString()
  }
};

Step 8: Respond to Webhook

This node automatically responds to the webhook caller.

Settings:

  • Response Code: 200
  • Response Body:
{
  "response": "={{ $json.response }}",
  "sessionId": "={{ $json.sessionId }}",
  "timestamp": "={{ $json.timestamp }}"
}

Step 9: Error Handling

Add an “On Error” workflow connection:

  1. Catch node to handle errors
  2. Function node to format error response
  3. Respond to Webhook with error message
const error = $input.item.json.error;
return {
  json: {
    response: "I'm sorry, I encountered an error. Please try again or contact support.",
    error: true,
    sessionId: $input.item.json.sessionId || 'unknown'
  }
};

Step 10: Test the Workflow

Activate the workflow and test with curl:

curl -X POST http://your-n8n-instance.com/webhook/ai-agent \
  -H "Content-Type: application/json" \
  -d '{
    "question": "How do I reset my password?",
    "sessionId": "test_session_123"
  }'

You should get a response with the AI answer based on your knowledge base!

Part 3: Building the Frontend

Now let’s create a beautiful chat interface for your website.

Option A: React Component

import React, { useState, useRef, useEffect } from 'react';

const AIAgentChat = ({ webhookUrl }) => {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState('');
  const [loading, setLoading] = useState(false);
  const [sessionId] = useState(() => `session_${Date.now()}`);
  const messagesEndRef = useRef(null);

  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  };

  useEffect(() => {
    scrollToBottom();
  }, [messages]);

  const sendMessage = async (e) => {
    e.preventDefault();
    if (!input.trim() || loading) return;

    const userMessage = {
      role: 'user',
      content: input,
      timestamp: new Date().toISOString()
    };

    setMessages((prev) => [...prev, userMessage]);
    setInput('');
    setLoading(true);

    try {
      const response = await fetch(webhookUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          question: input,
          sessionId: sessionId,
          conversationHistory: messages.map((msg) => ({
            role: msg.role,
            content: msg.content
          }))
        })
      });

      const data = await response.json();

      const aiMessage = {
        role: 'assistant',
        content: data.response,
        timestamp: data.timestamp
      };

      setMessages((prev) => [...prev, aiMessage]);
    } catch (error) {
      console.error('Error:', error);
      const errorMessage = {
        role: 'assistant',
        content: "I'm sorry, I encountered an error. Please try again.",
        error: true
      };
      setMessages((prev) => [...prev, errorMessage]);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="ai-agent-chat">
      <div className="chat-header">
        <h3>AI Assistant</h3>
        <p>Ask me anything about our products and services</p>
      </div>

      <div className="chat-messages">
        {messages.length === 0 && <div className="welcome-message">👋 Hi! I'm your AI assistant. How can I help you today?</div>}

        {messages.map((msg, idx) => (
          <div key={idx} className={`message ${msg.role}`}>
            <div className="message-content">{msg.content}</div>
            <div className="message-time">{new Date(msg.timestamp).toLocaleTimeString()}</div>
          </div>
        ))}

        {loading && (
          <div className="message assistant">
            <div className="message-content">
              <span className="typing-indicator">●</span>
              <span className="typing-indicator">●</span>
              <span className="typing-indicator">●</span>
            </div>
          </div>
        )}

        <div ref={messagesEndRef} />
      </div>

      <form onSubmit={sendMessage} className="chat-input-form">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Type your question..."
          disabled={loading}
          className="chat-input"
        />
        <button type="submit" disabled={loading || !input.trim()} className="chat-send-button">
          Send
        </button>
      </form>
    </div>
  );
};

export default AIAgentChat;

Option B: Astro Component

---
// AIAgentChat.astro
import { useState } from 'preact/hooks';

interface Props {
  webhookUrl: string;
}

const { webhookUrl } = Astro.props;
---

<div class="ai-agent-chat" data-webhook-url={webhookUrl}>
  <div class="chat-header">
    <h3>AI Assistant</h3>
    <p>Ask me anything about our products and services</p>
  </div>

  <div class="chat-messages" id="chat-messages">
    <div class="welcome-message">
      👋 Hi! I'm your AI assistant. How can I help you today?
    </div>
  </div>

  <form class="chat-input-form" id="chat-form">
    <input
      type="text"
      id="chat-input"
      placeholder="Type your question..."
      class="chat-input"
    />
    <button type="submit" class="chat-send-button">
      Send
    </button>
  </form>
</div>

<script>
  const chatContainer = document.querySelector('.ai-agent-chat');
  const messagesContainer = document.getElementById('chat-messages');
  const form = document.getElementById('chat-form');
  const input = document.getElementById('chat-input');
  const webhookUrl = chatContainer?.dataset.webhookUrl;

  let sessionId = `session_${Date.now()}`;
  let conversationHistory = [];

  function addMessage(role, content, isLoading = false) {
    const messageDiv = document.createElement('div');
    messageDiv.className = `message ${role}`;

    if (isLoading) {
      messageDiv.innerHTML = `
        <div class="message-content">
          <span class="typing-indicator">●</span>
          <span class="typing-indicator">●</span>
          <span class="typing-indicator">●</span>
        </div>
      `;
    } else {
      messageDiv.innerHTML = `
        <div class="message-content">${content}</div>
        <div class="message-time">${new Date().toLocaleTimeString()}</div>
      `;
    }

    messagesContainer?.appendChild(messageDiv);
    messagesContainer?.scrollTop = messagesContainer.scrollHeight;
  }

  form?.addEventListener('submit', async (e) => {
    e.preventDefault();
    const question = input?.value.trim();
    if (!question || !webhookUrl) return;

    // Add user message
    addMessage('user', question);
    conversationHistory.push({ role: 'user', content: question });
    input.value = '';

    // Add loading indicator
    addMessage('assistant', '', true);

    try {
      const response = await fetch(webhookUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          question: question,
          sessionId: sessionId,
          conversationHistory: conversationHistory
        })
      });

      const data = await response.json();

      // Remove loading indicator
      const lastMessage = messagesContainer?.lastElementChild;
      if (lastMessage) lastMessage.remove();

      // Add AI response
      addMessage('assistant', data.response);
      conversationHistory.push({ role: 'assistant', content: data.response });
    } catch (error) {
      console.error('Error:', error);
      const lastMessage = messagesContainer?.lastElementChild;
      if (lastMessage) lastMessage.remove();
      addMessage('assistant', "I'm sorry, I encountered an error. Please try again.");
    }
  });
</script>

<style>
  .ai-agent-chat {
    display: flex;
    flex-direction: column;
    height: 600px;
    max-width: 500px;
    border: 1px solid #e5e7eb;
    border-radius: 12px;
    background: white;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  }

  .chat-header {
    padding: 1rem;
    border-bottom: 1px solid #e5e7eb;
    background: #f9fafb;
    border-radius: 12px 12px 0 0;
  }

  .chat-header h3 {
    margin: 0 0 0.25rem 0;
    font-size: 1.125rem;
    font-weight: 600;
  }

  .chat-header p {
    margin: 0;
    font-size: 0.875rem;
    color: #6b7280;
  }

  .chat-messages {
    flex: 1;
    overflow-y: auto;
    padding: 1rem;
    display: flex;
    flex-direction: column;
    gap: 1rem;
  }

  .welcome-message {
    text-align: center;
    color: #6b7280;
    padding: 2rem 1rem;
  }

  .message {
    display: flex;
    flex-direction: column;
    max-width: 80%;
  }

  .message.user {
    align-self: flex-end;
  }

  .message.assistant {
    align-self: flex-start;
  }

  .message-content {
    padding: 0.75rem 1rem;
    border-radius: 12px;
    word-wrap: break-word;
  }

  .message.user .message-content {
    background: #3b82f6;
    color: white;
    border-bottom-right-radius: 4px;
  }

  .message.assistant .message-content {
    background: #f3f4f6;
    color: #1f2937;
    border-bottom-left-radius: 4px;
  }

  .message-time {
    font-size: 0.75rem;
    color: #9ca3af;
    margin-top: 0.25rem;
    padding: 0 0.5rem;
  }

  .typing-indicator {
    display: inline-block;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #9ca3af;
    margin: 0 2px;
    animation: typing 1.4s infinite;
  }

  .typing-indicator:nth-child(2) {
    animation-delay: 0.2s;
  }

  .typing-indicator:nth-child(3) {
    animation-delay: 0.4s;
  }

  @keyframes typing {
    0%, 60%, 100% {
      transform: translateY(0);
      opacity: 0.7;
    }
    30% {
      transform: translateY(-10px);
      opacity: 1;
    }
  }

  .chat-input-form {
    display: flex;
    padding: 1rem;
    border-top: 1px solid #e5e7eb;
    gap: 0.5rem;
  }

  .chat-input {
    flex: 1;
    padding: 0.75rem;
    border: 1px solid #d1d5db;
    border-radius: 8px;
    font-size: 0.875rem;
  }

  .chat-input:focus {
    outline: none;
    border-color: #3b82f6;
    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
  }

  .chat-send-button {
    padding: 0.75rem 1.5rem;
    background: #3b82f6;
    color: white;
    border: none;
    border-radius: 8px;
    font-weight: 500;
    cursor: pointer;
    transition: background 0.2s;
  }

  .chat-send-button:hover:not(:disabled) {
    background: #2563eb;
  }

  .chat-send-button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
</style>

Usage in Your Site

React:

import AIAgentChat from './components/AIAgentChat';

function App() {
  return (
    <div>
      <AIAgentChat webhookUrl="https://your-n8n-instance.com/webhook/ai-agent" />
    </div>
  );
}

Astro:

---
import AIAgentChat from '../components/AIAgentChat.astro';
---

<AIAgentChat webhookUrl="https://your-n8n-instance.com/webhook/ai-agent" />

Part 4: Advanced Features

Feature 1: Streaming Responses

For better UX, stream responses from the LLM. Update your n8n workflow to use OpenAI’s streaming:

n8n Function Node (Stream handler):

// This requires n8n's streaming support or a custom solution
// Consider using Server-Sent Events (SSE) for real-time streaming

Frontend (SSE support):

const eventSource = new EventSource(`${webhookUrl}?stream=true&question=${encodeURIComponent(question)}`);

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  if (data.done) {
    eventSource.close();
  } else {
    // Update message with streaming content
    updateMessage(data.content);
  }
};

Feature 2: Conversation Memory

Store conversation history in n8n using:

  • Redis (recommended for production)
  • PostgreSQL via n8n
  • Google Sheets (simple option)

Add to n8n workflow:

// Function Node: Save to Redis
const redis = require('redis');
const client = redis.createClient({
  url: process.env.REDIS_URL
});

await client.connect();
await client.set(
  `session:${sessionId}`,
  JSON.stringify(conversationHistory),
  { EX: 3600 } // Expire after 1 hour
);

Feature 3: Source Citations

Show where answers came from:

// In n8n Function Node - Format Response
const sources = searchResults.map((r) => ({
  text: r.payload.text.substring(0, 100) + '...',
  source: r.payload.source,
  score: r.score
}));

return {
  json: {
    response: aiResponse,
    sources: sources,
    sessionId: sessionId
  }
};

Frontend:

{
  message.sources && (
    <div className="sources">
      <strong>Sources:</strong>
      {message.sources.map((source, idx) => (
        <a key={idx} href={source.source} target="_blank">
          {source.source}
        </a>
      ))}
    </div>
  );
}

Feature 4: Rate Limiting

Protect your API from abuse:

n8n Workflow: Add a Function node that checks rate limits:

// Check rate limit (using Redis or in-memory)
const sessionId = $input.item.json.sessionId;
const rateLimitKey = `ratelimit:${sessionId}`;
const requests = (await redis.get(rateLimitKey)) || 0;

if (requests > 10) {
  // 10 requests per minute
  return {
    json: {
      error: 'Rate limit exceeded. Please try again later.',
      rateLimitExceeded: true
    }
  };
}

await redis.incr(rateLimitKey);
await redis.expire(rateLimitKey, 60);

Part 5: Deployment

Deploy n8n

Option A: Self-hosted

docker run -it --rm \
  --name n8n \
  -p 5678:5678 \
  -v ~/.n8n:/home/node/.n8n \
  n8nio/n8n

Option B: n8n Cloud

  • Sign up at n8n.io
  • Deploy workflows directly

Deploy Qdrant

Self-hosted (Production):

# docker-compose.yml
version: '3.8'

services:
  qdrant:
    image: qdrant/qdrant
    ports:
      - '6333:6333'
      - '6334:6334'
    volumes:
      - ./qdrant_storage:/qdrant/storage
    environment:
      - QDRANT__SERVICE__GRPC_PORT=6334

Deploy Frontend

Netlify/Vercel:

# Build and deploy
npm run build
netlify deploy --prod

Update webhook URL in your frontend to point to production n8n instance.

Cost Breakdown

Monthly costs (estimated):

  • n8n self-hosted: $5-10 (server)
  • Qdrant self-hosted: $5-10 (server)
  • OpenAI API: $5-20 (depending on usage - ~1000 queries/month)
  • Frontend hosting: $0 (Netlify free tier) or $10

Total: $15-40/month vs $200+/month for SaaS alternatives like Intercom or Drift.

Troubleshooting

Issue: Qdrant connection fails

Solution: Check firewall rules, verify Qdrant is running, check API key

Issue: Embeddings are slow

Solution: Cache embeddings, use batch processing, consider using local embeddings (all-MiniLM-L6-v2)

Issue: Responses are generic

Solution: Improve your knowledge base content, adjust similarity threshold, add more context to system prompt

Issue: Frontend can’t connect to n8n

Solution: Enable CORS in n8n, check webhook URL, verify n8n is publicly accessible

Best Practices

  1. Keep knowledge base updated: Regularly re-index new content
  2. Monitor costs: Track LLM API usage, set up alerts
  3. Test thoroughly: Test edge cases, error handling
  4. Add feedback loop: Let users rate responses to improve
  5. Security: Add authentication, rate limiting, input sanitization
  6. Performance: Cache frequent queries, optimize embeddings

Next Steps

Now that you have a working AI agent:

  1. Improve the knowledge base: Add more content, fine-tune chunking strategy
  2. Enhance the UI: Add typing indicators, markdown rendering, file uploads
  3. Add analytics: Track popular questions, user satisfaction
  4. Integrate with your CRM: Save conversations, route complex queries

Conclusion

You now have a complete AI agent system:

  • ✅ Knowledge base with semantic search
  • ✅ n8n workflow for orchestration
  • ✅ Beautiful frontend chat interface
  • ✅ Production-ready deployment

This setup gives you full control over your AI agent, significantly lower costs than SaaS alternatives, and the flexibility to customize everything to your needs.

The key advantage? You own the data, you control the costs, and you can iterate quickly without vendor lock-in.

Ready to deploy your AI agent? Start with the knowledge base, build the n8n workflow, and integrate the frontend. You’ll have a working agent in a weekend.


Need help building your AI agent? As a data engineer specializing in AI automation, I help businesses build custom AI solutions. Contact me for a free consultation on your AI agent needs.

For more on AI agents and automation:

Tools Used in This Article

This article mentions several tools from my tech stack.