Bitdoze Logo

FastHTML AI Tools: Build a Multi-Page Website Guide

Learn to build a FastHTML AI tools website with multiple pages, reusable components, a modular tool system, and OpenRouter-powered Pydantic AI step by step.

DragosDragos53 min read
FastHTML AI Tools: Build a Multi-Page Website Guide

This is Part 4 of the FastHTML series. If you’re new to the framework, start with FastHTML for beginners or the FastHTML multiple pages tutorial. If you’ve already built a simple AI-powered web app with FastHTML and Pydantic AI, this article is the natural next step: we’re scaling from a single-tool prototype to a full multi-page FastHTML AI tools platform.

The goal is practical: a modular site where adding a new AI tool means writing one file and registering it. By the end you’ll have a working “Bit Tools” platform with OpenRouter-powered Pydantic AI, reusable components, dynamic forms, and enough production awareness to avoid the common pitfalls. If you’re getting started programming with AI, this is a good hands-on project to build real fluency. FastHTML is one of the more interesting Python web frameworks for this kind of rapid AI tool prototyping.

Here’s what you’ll build:

  • A multi-page website with header, footer, and dynamic navigation
  • A modular tool system with a registry and factory pattern
  • OpenRouter-powered Pydantic AI integration (native, no manual OpenAI client)
  • Dynamic form generation from tool configuration
  • Error handling, rate limit awareness, and cost controls

What we’re building: Bit Tools platform

The Bit Tools platform offers three AI-powered content creation tools:

  • Title Generator: Creates engaging titles for YouTube videos, articles, or TikTok posts
  • Social Post Generator: Generates social media content for different platforms
  • Blog Outline Generator: Creates structured outlines for blog posts

Each tool has its own dedicated page with a custom form. Results display in a consistent layout with navigation highlighting, error handling, and a “try again” flow. The architecture separates concerns cleanly: components/ for reusable UI, pages/ for page content, and tools/ for the AI tool system.

Prerequisites

Before you start, you need:

  • Python 3.10+, required by Pydantic AI. Check with python3 --version.
  • pip or uv, either works. uv is faster for fresh installs.
  • An OpenRouter API key, get one at openrouter.ai/keys. The format is sk-or-v1-.... OpenRouter gives you access to 500+ models with one key. Free-tier models exist; paid models like gpt-4o-mini cost ~$0.15 per million input tokens, negligible for a demo app.
  • A text editor, whatever you’re comfortable with.

Python version requirement

Pydantic AI requires Python 3.10 or higher. The older openai Python package is no longer needed. Pydantic AI handles its own HTTP client when using the native openrouter: provider. Remove it from your environment if you have an older install.

Getting your OpenRouter API key

Sign up at openrouter.ai, go to Keys, and create a key. It starts with sk-or-v1-. You get free credits on signup, and free-tier models (:free variants) are available for testing. For production, add a small credit ($5-$10) to unlock higher rate limits.

Setting up a FastHTML AI tools project

Here’s the project structure. It’s unchanged from the original design, because the separation of concerns still holds:

bit-tools/
├── main.py                 # Main application entry point
├── requirements.txt        # Project dependencies
├── .env                    # API keys (not in version control)
├── components/             # Reusable UI components
│   ├── __init__.py
│   ├── header.py           # Navigation header
│   ├── footer.py           # Page footer
│   ├── page_layout.py      # Shared page layout
│   └── social_icons.py     # Social media icons
├── pages/                  # Individual page content
│   ├── __init__.py
│   ├── home.py             # Home page content
│   ├── about.py            # About page content
│   ├── contact.py          # Contact page content
│   ├── tools.py            # Tools listing page
│   └── tool_pages.py       # Individual tool pages
└── tools/                  # Tool implementations
    ├── __init__.py
    ├── base.py             # Base tool class
    ├── base_types.py       # Type definitions
    ├── errors.py           # Error handling
    ├── config.py           # Configuration management
    ├── factory.py          # Tool factory
    ├── registry.py         # Tool registry
    ├── utils.py            # Pydantic AI agent creation
    ├── title_generator.py  # Title generator tool
    ├── social_post_generator.py  # Social post generator tool
    └── blog_outline_generator.py # Blog outline generator tool

Installing dependencies

The updated requirements.txt drops the openai package. Pydantic AI’s native OpenRouter provider handles the HTTP client internally:

python-fasthtml
python-dotenv
pydantic-ai

Verify the install:

pip list | grep -E "fasthtml|pydantic"

You should see python-fasthtml (0.14.x) and pydantic-ai (2.x).

Creating the .env file

Create a .env file in the project root:

OPENROUTER_API_KEY=sk-or-v1-your-key-here
DEFAULT_MODEL=openai/gpt-4o-mini

About the .env file

