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.
Actualizado Publicado 56 min de lectura

FastHTML Tutorial Series
Part 4 of 6
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.
uves 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 comogpt-4o-minicuestan ~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:
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 toolInstalar las dependencias
El requirements.txt actualizado elimina el paquete openai. El proveedor nativo de OpenRouter de Pydantic AI gestiona el cliente HTTP internamente:
python-fasthtml
python-dotenv
pydantic-aipip
pip install python-fasthtml python-dotenv pydantic-aiuv
uv add python-fasthtml python-dotenv pydantic-aiVerifica la instalación:
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:
OPENROUTER_API_KEY=sk-or-v1-your-key-here
DEFAULT_MODEL=openai/gpt-4o-miniSobre 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)
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
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
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."""
passRegistro de herramientas
Archivo: 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()Fábrica de herramientas
Archivo: 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 TextGenerationToolEsta fábrica crea clases de herramientas que:
- Validan las entradas mediante el
BaseTool.validate_inputs()heredado - Formatean el prompt de usuario a partir de una plantilla
- Llaman al agente de IA con el prompt de sistema
- Postprocesan la salida en bruto (extraer títulos, formatear emails, etc.)
Manejo de errores y configuración
Archivo: 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,
}
}Archivo: 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"Archivo: tools/__init__.py. Este es el shim que el resto de la app importa:
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
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
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
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
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)—rtes el decorador de rutas@rt("/path")condef 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
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
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():
@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:
@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
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):
- Crea
tools/email_crafter.py - Define
system_prompt,user_prompt_template,input_form_fieldsypost_process_func - Llama a
create_text_generation_tool(...)con tu configuración - Instancia y registra:
registry.register(tool_instance, categories=["Communication"]) - 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:
# 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_tokensen 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()deBaseToolcomprueba los campos obligatorios y los límites de longitud. La validación del lado del servidor es importante aunque existan los atributos HTMLrequired— 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:
python main.pyDeberías ver una salida como:
Link: http://0.0.0.0:5001Luego verifica:
- Página de inicio: visita
http://localhost:5001/— debería mostrar la sección hero y las tarjetas de herramientas - Listado de herramientas: visita
http://localhost:5001/tools— debería mostrar las herramientas agrupadas por categoría - Página de herramienta: visita
http://localhost:5001/tools/ai-title-generator— debería mostrar el formulario - Envío del formulario: rellena un tema, selecciona plataforma y estilo, haz clic en Generate — debería devolver títulos generados por IA
- Página 404: visita
http://localhost:5001/nonexistent— debería mostrar la página 404 personalizada - 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
.envfuera 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.
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 conAsyncOpenAI - Patrones modernos de FastHTML:
fast_app()+@rtcon 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:
- Añadir autenticación de usuarios para proteger funciones de administración o guardar preferencias de usuario
- Añadir una base de datos SQLite para almacenar resultados de herramientas e historial de uso
- Desplegar detrás de un proxy inverso con Docker para ejecuciones de producción reproducibles
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:


