Logotipo de Bitdoze

Herramientas de IA con FastHTML: guía para crear un sitio web multipágina

Aprende a crear un sitio web de herramientas de IA con FastHTML: varias páginas, componentes reutilizables, un sistema modular de herramientas y Pydantic AI con OpenRouter, paso a paso.

Dragos

Actualizado Publicado 56 min de lectura

Herramientas de IA con FastHTML: guía para crear un sitio web multipágina

Esta es la parte 4 de la serie de FastHTML. Si eres nuevo en el framework, empieza con FastHTML para principiantes o el tutorial de FastHTML con varias páginas. Si ya construiste una app web sencilla con IA usando FastHTML y Pydantic AI, este artículo es el siguiente paso natural: pasamos de un prototipo con una sola herramienta a una plataforma completa de herramientas de IA multipágina con FastHTML.

El objetivo es práctico: un sitio modular donde añadir una nueva herramienta de IA significa escribir un archivo y registrarlo. Al final tendrás una plataforma «Bit Tools» funcional con Pydantic AI a través de OpenRouter, componentes reutilizables, formularios dinámicos y suficiente conciencia de producción para evitar los errores habituales. Si estás empezando a programar con IA, este es un buen proyecto práctico para ganar soltura real. FastHTML es uno de los frameworks web de Python más interesantes para este tipo de prototipado rápido de herramientas de IA.

Esto es lo que vas a construir:

  • Un sitio web multipágina con cabecera, pie de página y navegación dinámica
  • Un sistema modular de herramientas con registro y patrón de fábrica
  • Integración de Pydantic AI con OpenRouter (nativa, sin cliente manual de OpenAI)
  • Generación dinámica de formularios a partir de la configuración de cada herramienta
  • Manejo de errores, atención a los límites de peticiones y controles de costes

Qué vamos a construir: la plataforma Bit Tools

La plataforma Bit Tools ofrece tres herramientas de creación de contenido con IA:

  • Generador de títulos: crea títulos atractivos para vídeos de YouTube, artículos o publicaciones de TikTok
  • Generador de publicaciones sociales: genera contenido para redes sociales en distintas plataformas
  • Generador de esquemas de blog: crea esquemas estructurados para artículos de blog

Cada herramienta tiene su propia página dedicada con un formulario personalizado. Los resultados se muestran en un layout consistente con resaltado de navegación, manejo de errores y un flujo de «volver a intentarlo». La arquitectura separa las responsabilidades de forma limpia: components/ para la UI reutilizable, pages/ para el contenido de las páginas y tools/ para el sistema de herramientas de IA.

Requisitos previos

Antes de empezar, necesitas:

  • Python 3.10+, requerido por Pydantic AI. Compruébalo con python3 --version.
  • pip o uv, cualquiera de los dos sirve. uv es más rápido para instalaciones nuevas.
  • Una clave de API de OpenRouter, consigue una en openrouter.ai/keys. El formato es sk-or-v1-.... OpenRouter te da acceso a más de 500 modelos con una sola clave. Existen modelos gratuitos; los de pago como gpt-4o-mini cuestan ~0,15 $ por millón de tokens de entrada, insignificante para una app de demostración.
  • Un editor de texto, el que te resulte más cómodo.

Requisito de versión de Python

Pydantic AI requiere Python 3.10 o superior. El antiguo paquete openai de Python ya no es necesario. Pydantic AI gestiona su propio cliente HTTP al usar el proveedor nativo openrouter:. Elimínalo de tu entorno si tienes una instalación antigua.

Cómo obtener tu clave de API de OpenRouter

Regístrate en openrouter.ai, ve a Keys y crea una clave. Empieza por sk-or-v1-. Recibes créditos gratuitos al registrarte, y hay modelos gratuitos (variantes :free) disponibles para pruebas. Para producción, añade un crédito pequeño (5-10 $) para desbloquear límites de peticiones más altos.