The DEFAULT_MODEL value is an OpenRouter model slug. openai/gpt-4o-mini is the cheap default (~$0.15/M input tokens). Other options: openai/gpt-4.1-mini ($0.40/M) for better quality, or free models with :free suffix for testing. Keep .env out of version control. Add it to .gitignore.

Connecting Pydantic AI to OpenRouter in FastHTML

This is the biggest change from the original article. The old integration manually built an AsyncOpenAI client, passed it to OpenAIModel, then wrapped that in an Agent. That pattern is broken on current Pydantic AI (V2): OpenAIModel was renamed to OpenAIChatModel, and the whole manual-client dance is unnecessary.

Why OpenRouter?

OpenRouter is a model gateway that gives you access to hundreds of models (GPT-4o-mini, Claude, Gemini, Llama, and more) through a single API key. Instead of managing separate accounts and keys for each provider, you point everything at OpenRouter and swap models by changing a string.

For a multi-tool demo site, this matters: you can test with free models and switch to paid ones for production without touching code beyond a config value.

Pydantic AI now has first-class openrouter: prefix support. No manual AsyncOpenAI client, no base_url configuration, no openai package dependency.

Creating the Pydantic AI agent

File: tools/utils.py

Pydantic AI V2 breaking changes

If you’re upgrading from older code: OpenAIModel was renamed to OpenAIChatModel in Pydantic AI V2 (the old name is removed). The bare openai: prefix now defaults to the OpenAI Responses API. Use openrouter: for OpenRouter. The result accessor is result.output (not result.data, which is deprecated).

Common failure modes:

Error Cause Fix
RuntimeError: OPENROUTER_API_KEY is not set Missing or empty env var Check .env file exists, has the right key, and load_dotenv() runs before create_agent()
ModuleNotFoundError: No module named 'openai' Old code still imports openai Remove openai from requirements.txt and all imports
ValidationError on agent creation Wrong model slug format Use provider/model format (e.g., openai/gpt-4o-mini), not bare model names

FastHTML common imports

As of FastHTML 0.14.1, some modules were removed from fasthtml.common (notably Database/fastlite and pico components). This article doesn’t use them, but if you plan to add a SQLite database to your FastHTML app later, you’ll need explicit imports for the database layer.

Building the modular tool system (registry + factory)

The tool system has three layers: a base class that defines the interface, a factory that creates configured tool instances, and a registry that tracks them all.

Base tool and specialized types

File: tools/base.py

from abc import ABC, abstractmethod
from typing import Dict, Any, List


class BaseTool(ABC):
    """Abstract base class for all AI tools."""

    def __init__(self, name: str, description: str, icon: str):
        self._name = name
        self._description = description
        self._icon = icon

    @property
    def name(self) -> str:
        return self._name

    @property
    def description(self) -> str:
        return self._description

    @property
    def icon(self) -> str:
        return self._icon

    @property
    def id(self) -> str:
        """URL-friendly identifier derived from the name."""
        return self.name.lower().replace(" ", "-")

    @property
    def route(self) -> str:
        return f"/tools/{self.id}"

    @route.setter
    def route(self, value: str):
        self._route = value

    @property
    @abstractmethod
    def input_form_fields(self) -> Dict[str, Dict[str, Any]]:
        """Return form field configuration for this tool."""
        pass

    def validate_inputs(self, inputs: Dict[str, Any]) -> List[Dict[str, Any]]:
        """Validate inputs against field config. Returns list of errors."""
        errors = []
        for field_id, field_config in self.input_form_fields.items():
            if field_config.get("required", False) and not inputs.get(field_id):
                errors.append({
                    "field": field_id,
                    "code": "required",
                    "message": f"{field_config.get('label', field_id)} is required"
                })
            max_length = field_config.get("maxLength")
            if max_length and field_id in inputs and inputs[field_id]:
                if len(str(inputs[field_id])) > max_length:
                    errors.append({
                        "field": field_id,
                        "code": "max_length",
                        "message": f"{field_config.get('label', field_id)} exceeds {max_length} chars"
                    })
        return errors

    @abstractmethod
    async def process(self, inputs: Dict[str, Any]) -> Any:
        """Process user inputs and return results."""
        pass

Tool registry

File: tools/registry.py

class ToolRegistry:
    """Singleton registry for all available tools."""

    def __init__(self):
        self.tools = {}
        self.categories = {}

    def register(self, tool, categories=None):
        """Register a tool. Sets its route and optional categories."""
        self.tools[tool.id] = tool
        tool.route = f"/tools/{tool.id}"
        if categories:
            for category in categories:
                if category not in self.categories:
                    self.categories[category] = []
                self.categories[category].append(tool)

    def get_tool(self, tool_id: str):
        """Get a tool by ID."""
        return self.tools.get(tool_id)

    def get_all_tools(self):
        """Get all registered tools."""
        return list(self.tools.values())

    def get_tools_by_category(self, category: str):
        """Get all tools in a category."""
        return self.categories.get(category, [])

    def get_categories(self):
        """Get all category names."""
        return list(self.categories.keys())


# Singleton instance
registry = ToolRegistry()

Tool factory

File: tools/factory.py

from .base import BaseTool
from .utils import create_agent


def create_text_generation_tool(
    name,
    description,
    icon,
    system_prompt,
    user_prompt_template,
    input_form_fields,
    post_process_func=None,
):
    """Factory that creates a configured text generation tool class."""

    class TextGenerationTool(BaseTool):
        def __init__(self):
            super().__init__(name, description, icon)
            self.system_prompt = system_prompt
            self.user_prompt_template = user_prompt_template
            self.input_form_fields = input_form_fields
            self.post_process_func = post_process_func
            self.agent = create_agent()

        async def process(self, inputs):
            """Format prompt, call AI, post-process results."""
            user_prompt = self.user_prompt_template.format(**inputs)
            result = await self.agent.run(
                user_prompt,
                system_prompt=self.system_prompt,
            )
            text = result.output  # .output is the current accessor in Pydantic AI V2
            if self.post_process_func:
                return self.post_process_func(text)
            return [text]

    return TextGenerationTool

This factory creates tool classes that:

  1. Validate inputs via the inherited BaseTool.validate_inputs()
  2. Format the user prompt from a template
  3. Call the AI agent with the system prompt
  4. Post-process the raw output (extract titles, format emails, etc.)

Error handling and configuration

File: tools/errors.py

from enum import Enum
from typing import Dict, Any, Optional


class ErrorCode(Enum):
    INVALID_INPUT = "invalid_input"
    API_ERROR = "api_error"
    RATE_LIMIT = "rate_limit"
    INTERNAL_ERROR = "internal_error"


class ToolError(Exception):
    """Base exception for tool-related errors."""

    def __init__(self, code: ErrorCode, message: str, details: Optional[Dict] = None):
        self.code = code
        self.message = message
        self.details = details or {}
        super().__init__(message)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "error": {
                "code": self.code.value,
                "message": self.message,
                "details": self.details,
            }
        }

File: tools/config.py

import os
from dotenv import load_dotenv

load_dotenv()

OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "openai/gpt-4o-mini")
DEBUG = os.getenv("DEBUG", "true").lower() == "true"

File: tools/__init__.py. This is the shim that the rest of the app imports:

from .registry import registry

# Import tool modules so they register on import
from . import title_generator  # noqa: F401
from . import social_post_generator  # noqa: F401
from . import blog_outline_generator  # noqa: F401


def get_all_tools():
    return registry.get_all_tools()


def get_tool_by_id(tool_id: str):
    return registry.get_tool(tool_id)


def get_categories():
    return registry.get_categories()


def get_tools_by_category(category: str):
    return registry.get_tools_by_category(category)

The __init__.py imports trigger each tool module’s top-level registration code. This means tools auto-register when the tools package is imported. No manual wiring needed.

Header component

File: components/header.py

from fasthtml.common import *


def header(current_page="/"):
    """Navigation header with active-page highlighting."""
    nav_items = [
        ("Home", "/"),
        ("Tools", "/tools"),
        ("About", "/about"),
        ("Contact", "/contact"),
    ]

    nav_links = []
    for title, path in nav_items:
        is_current = current_page == path or (
            current_page.startswith("/tools/") and path == "/tools"
        )
        link_class = "text-white hover:text-gray-300 px-3 py-2"
        if is_current:
            link_class += " font-bold underline"
        nav_links.append(Li(A(title, href=path, cls=link_class)))

    return Header(
        Div(
            A("Bit Tools", href="/", cls="text-xl font-bold text-white"),
            Nav(Ul(*nav_links, cls="flex space-x-2"), cls="ml-auto"),
            cls="container mx-auto flex items-center justify-between px-4 py-3",
        ),
        cls="bg-blue-600 shadow-md",
    )

The is_current check handles a subtle case: when the user is on /tools/title-generator, the “Tools” nav link still highlights because the path starts with /tools/.

File: components/footer.py

from fasthtml.common import *


def footer():
    """Simple footer with copyright."""
    return Footer(
        Div(
            P(
                "© 2025 Bit Tools. All rights reserved.",
                cls="text-center text-gray-500",
            ),
            cls="container mx-auto px-4 py-6",
        ),
        cls="bg-gray-100 mt-auto",
    )

Page layout component

File: components/page_layout.py

from fasthtml.common import *
from .header import header
from .footer import footer


def page_layout(title, content, current_page="/"):
    """Full HTML document with header, footer, Tailwind, and analytics."""
    return Html(
        Head(
            Title(title),
            Meta(charset="UTF-8"),
            Meta(name="viewport", content="width=device-width, initial-scale=1.0"),
            Script(src="https://cdn.tailwindcss.com"),
            Script(
                defer=True,
                **{"data-domain": "bit-tools.com", "src": "https://an.bitdoze.com/js/script.js"},
            ),
        ),
        Body(
            Div(
                header(current_page),
                Main(
                    Div(content, cls="container mx-auto px-4 py-8"),
                    cls="flex-grow",
                ),
                footer(),
                cls="flex flex-col min-h-screen",
            )
        ),
    )

The flex column layout (flex flex-col min-h-screen + flex-grow on Main + mt-auto on footer) ensures the footer sticks to the bottom even on short pages.

Tailwind CDN note

The Tailwind Play CDN (cdn.tailwindcss.com) is fine for demos and prototyping. For production, either pin a Tailwind v3 CDN URL, use a local Tailwind build, or look at MonsterUI (FastHTML’s shadcn-style component library) for a production-ready approach. The Play CDN compiles Tailwind in the browser via JavaScript, which is not ideal for performance.

If you want to install Plausible analytics for your own domain, update the data-domain and src attributes in the layout to match your setup.

Creating multi-page routes with fast_app() and @rt

The current recommended FastHTML style uses fast_app() instead of FastHTML(), and @rt instead of @app.get/@app.post. The old style still works, but fast_app() + @rt with type-annotated parameters is cleaner and the direction the framework is heading.

Route handlers

File: main.py

from fasthtml.common import *

from pages.home import home as home_page
from pages.about import about as about_page
from pages.contact import contact as contact_page
from pages.tools import tools as tools_page
from pages.tool_pages import tool_page, tool_results_page
from tools import get_tool_by_id
from components.page_layout import page_layout

app, rt = fast_app()


@rt("/")
def get():
    return page_layout(title="Home - Bit Tools", content=home_page(), current_page="/")


@rt("/about")
def get():
    return page_layout(title="About Us - Bit Tools", content=about_page(), current_page="/about")


@rt("/contact")
def get():
    return page_layout(
        title="Contact Us - Bit Tools", content=contact_page(), current_page="/contact"
    )


@rt("/submit-contact")
def post(name: str, email: str, message: str):
    acknowledgment = Div(
        Div(
            H1("Thank You!", cls="text-2xl font-bold mb-4"),
            P(
                f"Hello {name}, we've received your message and will respond to {email} soon.",
                cls="mb-4",
            ),
            A(
                "Return Home",
                href="/",
                cls="inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600",
            ),
            cls="bg-white p-6 rounded-lg shadow-md",
        ),
        cls="max-w-md mx-auto",
    )
    return page_layout(
        title="Thank You - Bit Tools", content=acknowledgment, current_page="/contact"
    )


@rt("/tools")
def get():
    return page_layout(
        title="AI Tools - Bit Tools", content=tools_page(), current_page="/tools"
    )


@rt("/tools/{tool_id}")
def get(tool_id: str):
    tool = get_tool_by_id(tool_id)
    if not tool:
        error_content = Div(
            Div(
                H1("Tool Not Found", cls="text-2xl font-bold mb-4"),
                P("Sorry, the requested tool could not be found.", cls="mb-4"),
                A(
                    "Back to Tools",
                    href="/tools",
                    cls="inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600",
                ),
                cls="bg-white p-6 rounded-lg shadow-md",
            ),
            cls="max-w-md mx-auto",
        )
        return page_layout(
            title="Tool Not Found - Bit Tools",
            content=error_content,
            current_page="/tools",
        )
    return page_layout(
        title=f"{tool.name} - Bit Tools",
        content=tool_page(tool_id),
        current_page=f"/tools/{tool_id}",
    )


@rt("/{path:path}")
def get(path: str):
    error_content = Div(
        Div(
            H1("404 - Page Not Found", cls="text-2xl font-bold mb-4"),
            P(f"Sorry, the page '/{path}' does not exist.", cls="mb-4"),
            A(
                "Return Home",
                href="/",
                cls="inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600",
            ),
            cls="bg-white p-6 rounded-lg shadow-md",
        ),
        cls="max-w-md mx-auto",
    )
    return page_layout(
        title="404 Not Found - Bit Tools", content=error_content, current_page="/"
    )


if __name__ == "__main__":
    serve()

Key points about the modernized routes:

  • fast_app() returns (app, rt)rt is the route decorator
  • @rt("/path") with def get() / def post() replaces @app.get() / @app.post()
  • The contact form handler uses typed parameters (name: str, email: str, message: str) — FastHTML binds these from the form data automatically
  • The catch-all /{path:path} must be last so more specific routes match first

Page functions

The page functions themselves are unchanged. Here’s the home page as a representative example:

File: pages/home.py

from fasthtml.common import *
from fasthtml.components import NotStr
from tools import get_all_tools
from components.social_icons import social_icons


def home():
    """Home page content — hero + tool cards grid."""
    tools_list = get_all_tools()

    return Div(
        # Hero section
        Div(
            Div(
                Div(
                    H1(
                        "Welcome to ",
                        Span(
                            "Bit Tools",
                            cls="bg-clip-text text-transparent bg-gradient-to-r from-blue-500 to-indigo-500 sm:whitespace-nowrap",
                        ),
                        cls="text-5xl md:text-[3.50rem] font-bold leading-tighter tracking-tighter mb-4 font-heading",
                    ),
                    Div(
                        P(
                            "Create engaging content with our AI-powered tools.",
                            cls="text-xl text-gray-600 mb-8",
                        ),
                        cls="max-w-3xl mx-auto",
                    ),
                    social_icons(),
                    cls="text-center pb-10 md:pb-16",
                ),
                cls="py-12 md:py-20",
            ),
            cls="max-w-6xl mx-auto px-4 sm:px-6",
        ),
        # Tools grid
        Div(
            H2("Our Tools", cls="text-3xl font-bold text-center mb-8"),
            Div(
                *[
                    Div(
                        Div(
                            Div(NotStr(tool.icon), cls="text-blue-600 w-12 h-12 mr-4"),
                            Div(
                                H3(tool.name, cls="text-xl font-semibold mb-2"),
                                P(tool.description, cls="text-gray-600"),
                                cls="flex-1",
                            ),
                            cls="flex items-start",
                        ),
                        A(
                            "Try it now →",
                            href=tool.route,
                            cls="mt-4 inline-block text-blue-600 hover:text-blue-800 font-medium",
                        ),
                        cls="bg-white p-6 rounded-lg shadow-md hover:shadow-lg transition-shadow",
                    )
                    for tool in tools_list
                ],
                cls="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12",
            ),
            cls="py-8 max-w-6xl mx-auto px-4 sm:px-6",
        ),
        cls="relative overflow-hidden",
    )

NotStr tells FastHTML to render raw HTML/SVG content as-is (the tool icons are SVG strings, not escaped text).

The tools listing (pages/tools.py) and about/contact pages follow the same pattern — they import their content from the page modules and wrap it in page_layout().

Dynamic tool pages and form generation

File: pages/tool_pages.py

from fasthtml.common import *
from fasthtml.components import NotStr
from tools import get_tool_by_id


def tool_page(tool_id):
    """Generate a tool's input form from its field configuration."""
    tool = get_tool_by_id(tool_id)
    if not tool:
        return P("Tool not found.")

    form_fields = []
    for field_id, field_config in tool.input_form_fields.items():
        field_type = field_config["type"]
        label = field_config["label"]
        required = field_config.get("required", False)
        cls = "w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500"

        if field_type == "textarea":
            form_fields.append(
                Div(
                    Label(label, For=field_id, cls="block text-gray-700 mb-1"),
                    Textarea(
                        id=field_id,
                        name=field_id,
                        placeholder=field_config.get("placeholder", ""),
                        rows=field_config.get("rows", 3),
                        required=required,
                        cls=cls,
                    ),
                    cls="mb-4",
                )
            )
        elif field_type == "select":
            options = [
                Option(
                    opt["label"],
                    value=opt["value"],
                    selected=opt.get("selected", False),
                )
                for opt in field_config["options"]
            ]
            form_fields.append(
                Div(
                    Label(label, For=field_id, cls="block text-gray-700 mb-1"),
                    Select(
                        *options,
                        id=field_id,
                        name=field_id,
                        required=required,
                        cls=cls,
                    ),
                    cls="mb-4",
                )
            )
        elif field_type == "input":
            form_fields.append(
                Div(
                    Label(label, For=field_id, cls="block text-gray-700 mb-1"),
                    Input(
                        type=field_config.get("input_type", "text"),
                        id=field_id,
                        name=field_id,
                        placeholder=field_config.get("placeholder", ""),
                        required=required,
                        cls=cls,
                    ),
                    cls="mb-4",
                )
            )

    return Div(
        Div(
            # Tool header
            Div(
                Div(NotStr(tool.icon), cls="text-blue-600 w-16 h-16 mr-4"),
                Div(
                    H1(tool.name, cls="text-3xl font-bold mb-2"),
                    P(tool.description, cls="text-gray-600"),
                    cls="flex-1",
                ),
                cls="flex items-start mb-8",
            ),
            # Form
            Form(
                *form_fields,
                Button(
                    "Generate",
                    type="submit",
                    cls="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded",
                ),
                action=f"/tools/{tool_id}/process",
                method="post",
                cls="bg-white p-6 rounded-lg shadow-md",
            ),
            cls="max-w-2xl mx-auto",
        ),
        cls="container mx-auto px-4 py-8",
    )


def tool_results_page(tool_id, results):
    """Render tool results — handles lists, text, and dict (email) formats."""
    tool = get_tool_by_id(tool_id)

    # Error results
    if isinstance(results, dict) and "error" in results:
        results_display = Div(
            H2("Error", cls="text-2xl font-bold mb-4 text-red-600"),
            P(results["error"], cls="mb-4"),
            cls="bg-white p-6 rounded-lg shadow-md",
        )
    # Email results (dict with subject + body)
    elif isinstance(results, dict) and "subject" in results and "body" in results:
        results_display = Div(
            H2("Generated Email", cls="text-2xl font-bold mb-4"),
            Div(
                H3(f"Subject: {results['subject']}", cls="text-xl font-semibold mb-2"),
                Div(P(results["body"], cls="whitespace-pre-wrap"), cls="p-4 bg-gray-50 rounded-lg"),
                cls="bg-white p-6 rounded-lg shadow-md",
            ),
            cls="bg-white p-6 rounded-lg shadow-md",
        )
    # List results (titles, outlines, etc.)
    elif isinstance(results, list):
        results_display = Div(
            H2("Generated Results", cls="text-2xl font-bold mb-4"),
            Ul(
                *[
                    Li(P(item, cls="mb-2"), cls="mb-4 p-4 bg-gray-50 rounded-lg")
                    for item in results
                ],
                cls="list-none p-0",
            ),
            cls="bg-white p-6 rounded-lg shadow-md",
        )
    # Fallback: plain text
    else:
        results_display = Div(
            H2("Generated Results", cls="text-2xl font-bold mb-4"),
            Div(P(str(results), cls="whitespace-pre-wrap"), cls="p-4 bg-gray-50 rounded-lg"),
            cls="bg-white p-6 rounded-lg shadow-md",
        )

    return Div(
        Div(
            Div(
                Div(NotStr(tool.icon), cls="text-blue-600 w-16 h-16 mr-4"),
                Div(
                    H1(f"{tool.name} Results", cls="text-3xl font-bold mb-2"),
                    P(tool.description, cls="text-gray-600"),
                    cls="flex-1",
                ),
                cls="flex items-start mb-8",
            ),
            results_display,
            Div(
                A(
                    "← Try Again",
                    href=f"/tools/{tool_id}",
                    cls="inline-block mt-6 text-blue-600 hover:text-blue-800 font-medium",
                ),
                cls="mt-4",
            ),
            cls="max-w-2xl mx-auto",
        ),
        cls="container mx-auto px-4 py-8",
    )

The form generator reads each tool’s input_form_fields dict and creates the appropriate HTML elements (textarea, select, or input). The results renderer handles three output formats: lists (titles), dicts (emails with subject/body), and plain text.

Handling AI Tool Forms with Typed Parameters

The tool processing route uses FastHTML’s type-annotated parameters instead of manually unpacking request.form():

@rt("/tools/{tool_id}/process")
async def post(tool_id: str, request):
    tool = get_tool_by_id(tool_id)
    if not tool:
        # Return tool-not-found page (same pattern as the GET handler)
        ...

    try:
        form_data = await request.form()
        inputs = {key: value for key, value in form_data.items()}
        results = await tool.process(inputs)
        return page_layout(
            title=f"{tool.name} Results - Bit Tools",
            content=tool_results_page(tool_id, results),
            current_page=f"/tools/{tool_id}",
        )
    except Exception as e:
        error_content = Div(
            Div(
                H1("Processing Error", cls="text-2xl font-bold mb-4"),
                P(f"An error occurred: {str(e)}", cls="mb-4"),
                A(
                    "Try Again",
                    href=f"/tools/{tool_id}",
                    cls="inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600",
                ),
                cls="bg-white p-6 rounded-lg shadow-md",
            ),
            cls="max-w-md mx-auto",
        )
        return page_layout(
            title="Error - Bit Tools",
            content=error_content,
            current_page=f"/tools/{tool_id}",
        )

FastHTML typed parameters

FastHTML’s type-annotated handler parameters are automatically bound from form data, query strings, and path parameters. For simple tools where you know the field names upfront, you can replace the await request.form() pattern with explicit typed params:

@rt("/tools/title-generator/process")
async def post(topic: str, platform: str, style: str):
    inputs = {"topic": topic, "platform": platform, "style": style}
    ...

This is shorter and gives you FastHTML’s built-in coercion. The trade-off is that it couples the route to specific field names — fine for dedicated routes, less flexible for our generic /tools/{tool_id}/process handler.

One thing to know: this POST handler renders a full page on success. If a user refreshes the results page, the browser will re-submit the form and re-run the AI call. For a demo this is acceptable; for production, use a POST-redirect-GET pattern with RedirectResponse to store results in a session or database and redirect to a GET endpoint.

Example Tool: Title Generator in Action

Here’s the complete, runnable Title Generator implementation:

File: tools/title_generator.py

import re
from typing import List
from .factory import create_text_generation_tool
from .registry import registry


# System prompt: detailed instructions for the AI
title_system_prompt = """You are a versatile content title generator.
Create catchy, platform-specific titles that grab attention.

Rules:
- Generate exactly 10 unique titles
- Each title on its own line, numbered 1-10
- Match the tone to the requested style
- Optimize for the target platform (YouTube: curiosity gaps, Articles: clarity, TikTok: hooks)
- Keep titles concise (under 70 characters when possible)
- No quotes around titles, no extra commentary"""


# User prompt template — {topic}, {platform}, {style} come from form fields
title_user_prompt_template = (
    "Create 10 engaging {platform} titles for content about: {topic}\n"
    "Tone: {style}"
)


def process_titles(text: str) -> List[str]:
    """Extract and clean numbered titles from AI output."""
    lines = text.strip().split("\n")
    titles = []
    for line in lines:
        # Remove numbering (1., 2., etc.) and whitespace
        cleaned = re.sub(r"^\d+[\.\)\:]?\s*", "", line.strip())
        if cleaned and len(cleaned) > 5:
            titles.append(cleaned)

    # Deduplicate while preserving order
    seen = set()
    unique_titles = []
    for t in titles:
        if t.lower() not in seen:
            seen.add(t.lower())
            unique_titles.append(t)

    return unique_titles[:10]


# Create and register the tool
TitleGeneratorClass = create_text_generation_tool(
    name="AI Title Generator",
    description="Create engaging titles for YouTube videos, articles, or TikTok posts in various styles.",
    icon="""<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
        <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
    </svg>""",
    system_prompt=title_system_prompt,
    user_prompt_template=title_user_prompt_template,
    input_form_fields={
        "topic": {
            "type": "textarea",
            "label": "What's your content about?",
            "placeholder": "Describe your content topic in detail for better results...",
            "required": True,
            "rows": 3,
        },
        "platform": {
            "type": "select",
            "label": "Platform",
            "options": [
                {"value": "YouTube", "label": "YouTube", "selected": True},
                {"value": "Article", "label": "Article"},
                {"value": "TikTok", "label": "TikTok"},
            ],
        },
        "style": {
            "type": "select",
            "label": "Style",
            "options": [
                {"value": "Professional", "label": "Professional", "selected": True},
                {"value": "Funny", "label": "Funny"},
            ],
        },
    },
    post_process_func=process_titles,
)

title_generator_tool = TitleGeneratorClass()
registry.register(title_generator_tool, categories=["Content Creation"])

The other tools (Social Post Generator, Blog Outline Generator) follow the same pattern: define a system prompt, a user prompt template, form fields, and a post-processing function. Then create and register.

Extending: adding a new tool

To add the Email Crafter tool (or any new tool):

  1. Create tools/email_crafter.py
  2. Define system_prompt, user_prompt_template, input_form_fields, and post_process_func
  3. Call create_text_generation_tool(...) with your config
  4. Instantiate and register: registry.register(tool_instance, categories=["Communication"])
  5. Import the module in tools/__init__.py

The tool automatically appears on the home page, tools listing, and gets its own /tools/professional-email-crafter route.

How does the prompt template work?

The user_prompt_template uses Python’s str.format(). Form field values are passed as kwargs: if your form has fields topic, platform, and style, the template "Create {platform} titles about: {topic}" gets those values substituted. The system prompt is sent separately as the AI’s “role” instruction and stays constant across all requests for that tool.

How do I add a new tool?

Create a new Python file in tools/, call create_text_generation_tool() with your prompts and form fields, instantiate the returned class, and call registry.register(). Then add the import to tools/__init__.py. The factory handles the AI agent creation, prompt formatting, and result processing — you only supply the configuration.

How do I change the AI model?

Change the DEFAULT_MODEL value in your .env file. Any OpenRouter model slug works: openai/gpt-4o-mini, openai/gpt-4.1-mini, anthropic/claude-3.5-sonnet, etc. You can also pass a model name directly to create_agent("anthropic/claude-3.5-sonnet") for per-tool model overrides. Check openrouter.ai/models for available models and pricing.

Error Handling, Rate Limits, and Cost Controls

When your AI tool site hits real traffic, three things will bite you: API timeouts, rate limits, and runaway costs. Here’s how to handle them.

Exception handling in the process route

The process_tool handler already wraps the AI call in try/except. Pydantic AI raises standard Python exceptions for network errors and timeouts. The key ones:

# In your process route, catch specific exceptions:
from pydantic_ai import ModelHTTPError

try:
    results = await tool.process(inputs)
except ModelHTTPError as e:
    # OpenRouter returns 429 for rate limits, 402 for insufficient credits
    if hasattr(e, 'status_code') and e.status_code == 429:
        user_message = "Rate limit reached. Please try again in a minute."
    elif hasattr(e, 'status_code') and e.status_code == 402:
        user_message = "Insufficient credits. Add credits at openrouter.ai."
    else:
        user_message = f"AI service error: {e}"
except TimeoutError:
    user_message = "The AI took too long to respond. Try a simpler prompt."
except Exception as e:
    user_message = f"Something went wrong: {e}"

Cost awareness

A typical title generation call uses ~200–300 input tokens + ~200 output tokens. At gpt-4o-mini pricing ($0.15/M input, $0.60/M output), that’s roughly $0.0002 per request — effectively free. But for heavier tools (blog outlines with long system prompts) or expensive models, costs add up.

Practical cost controls:

  • Set max_tokens in the agent’s model settings to cap output length
  • Use free models (:free suffix) for development and testing
  • Set spend limits in the OpenRouter dashboard under Billing
  • Cache results if users frequently request the same inputs

OpenRouter rate limits

Free-tier OpenRouter accounts have strict rate limits (~20 requests/minute, 50 requests/day). After adding a $10 credit, the limit increases to ~1,000 requests/day. For a public-facing tool site, use a paid plan and set spend limits. Check openrouter.ai/docs/api_reference/limits for current numbers.

Security notes for public deployments

  • CSRF protection: FastHTML/Starlette doesn’t add CSRF tokens out of the box. For a public POST endpoint that calls a paid API, add rate limiting at minimum, or implement session-bound CSRF tokens.
  • Prompt injection: Users control the text that reaches the model. Don’t grant the Pydantic AI agent tools or secrets, and treat model output as untrusted.
  • Input validation: The validate_inputs() method on BaseTool checks required fields and length limits. Server-side validation is important even though HTML required attributes exist — they’re easily bypassed.

Verifying and Deploying Your AI Tools Website

Local smoke test

After creating all the files, run:

python main.py

You should see output like:

Link: http://0.0.0.0:5001

Then verify:

  1. Home page: Visit http://localhost:5001/ — should show the hero section and tool cards
  2. Tools listing: Visit http://localhost:5001/tools — should show tools grouped by category
  3. Tool page: Visit http://localhost:5001/tools/ai-title-generator — should show the form
  4. Form submission: Fill in a topic, select platform and style, click Generate — should return AI-generated titles
  5. 404 page: Visit http://localhost:5001/nonexistent — should show the custom 404 page
  6. Console output: Check for errors in the terminal. Missing API key errors appear here first.

Common issues at this stage:

Symptom Cause Fix
ModuleNotFoundError Missing dependency or wrong Python env Run pip install -r requirements.txt in the active env
RuntimeError: OPENROUTER_API_KEY Missing .env or load_dotenv() not called Add the key to .env, ensure config.py loads it
Blank page / no styles Tailwind CDN blocked or offline Check browser console for script errors
Form submits but no results API key invalid or model not found Check OpenRouter dashboard, verify model slug

Production notes

FastHTML’s serve() is a development server (uvicorn under the hood). For production:

  • Reverse proxy: Put Caddy, Nginx, or Traefik in front for automatic HTTPS and static file serving. If you need a VPS for this, Hetzner Cloud offers reliable affordable VPS hosting starting at ~€4/month.
  • Workers: serve(host="0.0.0.0", reload=False) disables hot-reload. For multiple workers: serve(host="0.0.0.0", workers=2) (FastHTML passes kwargs to uvicorn).
  • Environment: Keep .env out of version control. In production, use system environment variables or a secrets manager.
  • Tailwind: Switch from the Play CDN to a local Tailwind build or MonsterUI for production performance.
  • Docker: A simple Dockerfile works well for reproducible deploys:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]

Running in production

FastHTML’s serve() is for development only. For production, run behind Caddy or Traefik with automatic HTTPS. serve() accepts uvicorn kwargs — use host="0.0.0.0", port=5001, reload=False, and workers=N for production settings.

Conclusion

You now have a working multi-page FastHTML AI tools platform with:

  • Modular architecture: components, pages, and tools are separate concerns
  • Native OpenRouter integration: Pydantic AI’s openrouter: prefix replaces the old manual AsyncOpenAI client setup
  • Modern FastHTML patterns: fast_app() + @rt with type-annotated parameters
  • Dynamic form generation: add a new tool by writing one config file
  • Production awareness: error handling, rate limits, cost controls, and deployment guidance

Next steps to extend this platform:

If you’re new to AI development and want more context on how LLMs and API-based tools work, check the getting started programming with AI guide.

FastHTML Series

Below are the articles in our FastHTML series to help you get started: