---
title: "Herramientas de IA con FastHTML: guía para crear un sitio web multipágina"
description: "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."
date: 2025-03-03
categories: ["web-development"]
tags: ["fasthtml","pydantic-ai","openrouter"]
---

import Button from "@components/widgets/Button.astro";
import Notice from "@components/widgets/Notice.astro";
import ListCheck from "@components/widgets/ListCheck.astro";
import Accordion from "@components/widgets/Accordion.astro";
import Tabs from "@components/widgets/Tabs.astro";
import Tab from "@components/widgets/Tab.astro";

Esta es la parte 4 de la serie de FastHTML. Si eres nuevo en el framework, empieza con [FastHTML para principiantes](/es/empezar-fasthtml/) o el tutorial de [FastHTML con varias páginas](/es/fasthtml-multiples-paginas/). Si ya construiste una [app web sencilla con IA usando FastHTML y Pydantic AI](/es/fasthtml-herramientas-pydanticai/), 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](/es/guia-programacion-ia-principiantes/), este es un buen proyecto práctico para ganar soltura real. FastHTML es uno de los [frameworks web de Python](/best-python-web-frameworks/) más interesantes para este tipo de prototipado rápido de herramientas de IA.

Esto es lo que vas a construir:

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

## 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](https://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.

<Notice type="warning" title="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.
</Notice>

<Notice type="info" title="Cómo obtener tu clave de API de OpenRouter">
Regístrate en [openrouter.ai](https://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.
</Notice>

## 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 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:

```
python-fasthtml
python-dotenv
pydantic-ai
```

<Tabs>
<Tab name="pip">
```bash
pip install python-fasthtml python-dotenv pydantic-ai
```
</Tab>
<Tab name="uv">
```bash
uv add python-fasthtml python-dotenv pydantic-ai
```
</Tab>
</Tabs>

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
```

<Notice type="info" title="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`.
</Notice>

## 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`**

<Tabs>
<Tab name="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.
</Tab>
<Tab name="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.).
</Tab>
</Tabs>

<Notice type="warning" title="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).
</Notice>

**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 |

<Notice type="info" title="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](/es/fasthtml-sqlite-db/) más adelante, necesitarás importaciones explícitas para la capa de base de datos.
</Notice>

## 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.

<Notice type="info" title="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.
</Notice>

Si quieres [instalar Plausible Analytics](/es/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}",
        )
```

<Notice type="info" title="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`.
</Notice>

**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`.

<Accordion label="¿Cómo funciona la plantilla de prompt?" group="faq">
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.
</Accordion>

<Accordion label="¿Cómo añado una nueva herramienta?" group="faq">
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.
</Accordion>

<Accordion label="¿Cómo cambio el modelo de IA?" group="faq">
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](https://openrouter.ai/models) para ver los modelos disponibles y los precios.
</Accordion>

## 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

<Notice type="warning" title="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](https://openrouter.ai/docs/api_reference/limits) para las cifras actuales.
</Notice>

### 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:

```
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](https://go.bitdoze.com/hetzner) ofrece un [hosting VPS asequible](https://go.bitdoze.com/hetzner) 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](/es/subidas-precios-vps/)._

```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"]
```

<Notice type="info" title="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.
</Notice>

## 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:

- [Añadir autenticación de usuarios](/es/fasthtml-autenticacion-usuarios/) para proteger funciones de administración o guardar preferencias de usuario
- [Añadir una base de datos SQLite](/es/fasthtml-sqlite-db/) 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](/es/guia-programacion-ia-principiantes/).

## Serie de FastHTML

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

- [FastHTML Get Started](/es/empezar-fasthtml/)
- [FastHTML Multiple Pages](/es/fasthtml-multiples-paginas/)
- [Building a Simple AI-Powered Web App with FastHTML and Pydantic AI](/es/fasthtml-herramientas-pydanticai/)
- [Herramientas de IA con FastHTML](https://www.bitdoze.com/es/fasthtml-herramientas-ia-complejas/)
- [Adding SQLite Database History to Your FastHTML AI Title Generator](/es/fasthtml-sqlite-db/)
- [FastHTML Authentication](/es/fasthtml-autenticacion-usuarios/)