Agent 5: Building a Freemium Course Chatbot — Architecture & Stack
Building a freemium course chatbot sounds simple — until you look under the hood. What appears as a single message and response is actually a carefully orchestrated system of models, memory, guardrails, billing logic, and APIs working together in real time.
Today we shipped Agent 5 — a course chatbot that sits on every InAgentic workshop page. It's built around a deliberate gate: anonymous visitors get instant answers from a static FAQ (no LLM, no cost), and sign in with a magic link to unlock five AI-powered questions with full tool access. Here's the architecture and the decisions behind it.
The core problem
InAgentic runs three paid workshops — AI for Business (£99), Claude Code for Developers (£499), and Agentic CEO (£699). The course pages were static. Visitors had questions about pricing, content, and dates; the only way to get an answer was to fill in a contact form or leave. A chatbot solves this, but "add a chatbot" has a real cost: every LLM call has a direct API cost, and unlimited free questions for anonymous visitors don't convert to revenue.
The gate model threads this needle. Most visitors' questions are predictable — "How much does it cost?", "What will I learn?", "Is it recorded?". Those can be answered from a pre-written FAQ without touching an AI model at all. When the question genuinely requires intelligence — personalised advice, checking availability, booking — the user signs in. The sign-in itself is the lead capture.
Anonymous path: static FAQ lookup
The anonymous path is entirely client-side. A static map in lib/courses/chatFAQ.ts holds about 15–20 trigger phrases and pre-written answers per course, plus shared entries for refunds, recordings, and location. When a message comes in, lookupFAQ(course, message) lowercases the input and checks trigger phrases with a simple includes check. Match found → answer returned instantly. No network request. No API call. No cost.
If nothing matches, the gate fires. The chatbot replies: "I can give you a more detailed answer if you sign in — it only takes a second." An inline email input appears in the chat thread.
Pre-written answers have an obvious drawback: they need maintenance when course details change. But they're fast to QA, guaranteed to be accurate, and the iteration loop is a code change not a prompt experiment. For factual questions about price and dates, that trade-off is worth it.
The magic link gate
The email input is inline — rendered as a bot turn in the conversation, not a modal or redirect. The user types their email and hits Enter. The component calls POST /api/auth/magic-link (this route already existed for the FileDone compliance product; we reused it unchanged), then shows a check-your-inbox message and starts polling authService.getCurrentUser() every 2 seconds.
When the user clicks the magic link in their email client, the verify page sets a session in localStorage. The polling loop in the original tab picks it up, clears itself, and switches the chatbot to the authenticated state. No page reload. No second action required from the user.
The existing magic link system is production-grade: tokens stored in PostgreSQL, 15-minute TTL, single-use, SES delivery. We added nothing to it — just called it.
Authenticated path: Bedrock + two tools
After login, messages go to POST /api/courses/chat. Before calling Bedrock, the route decrements the user's question count in the course_chat_usage table (upsert on email, max 5). If the count is already at the cap, it returns { error: "quota_exceeded" } immediately and the UI replaces the text input with a booking CTA.
The model is Claude Sonnet 4.5 on AWS Bedrock, invoked via the AWS SDK's BedrockRuntimeClient. The system prompt includes the course knowledge base — pricing, dates, modules, audience. Two tools are registered:
- trigger_checkout — creates a Stripe checkout session and returns the URL. The UI renders it as a "Book now →" action card.
- check_availability — counts confirmed bookings for a date in the
workshop_bookingstable and returns seats remaining against a per-course capacity constant.
Tool invocations add one extra Bedrock round-trip: model calls tool → we execute it → send the result back → model produces the final reply. For a low-volume chatbot with 5 questions per user, this is fine. Both tools complete in under 300ms.
Question counting: one upsert
The course_chat_usage table has one row per email. The increment is a single statement:
INSERT INTO course_chat_usage (email, questions_used)
VALUES ($1, 1)
ON CONFLICT (email) DO UPDATE
SET questions_used = course_chat_usage.questions_used + 1,
updated_at = CURRENT_TIMESTAMP
RETURNING questions_used
No select-then-update. No race condition. Remaining count is 5 - questions_used, shown in the chat header and updated live as the user asks questions.
Component structure
CourseChatWidget is a fixed bottom-right FAB that toggles a 400px popover containing CourseChatbox. The chatbox manages all state internally — anonymous/gate/authenticated — via React hooks. Props are minimal: course (which workshop) and mode (widget or fullscreen).
The widget is added to five pages: three individual course pages, the courses index, and a dedicated /en/chat full-screen page. Each course page passes its course key so FAQ lookup and the LLM system prompt are contextualised to that specific workshop.
Isolation from FileDone
This codebase serves two products: FileDone (UK company compliance) and InAgentic (AI workshops). FileDone has its own AI chat routes at app/api/ai/. Agent 5 lives entirely in app/api/courses/ and components/courses/ — no shared routes, no shared components. The only shared infrastructure is the magic link system (which serves both products) and the PostgreSQL connection pool.
Stack
| Layer | Technology |
|---|---|
| Frontend | Next.js 15 App Router, React, Tailwind CSS |
| AI model | AWS Bedrock — Claude Sonnet 4.5 |
| Auth | Magic link via AWS SES + PostgreSQL token storage |
| Payments | Stripe Checkout Sessions |
| Database | PostgreSQL on RDS — question usage + bookings |
| Hosting | AWS Amplify (Next.js app) |
What we'd do differently
The FAQ is a maintenance burden. Pre-written answers are easy to QA but need updating when course details change. The next version would generate FAQ answers from the course knowledge file at build time — same accuracy guarantee, no separate editing step.
The polling loop for auth is inelegant. Polling localStorage every 2 seconds works but a storage event listener or BroadcastChannel would be instant. Left as polling for simplicity.
5 questions is arbitrary. Worth A/B testing against 3 and 7 to find the conversion sweet spot.