Configurar un proyecto de herramientas de IA con FastHTML

Esta es la estructura del proyecto. No ha cambiado respecto al diseño original, porque la separación de responsabilidades sigue siendo válida:

text
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

Instalar las dependencias

El requirements.txt actualizado elimina el paquete openai. El proveedor nativo de OpenRouter de Pydantic AI gestiona el cliente HTTP internamente:

text
python-fasthtml
python-dotenv
pydantic-ai

pip

bash
pip install python-fasthtml python-dotenv pydantic-ai

uv

bash
uv add python-fasthtml python-dotenv pydantic-ai

Verifica la instalación:

bash
pip list | grep -E "fasthtml|pydantic"

Deberías ver python-fasthtml (0.14.x) y pydantic-ai (2.x).

Crear el archivo .env

Crea un archivo .env en la raíz del proyecto:

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

Sobre el archivo .env

El valor de DEFAULT_MODEL es un slug de modelo de OpenRouter. openai/gpt-4o-mini es la opción barata por defecto (~0,15 $/M de tokens de entrada). Otras opciones: openai/gpt-4.1-mini (0,40 $/M) para mejor calidad, o modelos gratuitos con el sufijo :free para pruebas. Mantén .env fuera del control de versiones. Añádelo a .gitignore.

Conectar Pydantic AI con OpenRouter en FastHTML

Este es el mayor cambio respecto al artículo original. La integración antigua construía manualmente un cliente AsyncOpenAI, lo pasaba a OpenAIModel y luego lo envolvía en un Agent. Ese patrón está roto en el Pydantic AI actual (V2): OpenAIModel pasó a llamarse OpenAIChatModel, y todo ese baile del cliente manual ya no es necesario.

¿Por qué OpenRouter?

OpenRouter es una pasarela de modelos que te da acceso a cientos de modelos (GPT-4o-mini, Claude, Gemini, Llama y más) a través de una única clave de API. En lugar de gestionar cuentas y claves separadas para cada proveedor, apuntas todo a OpenRouter y cambias de modelo modificando una cadena de texto.

Para un sitio de demostración con varias herramientas, esto importa: puedes probar con modelos gratuitos y pasar a los de pago en producción sin tocar código más allá de un valor de configuración.

Pydantic AI ahora tiene soporte de primera clase para el prefijo openrouter:. Sin cliente manual AsyncOpenAI, sin configuración de base_url, sin dependencia del paquete openai.

Crear el agente de Pydantic AI

Archivo: tools/utils.py

Simple (recomendado)

python
import os
from pydantic_ai import Agent


def create_agent(model_name: str | None = None) -> Agent:
    """Create a Pydantic AI agent backed by OpenRouter.

    Uses OPENROUTER_API_KEY from the environment (or .env via load_dotenv).
    model_name is the OpenRouter slug, e.g. "openai/gpt-4o-mini".
    """
    if not os.getenv("OPENROUTER_API_KEY"):
        raise RuntimeError(
            "OPENROUTER_API_KEY is not set. "
            "Add it to your .env file or export it."
        )
    model = model_name or os.getenv("DEFAULT_MODEL", "openai/gpt-4o-mini")
    return Agent(f"openrouter:{model}")

El prefijo openrouter: le indica a Pydantic AI que use su proveedor integrado de OpenRouter. Recoge OPENROUTER_API_KEY del entorno automáticamente.

Proveedor explícito

python
import os
from pydantic_ai import Agent
from pydantic_ai.models.openrouter import OpenRouterModel
from pydantic_ai.providers.openrouter import OpenRouterProvider


def create_agent(model_name: str | None = None) -> Agent:
    """Create a Pydantic AI agent with explicit OpenRouter config."""
    api_key = os.getenv("OPENROUTER_API_KEY")
    if not api_key:
        raise RuntimeError(
            "OPENROUTER_API_KEY is not set. "
            "Add it to your .env file or export it."
        )
    model_slug = model_name or os.getenv("DEFAULT_MODEL", "openai/gpt-4o-mini")
    model = OpenRouterModel(
        model_slug,
        provider=OpenRouterProvider(api_key=api_key),
    )
    return Agent(model)

Usa esta forma cuando necesites pasar opciones extra del proveedor (cabeceras personalizadas, timeouts, etc.).

Cambios incompatibles en Pydantic AI V2

Si actualizas desde código antiguo: OpenAIModel pasó a llamarse OpenAIChatModel en Pydantic AI V2 (el nombre antiguo se eliminó). El prefijo openai: sin más ahora apunta por defecto a la API Responses de OpenAI. Usa openrouter: para OpenRouter. El accesor del resultado es result.output (no result.data, que está obsoleto).

Fallos habituales:

Error Causa Solución
RuntimeError: OPENROUTER_API_KEY is not set Variable de entorno ausente o vacía Comprueba que el archivo .env existe, tiene la clave correcta y que load_dotenv() se ejecuta antes de create_agent()
ModuleNotFoundError: No module named 'openai' Código antiguo que aún importa openai Elimina openai de requirements.txt y de todas las importaciones
ValidationError al crear el agente Formato incorrecto del slug de modelo Usa el formato proveedor/modelo (p. ej., openai/gpt-4o-mini), no nombres de modelo sueltos

Importaciones comunes de FastHTML

Desde FastHTML 0.14.1, algunos módulos se eliminaron de fasthtml.common (en particular Database/fastlite y los componentes pico). Este artículo no los usa, pero si planeas añadir una base de datos SQLite a tu app FastHTML más adelante, necesitarás importaciones explícitas para la capa de base de datos.

Construir el sistema modular de herramientas (registro + fábrica)

El sistema de herramientas tiene tres capas: una clase base que define la interfaz, una fábrica que crea instancias configuradas de herramientas y un registro que las controla a todas.

Herramienta base y tipos especializados

Archivo: tools/base.py

python
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

Registro de herramientas

Archivo: tools/registry.py

python
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()

Fábrica de herramientas

Archivo: tools/factory.py

python
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

Esta fábrica crea clases de herramientas que:

  1. Validan las entradas mediante el BaseTool.validate_inputs() heredado
  2. Formatean el prompt de usuario a partir de una plantilla
  3. Llaman al agente de IA con el prompt de sistema
  4. Postprocesan la salida en bruto (extraer títulos, formatear emails, etc.)

Manejo de errores y configuración

Archivo: tools/errors.py

python
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,
            }
        }

Archivo: tools/config.py

python
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"

Archivo: tools/__init__.py. Este es el shim que el resto de la app importa:

python
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)

Las importaciones de __init__.py disparan el código de registro de nivel superior de cada módulo de herramientas. Esto significa que las herramientas se autorregistran cuando se importa el paquete tools. No hace falta cableado manual.

Crear componentes reutilizables (cabecera, pie de página y layout)

Componente de cabecera

Archivo: components/header.py

python
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",
    )

La comprobación is_current maneja un caso sutil: cuando el usuario está en /tools/title-generator, el enlace «Tools» de la navegación sigue resaltado porque la ruta empieza por /tools/.

Componente de pie de página

Archivo: components/footer.py

python
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",
    )

Componente de layout de página

Archivo: components/page_layout.py

python
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",
            )
        ),
    )

El layout en columna con flex (flex flex-col min-h-screen + flex-grow en Main + mt-auto en el pie) asegura que el pie de página se quede abajo incluso en páginas cortas.

Nota sobre el CDN de Tailwind

El Play CDN de Tailwind (cdn.tailwindcss.com) está bien para demos y prototipos. Para producción, fija una URL del CDN de Tailwind v3, usa una compilación local de Tailwind o echa un vistazo a MonsterUI (la biblioteca de componentes estilo shadcn de FastHTML) para un enfoque listo para producción. El Play CDN compila Tailwind en el navegador mediante JavaScript, lo que no es ideal para el rendimiento.

Si quieres instalar Plausible Analytics para tu propio dominio, actualiza los atributos data-domain y src del layout para que coincidan con tu configuración.

Crear rutas multipágina con fast_app() y @rt

El estilo actual recomendado de FastHTML usa fast_app() en lugar de FastHTML(), y @rt en lugar de @app.get/@app.post. El estilo antiguo sigue funcionando, pero fast_app() + @rt con parámetros anotados con tipos es más limpio y es la dirección hacia la que se dirige el framework.

Manejadores de rutas

Archivo: main.py

python
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()

Puntos clave sobre las rutas modernizadas:

  • fast_app() devuelve (app, rt) — rt es el decorador de rutas
  • @rt("/path") con def get() / def post() reemplaza a @app.get() / @app.post()
  • El manejador del formulario de contacto usa parámetros tipados (name: str, email: str, message: str) — FastHTML los vincula automáticamente desde los datos del formulario
  • La ruta comodín /{path:path} debe ir la última para que las rutas más específicas coincidan primero

Funciones de página

Las funciones de página en sí no han cambiado. Aquí está la página de inicio como ejemplo representativo:

Archivo: pages/home.py

python
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 le dice a FastHTML que renderice el contenido HTML/SVG en bruto tal cual (los iconos de las herramientas son cadenas SVG, no texto escapado).

El listado de herramientas (pages/tools.py) y las páginas de about/contacto siguen el mismo patrón: importan su contenido desde los módulos de página y lo envuelven en page_layout().

Páginas dinámicas de herramientas y generación de formularios

Archivo: pages/tool_pages.py

python
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",
    )

El generador de formularios lee el diccionario input_form_fields de cada herramienta y crea los elementos HTML apropiados (textarea, select o input). El renderizador de resultados maneja tres formatos de salida: listas (títulos), diccionarios (emails con asunto/cuerpo) y texto plano.

Manejar formularios de herramientas de IA con parámetros tipados

La ruta de procesamiento de herramientas usa los parámetros anotados con tipos de FastHTML en lugar de desempaquetar manualmente request.form():

python
@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}",
        )

Parámetros tipados de FastHTML

Los parámetros anotados con tipos de los manejadores de FastHTML se vinculan automáticamente desde los datos del formulario, las cadenas de consulta y los parámetros de ruta. Para herramientas simples donde conoces los nombres de los campos de antemano, puedes reemplazar el patrón await request.form() por parámetros tipados explícitos:

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

Es más corto y te da la coerción integrada de FastHTML. La contrapartida es que acopla la ruta a nombres de campo concretos — bien para rutas dedicadas, menos flexible para nuestro manejador genérico /tools/{tool_id}/process.

Una cosa que debes saber: este manejador POST renderiza una página completa en caso de éxito. Si un usuario recarga la página de resultados, el navegador reenviará el formulario y volverá a ejecutar la llamada a la IA. Para una demo es aceptable; para producción, usa un patrón POST-redirect-GET con RedirectResponse para guardar los resultados en una sesión o base de datos y redirigir a un endpoint GET.

Herramienta de ejemplo: el generador de títulos en acción

Aquí está la implementación completa y ejecutable del generador de títulos:

Archivo: tools/title_generator.py

python
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"])

Las demás herramientas (generador de publicaciones sociales, generador de esquemas de blog) siguen el mismo patrón: define un prompt de sistema, una plantilla de prompt de usuario, campos de formulario y una función de postprocesado. Luego crea y registra.

Ampliar: añadir una nueva herramienta

Para añadir la herramienta Email Crafter (o cualquier herramienta nueva):

  1. Crea tools/email_crafter.py
  2. Define system_prompt, user_prompt_template, input_form_fields y post_process_func
  3. Llama a create_text_generation_tool(...) con tu configuración
  4. Instancia y registra: registry.register(tool_instance, categories=["Communication"])
  5. Importa el módulo en tools/__init__.py

La herramienta aparece automáticamente en la página de inicio, en el listado de herramientas y obtiene su propia ruta /tools/professional-email-crafter.

¿Cómo funciona la plantilla de prompt?

El user_prompt_template usa str.format() de Python. Los valores de los campos del formulario se pasan como kwargs: si tu formulario tiene campos topic, platform y style, la plantilla "Create {platform} titles about: {topic}" recibe esos valores sustituidos. El prompt de sistema se envía por separado como la instrucción de «rol» de la IA y se mantiene constante en todas las peticiones de esa herramienta.

¿Cómo añado una nueva herramienta?

Crea un nuevo archivo Python en tools/, llama a create_text_generation_tool() con tus prompts y campos de formulario, instancia la clase devuelta y llama a registry.register(). Luego añade la importación a tools/__init__.py. La fábrica se encarga de la creación del agente de IA, el formateo del prompt y el procesado del resultado — tú solo aportas la configuración.

¿Cómo cambio el modelo de IA?

Cambia el valor de DEFAULT_MODEL en tu archivo .env. Vale cualquier slug de modelo de OpenRouter: openai/gpt-4o-mini, openai/gpt-4.1-mini, anthropic/claude-3.5-sonnet, etc. También puedes pasar un nombre de modelo directamente a create_agent("anthropic/claude-3.5-sonnet") para usar modelos distintos por herramienta. Consulta openrouter.ai/models para ver los modelos disponibles y los precios.

Manejo de errores, límites de peticiones y control de costes

Cuando tu sitio de herramientas de IA recibe tráfico real, tres cosas te van a morder: timeouts de API, límites de peticiones y costes descontrolados. Así se manejan.

Manejo de excepciones en la ruta de procesamiento

El manejador process_tool ya envuelve la llamada a la IA en try/except. Pydantic AI lanza excepciones estándar de Python para errores de red y timeouts. Las más importantes:

python
# 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}"

Control de costes

Una llamada típica de generación de títulos usa ~200–300 tokens de entrada + ~200 tokens de salida. Con los precios de gpt-4o-mini (0,15 $/M entrada, 0,60 $/M salida), eso es aproximadamente 0,0002 $ por petición — efectivamente gratis. Pero para herramientas más pesadas (esquemas de blog con prompts de sistema largos) o modelos caros, los costes se acumulan.

Controles de costes prácticos:

  • Define max_tokens en la configuración del modelo del agente para limitar la longitud de salida
  • Usa modelos gratuitos (sufijo :free) para desarrollo y pruebas
  • Establece límites de gasto en el panel de OpenRouter, en Billing
  • Cachea resultados si los usuarios solicitan con frecuencia las mismas entradas

Límites de peticiones de OpenRouter

Las cuentas gratuitas de OpenRouter tienen límites de peticiones estrictos (~20 peticiones/minuto, 50 peticiones/día). Tras añadir un crédito de 10 $, el límite sube a ~1.000 peticiones/día. Para un sitio de herramientas de cara al público, usa un plan de pago y establece límites de gasto. Consulta openrouter.ai/docs/api_reference/limits para las cifras actuales.

Notas de seguridad para despliegues públicos

  • Protección CSRF: FastHTML/Starlette no añade tokens CSRF de serie. Para un endpoint POST público que llama a una API de pago, añade como mínimo limitación de peticiones, o implementa tokens CSRF vinculados a la sesión.
  • Inyección de prompt: los usuarios controlan el texto que llega al modelo. No concedas al agente de Pydantic AI herramientas ni secretos, y trata la salida del modelo como no confiable.
  • Validación de entradas: el método validate_inputs() de BaseTool comprueba los campos obligatorios y los límites de longitud. La validación del lado del servidor es importante aunque existan los atributos HTML required — son fáciles de saltar.

Verificar y desplegar tu sitio web de herramientas de IA

Prueba de humo local

Tras crear todos los archivos, ejecuta:

bash
python main.py

Deberías ver una salida como:

text
Link: http://0.0.0.0:5001

Luego verifica:

  1. Página de inicio: visita http://localhost:5001/ — debería mostrar la sección hero y las tarjetas de herramientas
  2. Listado de herramientas: visita http://localhost:5001/tools — debería mostrar las herramientas agrupadas por categoría
  3. Página de herramienta: visita http://localhost:5001/tools/ai-title-generator — debería mostrar el formulario
  4. Envío del formulario: rellena un tema, selecciona plataforma y estilo, haz clic en Generate — debería devolver títulos generados por IA
  5. Página 404: visita http://localhost:5001/nonexistent — debería mostrar la página 404 personalizada
  6. Salida de consola: busca errores en el terminal. Los errores de clave de API ausente aparecen aquí primero.

Problemas habituales en esta fase:

Síntoma Causa Solución
ModuleNotFoundError Falta una dependencia o el entorno de Python es incorrecto Ejecuta pip install -r requirements.txt en el entorno activo
RuntimeError: OPENROUTER_API_KEY Falta .env o no se llamó a load_dotenv() Añade la clave a .env, asegúrate de que config.py la carga
Página en blanco / sin estilos El CDN de Tailwind está bloqueado o sin conexión Revisa la consola del navegador en busca de errores de script
El formulario se envía pero no hay resultados Clave de API inválida o modelo no encontrado Revisa el panel de OpenRouter, verifica el slug del modelo

Notas de producción

El serve() de FastHTML es un servidor de desarrollo (uvicorn por debajo). Para producción:

  • Proxy inverso: pon Caddy, Nginx o Traefik delante para HTTPS automático y servir archivos estáticos. Si necesitas un VPS para esto, Hetzner Cloud ofrece un hosting VPS asequible fiable desde ~4 €/mes.
  • Workers: serve(host="0.0.0.0", reload=False) desactiva la recarga en caliente. Para varios workers: serve(host="0.0.0.0", workers=2) (FastHTML pasa los kwargs a uvicorn).
  • Entorno: mantén .env fuera del control de versiones. En producción, usa variables de entorno del sistema o un gestor de secretos.
  • Tailwind: cambia del Play CDN a una compilación local de Tailwind o a MonsterUI para el rendimiento en producción.
  • Docker: un Dockerfile sencillo funciona bien para despliegues reproducibles:

Los precios de los VPS subieron en todos los proveedores en 2026 — si estás replanteándote una máquina alquilada, mira qué cambió y cuándo gana un mini PC.

dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]

Ejecución en producción

El serve() de FastHTML es solo para desarrollo. Para producción, ejecútalo detrás de Caddy o Traefik con HTTPS automático. serve() acepta kwargs de uvicorn — usa host="0.0.0.0", port=5001, reload=False y workers=N para la configuración de producción.

Conclusión

Ahora tienes una plataforma multipágina de herramientas de IA con FastHTML funcionando, con:

  • Arquitectura modular: componentes, páginas y herramientas como responsabilidades separadas
  • Integración nativa con OpenRouter: el prefijo openrouter: de Pydantic AI reemplaza la antigua configuración manual con AsyncOpenAI
  • Patrones modernos de FastHTML: fast_app() + @rt con parámetros anotados con tipos
  • Generación dinámica de formularios: añade una nueva herramienta escribiendo un solo archivo de configuración
  • Conciencia de producción: manejo de errores, límites de peticiones, controles de costes y orientación de despliegue

Siguientes pasos para ampliar esta plataforma:

Si eres nuevo en el desarrollo con IA y quieres más contexto sobre cómo funcionan los LLM y las herramientas basadas en API, consulta la guía de primeros pasos para programar con IA.

Serie de FastHTML

A continuación están los artículos de nuestra serie de FastHTML para ayudarte a empezar: