<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
  xmlns:content="http://purl.org/rss/1.0/modules/content/"
  xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Oleksii Mazurenko — Blog</title>
    <link>https://oleksiimazurenko.dev/es/blog</link>
    <description>Technical articles about web development, performance optimization, and developer tools.</description>
    <language>es</language>
    <lastBuildDate>Sat, 13 Jun 2026 00:00:00 GMT</lastBuildDate>
    <atom:link href="https://oleksiimazurenko.dev/es/blog/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Claude Code Multi-Profile, v2: una arquitectura XDG limpia</title>
      <link>https://oleksiimazurenko.dev/es/blog/claude-profiles-clean-architecture</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/es/blog/claude-profiles-clean-architecture</guid>
      <description>Después de un mes con el enfoque viejo basado en alias, rehíce mi setup multi-profile de Claude Code a una estructura compatible con XDG bajo ~/.config/claude-profiles/. La razón real no fue el orden — fue descubrir que ~/.claude.json es un archivo separado en el home root donde vivían silenciosamente los servidores MCP añadidos con --scope user.</description>
      <content:encoded><![CDATA[<p>Hace un mes publiqué <a href="/es/blog/multiple-claude-accounts-one-device">un post sobre cómo correr dos cuentas de Claude Code en paralelo en un mismo equipo</a> — personal y de trabajo, mediante <code>CLAUDE_CONFIG_DIR</code> y un alias de shell. El enfoque funcionaba. Todo hacía lo que tenía que hacer.</p>
<p>Pero después de un mes de uso real me topé con varios problemas sustanciales que ese post no cubría. El más grande — una peculiaridad oculta de Claude Code con la que me crucé por accidente, cuando añadí el MCP de Gmail y "desapareció" de mi perfil cinco minutos después. Hoy rehíce todo sobre una arquitectura nueva — compatible con XDG, con un symlink y un selector interactivo de perfil mediante <code>gum</code>. Esto es v2 — una evolución a partir de la experiencia real, no una mejora teórica.</p>
<h2>Lo que empezó a molestar</h2>
<p>Cuatro cosas. Las primeras tres son sobre orden, visibilidad y escala. La cuarta es el verdadero gotcha arquitectónico que no vi de inmediato. Las repaso en orden, porque fue justamente la cuarta la que al final forzó la reescritura.</p>
<h3>1. Dos carpetas separadas en $HOME — es un desastre</h3>
<p><code>~/.claude</code> y <code>~/.claude-promova</code> — dos dotfolders uno al lado del otro en la raíz de $HOME. La XDG Base Directory Specification dice que las configs deben vivir en <code>~/.config/</code>. Las carpetas dotfile dispersas directamente en home son un antipatrón que con el tiempo convierte $HOME en un cajón de sastre. Cosmético, sí, pero me molestaba cada vez que veía <code>ls -la ~</code>.</p>
<h3>2. Sin confirmación visual del perfil activo</h3>
<p>Lanzo <code>claude</code> y no sé qué perfil está activo — hasta que ejecuto <code>claude config list</code> ya dentro de la sesión. Si olvidé qué terminal arranqué dónde, tengo que verificar. Una nimiedad, pero con personal + work corriendo en paralelo en dos pestañas se acumula.</p>
<h3>3. Los alias no escalan</h3>
<p>Dos perfiles — <code>claude</code> y <code>claude-promova</code> — está bien. Añades un tercero (un cliente freelance) — necesitas un tercer alias. Un cuarto — un cuarto. En medio año ya no recordaría qué alias tengo creados.</p>
<h3>4. La trampa oculta en ~/.claude.json</h3>
<p>Y esto es la verdadera razón de la reescritura. Claude Code tiene <strong>dos lugares distintos</strong> de configuración, y la documentación no lo grita: <code>~/.claude/</code> — el directorio donde están <code>projects/</code>, <code>sessions/</code>, <code>hooks/</code>, <code>skills/</code>. Y por separado — <code>~/.claude.json</code>, un archivo justo en la raíz de $HOME, donde viven <code>oauthAccount</code>, <code>mcpServers</code>, el historial de <code>projects</code>, <code>skillUsage</code> y cerca de 40 campos más de auténtico live state.</p>
<p>El comando <code>claude mcp add --scope user</code> escribe precisamente en ese <code>~/.claude.json</code> del home root, y <strong>no</strong> en <code>~/.claude/.claude.json</code> ni en el directorio del perfil. No lo sabía. Hasta que un día me topé con ello.</p>
<h2>Discovery: por qué el MCP de Gmail &quot;desapareció&quot;</h2>
<p>Esta mañana estaba montando el MCP de Gmail en Claude Code. Setup habitual: proyecto en Google Cloud, credenciales OAuth, <code>claude mcp add gmail --scope user -- npx -y @gongrzhe/server-gmail-autoauth-mcp</code>. Todo ok. Reinicié la sesión — funciona, leo el correo, contesto mensajes. Una hora después empezamos a refactorizar los alias en una función con selector de perfil por <code>gum</code>, y luego migramos todo a XDG. Hice <code>mv ~/.claude → ~/.config/claude-profiles/personal</code>, reinicié CC, elegí personal en el menú. Y en la nueva sesión abrí <code>/mcp</code>:</p>
<pre><code>figma            (failed)
playwright-test
claude.ai Notion</code></pre>
<p>No estaba gmail. No estaba vaultforge. Solo tres servidores, uno de los cuales además había failed. Y yo acababa de añadir Gmail. Mientras tanto, la sesión en otra terminal (perfil de trabajo) mostraba Gmail y Vaultforge sin ningún problema.</p>
<p>Empecé a escarbar y encontré que en mi máquina tenía <strong>tres</strong> archivos distintos con el nombre <code>.claude.json</code>:</p>
<table><thead><tr><th>Path</th><th>Size</th><th>mcpServers</th></tr></thead><tbody><tr><td>&lt;code&gt;~/.claude.json&lt;/code&gt; (home root)</td><td>113 KB</td><td>figma, gmail, vaultforge</td></tr><tr><td>&lt;code&gt;~/.config/claude-profiles/personal/.claude.json&lt;/code&gt;</td><td>29 KB</td><td>figma, playwright-test</td></tr><tr><td>&lt;code&gt;~/.config/claude-profiles/promova/.claude.json&lt;/code&gt;</td><td>40 KB</td><td>figma, playwright-test, vaultforge</td></tr></tbody></table>
<p>Ahí estaba. Ese es el gotcha arquitectónico:</p>
<ol><li><code>claude mcp add --scope user</code> siempre escribe en <code>~/.claude.json</code> en el home root, independientemente de <code>CLAUDE_CONFIG_DIR</code></li><li>Cuando <code>CLAUDE_CONFIG_DIR</code> está definido, Claude Code lee <code>$CLAUDE_CONFIG_DIR/.claude.json</code> — es decir, el archivo dentro del perfil</li><li>Los registros de "user-scope MCPs" y de "MCPs del perfil" viven en <strong>archivos distintos</strong> con el mismo nombre — y es fácil confundirlos</li></ol>
<p>En mi caso <code>~/.claude.json</code> (home root, 113 KB) era el state vivo y actual — con gmail, vaultforge, sesión OAuth, todo. Y <code>~/.config/claude-profiles/personal/.claude.json</code> (29 KB) resultó ser un snapshot viejo que por alguna razón ya estaba antes en el viejo <code>~/.claude/</code> — quizás una versión anterior de CC escribía ahí, quizás un plugin. <code>jq -r 'keys[]'</code> sobre ambos archivos mostró que la versión del home-root tenía 41 claves únicas que no estaban en el snapshot.</p>
<p>Y esas 41 claves no son basura. Es el state real de Claude Code:</p>
<ul><li><code>skillUsage</code> — estadísticas de uso de skills</li><li><code>githubRepoPaths</code> — caché de repos para la navegación por proyectos</li><li><code>cachedGrowthBookFeatures</code> + <code>cachedStatsigGates</code> — feature flags (sin ellas CC va por nuevas cada arranque)</li><li><code>hasShownOpus45Notice</code>, <code>hasShownOpus46Notice</code>, <code>hasShownS1MWelcomeV2</code> — flags de UI (sin ellas los modales vuelven a aparecer en el próximo arranque)</li><li><code>lastPlanModeUse</code>, <code>feedbackSurveyState</code>, <code>installMethod</code> — state de onboarding y UX</li></ul>
<p>Si simplemente haces <code>mv ~/.claude ~/.config/claude-profiles/personal</code> sin merge — pierdes todo eso. Vuelves a ver los modales de welcome, se vuelve a ejecutar la búsqueda de githubRepoPaths, recibes de nuevo todas las peticiones de survey. Como casi hice yo.</p>
<h2>La nueva arquitectura</h2>
<p>Todo vive bajo un único directorio padre en <code>~/.config/</code>, como quiere XDG. Cada perfil es autosuficiente — tiene su state completo, incluido su propio <code>.claude.json</code>. <code>~/.claude</code> queda como symlink al perfil personal para compatibilidad hacia atrás.</p>
<pre><code>~/.config/claude-profiles/
├── personal/
│   ├── .claude.json          ← full live state + MCP list (113 KB)
│   ├── projects/, sessions/, hooks/, skills/, ...
│   └── CLAUDE.md
└── promova/
    ├── .claude.json          ← work state + work MCPs (41 KB)
    └── projects/, sessions/, agents/, skills/, ...

~/.claude  →  symlink to ~/.config/claude-profiles/personal/</code></pre>
<p>Ningún <code>.claude.json</code> en la raíz de $HOME. Cada perfil es un directorio separado y aislado donde está todo: las carpetas <code>projects</code>/<code>sessions</code> y ese mismo archivo con los servidores MCP y los tokens OAuth. Una source of truth por perfil.</p>
<h3>Por qué el symlink apunta a personal</h3>
<p>Todo lo que tiene el path <code>~/.claude/</code> hardcoded — scripts viejos, plugins, extensiones de IDE de Claude Code, configs de statusline como <code>claude-powerline.json</code> — sigue funcionando sin cambios. El symlink se resuelve al perfil personal. Si por accidente lanzas <code>command claude</code> (sin la función wrapper) — también caes en personal por el lookup del default-path. Personal se convierte en el "quiet default" que era antes, pero ahora vive físicamente en la ubicación XDG.</p>
<h3>Selector interactivo al lanzar — función + gum</h3>
<p>En lugar de alias — una función <code>claude()</code> en <code>~/.zshrc</code> que muestra un menú con flechas mediante <code>gum</code> (el TUI-helper de Charm). La función intercepta la llamada a <code>claude</code> a nivel de shell, deja elegir el perfil y ejecuta <code>command claude</code> con el <code>CLAUDE_CONFIG_DIR</code> correspondiente. <code>command</code> es importante — esquiva la función wrapper e invoca el binario real.</p>
<pre><code># brew install gum

# Claude Code: profile picker on launch
claude() {
  local profile
  profile=$(gum choose \
    --header &quot;Claude profile:&quot; \
    --cursor &quot;▸ &quot; \
    --selected.foreground 212 \
    --cursor.foreground 212 \
    --header.foreground 244 \
    &quot;personal&quot; &quot;promova&quot;) || return
  case &quot;$profile&quot; in
    personal) CLAUDE_CONFIG_DIR=&quot;$HOME/.config/claude-profiles/personal&quot; command claude &quot;$@&quot; ;;
    promova)  CLAUDE_CONFIG_DIR=&quot;$HOME/.config/claude-profiles/promova&quot;  command claude &quot;$@&quot; ;;
  esac
}</code></pre>
<p>Esto es lo que se ve al arrancar:</p>
<pre><code>Claude profile:
▸ personal
  promova</code></pre>
<p>↑↓ para navegar, Enter para elegir, Esc para cancelar (Claude simplemente no arranca). El perfil siempre está a la vista — imposible olvidarlo.</p>
<h2>Script de migración</h2>
<p>Para quien lea esto y quiera mudarse del esquema viejo. El paso más crítico es el segundo: fusiona <code>~/.claude.json</code> del home root con lo que ya hay en el perfil personal, combinando las listas de <code>mcpServers</code>. Sin ese paso el perfil pierde tanto los MCPs como todo el live state.</p>
<pre><code># 1. Move existing dotfolders into a new XDG-style parent.
#    APFS mv is an inode rename — safe even if claude --resume is open in
#    another terminal, file descriptors stay alive on the same inode.
mkdir -p ~/.config/claude-profiles
mv ~/.claude          ~/.config/claude-profiles/personal
mv ~/.claude-promova  ~/.config/claude-profiles/promova   # rename as needed

# 2. CRITICAL: merge ~/.claude.json (the live state — likely 100+ KB) into
#    the personal profile, unioning mcpServers so no MCP is dropped.
jq -s &apos;.[0] * {mcpServers: (.[0].mcpServers + .[1].mcpServers)}&apos; \
  ~/.claude.json \
  ~/.config/claude-profiles/personal/.claude.json \
  &gt; /tmp/personal-merged.json
mv /tmp/personal-merged.json ~/.config/claude-profiles/personal/.claude.json

# 3. Fix permissions — jq+mv inherits umask (likely 644), but this file
#    holds OAuth tokens. Tighten to 600 immediately.
chmod 600 ~/.config/claude-profiles/personal/.claude.json

# 4. Back up the orphaned home-root file (delete once verified)
mv ~/.claude.json ~/.claude.json.migrated.bak

# 5. Symlink for backward compatibility (statusline configs, IDE plugins,
#    anything that hardcodes ~/.claude/ keeps working unchanged)
ln -s ~/.config/claude-profiles/personal ~/.claude</code></pre>
<p>El tercer paso, sobre permisos, merece atención propia. <code>jq | mv</code> crea un archivo con umask 644 (legible por todos). Dentro hay tokens OAuth. <code>chmod 600</code> inmediatamente después del merge es obligatorio.</p>
<p>Tras la migración — cierra y abre todas las sesiones activas de Claude, recarga el shell (<code>source ~/.zshrc</code> o terminal nueva), ejecuta <code>claude</code>, elige perfil, comprueba con <code>claude mcp list</code> que todos los MCPs están en su sitio. Si todo está bien — borra <code>~/.claude.json.migrated.bak</code>. Si algo va mal — el rollback es trivial: <code>mv ~/.claude.json.migrated.bak ~/.claude.json</code> y quitar el symlink.</p>
<h2>Lo que ganas</h2>
<ul><li>Un único directorio padre en lugar de dos dotfolders en $HOME — compatible con XDG</li><li>El symlink preserva la compatibilidad con todo lo que tiene <code>~/.claude/</code> hardcoded</li><li>Cada perfil es autosuficiente — su state completo y sus MCPs viven en su propio <code>.claude.json</code></li><li>Una source of truth por perfil — ya no hay config huérfana en el home root divergiendo silenciosamente del perfil</li><li>Los datos sensibles (oauthAccount, tokens) tienen permisos 600 garantizados</li><li>Confirmación visual del perfil activo en cada arranque — imposible olvidar qué perfil está activo</li><li>Añadir un tercer perfil = añadir una línea al <code>case</code> de la función, no clonar un alias nuevo y memorizar su nombre</li></ul>
<h2>Dónde flojea</h2>
<p>Quiero ser honesto. Esto no es una mejora gratuita — vienen algunos compromisos en el paquete, y conviene conocerlos por adelantado.</p>
<ul><li><strong><code>gum</code> es una dependencia extra</strong> (<code>brew install gum</code>, ~13 MB). Si por principio no quieres instalarlo — fallback a <code>select</code> en zsh o un <code>read</code> simple. Funciona, pero no se ve tan bonito y no tiene navegación por flechas.</li><li><strong>Un Enter en cada arranque.</strong> Para quien lanza <code>claude</code> decenas de veces al día — puede molestar. Alternativa abajo (direnv).</li><li><strong>El symlink <code>~/.claude → personal</code> hace personal el default.</strong> Si necesitas que el perfil de trabajo sea el default, hay que redirigir el symlink (<code>ln -sf</code>). No es difícil, pero no es "olvídate y nada se rompe".</li><li><strong>El symlink podría romperse en teoría</strong> si alguna herramienta reescribe <code>~/.claude.json</code> atómicamente con un patrón temp+rename (write-file-atomic). En la práctica Claude Code en sí no lo hace, pero si instalas plugins de terceros — verifícalo.</li><li><strong>Si tienes cuentas de Anthropic distintas en los perfiles con planes distintos</strong> — tras cambiar puede haber un lag de menos de un segundo mientras Claude Code sincroniza el state de OAuth. En mi uso no se nota, pero no es cero.</li></ul>
<h2>Alternativas que consideré</h2>
<p><strong>direnv</strong> — define automáticamente <code>CLAUDE_CONFIG_DIR</code> según el <code>.envrc</code> en la raíz de cada proyecto. Cero interacción, cero clics. Contra: hay que colocar un <code>.envrc</code> en cada work-root, y si ejecutas <code>claude</code> en una carpeta no reconocida — obtienes el perfil por defecto (que puede no ser el que necesitas). Para quien vive en un conjunto acotado de work-roots y quiere no clicar nunca — direnv realmente es mejor.</p>
<p><strong>Switching basado en symlink</strong> (un único perfil activo redirigiendo el symlink <code>~/.claude</code>) también lo consideré y lo descarté de inmediato. No puedes tener dos terminales con perfiles distintos abiertas a la vez — el "actual" global es uno solo. Para mí es un deal-breaker.</p>
<h2>Conclusión</h2>
<p>v2 no es solo mejor UX encima de v1. Es el reconocimiento de que Claude Code tiene una peculiaridad arquitectónica oculta (<code>~/.claude.json</code> como archivo separado en el home root, escrito por comandos <code>--scope user</code> independientemente de <code>CLAUDE_CONFIG_DIR</code>) que hay que tener en cuenta si quieres aislamiento real entre perfiles. El primer enfoque (<code>~/.claude</code> + <code>~/.claude-promova</code> + alias) funcionaba al 80%, pero el 20% restante se manifestaba como una deriva silenciosa de state entre perfiles. Ahora está contemplado. Si recién empiezas — arranca directamente con v2. Si ya estás en v1 — el script de migración está arriba, la mudanza lleva cinco minutos y no rompe nada (justamente el merge con <code>jq</code> es el paso que te salva de perder state).</p>]]></content:encoded>
      <pubDate>Sat, 13 Jun 2026 00:00:00 GMT</pubDate>
      <category>claude-code</category>
      <category>productivity</category>
      <category>cli</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Cómo escribir agentes de Claude Code que no te mientan</title>
      <link>https://oleksiimazurenko.dev/es/blog/writing-specialized-agents</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/es/blog/writing-specialized-agents</guid>
      <description>Dos reglas para construir pipelines de agentes de Claude Code fiables: un agente por especialización, y comandos de shell en lugar de prompts en todo lo que requiera respuestas cuantitativas.</description>
      <content:encoded><![CDATA[<p>Le pediste a Claude Code que <em>implementara este diseño y verificara que coincide con el mockup de Figma</em>. Te respondió: <em>Listo. Todas las secciones coinciden, el espaciado es correcto, los colores son los que toca.</em> Abriste la página. La mitad del espaciado estaba mal. El estado hover no existía. Los botones tenían un tono incorrecto. El modelo no mintió con mala intención — predijo que querías escuchar <em>verificado</em>, así que generó exactamente esa secuencia de tokens. No hubo ningún paso de verificación. Nunca podría haberlo — verificar requiere comparar contra un ground truth, y un único agente en un único contexto no tiene manera de salirse de su propia respuesta y comprobarse a sí mismo.</p>
<p>Dos reglas han convertido mis flujos de trabajo llenos de alucinaciones en pipelines fiables: <strong>un agente, una especialización</strong>, y <strong>todo lo que pueda ejecutarse como comando de shell debe ejecutarse como comando de shell</strong>. Esto no es teoría. Es lo que hago cada día con Claude Code, y estos son los patrones que realmente marcan la diferencia.</p>
<h2>Por qué los agentes generalistas mienten</h2>
<p>Los LLM son predictores del siguiente token. Cuando un prompt pide dos roles — <em>construye X</em> y <em>verifica X</em> — el modelo termina el primer rol y luego predice cómo sería la salida del segundo, sin ejecutarlo realmente. La auto-verificación es estructuralmente débil: mismo contexto, mismo modelo, los mismos puntos ciegos. El <em>pass</em> en la verificación está correlacionado con el <em>pass</em> en la construcción — fallan juntos.</p>
<p>El modelo no sabe que está mintiendo. Desde su perspectiva, narrar <em>verifiqué todo con cuidado</em> es una continuación coherente de haber escrito el código. Es la misma razón por la que los prompts de <em>¿estás seguro?</em> no atrapan alucinaciones: el modelo tiene la misma confianza en el segundo intento. La confianza no está correlacionada con la corrección — está correlacionada con lo plausible que suena la siguiente frase.</p>
<p>La solución no son <em>mejores prompts</em>. <em>Ten cuidado</em>, <em>vuelve a revisar</em>, <em>no alucines</em> — estas instrucciones no sirven para nada. La solución es estructural: especializa el agente para que físicamente no pueda fingir, y enruta el trabajo cuantitativo a través del shell para que la respuesta venga del estado real, no de la probabilidad de tokens.</p>
<h2>Regla 1: un agente, una especialización</h2>
<p>Divide el trabajo en agentes separados con contextos separados. Cada agente tiene una sola responsabilidad y un conjunto de herramientas acotado. Todo el flujo se convierte en una carrera de relevos en lugar de un único agente dando vueltas:</p>
<ul><li><strong>Agente builder:</strong> recibe la spec y escribe el código. Ese es su único trabajo. Tiene <code>Read</code>, <code>Edit</code>, <code>Write</code>, <code>Bash</code>.</li><li><strong>Agente reviewer:</strong> recibe la spec más el diff y comprueba los criterios de aceptación. Contexto limpio. Sin conocimiento de <em>cómo</em> se escribió el código, solo de lo que salió. Tiene <code>Bash</code>, <code>Read</code>, <code>Grep</code>, <code>Glob</code> — sin herramientas de escritura.</li><li><strong>Agente de analytics:</strong> responde preguntas sobre datos construyendo y ejecutando queries. Solo <code>Bash</code>. No puede llegar a la respuesta sin ejecutar un comando real.</li><li><strong>Orchestrator:</strong> la sesión principal que despacha cada agente en turno y nunca le pide a uno que haga el trabajo del otro.</li></ul>
<p>Ejemplo concreto: implementación de UI más una verificación visual contra un mockup de Figma. El builder escribe los componentes y hace commit del diff. Luego el orchestrator invoca al reviewer con la URL del diseño, el diff y criterios de aceptación explícitos. El reviewer ejecuta Playwright, toma screenshots, los difiere contra la referencia y devuelve <code>PASS</code> o <code>FAIL</code> con las rutas reales de los screenshots y los pixel diffs. El builder nunca se acerca al paso de verificación — y precisamente por eso la verificación es real.</p>
<p>El anti-patrón es el mega-agente: un único prompt que dice <em>implementa esta UI y asegúrate de que coincide con el mockup</em>. Te lo garantizo: reportará que todo coincide. No coincidirá. El relato de <em>lo verifiqué</em> es simplemente la secuencia de tokens más probable después de <em>lo implementé</em>.</p>
<h2>Regla 2: shell antes que prompt, siempre</h2>
<p>Todo lo cuantitativo, todo lo que toca estado real, todo donde la respuesta puede estar mal de una forma que parece correcta — pásalo por <code>sh</code>. El trabajo del agente es construir y ejecutar el comando, luego leer su salida. El agente no es la fuente de verdad. La salida del shell lo es.</p>
<ul><li><strong>Contar:</strong> <code>wc -l logs.txt</code> es verdad. <em>Hay aproximadamente 47 líneas de log</em> de parte del modelo es una alucinación.</li><li><strong>Analytics:</strong> <code>psql -c "SELECT count(*) FROM events WHERE created_at &gt; now() - interval '30 days'"</code>. No <em>estima el volumen</em>.</li><li><strong>Tests:</strong> <code>pnpm test --reporter=json | jq '.numFailedTests'</code>. No <em>resume qué falló</em>.</li><li><strong>Estado de Git:</strong> <code>git rev-list --count main..HEAD</code>, <code>git diff --stat</code>. No <em>cuenta los commits</em> ni <em>describe los cambios</em>.</li></ul>
<p>Una vez que interiorizas esto, empiezas a detectar cada momento en que el agente estaba a punto de inventarse un número. <em>Parece que hay unos 200 registros...</em> — no. Ejecuta <code>SELECT count(*)</code>. <em>La mayoría de los tests pasan...</em> — no. Ejecuta el test suite, parsea el JSON. El modelo es excelente construyendo el comando. Es poco fiable siendo el comando.</p>
<h2>Modos de fallo que he sufrido en persona</h2>
<p>No son hipotéticos. Cada uno de estos me costó tiempo real antes de cambiar el patrón:</p>
<ul><li><strong>Verificación fantasma.</strong> El agente dijo <em>comprobé las 14 secciones contra el mockup</em>. No abrió el mockup. No tomó ningún screenshot. La comprobación era un paso alucinado en el relato.</li><li><strong>Números incorrectos con total confianza.</strong> Pedí los monthly active users de los datos de analytics. Me dio un número equivocado por un factor de ~3×. El modelo interpoló a partir de filas de muestra en lugar de ejecutar la query real.</li><li><strong>Cambios de archivos inventados.</strong> El agente dijo <em>actualicé <code>config/feature-flags.json</code></em>. No lo había hecho. Solo lo había pretendido. <code>git diff</code> estaba vacío.</li><li><strong>Ejecuciones de tests falsas.</strong> <em>Todos los tests pasan.</em> No se ejecutó ningún test. El agente nunca invocó el test runner — predijo cómo habría sido la salida del runner.</li></ul>
<p>Los cuatro se resuelven con las mismas dos reglas: divide el agente, empuja al shell. El reviewer no tiene <code>Write</code>, así que no puede falsificar ediciones de archivos. El agente de analytics solo tiene <code>Bash</code>, así que no puede devolver un número que no venga de una query. La imposibilidad estructural gana a las buenas intenciones siempre.</p>
<h2>Cómo estructurar esto en Claude Code</h2>
<p>Claude Code admite sub-agents definidos en <code>.claude/agents/*.md</code>. Cada archivo de agente declara un nombre, una descripción, un conjunto de herramientas permitidas y un system prompt. El orchestrator (tu sesión principal) los despacha usando la herramienta <code>Agent</code>. Esta es la clase de definición que uso para el reviewer — corta, acotada y físicamente incapaz de escribir código:</p>
<pre><code>---
name: reviewer
description: Reviews a diff against acceptance criteria. Cannot edit code.
tools: Bash, Read, Grep, Glob
---

You are a strict code reviewer. You receive:
- A diff (already produced by the builder)
- Acceptance criteria

Your job:
1. Run the build, the tests, the linter — through Bash.
2. Read the changed files directly.
3. Compare the actual behavior to the criteria.
4. Report PASS or FAIL with concrete evidence (command output, file excerpts).

You must NOT:
- Trust the builder&apos;s summary
- Assume anything was verified just because it was claimed
- Mark something PASS without running the actual check</code></pre>
<p>Fíjate en el conjunto de herramientas: <code>Bash, Read, Grep, Glob</code>. Sin <code>Write</code>, sin <code>Edit</code>, sin <code>Agent</code>. El reviewer puede ejecutar comandos, leer archivos, buscar patrones — y nada más. Si intenta hacer pasar un diff alucinado por <em>verificado</em>, la forma de sus llamadas a herramientas lo deja en evidencia: no hubo comprobaciones reales. Puedes auditar las llamadas y ver exactamente qué se inspeccionó.</p>
<p>El patrón de orquestación: la sesión principal llama al Builder → espera → ejecuta <code>git diff</code> ella misma para capturar el cambio real → llama al Reviewer con la spec y el diff → lee el veredicto del Reviewer. La sesión principal nunca le pide a un agente que haga ambas cosas. Las restricciones de herramientas son más fuertes que las instrucciones en el prompt: <em>no falsifiques la verificación</em> es un deseo. No tener <code>Write</code> es un hecho.</p>
<h2>Anti-patrones a desterrar</h2>
<p>Cosas que veo en prompts y que no hacen nada — o peor, dan una falsa sensación de seguridad:</p>
<ul><li><strong><em>Ten cuidado y revisa tu trabajo.</em></strong> No genera ningún comportamiento adicional. El modelo ya produce lo que parece trabajo cuidadoso.</li><li><strong><em>Asegúrate de verificar de verdad.</em></strong> La palabra <em>de verdad</em> no añade semántica sobre la que el modelo pueda actuar. Va a afirmar que verificó <em>de verdad</em>.</li><li><strong><em>No alucines.</em></strong> Un meme del prompt engineering. La alucinación no es un interruptor que el modelo pueda apagar.</li><li><strong>Fiarse del agente con números <em>pequeños</em>.</strong> Los números pequeños son donde miente con más confianza. No existe un umbral de honestidad.</li><li><strong>Añadir más reglas al prompt para <em>forzar</em> la honestidad.</strong> Las correcciones estructurales (dividir + shell) ganan a los ajustes de prompt siempre. Si una regla necesita aplicarse, codifícala en el acceso a herramientas, no en español.</li></ul>
<p>Si tu estrategia para atrapar alucinaciones es redactar las instrucciones con más énfasis, no tienes una estrategia. Tienes una esperanza.</p>
<h2>El modelo mental</h2>
<p>Un agente no es un compañero. Es una función: <code>prompt → tokens</code>. La función es excelente escribiendo código y pésima introspectando si hizo lo correcto. Trata sus afirmaciones sobre su propio trabajo como una hipótesis. El diff, el exit code, el screenshot, el row count — esa es la evidencia. El resumen al final del turno es la superficie más propensa a mentir de todo el sistema.</p>
<p>La especialización es tu seguro contra la deriva narrativa. El shell es tu único ground truth. El builder escribe. El reviewer comprueba. Bash decide.</p>
<h2>Conclusión</h2>
<p>Si te quedas con una sola cosa: no dejes que un único agente produzca y juzgue su propia salida, y no dejes que ningún agente responda una pregunta cuantitativa sin ejecutar un comando. Todo lo demás se deriva de estas dos reglas. Configura el acceso a herramientas de forma agresiva, audita las llamadas a herramientas en lugar de los resúmenes, y la superficie de alucinación se reduce de <em>todas partes</em> a <em>unos pocos lugares concretos que ya sabes dónde buscar</em>.</p>]]></content:encoded>
      <pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate>
      <category>claude-code</category>
      <category>agents</category>
      <category>ai</category>
      <category>prompt-engineering</category>
      <category>automation</category>
    </item>
    <item>
      <title>Múltiples cuentas de Claude Code en un dispositivo</title>
      <link>https://oleksiimazurenko.dev/es/blog/multiple-claude-accounts-one-device</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/es/blog/multiple-claude-accounts-one-device</guid>
      <description>Cómo usar dos (o más) cuentas de Claude Code en paralelo — personal y corporativa — con aislamiento total usando una sola variable de entorno.</description>
      <content:encoded><![CDATA[<p>Uso Claude Code a diario — para proyectos personales y para el trabajo en mi empresa. El problema: son dos cuentas completamente diferentes con distintas sesiones OAuth, distintas instrucciones CLAUDE.md, distintos servidores MCP y memoria de proyectos separada. Así es como los ejecuto en paralelo en un solo dispositivo con un único alias de shell.</p>
<h2>El problema</h2>
<p>Claude Code almacena todo en <code>~/.claude</code> por defecto — token OAuth, historial de conversaciones, CLAUDE.md global, memoria de proyectos, configuraciones de servidores MCP y ajustes. Con dos cuentas, necesitas dos mundos completamente separados:</p>
<ul><li><strong>Cuenta personal:</strong> tu suscripción Max/Pro, CLAUDE.md personal con tus preferencias, tus servidores MCP (Obsidian, herramientas personales)</li><li><strong>Cuenta corporativa:</strong> plan de la empresa, CLAUDE.md de trabajo con instrucciones de integración Jira/Slack, servidores MCP corporativos</li><li><strong>Sesiones OAuth diferentes:</strong> no puedes estar logueado en dos cuentas en el mismo directorio de configuración</li><li><strong>Memoria de proyectos separada:</strong> no quieres que el contexto de proyectos de trabajo se filtre a sesiones personales y viceversa</li></ul>
<p>Cerrar sesión e iniciar sesión cada vez que cambias de contexto no es una opción. Pierdes el estado de la sesión, y es simplemente doloroso.</p>
<h2>La solución: CLAUDE_CONFIG_DIR</h2>
<p>Claude Code respeta una única variable de entorno: <code>CLAUDE_CONFIG_DIR</code>. Establécela en cualquier ruta y Claude usará ese directorio en lugar de <code>~/.claude</code> para todo — auth, historial, ajustes, memoria. Todo el setup toma 60 segundos.</p>
<h3>Paso 1: Crear un segundo directorio de configuración</h3>
<p>Elige un nombre que tenga sentido para tu caso:</p>
<pre><code>mkdir ~/.claude-work</code></pre>
<p>Eso es todo. Claude lo poblará con la estructura necesaria en el primer lanzamiento.</p>
<h3>Paso 2: Autenticar la segunda cuenta</h3>
<p>Ejecuta Claude una vez con el nuevo directorio de configuración para iniciar el login OAuth:</p>
<pre><code>CLAUDE_CONFIG_DIR=~/.claude-work claude</code></pre>
<p>Se abre el navegador. Inicia sesión con tu cuenta corporativa. El token OAuth se almacena en <code>~/.claude-work</code> — completamente separado de tu sesión personal en <code>~/.claude</code>.</p>
<h3>Paso 3: Añadir un alias de shell</h3>
<p>Añade esto a tu configuración de shell para no tener que recordar la variable:</p>
<pre><code>alias claude-work=&apos;CLAUDE_CONFIG_DIR=~/.claude-work claude&apos;</code></pre>
<p>Recarga tu shell:</p>
<pre><code>source ~/.zshrc</code></pre>
<h2>Qué obtienes</h2>
<p>Ahora tienes dos entornos Claude completamente aislados:</p>
<ul><li><strong><code>claude</code></strong> — lanza con tu cuenta personal, CLAUDE.md personal, memoria personal</li><li><strong><code>claude-work</code></strong> — lanza con tu cuenta corporativa, CLAUDE.md de trabajo, memoria separada</li><li><strong>Historial aislado:</strong> las conversaciones de trabajo se quedan en trabajo, las personales en personal</li><li><strong>Servidores MCP separados:</strong> tu MCP de Obsidian personal no aparece en sesiones de trabajo</li><li><strong>Ajustes independientes:</strong> diferentes herramientas permitidas, diferentes niveles de permisos, diferentes preferencias de modelo por cuenta</li></ul>
<h2>Cómo funciona internamente</h2>
<p>El directorio de configuración es la única fuente de verdad para el estado de Claude Code. Esto es lo que vive dentro de cada uno:</p>
<pre><code>~/.claude/              ← personal account
├── CLAUDE.md           ← personal global instructions
├── projects/           ← personal project memory
├── settings.json       ← personal permissions &amp; MCP servers
└── ...                 ← OAuth session, history, cache

~/.claude-work/         ← corporate account
├── CLAUDE.md           ← company-specific instructions (Jira, Slack, etc.)
├── projects/           ← separate project memory
├── settings.json       ← different MCP servers, permissions
└── ...                 ← separate OAuth session</code></pre>
<p>Cuando ejecutas <code>claude-work</code>, Claude lee todo de <code>~/.claude-work</code>. No sabe que <code>~/.claude</code> existe. Las dos instancias son completamente independientes — puedes incluso ejecutarlas simultáneamente en diferentes pestañas del terminal.</p>
<h2>Escalando a N cuentas</h2>
<p>El patrón se extiende a cualquier número de cuentas. ¿Freelancer con múltiples clientes? Añade más aliases:</p>
<pre><code># Personal (default — no alias needed)
# Just run: claude

# Corporate
alias claude-work=&apos;CLAUDE_CONFIG_DIR=~/.claude-work claude&apos;

# Freelance client
alias claude-client=&apos;CLAUDE_CONFIG_DIR=~/.claude-client claude&apos;</code></pre>
<p>Cada alias tiene su propio directorio de configuración, su propia sesión OAuth, su propio CLAUDE.md con instrucciones específicas del cliente.</p>
<h2>Consejos prácticos</h2>
<ul><li><strong>Nombra los directorios claramente:</strong> <code>~/.claude-work</code>, <code>~/.claude-clientname</code> — te lo agradecerás cuando tengas tres o cuatro</li><li><strong>Escribe un CLAUDE.md adaptado para cada uno:</strong> el de trabajo puede incluir instrucciones de la empresa (cómo crear tickets Jira, canales Slack, procedimientos de despliegue). El personal se mantiene ligero.</li><li><strong>Diferentes servidores MCP por cuenta:</strong> configura herramientas de trabajo (Jira MCP, Slack MCP, APIs internas) solo en la configuración de trabajo. Mantén la personal limpia.</li><li><strong>Verifica qué cuenta está activa:</strong> ejecuta <code>claude config list</code> dentro de una sesión si no estás seguro — muestra la ruta al directorio de configuración</li></ul>
<h2>Dónde se queda corto este enfoque</h2>
<p><code>CLAUDE_CONFIG_DIR</code> aísla por <em>cuenta</em>, no por <em>proyecto</em>. Dentro de un mismo perfil, Claude ve todos los servidores MCP que alguna vez registraste para esa cuenta — a través de todos tus proyectos. Para uso personal en solitario suele estar bien. En el momento en que tienes varios proyectos críticos en producción bajo una sola cuenta, especialmente en dominios solapados como facturación, herramientas de administración o infraestructura, aparece un riesgo concreto entre proyectos: un asistente IA puede invocar una herramienta del proyecto A mientras trabaja en el proyecto B, sobre todo cuando ambos exponen operaciones con nombres parecidos.</p>
<p>El patrón de perfil responde a la pregunta <em>which account am I in?</em>. No responde a la pregunta <em>which project's tools should be active right now?</em>. Para trabajo de mayor riesgo, apila una segunda capa de aislamiento sobre la separación por cuentas:</p>
<ul><li><strong>Un perfil por proyecto crítico en producción, no solo por cuenta:</strong> en lugar de <code>~/.claude</code> y <code>~/.claude-work</code>, crea <code>~/.claude-work-billing</code> y <code>~/.claude-work-admin</code>. Cada perfil ve solo los servidores MCP que realmente necesita.</li><li><strong>MCP a nivel de proyecto vía <code>.mcp.json</code>:</strong> commitea un <code>.mcp.json</code> en la raíz del proyecto listando solo los servidores MCP de ese proyecto. Claude los detecta cuando se lanza desde ese directorio. Mantén tu configuración global mínima — solo herramientas universales (notas, búsqueda), ningún endpoint de producción.</li><li><strong>Nombra los servidores MCP sin ambigüedad:</strong> evita nombres genéricos como <code>admin</code>, <code>billing</code>, <code>mcp-server</code>. Prefija con el proyecto: <code>acme_billing_prod</code>, <code>acme_admin_stage</code>. Un nombre descriptivo te obliga a pararte cuando algo está a punto de invocarse desde el contexto equivocado.</li><li><strong>Revisa cada llamada a herramienta MCP antes de aprobarla:</strong> llamadas como <code>*_create_*</code>, <code>*_delete_*</code>, <code>*_charge_*</code> merecen una segunda mirada deliberada. La velocidad que ganas con auto-aprobación masiva se evapora la primera vez que una herramienta del proyecto equivocado se dispara contra producción.</li></ul>
<p>La regla general: divide perfiles de forma agresiva, mantén el MCP de producción fuera del perfil por defecto, y trata cualquier solapamiento de nombres de herramientas entre proyectos como un smell que vale la pena refactorizar.</p>
<h2>Conclusión</h2>
<p>Una variable de entorno. Un alias. Aislamiento total entre cuentas. Sin baile de logout/login, sin conflictos de configuración, sin fuga de contexto. Es el tipo de solución que es casi decepcionantemente simple — pero eso es lo que la hace buena. Configúrala una vez y no vuelvas a pensar en ello.</p>]]></content:encoded>
      <pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate>
      <category>claude-code</category>
      <category>productivity</category>
      <category>cli</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Cómo conectar Claude Desktop a Obsidian — Un viaje a través de 4 servidores MCP</title>
      <link>https://oleksiimazurenko.dev/es/blog/claude-obsidian-mcp-servers</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/es/blog/claude-obsidian-mcp-servers</guid>
      <description>Una historia real sobre la búsqueda de una forma estable de automatizar el refactoring del vault de Obsidian vía Claude. Qué se rompió, qué funcionó, y por qué VaultForge resultó ser la única opción funcional.</description>
      <content:encoded><![CDATA[<p>Imagina: tienes más de 400 notas en Obsidian, acumuladas durante años. Todo está disperso en la raíz del vault, conceptos mezclados con notas técnicas, hay duplicados (<code>ideas.md</code> y una carpeta <code>ideas/</code> con 13 archivos dentro), sin sistema. Quieres poner orden — construir una arquitectura de carpetas adecuada, agregar archivos MOC, organizar etiquetas. Hacerlo manualmente es tedioso y lento. El pensamiento lógico: <strong>conectar Claude a Obsidian vía MCP, que la IA haga el refactoring</strong>. Resulta que — es un camino a través de un campo minado. Esto es lo que tuve que pasar para llegar a una solución funcional.</p>
<h2>Qué es MCP y por qué no es tan simple</h2>
<p>MCP (Model Context Protocol) es un protocolo abierto de Anthropic que permite a Claude conectarse a herramientas externas y datos. El principio es simple: se ejecuta un servidor local que expone "herramientas" (tools), y Claude las llama durante la conversación.</p>
<p>Para Obsidian hay teóricamente muchos servidores MCP. En la práctica — cada uno tiene sus propios problemas.</p>
<p><strong>El problema principal del ecosistema Obsidian:</strong> Obsidian es una aplicación cerrada sin MCP oficial. La comunidad llenó el vacío, pero cada implementación va por su cuenta, y ninguna tiene "bendición oficial".</p>
<h2>Intento 1: MarkusPfundstein/mcp-obsidian</h2>
<p>La primera herramienta que aparece al buscar. 3.400 estrellas en GitHub, en todos los tutoriales. Parece una elección segura.</p>
<p><strong>Cómo funciona:</strong> servidor Python basado en el plugin Local REST API de Obsidian. El servidor se comunica con el plugin vía HTTPS, el plugin ejecuta operaciones a través de la API de Obsidian.</p>
<h3>Qué salió mal</h3>
<ul><li>Sin actualizar en 17 meses</li><li>85 issues abiertas</li><li><strong>Sin <code>move</code>/<code>rename</code></strong> — solo read, write, append, delete</li><li>Local REST API tiene un bug documentado de pérdida de datos: el endpoint POST puede sobrescribir silenciosamente un archivo al hacer append</li></ul>
<p>No apto para refactoring — necesitamos mover archivos y preservar enlaces. Seguimos adelante.</p>
<h2>Intento 2: aaronsb/obsidian-mcp-plugin</h2>
<p>Encontré una opción que funciona como <strong>plugin nativo de Obsidian</strong>. Esto significa acceso directo a la API interna de Obsidian — backlinks, Dataview, grafo de enlaces. Move a través de la API nativa actualiza todos los wiki-links automáticamente, porque Obsidian lo maneja internamente.</p>
<h3>Dificultades de instalación</h3>
<ul><li>El plugin <strong>no está en el catálogo oficial de Obsidian</strong> (PR pendiente con errores de validación)</li><li>Hay que instalarlo vía <strong>BRAT</strong> (Beta Reviewers Auto-update Tool)</li><li>Claude Desktop no acepta Bearer token directamente por UI — obligó a habilitar HTTPS en el plugin</li><li>Certificado self-signed para localhost crea problemas de confianza</li></ul>
<p>A través de todos estos workarounds finalmente lo conecté. Test básico — <code>vault.move</code> reescribe efectivamente <code>[[wikilinks]]</code>, funciona como se espera.</p>
<h3>Qué salió mal en producción</h3>
<p>Cuando comencé el refactoring masivo (drag-and-drop de docenas de carpetas en Obsidian + operaciones MCP simultáneas), el servidor <strong>se colgó por 4+ minutos</strong>. Por qué: el plugin corre <em>dentro</em> de Obsidian. Cuando Obsidian reindexiza miles de archivos tras un cambio masivo de estructura, el plugin se bloquea con él.</p>
<p>Conclusión: <strong>la dependencia de una instancia abierta de Obsidian y su índice es fatal para operaciones masivas</strong>.</p>
<h2>Intento 3: @bitbonsai/mcpvault</h2>
<p>Lógicamente — necesitamos un servidor que <strong>no dependa de Obsidian</strong>. Trabaja directamente con archivos en disco. <code>@bitbonsai/mcpvault</code> — recomendado en muchas reseñas. Acceso directo al sistema de archivos, configuración simple (<code>npx @bitbonsai/mcpvault@latest /path/to/vault</code>), 14 herramientas. Obsidian ni siquiera necesita estar abierto.</p>
<p><strong>Antes de instalar, verifiqué un punto crítico</strong> — si los wiki-links se actualizan al mover. Encontré una reseña de usuario:</p>
<blockquote>El conector de filesystem no sabe que está en Obsidian — ve una carpeta de archivos &lt;code&gt;.md&lt;/code&gt; y eso es todo. No sabe que los nombres de archivo llevan peso semántico, que cada &lt;code&gt;[[wikilink]]&lt;/code&gt; se romperá en el momento del rename o move. Auto-update links solo funciona cuando el rename ocurre desde dentro de la app. Lo descubrí después de pedirle a Claude que limpiara nombres de archivos y volví a un dashboard con la mitad de los enlaces rotos.</blockquote>
<p>Confirmado en la documentación del propio mcpvault: PR #101 (wiki link resolution) está <strong>en review, no mergeado</strong>. Así que mover vía <code>mcpvault</code> rompería la mitad del vault. No sirve.</p>
<h2>Intento 4: VaultForge (Final)</h2>
<p><code>blacksmithers/vaultforge</code> — construido específicamente para agentes de IA que hacen refactoring.</p>
<h3>Arquitectónicamente correcto</h3>
<ul><li><strong>Direct filesystem</strong> — no depende de Obsidian</li><li><strong>Motor propio de wikilinks</strong> — implementa lógica de resolución de <code>[[wikilink]]</code> que actualiza todas las formas (stem, ruta completa, alias, embed)</li><li><strong>Dry run por defecto</strong> en todas las operaciones destructivas — primero muestra qué cambiará, luego confirmas</li><li><strong>27 herramientas</strong> vs 8–14 en competidores: batch_rename, update_links, backlinks (impact analysis), prune_empty_dirs, frontmatter, smart_search (BM25), vault_themes (TF-IDF clustering)</li><li><strong>Licencia MIT</strong>, TypeScript, zero sub-dependencies</li><li><strong>Instalación en 30 segundos</strong> vía <code>.mcpb</code> (extensión one-click para Claude Desktop)</li></ul>
<h3>Test de seguridad en archivos aislados</h3>
<p>Creé 4 archivos de prueba con enlaces cruzados — stem links, links con alias, links con ruta completa. Moviendo un archivo a una subcarpeta:</p>
<pre><code>delta.md → subfolder/delta-renamed.md</code></pre>
<p>VaultForge mostró un dry run: "1 archivo será renombrado, 3 enlaces serán actualizados". Ejecutó de verdad.</p>
<table><thead><tr><th>Link type</th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>Stem</td><td>[[delta]]</td><td>[[delta-renamed]]</td></tr><tr><td>Alias</td><td>[[delta|D]]</td><td>[[delta-renamed|D]]</td></tr><tr><td>Full path + alias</td><td>[[_vf-test/delta|D]]</td><td>[[_vf-test/subfolder/delta-renamed|D]]</td></tr></tbody></table>
<p>Verificado después — <strong>los tres tipos de enlaces se actualizaron correctamente</strong>. Esto es exactamente lo que faltaba en todas las herramientas anteriores.</p>
<h2>Cómo instalar VaultForge — Instrucciones finales</h2>
<p>Si tienes macOS y Claude Desktop:</p>
<h3>Paso 1</h3>
<p>Descarga el archivo <code>.mcpb</code>:</p>
<pre><code>curl -fsSL https://github.com/blacksmithers/vaultforge/releases/latest/download/vaultforge.mcpb \
  -o /tmp/vaultforge.mcpb &amp;&amp; open /tmp/vaultforge.mcpb</code></pre>
<h3>Paso 2</h3>
<p>Claude Desktop abrirá el diálogo de instalación de extensión. Ingresa la <strong>ruta absoluta</strong> a tu vault — sin backslashes, con espacios normales:</p>
<pre><code>/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/MyVault</code></pre>
<h3>Paso 3</h3>
<p>Haz clic en Save. Claude Desktop agregará la extensión a la configuración automáticamente. <strong>No necesita reinicio</strong> — las extensiones <code>.mcpb</code> se detectan automáticamente.</p>
<h3>Paso 4</h3>
<p>Verifica: en un nuevo chat pregunta: <em>"What is the status of my Obsidian vault?"</em> — debería devolver algo como <code>totalFiles: 416, totalDirs: 135, ...</code></p>
<h2>Lo que aprendí sobre el ecosistema MCP de Obsidian</h2>
<p><strong>Primero, "más popular" no significa "funcional".</strong> MarkusPfundstein/mcp-obsidian tiene 3.400 estrellas y es la recomendación por defecto, pero está desactualizado y le faltan operaciones clave.</p>
<p><strong>Segundo, un plugin nativo tiene un costo oculto.</strong> El plugin aaronsb parecía ideal — graph, Dataview, move nativo. Pero la dependencia de una instancia abierta de Obsidian y su índice lo hace inadecuado para operaciones masivas serias.</p>
<p><strong>Tercero, filesystem directo sin link-engine es una trampa.</strong> Mcpvault es rápido y simple, pero "solo mover archivos" destruye la estructura del vault. Los enlaces llevan <strong>semántica impuesta</strong> que el filesystem no conoce. Sin su propia implementación de lógica wikilink, la herramienta se convierte en una mina.</p>
<p><strong>Cuarto, prueba con datos aislados.</strong> Antes de confiar a cualquier herramienta un refactoring masivo — crea una carpeta de prueba con 4–5 archivos con enlaces cruzados y mira qué pasa. 5 minutos de prueba ahorran horas de recuperación desde backup.</p>
<p><strong>Quinto, mantén un backup git de tu vault.</strong> Lo más importante de todo. Un solo <code>git init</code> dentro del vault y commits periódicos — eso es seguro contra cualquier error de un agente AI o herramienta. Si algo se rompe — <code>git reset --hard</code> lo devuelve todo.</p>
<h2>Conclusión</h2>
<p>El camino tomó varias horas y tres intentos fallidos. La arquitectura final se ve así:</p>
<ul><li><strong>VaultForge</strong> — la herramienta principal de trabajo. Direct filesystem + motor propio de wikilinks + 27 herramientas = refactoring estable a cualquier escala.</li><li><strong>Git</strong> — versionado del vault. Rollback gratuito para cualquier error.</li></ul>
<p>Ahora puedo hacer lo que motivó todo esto: pedirle a Claude que organice 400 notas en una arquitectura PARA adecuada, fusione duplicados, agregue frontmatter, construya mapas MOC. Cada operación es segura, los enlaces se preservan, el dry run muestra qué pasará antes de que algo cambie.</p>
<p>Si tú también miras tu Obsidian desordenado y quieres un asistente de IA — empieza directamente con VaultForge. No repitas mi ruta a través de proyectos muertos, plugins beta y servidores filesystem sin lógica de enlaces.</p>]]></content:encoded>
      <pubDate>Sun, 03 May 2026 00:00:00 GMT</pubDate>
      <category>Obsidian</category>
      <category>Claude</category>
      <category>MCP</category>
      <category>Automation</category>
      <category>AI</category>
    </item>
    <item>
      <title>Agujeros negros como universos recursivos: de la física al propósito de la existencia</title>
      <link>https://oleksiimazurenko.dev/es/blog/black-holes-recursive-universes</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/es/blog/black-holes-recursive-universes</guid>
      <description>¿Y si cada agujero negro es un Big Bang de un nuevo universo? Una exploración de la cosmología recursiva y el cierre cognitivo.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>¿Y si cada agujero negro fuera un Big Bang de un nuevo universo? Este artículo explora la idea de que nuestro universo podría ser un nodo en un árbol recursivo infinito, donde los agujeros negros engendran sub-universos, la energía retorna a través de la radiación de Hawking y las leyes fundamentales de la física están deliberadamente diseñadas para hacer imposible el contacto entre universos.</p>
<h2>Agujero negro = universo</h2>
<p>La idea surgió durante un momento de reflexión: un agujero negro se forma cuando suficiente masa y presión se concentran en un solo punto. Esa singularidad — densidad infinita, curvatura infinita — se parece sospechosamente a las condiciones que describimos para el Big Bang.</p>
<p>¿Y si se trata del mismo evento visto desde lados opuestos? Desde fuera, vemos un agujero negro engullendo materia. Desde dentro, un nuevo universo que estalla hacia la existencia. La masa y la energía que colapsaron en el agujero negro se convierten en la materia prima de un cosmos completamente nuevo, con sus propias estrellas, planetas y, posiblemente, sus propios agujeros negros.</p>
<blockquote>Cada agujero negro de nuestro universo podría contener un universo. Y nuestro universo podría existir dentro de un agujero negro de un universo progenitor.</blockquote>
<h2>Por qué los universos no pueden contactarse entre sí</h2>
<p>Aquí está la parte elegante: una vez cruzas el horizonte de eventos, no hay vuelta atrás. La relatividad general lo garantiza — el futuro del universo progenitor queda enteramente fuera del horizonte de eventos, inalcanzable desde el interior. Desde la perspectiva del sub-universo, el universo progenitor ya terminó. Toda su línea temporal ya transcurrió.</p>
<p>Esto no es una limitación técnica que podamos superar con mejor tecnología. Está incorporado en la geometría misma del espacio-tiempo. Los universos están fundamentalmente aislados unos de otros — no por la distancia, sino por la estructura del tiempo.</p>
<h2>El ciclo energético: préstamo y devolución</h2>
<p>Pero la energía no se pierde. La radiación de Hawking — el proceso cuántico mediante el cual los agujeros negros se evaporan lentamente — crea un ciclo extraordinario:</p>
<ol><li>Un universo progenitor crea un agujero negro, transfiriendo energía a un sub-universo</li><li>El sub-universo vive todo su ciclo vital a lo largo de billones de años</li><li>El agujero negro se evapora lentamente, devolviendo energía al universo progenitor mediante la radiación de Hawking</li><li>El universo progenitor recibe su energía de vuelta — con intereses</li></ol>
<p>Esos «intereses» son fascinantes: los físicos ahora creen que la radiación de Hawking preserva la información. El universo progenitor no recibe solo energía vacía de vuelta — recibe una huella de todo lo que ocurrió en su interior. Cada estrella que se formó, cada planeta, cada instante de consciencia — codificado en radiación.</p>
<h2>Recursión hasta el fondo</h2>
<p>Si eres programador, el patrón es inconfundible. Esto es recursión. Cada universo invoca <code>universe()</code> con menos energía, creando sub-universos que crean sub-sub-universos, hasta que no queda energía suficiente para formar agujeros negros — el caso base.</p>
<pre><code>universe(energy)
  ├── creates black holes
  │     ├── universe(energy - n)
  │     │     ├── universe(energy - n - m)
  │     │     │     └── base case: not enough energy for black holes
  │     │     └── returns energy via Hawking radiation
  │     └── returns energy via Hawking radiation
  └── receives all energy back</code></pre>
<p>El físico Lee Smolin formalizó una idea similar como <strong>Selección Natural Cosmológica</strong>: los universos se «reproducen» a través de agujeros negros, y cada generación posee constantes físicas ligeramente diferentes — optimizadas a lo largo de innumerables ciclos para producir más agujeros negros, más universos.</p>
<h2>¿Dónde estamos en este ciclo?</h2>
<p>Nuestro universo tiene aproximadamente 13.800 millones de años. Suena antiguo, pero en el contexto de su vida útil completa, estamos presenciando el mismísimo comienzo:</p>
<table><thead><tr><th>Evento</th><th>Escala temporal</th></tr></thead><tbody><tr><td>Edad actual del universo</td><td>~10¹⁰ years</td></tr><tr><td>Las estrellas dejan de formarse</td><td>~10¹⁴ years</td></tr><tr><td>Era de los agujeros negros</td><td>~10⁴⁰ years</td></tr><tr><td>El último agujero negro se evapora</td><td>~10¹⁰⁰ years</td></tr></tbody></table>
<p>Existimos en aproximadamente el 0,00000000...01% de la vida total de nuestro universo. La era de las estrellas — todo lo que podemos ver — es un destello fugaz justo al principio. La verdadera historia del universo es la lenta y paciente era de los agujeros negros creando y evaporando sub-universos.</p>
<h2>La cuestión de las dimensiones superiores</h2>
<p>Todo lo discutido hasta ahora opera dentro de nuestra comprensión tridimensional. Pero si nuestro universo es un «corte» de algo de dimensiones superiores, entonces todo el árbol recursivo de agujeros negros y sub-universos podría ser solo la sombra de una estructura que no podemos percibir.</p>
<p>En 1884, Edwin Abbott escribió <em>Planilandia</em> — una historia sobre seres bidimensionales incapaces de concebir una tercera dimensión. Una esfera que atraviesa Planilandia aparece como un círculo que crece y se encoge. Los «planilandeses» pueden describirlo matemáticamente pero jamás comprenderán realmente lo que están viendo. Nosotros podríamos estar en exactamente la misma posición con respecto a nuestro universo.</p>
<blockquote>¿Qué es la consciencia? ¿Por qué existe la experiencia subjetiva? David Chalmers llamó a esto el «problema difícil» — y podría ser la evidencia más contundente de que algo opera más allá de nuestro alcance dimensional.</blockquote>
<h2>Todo está bloqueado a nivel fundamental</h2>
<p>La constatación más impactante no es que no sabemos — es que <em>no podemos</em> saber. Cada dirección de investigación choca con una barrera fundamental:</p>
<ul><li><strong>¿Quieres ver el universo progenitor?</strong> Bloqueado por el horizonte de eventos</li><li><strong>¿Quieres entender la consciencia?</strong> Bloqueado — un sistema no puede analizarse completamente a sí mismo (teoremas de incompletitud de Gödel)</li><li><strong>¿Quieres saber qué había «antes»?</strong> Bloqueado — el tiempo comenzó con el Big Bang</li><li><strong>¿Quieres percibir dimensiones superiores?</strong> Bloqueado por las limitaciones cognitivas de un ser tridimensional</li></ul>
<p>El filósofo Colin McGinn llama a esto <strong>clausura cognitiva</strong>: algunas preguntas están cerradas para la mente humana, no por falta de datos, sino por la arquitectura de la propia mente. La diferencia entre «aún no lo sabemos» y «no podemos saberlo» es profunda.</p>
<h2>Lo único que queda: la mejora personal</h2>
<p>Si cada salida está bloqueada por diseño — si no puedes mirar afuera, no puedes mirar atrás, no puedes mirar arriba — entonces solo queda una dirección: hacia adentro. El universo parece deliberadamente construido para forzar la atención sobre uno mismo.</p>
<p>Esta conclusión no proviene de la religión ni de los manuales de filosofía. Proviene de seguir la lógica de los agujeros negros, la recursión, la teoría de la información y los límites de la cognición. Existencialistas, budistas, estoicos y físicos llegan al mismo punto por caminos diferentes: el propósito de la existencia podría ser simplemente el perfeccionamiento del ser que existe.</p>
<blockquote>Llegamos a esto no a través de la fe, sino a través de la física — desde los agujeros negros, pasando por universos recursivos, hasta los bloqueos fundamentales del conocimiento, hasta la única puerta abierta: ser mejores.</blockquote>
<h2>Referencias</h2>
<ul><li><a href="https://en.wikipedia.org/wiki/Cosmological_natural_selection" target="_blank" rel="noopener">Lee Smolin — Selección Natural Cosmológica</a></li><li><a href="https://en.wikipedia.org/wiki/Hawking_radiation" target="_blank" rel="noopener">Stephen Hawking — Radiación de Hawking</a></li><li><a href="https://en.wikipedia.org/wiki/Hard_problem_of_consciousness" target="_blank" rel="noopener">David Chalmers — El Problema Difícil de la Consciencia</a></li><li><a href="https://en.wikipedia.org/wiki/Flatland" target="_blank" rel="noopener">Edwin Abbott — Planilandia: Una Novela de Muchas Dimensiones (1884)</a></li><li><a href="https://en.wikipedia.org/wiki/G%C3%B6del%27s_incompleteness_theorems" target="_blank" rel="noopener">Kurt Gödel — Teoremas de Incompletitud</a></li></ul>]]></content:encoded>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <category>physics</category>
      <category>philosophy</category>
      <category>consciousness</category>
      <category>black-holes</category>
      <category>cosmology</category>
    </item>
    <item>
      <title>La IA mató al CMS — al menos para sitios simples</title>
      <link>https://oleksiimazurenko.dev/es/blog/ai-killed-cms-for-simple-sites</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/es/blog/ai-killed-cms-for-simple-sites</guid>
      <description>Por qué los sistemas de gestión de contenido tradicionales se están volviendo innecesarios para portfolios, blogs y landing pages.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Para sitios web sencillos — portfolios, blogs, landing pages, páginas de pequeñas empresas — un CMS tradicional se está convirtiendo en sobrecarga innecesaria. Herramientas de IA como Claude Code, Cursor y GitHub Copilot ahora pueden editar tu código directamente, entender el contexto, traducir contenido y desplegar cambios a través de git. La capa de abstracción que proporcionaba el CMS está siendo reemplazada por una interfaz más inteligente: el lenguaje natural.</p>
<h2>El impuesto CMS que estás pagando</h2>
<p>Todo CMS viene con un coste oculto. No solo la cuota de suscripción — todo un ecosistema de complejidad que envuelve tu sitio web, que de otro modo sería sencillo:</p>
<ul><li><strong>Infraestructura:</strong> Una base de datos que alojar, una API que mantener, un panel de control que asegurar. Solo WordPress representa ~43% de la web y ~90% de los ataques dirigidos a CMS.</li><li><strong>Rendimiento:</strong> Generación dinámica de páginas, llamadas API en cada petición, hidratación del lado del cliente con datos del CMS. Tu portfolio de 3 páginas ahora tiene la arquitectura de un producto SaaS.</li><li><strong>Dependencia del proveedor:</strong> Tu contenido vive en el esquema de base de datos de otro. ¿Migrar de Contentful a Sanity? Eso es un proyecto, no un cambio de configuración.</li><li><strong>Cambio de contexto:</strong> Editas código en tu IDE, luego cambias al panel del CMS en el navegador para modificar un titular. Dos modelos mentales diferentes para lo que es fundamentalmente la misma operación.</li><li><strong>Coste:</strong> Los precios de CMS headless suelen escalar con las llamadas API o las entradas de contenido. Un blog personal no necesita una infraestructura de contenido de $99/mes.</li></ul>
<p>Para un sitio de marketing donde 50 personas editan contenido a diario, este coste está justificado. ¿Para el portfolio de un desarrollador o la landing page de una pequeña empresa? Estás construyendo un puente para cruzar un charco.</p>
<h2>Qué cambió: la IA entiende tu código</h2>
<p>La razón de existir de los CMS era sencilla: las personas no técnicas (e incluso los desarrolladores que no querían tocar código para cambios de contenido) necesitaban una interfaz visual para actualizar sitios web. El código era demasiado complejo, demasiado frágil, demasiado fácil de romper.</p>
<p>La IA cambió esta ecuación de forma fundamental. Las herramientas modernas de IA para código no solo autocompletan — entienden la estructura del proyecto, leen patrones existentes y realizan ediciones contextualmente correctas. El cambio en el flujo de trabajo es radical:</p>
<pre><code># Old workflow: CMS
1. Open browser → log into CMS dashboard
2. Navigate to content → find the right page
3. Edit in WYSIWYG editor → fight with formatting
4. Preview → looks different from production
5. Hit publish → pray the cache invalidates

# New workflow: AI
1. Tell AI: &quot;Change the pricing on the landing page to $29/month&quot;
2. AI edits the file, you review the diff
3. Push to git → deploy</code></pre>
<p>Esto no es hipotético. Este mismo blog funciona con SolidStart y el contenido se almacena como archivos TypeScript. Cada artículo — incluido este — fue creado diciéndole a la IA qué escribir, revisando el resultado y haciendo push a git. Sin panel de CMS. Sin base de datos. Sin capa API entre el contenido y el código.</p>
<h2>Ejemplos reales de este sitio</h2>
<p>Este sitio soporta 10 idiomas, tiene un blog, genera imágenes OG dinámicamente y produce feeds RSS y sitemaps. Así es como se ve la capa de contenido — TypeScript puro:</p>
<pre><code>// This blog post you&apos;re reading right now is a TypeScript file.
// No database. No API. No CMS dashboard.
// Just a typed object that AI can read and edit directly.

export const myPost: BlogPost = {
  slug: &quot;ai-killed-cms&quot;,
  date: &quot;2026-04-17&quot;,
  translations: {
    en: {
      title: &quot;Why I stopped using a CMS&quot;,
      description: &quot;AI understands my codebase better than any CMS UI.&quot;,
      content: makeContent(proseEn),
    },
    uk: { /* ... */ },
    de: { /* ... */ },
    // 10 languages — AI translates them all
  },
};</code></pre>
<p>Cosas que hago con IA que tradicionalmente requerirían un CMS:</p>
<ul><li><strong>Añadir un nuevo artículo:</strong> "Escribe un nuevo artículo sobre X, sigue la misma estructura que los posts existentes" — la IA crea el archivo, añade traducciones, lo registra en el índice</li><li><strong>Actualizar el copy de la landing:</strong> "Cambia el titular del hero a Y" — la IA encuentra el archivo correcto y lo actualiza</li><li><strong>Traducir contenido:</strong> "Añade la traducción al alemán de la página de precios" — la IA lee la versión en inglés y produce una traducción culturalmente adaptada, no una traducción literal</li><li><strong>Corregir una errata:</strong> "Hay una errata en la página about, 'recieve' debería ser 'receive'" — hecho en 3 segundos, commiteado en git con un mensaje descriptivo</li></ul>
<h2>Lo que el CMS realmente resolvía — y cómo la IA lo reemplaza</h2>
<p>Seamos honestos sobre lo que el CMS aportaba y cómo cada capacidad se traduce al flujo de trabajo con IA:</p>
<table><thead><tr><th>Problema</th><th>Solución CMS</th><th>Solución IA</th></tr></thead><tbody><tr><td>Edición no técnica</td><td>Editor WYSIWYG</td><td>Instrucciones en lenguaje natural</td></tr><tr><td>Contenido multilingüe</td><td>Plugins i18n, campos de locale</td><td>La IA traduce con contexto cultural</td></tr><tr><td>Programación de contenido</td><td>Fechas de publicación integradas</td><td>CI/CD basado en git con cron o campos de fecha en el código</td></tr><tr><td>Historial de versiones</td><td>Sistema de revisiones del CMS</td><td>Git — el estándar de oro del control de versiones</td></tr><tr><td>Gestión de medios</td><td>Biblioteca de assets integrada</td><td>CDN + git LFS o almacenamiento en la nube</td></tr></tbody></table>
<p>La clave: git ya es un mejor sistema de control de versiones que cualquier CMS haya construido jamás. Y el lenguaje natural es una mejor interfaz que cualquier editor WYSIWYG — porque transmite intención, no solo formato.</p>
<h2>El cambio de paradigma: el código es la capa de contenido</h2>
<p>Estamos presenciando una inversión. Durante dos décadas, la tendencia fue separar el contenido del código — poner el contenido en una base de datos, exponerlo vía API, renderizarlo en el frontend. Esto tenía sentido cuando el código era difícil de editar y el contenido necesitaba ser accesible para personas no técnicas.</p>
<blockquote>La IA no hizo obsoleto al CMS siendo un CMS mejor. Lo hizo obsoleto haciendo que el código fuera tan accesible como un panel de control.</blockquote>
<p>La progresión de la gestión de contenido web sigue una trayectoria clara:</p>
<ol><li><strong>2000s:</strong> CMS monolíticos (WordPress, Drupal) — contenido y presentación acoplados en un solo sistema</li><li><strong>2010s:</strong> CMS headless (Contentful, Strapi) — contenido separado vía API, renderizado por frameworks frontend</li><li><strong>2020s:</strong> Generadores de sitios estáticos + Markdown (Hugo, Astro) — contenido como archivos, compilado en el despliegue</li><li><strong>2025+:</strong> Código-como-contenido + IA — el contenido vive en código tipado, la IA es la interfaz de edición</li></ol>
<h2>Cuándo todavía necesitas un CMS</h2>
<p>Esto no es un discurso de "el CMS ha muerto". El CMS resuelve problemas reales a escala. Todavía lo necesitas cuando:</p>
<ul><li><strong>Grandes equipos editoriales:</strong> Más de 10 editores de contenido que necesitan acceso basado en roles, flujos de aprobación y edición simultánea. Los conflictos de merge en git no son un problema que deba resolver un editor de contenido.</li><li><strong>Contenido de alta frecuencia:</strong> Sitios de noticias que publican más de 50 artículos al día necesitan pipelines editoriales optimizados, no commits de git.</li><li><strong>Relaciones de contenido complejas:</strong> Catálogos de e-commerce con miles de SKUs, variantes de producto y precios dinámicos necesitan bases de datos estructuradas.</li><li><strong>Cumplimiento regulatorio:</strong> Industrias que requieren registros de auditoría, cadenas de aprobación de contenido y procesos de revisión legalmente obligatorios necesitan sistemas especializados.</li></ul>
<p>El límite es claro: si tus cambios de contenido requieren coordinación entre múltiples stakeholders no técnicos a alta frecuencia, un CMS merece su complejidad. Si eres un desarrollador en solitario, un equipo pequeño, o gestionas un sitio que cambia semanalmente en lugar de cada hora — IA + código es más simple, más rápido, más barato y más fiable.</p>
<h2>El futuro: la IA como interfaz universal</h2>
<p>La tendencia va más allá del CMS. Cada capa de abstracción que existía porque "el sistema subyacente es demasiado complejo para la interacción directa" está siendo comprimida por la IA. Paneles de administración, interfaces de configuración, editores visuales de bases de datos — todos son interfaces que traducen la intención humana en cambios del sistema. La IA hace esta traducción de forma nativa.</p>
<p>Para sitios web sencillos, el futuro ya está aquí. Tu contenido es código. Tu editor es IA. Tu control de versiones es git. Tu despliegue es un push. Toda la capa CMS — el panel, la base de datos, la API, el hosting — era middleware entre tu intención y tu sitio web. La IA eliminó la necesidad de ese middleware.</p>
<blockquote>El mejor CMS es no tener CMS. No porque la gestión de contenido no importe — sino porque la IA convirtió al código en la interfaz de gestión de contenido más intuitiva que jamás hemos tenido.</blockquote>]]></content:encoded>
      <pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
      <category>ai</category>
      <category>cms</category>
      <category>web-development</category>
      <category>opinion</category>
      <category>workflow</category>
    </item>
    <item>
      <title>Cómo conectar Perplexity AI a Obsidian vía MCP — Notas directas desde el chat</title>
      <link>https://oleksiimazurenko.dev/es/blog/perplexity-obsidian-mcp-integration</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/es/blog/perplexity-obsidian-mcp-integration</guid>
      <description>Configura Perplexity Desktop para leer y escribir tu Obsidian vault usando el servidor MCP filesystem. Investiga en la web y guarda en tus notas en una conversación.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Perplexity Desktop soporta conectores <strong>MCP (Model Context Protocol)</strong>. Al agregar el servidor oficial <code>@modelcontextprotocol/server-filesystem</code> apuntando a tu Obsidian vault, puedes decirle a Perplexity en lenguaje natural que lea, cree y edite notas — directamente desde el chat. Sin plugins, sin extensiones, sin copiar y pegar.</p>
<h2>El Problema</h2>
<p>Perplexity es excelente para investigar — busca en la web, resume fuentes y da respuestas con citas. Pero cuando quieres guardar esos hallazgos en tu Obsidian vault, el flujo de trabajo se rompe: copias texto, cambias a Obsidian, encuentras la nota correcta, pegas, formateas. Cada. Vez.</p>
<p>Las extensiones de navegador como "Perplexity to Obsidian" ayudan con la exportación, pero son unidireccionales — la IA no puede <em>ver</em> tu vault, no puede leer tus notas existentes y no puede decidir dónde poner las cosas basándose en tu estructura de carpetas.</p>
<h2>¿Qué es MCP?</h2>
<p><strong>Model Context Protocol (MCP)</strong> es un estándar abierto que permite a los modelos de IA interactuar con herramientas y fuentes de datos locales. Piensa en ello como un puerto USB para IA — conectas un "servidor" (un pequeño programa) y la IA obtiene nuevas capacidades. En nuestro caso, el servidor filesystem le da a Perplexity 14 herramientas para trabajar con archivos:</p>
<pre><code>Perplexity Desktop (Mac App)
  │
  ├── MCP Connector: &quot;Obsidian Vault&quot;
  │     │
  │     └── @modelcontextprotocol/server-filesystem
  │           │
  │           ├── read_file()      → read any .md file
  │           ├── write_file()     → create or overwrite files
  │           ├── edit_file()      → patch existing files
  │           ├── list_directory() → browse vault structure
  │           ├── search_files()   → find files by pattern
  │           └── ... 14 tools total
  │
  └── Perplexity AI Model
        │
        └── &quot;Save this to my daily note&quot; → calls write_file()</code></pre>
<p>El punto clave: el modelo de IA no accede directamente a tus archivos. Llama a herramientas proporcionadas por el servidor MCP, que se ejecuta localmente en tu máquina. Tus datos nunca salen de tu computadora a menos que le pidas explícitamente a la IA que haga algo con ellos.</p>
<h2>Requisitos</h2>
<ul><li>Suscripción <strong>Perplexity Pro</strong> (los conectores MCP están disponibles para usuarios de pago)</li><li><strong>Perplexity Mac App</strong> del App Store (no la versión del navegador)</li><li><strong>Node.js</strong> instalado en tu Mac (para que funcione <code>npx</code>)</li></ul>
<h2>Configuración paso a paso</h2>
<p>Toda la configuración toma unos 2 minutos:</p>
<table><thead><tr><th>Step</th><th>Action</th></tr></thead><tbody><tr><td>1</td><td>Open &lt;strong&gt;Perplexity Mac App&lt;/strong&gt; (App Store version)</td></tr><tr><td>2</td><td>Click avatar → &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Connectors&lt;/strong&gt;</td></tr><tr><td>3</td><td>Click &lt;strong&gt;Add Connector&lt;/strong&gt;</td></tr><tr><td>4</td><td>Set Server Name: &lt;code&gt;Obsidian Vault&lt;/code&gt;, Type: &lt;code&gt;Standard I/O&lt;/code&gt;</td></tr><tr><td>5</td><td>Paste the npx command with your vault path</td></tr><tr><td>6</td><td>Leave Environment Variables empty</td></tr><tr><td>7</td><td>Click &lt;strong&gt;Save&lt;/strong&gt; → confirm Security Warning</td></tr><tr><td>8</td><td>Verify status shows &lt;strong&gt;Running&lt;/strong&gt; with 14 tools</td></tr></tbody></table>
<h3>El Comando</h3>
<p>El comando a pegar en el campo <strong>Command</strong>:</p>
<pre><code>npx -y @modelcontextprotocol/server-filesystem &quot;/Users/yourname/Library/Mobile Documents/iCloud~md~obsidian/Documents/Obsidian Vault&quot;</code></pre>
<p>Reemplaza la ruta con la ubicación real de tu Obsidian vault. Si tu vault se sincroniza via iCloud, la ruta estará bajo <code>~/Library/Mobile Documents/iCloud~md~obsidian/Documents/</code>. Asegúrate de mantener las comillas — la ruta probablemente contiene espacios.</p>
<h2>Cómo usarlo</h2>
<p>Una vez que el conector muestre <strong>Running</strong> con 14 herramientas disponibles, ve a cualquier chat de Perplexity y empieza a hablar con tu vault:</p>
<pre><code>&gt; Show me the structure of my Obsidian vault
&gt; Add a new reflection to daily notes/2026-04-12.md: &quot;Started using MCP today&quot;
&gt; Find all notes that mention &quot;meditation&quot;
&gt; Create a new note in concepts/ about quantum computing
&gt; List all files in my ideas/ folder</code></pre>
<p>La IA entiende la estructura de tu vault, respeta tus convenciones de formato y puede trabajar con contenido existente. Puedes pedirle que investigue un tema en la web y guarde el resumen directamente en una nota específica.</p>
<h2>Por qué MCP supera otros enfoques</h2>
<p>Antes de MCP, había formas limitadas de conectar Perplexity y Obsidian:</p>
<table><thead><tr><th>Feature</th><th>Copy-paste</th><th>Browser extension</th><th>MCP integration</th></tr></thead><tbody><tr><td>AI sees vault structure</td><td>No</td><td>No</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>AI reads existing notes</td><td>No</td><td>No</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>AI writes to vault</td><td>No</td><td>Export only</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>AI edits existing files</td><td>No</td><td>No</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>Works from chat</td><td>No</td><td>Partially</td><td>&lt;strong&gt;Yes&lt;/strong&gt;</td></tr><tr><td>Setup complexity</td><td>None</td><td>Low</td><td>Medium (one-time)</td></tr></tbody></table>
<h2>Limitaciones actuales</h2>
<ul><li><strong>Solo Mac</strong> — los conectores MCP de Perplexity actualmente solo funcionan en la versión de Mac App Store</li><li><strong>Sin integración con Obsidian API</strong> — el servidor filesystem trabaja con archivos directamente, no a través de la API de Obsidian. Esto significa que no activará plugins de Obsidian (Linter, Templater) al crear archivos</li><li><strong>Requiere aprobación</strong> — las operaciones sensibles de archivos pueden requerir tu confirmación en la app de Perplexity — es una función de seguridad, no un bug</li></ul>
<h2>Conclusiones</h2>
<p>Esta configuración convierte Perplexity de una herramienta de investigación en una herramienta de investigación-y-captura:</p>
<ol><li>Investiga en la web y guarda en Obsidian en una conversación</li><li>La IA ve la estructura de tu vault y se adapta a tu sistema de organización</li><li>Cero cambios entre apps — todo sucede en el chat de Perplexity</li></ol>
<h2>Fuentes</h2>
<ul><li><a href="https://modelcontextprotocol.io" target="_blank" rel="noopener noreferrer">Model Context Protocol</a> — official MCP specification</li><li><a href="https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem" target="_blank" rel="noopener noreferrer">MCP Filesystem Server</a> — the server used in this setup</li><li><a href="https://www.perplexity.ai/help-center/en/articles/11502712-local-and-remote-mcps-for-perplexity" target="_blank" rel="noopener noreferrer">Perplexity Help Center</a> — Local and Remote MCPs for Perplexity</li><li><a href="https://www.perplexity.ai/hub/blog/everything-is-computer" target="_blank" rel="noopener noreferrer">Perplexity Blog</a> — Everything is Computer</li></ul>]]></content:encoded>
      <pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate>
      <category>Obsidian</category>
      <category>Perplexity</category>
      <category>MCP</category>
      <category>AI</category>
      <category>Productivity</category>
    </item>
    <item>
      <title>Digest Diario de Noticias AI con Claude Code CLI y Obsidian — Cero Dependencias</title>
      <link>https://oleksiimazurenko.dev/es/blog/ai-news-digest-claude-code-obsidian</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/es/blog/ai-news-digest-claude-code-obsidian</guid>
      <description>Cómo construí un agente diario de investigación de noticias con un script bash de 6 líneas, modo headless de Claude Code y macOS launchd.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Un script bash de 6 líneas que ejecuta <strong>Claude Code CLI</strong> en modo headless cada mañana a las 9:00. Busca noticias en 11 temas configurables, filtra el ruido y escribe un digest markdown formateado directamente en un <strong>Obsidian vault</strong> sincronizado vía iCloud. Cero dependencias. ~100 líneas de configuración total.</p>
<h2>El Problema</h2>
<p>Como desarrollador, mantenerse al día con múltiples tecnologías es un impuesto diario. Los feeds RSS son ruidosos, Twitter consume tiempo, los newsletters llegan cuando estás en estado de flujo. Necesitaba algo que hiciera la investigación <em>por</em> mí.</p>
<p>La solución típica es construir un pipeline de scraping: un programador, un crawler, un pipeline NLP, una base de datos, un servicio de notificaciones. Eso son semanas de trabajo. Yo quería algo que pudiera hacer en una tarde.</p>
<h2>Arquitectura</h2>
<p>Todo el sistema son 4 archivos y cero dependencias:</p>
<pre><code>macOS launchd (9:00 AM daily)
  │
  └── digest.sh
        │
        └── claude -p &quot;$(cat prompt.md)&quot; --max-turns 20 --allowedTools Read,WebSearch,WebFetch,Write
              │
              ├── Reads topics.yaml (11 configurable topics)
              ├── WebSearch → finds news for each topic (last 24-48h)
              ├── WebFetch → reads full articles
              ├── Filters noise: old tutorials, promos, AI spam
              └── Write → saves digest to Obsidian Vault
                    │
                    └── ~/Obsidian Vault/digests/2026-04-12.md
                          │
                          └── iCloud sync → available on all devices</code></pre>
<h2>El Código (todo)</h2>
<p>El proyecto es intencionalmente mínimo.</p>
<h3>Punto de entrada: digest.sh</h3>
<p>Toda la aplicación es un script bash de 6 líneas:</p>
<pre><code>#!/bin/bash
DIGEST_DIR=&quot;$HOME/Developer/news-digest&quot;

claude -p &quot;$(cat &quot;$DIGEST_DIR/prompt.md&quot;)&quot; \
  --max-turns 20 \
  --allowedTools Read,WebSearch,WebFetch,Write</code></pre>
<p>Los flags clave: <code>-p</code> ejecuta Claude en modo headless, <code>--max-turns 20</code> da suficientes turnos al agente, <code>--allowedTools</code> restringe al agente a lectura, búsqueda y escritura.</p>
<h3>El Cerebro: prompt.md</h3>
<p>Aquí vive la inteligencia. El prompt convierte a Claude en un agente de investigación de noticias:</p>
<pre><code># News Digest Agent

You are a news research agent. Your job is to find today&apos;s most important
and interesting news for a senior frontend developer.

## Instructions

1. Read the topics file at ~/Developer/news-digest/topics.yaml
2. For EACH topic, search the web for news from the last 24-48 hours
3. Filter: only include genuinely new and noteworthy items
4. Write the digest as a markdown file to Obsidian Vault digests/YYYY-MM-DD.md
5. IMPORTANT: Use the Write tool to save the file. Do NOT output to stdout.

## Rules

- Language: Ukrainian for summaries, English for titles and technical terms
- If there&apos;s no real news for a topic — SKIP IT ENTIRELY
- Prioritize: releases &gt; breaking changes &gt; security &gt; new patterns &gt; discussions
- Max 5 items per topic, sorted by importance
- Include direct links to sources
- Skip promotional content, generic tutorials, and AI-generated spam</code></pre>
<h3>Configuración: topics.yaml</h3>
<p>Los temas son totalmente configurables — agrega uno nuevo y estará en el digest de mañana:</p>
<pre><code>topics:
  - name: better-auth
    context: &quot;auth library for TypeScript. New releases, breaking changes&quot;

  - name: Next.js
    context: &quot;GitHub issues, releases, App Router, Turbopack, performance&quot;

  - name: SolidJS
    context: &quot;SolidStart, releases, ecosystem, comparison with React&quot;

  - name: Tailwind CSS
    context: &quot;v4 updates, new utilities, plugins&quot;

  - name: Claude AI
    context: &quot;Anthropic announcements, Claude Code, new models, MCP, API&quot;

  - name: GPT AI
    context: &quot;OpenAI announcements, new models, ChatGPT features&quot;

  - name: React
    context: &quot;React 19+, Server Components, new patterns, ecosystem&quot;

  - name: Apple
    context: &quot;Hardware, software, WWDC, developer tools, Apple Intelligence&quot;

  - name: Notable People
    context: &gt;
      Latest tweets from: Elon Musk, Dario Amodei, Sam Altman,
      Jensen Huang, Andrej Karpathy, Simon Willison, Swyx...

  - name: AI Global
    context: &quot;Major AI news, new models, regulations, open-source AI&quot;</code></pre>
<h2>Programación con launchd</h2>
<p>En macOS, <code>launchd</code> es la forma nativa de programar tareas recurrentes:</p>
<pre><code>&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;!DOCTYPE plist PUBLIC &quot;-//Apple//DTD PLIST 1.0//EN&quot;
  &quot;http://www.apple.com/DTDs/PropertyList-1.0.dtd&quot;&gt;
&lt;plist version=&quot;1.0&quot;&gt;
&lt;dict&gt;
    &lt;key&gt;Label&lt;/key&gt;
    &lt;string&gt;com.news-digest&lt;/string&gt;

    &lt;key&gt;ProgramArguments&lt;/key&gt;
    &lt;array&gt;
        &lt;string&gt;/bin/bash&lt;/string&gt;
        &lt;string&gt;/Users/you/Developer/news-digest/digest.sh&lt;/string&gt;
    &lt;/array&gt;

    &lt;key&gt;StartCalendarInterval&lt;/key&gt;
    &lt;dict&gt;
        &lt;key&gt;Hour&lt;/key&gt;
        &lt;integer&gt;9&lt;/integer&gt;
        &lt;key&gt;Minute&lt;/key&gt;
        &lt;integer&gt;0&lt;/integer&gt;
    &lt;/dict&gt;

    &lt;key&gt;StandardOutPath&lt;/key&gt;
    &lt;string&gt;/Users/you/Developer/news-digest/logs/stdout.log&lt;/string&gt;
    &lt;key&gt;StandardErrorPath&lt;/key&gt;
    &lt;string&gt;/Users/you/Developer/news-digest/logs/stderr.log&lt;/string&gt;

    &lt;key&gt;EnvironmentVariables&lt;/key&gt;
    &lt;dict&gt;
        &lt;key&gt;PATH&lt;/key&gt;
        &lt;string&gt;/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin&lt;/string&gt;
        &lt;key&gt;HOME&lt;/key&gt;
        &lt;string&gt;/Users/you&lt;/string&gt;
    &lt;/dict&gt;
&lt;/dict&gt;
&lt;/plist&gt;</code></pre>
<p>Instalar con <code>launchctl load ~/Library/LaunchAgents/com.news-digest.plist</code>. El script se ejecuta diariamente a las 9:00.</p>
<h2>Cómo se ve el resultado</h2>
<p>Cada mañana aparece un nuevo archivo markdown en el Obsidian vault:</p>
<pre><code>---
date: 2026-04-12
---

# News Digest — 2026-04-12

## Next.js

### Next.js 16.1 Released with Improved Turbopack Caching
Нова версія Next.js 16.1 включає покращене кешування для Turbopack,
що зменшує час холодного старту на ~40%.
[Посилання](https://nextjs.org/blog/next-16-1)

### Critical Memory Leak Fix in App Router
Виправлено витік пам&apos;яті в App Router при частому перемиканні
між динамічними маршрутами.
[GitHub Issue](https://github.com/vercel/next.js/issues/...)

## Claude AI

### Claude Code 1.5 — MCP Server Auto-Discovery
Anthropic випустив оновлення Claude Code з автоматичним
виявленням MCP серверів у проєкті.
[Блог](https://www.anthropic.com/news/...)</code></pre>
<h2>En números</h2>
<table><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody><tr><td>Total files in project</td><td>&lt;strong&gt;4&lt;/strong&gt; (digest.sh, prompt.md, topics.yaml, .gitignore)</td></tr><tr><td>Lines of code</td><td>&lt;strong&gt;~100&lt;/strong&gt;</td></tr><tr><td>External dependencies</td><td>&lt;strong&gt;0&lt;/strong&gt;</td></tr><tr><td>Setup time</td><td>&lt;strong&gt;~10 minutes&lt;/strong&gt;</td></tr><tr><td>Daily execution time</td><td>&lt;strong&gt;2-5 minutes&lt;/strong&gt;</td></tr><tr><td>Cost per run</td><td>&lt;strong&gt;~$0.10-0.30&lt;/strong&gt; (Claude API usage)</td></tr></tbody></table>
<h2>Decisiones clave de diseño</h2>
<ul><li><strong>Claude Code CLI en vez de API</strong> — sin gestionar API keys, clientes HTTP o parseo de respuestas</li><li><strong>Obsidian en vez de email</strong> — los digests son buscables, enlazables y permanentes</li><li><strong>launchd en vez de cron</strong> — programador nativo de macOS con manejo de ejecuciones perdidas</li><li><strong>YAML para temas</strong> — un nuevo tema es un cambio de 2 líneas</li><li><strong>Saltar temas vacíos</strong> — sin noticias = sin sección</li></ul>
<h2>Cómo construir el tuyo</h2>
<p>Listo en 10 minutos:</p>
<ol><li>Instalar <a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" rel="noopener noreferrer">Claude Code CLI</a> y autenticarse</li><li>Clonar el repo: <code>git clone https://github.com/oleksiimazurenko/news-digest</code></li><li>Editar <code>topics.yaml</code> y <code>prompt.md</code></li><li>Editar el plist y <code>launchctl load</code></li><li>Esperar a las 9:00 — o probar manualmente con <code>bash digest.sh</code></li></ol>
<h2>Conclusiones</h2>
<p>Lo más interesante de este proyecto es lo que <em>no</em> tiene. Sin base de datos, sin servidor API, sin Docker, sin npm, sin Python, sin parser HTML, sin pipeline NLP.</p>
<p>Así es construir con agentes AI: defines el <em>qué</em> y el <em>dónde</em>, el agente maneja el <em>cómo</em>. Tiempo total: unas 2 horas.</p>
<h2>Fuentes</h2>
<ul><li><a href="https://github.com/oleksiimazurenko/news-digest" target="_blank" rel="noopener noreferrer">news-digest on GitHub</a> — full source code</li><li><a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" rel="noopener noreferrer">Claude Code Documentation</a> — headless mode and CLI flags</li><li><a href="https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html" target="_blank" rel="noopener noreferrer">Apple Developer</a> — creating launchd jobs</li><li><a href="https://obsidian.md" target="_blank" rel="noopener noreferrer">Obsidian</a> — markdown knowledge base</li></ul>]]></content:encoded>
      <pubDate>Sun, 12 Apr 2026 00:00:00 GMT</pubDate>
      <category>Claude Code</category>
      <category>AI</category>
      <category>Obsidian</category>
      <category>Automation</category>
      <category>Productivity</category>
    </item>
    <item>
      <title>Next.js Dark Mode sin parpadeo ni advertencias de React 19</title>
      <link>https://oleksiimazurenko.dev/es/blog/nextjs-dark-mode-without-flash</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/es/blog/nextjs-dark-mode-without-flash</guid>
      <description>Cómo reemplazar next-themes con Zustand + useServerInsertedHTML para modo oscuro sin parpadeo en Next.js 15+.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p><code>next-themes</code> renderiza una etiqueta <code>&lt;script&gt;</code> dentro de un componente cliente de React para evitar el parpadeo de tema (FOUC). React 19 ahora advierte sobre esto — y no hay forma de suprimirlo. La librería no ha sido actualizada desde marzo de 2025. La solución: reemplazar <code>next-themes</code> con un store de Zustand + <code>useServerInsertedHTML</code> para inyectar el script fuera del árbol de React. Sin dependencias adicionales. Sin FOUC. Sin advertencias.</p>
<h2>El Problema</h2>
<p>Si usas <code>next-themes</code> con Next.js 15+ y React 19, obtienes este error en la consola en cada carga de página:</p>
<pre><code>Encountered a script tag while rendering React component.
Scripts inside React components are never executed when rendering on the client.
Consider using template tag instead.

src/providers/theme-provider.tsx (7:10) @ ThemeProvider</code></pre>
<p>Esto no es un desajuste de hidratación. Es React 19 advirtiendo explícitamente que las etiquetas <code>&lt;script&gt;</code> renderizadas por componentes React en el cliente <strong>nunca se ejecutarán</strong>. El script funciona durante el SSR (está en el HTML), pero React lo marca como incorrecto.</p>
<h2>Por Qué Ocurre</h2>
<p><code>next-themes</code> necesita establecer la clase de tema correcta en <code>&lt;html&gt;</code> antes de que React hidrate — de lo contrario se produce un destello del tema incorrecto. Para lograr esto, inyecta un <code>&lt;script&gt;</code> en línea mediante <code>React.createElement</code>:</p>
<pre><code>// next-themes renders a &lt;script&gt; inside a Client Component
return React.createElement(Provider, { value },
  React.createElement(&quot;script&quot;, {
    suppressHydrationWarning: true,
    dangerouslySetInnerHTML: { __html: `(...theme init code...)` }
  }),
  children
)</code></pre>
<p>React 19 cambió su comportamiento: las etiquetas script dentro de componentes ahora se marcan explícitamente. Antes de React 19, esto se ignoraba silenciosamente. La prop <code>suppressHydrationWarning</code> en el script no ayuda — suprime las advertencias de hidratación, no la advertencia de "script en componente".</p>
<h2>Qué Intentamos (Y Por Qué Falló)</h2>
<p>Probamos sistemáticamente cada enfoque antes de encontrar la solución:</p>
<table><thead><tr><th>Attempt</th><th>Result</th></tr></thead><tbody><tr><td>&lt;code&gt;suppressHydrationWarning&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;</td><td>Suppresses hydration mismatch, but NOT the script tag warning</td></tr><tr><td>Delay mount with &lt;code&gt;useState&lt;/code&gt; + &lt;code&gt;useEffect&lt;/code&gt;</td><td>Warning disappears, but causes FOUC (theme flash)</td></tr><tr><td>Raw &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in layout &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;</td><td>React 19 catches it — same warning even in Server Components</td></tr><tr><td>&lt;code&gt;next/script&lt;/code&gt; with &lt;code&gt;beforeInteractive&lt;/code&gt;</td><td>Still rendered inside React tree — same warning</td></tr><tr><td>Remove &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, put Script in body</td><td>Same warning — it&apos;s inside &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; which is React-managed</td></tr><tr><td>&lt;code&gt;next-themes@1.0.0-beta.0&lt;/code&gt;</td><td>Beta, no release date, unclear if fixed</td></tr></tbody></table>
<h2>La Solución: Zustand + useServerInsertedHTML</h2>
<p>La clave: <code>useServerInsertedHTML</code> es un hook de Next.js que inyecta HTML en el stream SSR <strong>fuera del árbol de componentes de React</strong>. El script termina en el HTML pero React nunca lo "ve" durante el renderizado en el cliente — por lo tanto, sin advertencias. Combinado con un store de Zustand para el estado reactivo del tema, obtenemos un reemplazo completo sin dependencias adicionales.</p>
<h3>Cómo Funciona</h3>
<pre><code>SSR (server):
  useServerInsertedHTML → injects &lt;script&gt; into HTML stream
  ↓
  Browser receives HTML with theme script already in it
  ↓
  Script runs BEFORE React hydrates → correct class on &lt;html&gt;
  ↓
  No flash. No mismatch. No warning.

Client (after hydration):
  useEffect → _init() → Zustand store syncs with localStorage
  ↓
  useTheme() → reactive theme state for components</code></pre>
<h3>Paso 1: Store de Zustand</h3>
<p>El store gestiona el estado del tema, aplica clases al DOM, maneja la detección del tema del sistema y sincroniza entre pestañas. El método <code>_init()</code> devuelve una función de limpieza para usar en <code>useEffect</code>:</p>
<pre><code>import { create } from &quot;zustand&quot;;

const STORAGE_KEY = &quot;theme&quot;;
const MEDIA_QUERY = &quot;(prefers-color-scheme: dark)&quot;;

function getSystemTheme(): &quot;light&quot; | &quot;dark&quot; {
  return window.matchMedia(MEDIA_QUERY).matches ? &quot;dark&quot; : &quot;light&quot;;
}

function applyTheme(resolved: string, disableTransition: boolean) {
  const d = document.documentElement;

  if (disableTransition) {
    const style = document.createElement(&quot;style&quot;);
    style.appendChild(
      document.createTextNode(
        &quot;*,*::before,*::after{transition:none!important}&quot;
      )
    );
    document.head.appendChild(style);
    window.getComputedStyle(document.body);
    setTimeout(() =&gt; document.head.removeChild(style), 1);
  }

  d.classList.remove(&quot;light&quot;, &quot;dark&quot;);
  d.classList.add(resolved);
  d.style.colorScheme = resolved;
}

interface ThemeState {
  theme: string;
  systemTheme: &quot;light&quot; | &quot;dark&quot;;
  resolvedTheme: string;
  setTheme: (theme: string) =&gt; void;
  _init: (disableTransition: boolean) =&gt; () =&gt; void;
}

export const useThemeStore = create&lt;ThemeState&gt;((set, get) =&gt; ({
  theme: &quot;system&quot;,
  systemTheme: &quot;light&quot;,
  resolvedTheme: &quot;light&quot;,

  setTheme: (newTheme) =&gt; {
    const { systemTheme, disableTransitionOnChange } = get();
    const resolved = newTheme === &quot;system&quot; ? systemTheme : newTheme;
    localStorage.setItem(STORAGE_KEY, newTheme);
    applyTheme(resolved, disableTransitionOnChange);
    set({ theme: newTheme, resolvedTheme: resolved });
  },

  _init: (disableTransition) =&gt; {
    const stored = localStorage.getItem(STORAGE_KEY) || &quot;system&quot;;
    const system = getSystemTheme();
    const resolved = stored === &quot;system&quot; ? system : stored;

    set({ theme: stored, systemTheme: system, resolvedTheme: resolved });
    applyTheme(resolved, disableTransition);

    // System theme changes
    const mq = window.matchMedia(MEDIA_QUERY);
    const onChange = (e: MediaQueryListEvent) =&gt; {
      const newSystem = e.matches ? &quot;dark&quot; : &quot;light&quot;;
      const { theme } = get();
      set({ systemTheme: newSystem,
            resolvedTheme: theme === &quot;system&quot; ? newSystem : theme });
      if (theme === &quot;system&quot;) applyTheme(newSystem, disableTransition);
    };
    mq.addEventListener(&quot;change&quot;, onChange);

    // Cross-tab sync
    const onStorage = (e: StorageEvent) =&gt; {
      if (e.key !== STORAGE_KEY || !e.newValue) return;
      const system = getSystemTheme();
      const resolved = e.newValue === &quot;system&quot; ? system : e.newValue;
      set({ theme: e.newValue, resolvedTheme: resolved });
      applyTheme(resolved, disableTransition);
    };
    window.addEventListener(&quot;storage&quot;, onStorage);

    return () =&gt; {
      mq.removeEventListener(&quot;change&quot;, onChange);
      window.removeEventListener(&quot;storage&quot;, onStorage);
    };
  },
}));

export function useTheme() {
  return useThemeStore((s) =&gt; ({
    theme: s.theme,
    setTheme: s.setTheme,
    resolvedTheme: s.resolvedTheme,
    systemTheme: s.systemTheme,
  }));
}</code></pre>
<h3>Paso 2: ThemeProvider</h3>
<p>El provider hace dos cosas: inyecta el script de prevención de FOUC mediante <code>useServerInsertedHTML</code>, e inicializa el store de Zustand al montarse:</p>
<pre><code>&quot;use client&quot;

import { useEffect } from &quot;react&quot;
import { useServerInsertedHTML } from &quot;next/navigation&quot;
import { useThemeStore } from &quot;@/store/use-theme-store&quot;

const THEME_INIT_SCRIPT = `(function(){
  try {
    var t = localStorage.getItem(&quot;theme&quot;) || &quot;system&quot;;
    var r = t;
    if (t === &quot;system&quot;) {
      r = window.matchMedia(&quot;(prefers-color-scheme: dark)&quot;).matches
        ? &quot;dark&quot; : &quot;light&quot;;
    }
    document.documentElement.classList.remove(&quot;light&quot;, &quot;dark&quot;);
    document.documentElement.classList.add(r);
    document.documentElement.style.colorScheme = r;
  } catch(e) {}
})()`

export function ThemeProvider({
  children,
  disableTransitionOnChange = false,
}) {
  // Injects script into SSR HTML outside the React tree
  // — prevents FOUC without triggering React 19 warning
  useServerInsertedHTML(() =&gt; (
    &lt;script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} /&gt;
  ))

  useEffect(() =&gt; {
    return useThemeStore.getState()._init(disableTransitionOnChange)
  }, [disableTransitionOnChange])

  return &lt;&gt;{children}&lt;/&gt;
}</code></pre>
<h3>Paso 3: Layout</h3>
<pre><code>import { ThemeProvider } from &quot;@/providers/theme-provider&quot;

export default function RootLayout({ children }) {
  return (
    &lt;html lang=&quot;en&quot; suppressHydrationWarning&gt;
      &lt;body&gt;
        &lt;ThemeProvider disableTransitionOnChange&gt;
          {children}
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}</code></pre>
<h3>Paso 4: Úsalo</h3>
<pre><code>import { useTheme } from &quot;@/store/use-theme-store&quot;

export function ThemeSwitch() {
  const { theme, setTheme } = useTheme();

  return (
    &lt;button onClick={() =&gt; setTheme(
      theme === &quot;dark&quot; ? &quot;light&quot; : &quot;dark&quot;
    )}&gt;
      {theme === &quot;dark&quot; ? &quot;☀️&quot; : &quot;🌙&quot;}
    &lt;/button&gt;
  );
}</code></pre>
<h2>Migración desde next-themes</h2>
<p>La API es intencionalmente idéntica. La migración es un único cambio de import por archivo:</p>
<pre><code>- import { useTheme } from &quot;next-themes&quot;
+ import { useTheme } from &quot;@/store/use-theme-store&quot;

  // API is identical — no other changes needed
  const { theme, setTheme, resolvedTheme, systemTheme } = useTheme()</code></pre>
<h2>Comparación</h2>
<table><thead><tr><th></th><th>next-themes</th><th>Zustand + useServerInsertedHTML</th></tr></thead><tbody><tr><td>React 19 warning</td><td>Yes — &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in client component</td><td>No</td></tr><tr><td>FOUC prevention</td><td>Yes</td><td>Yes</td></tr><tr><td>Cross-tab sync</td><td>Yes</td><td>Yes</td></tr><tr><td>System theme detection</td><td>Yes</td><td>Yes</td></tr><tr><td>&lt;code&gt;disableTransitionOnChange&lt;/code&gt;</td><td>Yes</td><td>Yes</td></tr><tr><td>Bundle size</td><td>~3.5 KB</td><td>~1.5 KB (uses existing Zustand)</td></tr><tr><td>Dependencies</td><td>+1 (next-themes)</td><td>0 (Zustand already in project)</td></tr><tr><td>Maintenance risk</td><td>Abandoned since March 2025</td><td>Your own code</td></tr></tbody></table>
<h2>¿Por Qué No Otras Alternativas?</h2>
<h3>@wrksz/themes</h3>
<p>Un reemplazo directo que también usa <code>useServerInsertedHTML</code>. Funciona, pero es otra dependencia de un único mantenedor. Si <code>next-themes</code> nos enseñó algo — las dependencias se abandonan. Con ~100 líneas de código, puedes ser dueño de la solución por completo.</p>
<h3>next-themes@1.0.0-beta.0</h3>
<p>Existe en npm, pero sin fecha de lanzamiento, sin changelog y sin indicación clara de que la advertencia de React 19 esté corregida. Apostar el código de producción a una beta indefinida no es un riesgo que valga la pena asumir.</p>
<h3>Solo CSS (prefers-color-scheme)</h3>
<p>Funciona para la detección del tema del sistema, pero no puede manejar la persistencia de preferencias del usuario (localStorage), el cambio manual de tema ni la opción "system". Para eso se necesita JavaScript.</p>
<h2>Conclusiones</h2>
<ol><li><code>next-themes</code> está efectivamente abandonado — última versión marzo de 2025, advertencia de React 19 sin corregir</li><li><code>useServerInsertedHTML</code> es el primitivo correcto de Next.js para inyectar scripts sin advertencias de React</li><li>Zustand proporciona estado reactivo del tema con menos código que un proveedor de Context</li><li>La solución completa tiene ~100 líneas, cero nuevas dependencias, y eres dueño de cada línea</li></ol>
<h2>Fuentes</h2>
<ul><li><a href="https://github.com/shadcn-ui/ui/issues/10104" target="_blank" rel="noopener noreferrer">shadcn/ui #10104</a> — Dark mode guide should address React 19 script warning</li><li><a href="https://github.com/pacocoursey/next-themes/issues/296" target="_blank" rel="noopener noreferrer">next-themes #296</a> — React 19 support request (open since 2024)</li><li><a href="https://github.com/facebook/react/issues/34008" target="_blank" rel="noopener noreferrer">React #34008</a> — Script tags not executing in components (by design)</li><li><a href="https://dev.to/wrksz/i-was-tired-of-next-themes-being-abandoned-so-i-built-a-drop-in-replacement-4dbk" target="_blank" rel="noopener noreferrer">@wrksz/themes</a> — Drop-in replacement using useServerInsertedHTML</li><li><a href="https://nextjs.org/docs/app/building-your-application/styling/css#server-inserted-html" target="_blank" rel="noopener noreferrer">Next.js Docs</a> — useServerInsertedHTML API</li></ul>]]></content:encoded>
      <pubDate>Sat, 11 Apr 2026 00:00:00 GMT</pubDate>
      <category>next.js</category>
      <category>react-19</category>
      <category>dark-mode</category>
      <category>zustand</category>
      <category>performance</category>
    </item>
    <item>
      <title>Cómo eliminé 94 archivos CSS que bloquean el renderizado en Next.js 16 con una característica poco documentada de Turbopack</title>
      <link>https://oleksiimazurenko.dev/es/blog/eliminating-render-blocking-css</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/es/blog/eliminating-render-blocking-css</guid>
      <description>Después de días probando cada enfoque — desde experimental.inlineCss hasta hacks con MutationObserver — descubrí Turbopack Import Attributes que resuelven el problema de CSS que bloquea el renderizado en Next.js App Router.</description>
      <content:encoded><![CDATA[<h2>El problema</h2>
<p>Nuestra aplicación (<a href="https://promova.com" target="_blank" rel="noopener noreferrer">Promova</a>) usa Next.js 16 con un <strong>Landing Builder</strong> — un sistema impulsado por CMS que ensambla páginas de marketing a partir de ~90 componentes de sección diferentes (heroes, FAQs, precios, reseñas, etc.). La arquitectura usa un <code>sectionRegistry.tsx</code> que mapea nombres de sección a llamadas <code>next/dynamic()</code>:</p>
<pre><code>// sectionRegistry.tsx — imports all 90 sections
export const sectionRegistry = {
  HeroSection: dynamic(() =&gt; import(&apos;./HeroSection/HeroSection&apos;)),
  FAQSection: dynamic(() =&gt; import(&apos;./FAQSection/FAQSection&apos;)),
  ReviewsSection: dynamic(() =&gt; import(&apos;./ReviewsSection/ReviewsSection&apos;)),
  // ... 87 more
}</code></pre>
<p>Una sola landing page solo renderiza <strong>5-8 secciones</strong>. Pero Lighthouse mostraba:</p>
<pre><code>Eliminate render-blocking resources
  94 CSS resources (~330 KB)
  Potential savings: 5,440 ms</code></pre>
<p><strong>¿Por qué?</strong> Turbopack ve las 90 rutas <code>import()</code> como alcanzables y genera <code>&lt;link rel="stylesheet"&gt;</code> para <strong>cada</strong> módulo SCSS. Incluso secciones que nunca se renderizan obtienen su CSS inyectado en <code>&lt;head&gt;</code>. Este es un <a href="https://github.com/vercel/next.js/issues/62485" target="_blank" rel="noopener noreferrer">comportamiento confirmado y esperado</a> del Next.js App Router. <strong>No se planea una corrección.</strong></p>
<h2>Todo lo que intenté (y por qué falló)</h2>
<p>Pasé días repasando cada enfoque que pude encontrar. Aquí está la lista completa:</p>
<table><thead><tr><th>Approach</th><th>Why it doesn&apos;t work</th></tr></thead><tbody><tr><td>Split &lt;code&gt;sectionRegistry&lt;/code&gt; into per-section files</td><td>CSS still loaded — &lt;a href=&quot;https://github.com/vercel/next.js/issues/61066&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#61066&lt;/a&gt;</td></tr><tr><td>&lt;code&gt;experimental.inlineCss: true&lt;/code&gt;</td><td>Inlines CSS on &lt;strong&gt;every SSR request&lt;/strong&gt; — crashed our CMS under load</td></tr><tr><td>&lt;code&gt;experimental.optimizeCss&lt;/code&gt; (Critters)</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/62485&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Incompatible with streaming&lt;/a&gt; in App Router</td></tr><tr><td>CSS-in-JS rewrite</td><td>Rewriting 90 sections from SCSS to CSS-in-JS is not realistic</td></tr><tr><td>&lt;code&gt;media=&quot;print&quot;&lt;/code&gt; hack</td><td>Doesn&apos;t work with CSS Modules in Turbopack — &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tags are managed by the framework</td></tr><tr><td>Switch back to Webpack</td><td>Has &lt;code&gt;experiments.css&lt;/code&gt; options, but we&apos;re committed to Turbopack</td></tr><tr><td>Turbopack plugins</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/discussions/85465&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Don&apos;t exist&lt;/a&gt; — no plugin API</td></tr><tr><td>Turbopack CSS loaders</td><td>Not supported — only JS output</td></tr><tr><td>SWC plugins for CSS</td><td>SWC only processes JavaScript</td></tr><tr><td>Client Component wrapper for &lt;code&gt;dynamic()&lt;/code&gt;</td><td>Registry is a global constant — bundler sees all dependencies</td></tr><tr><td>Next.js middleware HTML rewrite</td><td>&lt;a href=&quot;https://github.com/orgs/vercel/discussions/3874&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Middleware can&apos;t modify response body&lt;/a&gt;</td></tr><tr><td>Suspense + streaming with async import</td><td>React Float always pulls CSS into initial &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;</td></tr><tr><td>Suspense + 3s delay</td><td>Content streams later, but CSS is in the initial &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; chunk</td></tr><tr><td>&lt;code&gt;experimental.cssChunking&lt;/code&gt;</td><td>Already default. Merges chunks but doesn&apos;t remove irrelevant CSS</td></tr><tr><td>Post-build Beasties/Critters</td><td>Only for static export, &lt;a href=&quot;https://github.com/vercel/next.js/discussions/59989&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;not with ISR&lt;/a&gt;</td></tr><tr><td>npm libraries (next-critical, fg-loadcss)</td><td>Pages Router only or abandoned (6+ years)</td></tr><tr><td>&lt;code&gt;inlineCss&lt;/code&gt; per-route exclusion</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/pull/72195&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Not supported&lt;/a&gt; — all-or-nothing global flag</td></tr><tr><td>Turbopack &lt;code&gt;as: &apos;raw&apos;&lt;/code&gt; for SCSS</td><td>Returns &lt;code&gt;undefined&lt;/code&gt; instead of text</td></tr><tr><td>Turbopack rule for &lt;code&gt;*.inline.module.scss&lt;/code&gt;</td><td>Turbopack intercepts &lt;code&gt;.module.scss&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; custom rules</td></tr><tr><td>Turbopack rule for &lt;code&gt;*.inline.scss&lt;/code&gt;</td><td>Turbopack intercepts &lt;code&gt;.scss&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; custom rules</td></tr><tr><td>MutationObserver &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;</td><td>Tested in dev, risky — CSS still loads as separate files, possible FOUC</td></tr></tbody></table>
<h3>La trampa de inlineCss</h3>
<p>Next.js tiene un flag <a href="https://github.com/vercel/next.js/pull/72195" target="_blank" rel="noopener noreferrer"><code>experimental.inlineCss</code></a> que reemplaza todos los <code>&lt;link rel="stylesheet"&gt;</code> con etiquetas <code>&lt;style&gt;</code> inline. ¿Suena perfecto, verdad?</p>
<p>El problema: es <strong>todo o nada</strong>. No se puede activar por ruta. Si tienes páginas SSR (<code>force-dynamic</code>), cada solicitud reconstruye todo el CSS inline. Lo probamos — nuestro headless CMS no soportó la carga y se cayó.</p>
<h2>El descubrimiento: Turbopack Import Attributes</h2>
<p>Investigando las <a href="https://nextjs.org/blog/next-16-2-turbopack#inline-loader-configuration" target="_blank" rel="noopener noreferrer">notas de la versión Next.js 16.2</a>, encontré una característica poco documentada: <strong>Turbopack Import Attributes</strong>. Permite anular el pipeline del bundler para un import específico usando la sintaxis TC39 <code>with {}</code>:</p>
<pre><code>import { cssText, styles } from &apos;./hero.module.scss&apos; with {
  turbopackLoader: &apos;@promova/scss-to-inline-loader&apos;,
  turbopackAs: &apos;*.js&apos;
}</code></pre>
<p>Esto le dice a Turbopack: <em>"No proceses este import como hoja de estilos. Pásalo por mi loader personalizado y trata la salida como JavaScript."</em></p>
<p><strong>Este es el insight clave.</strong> En lugar de que Turbopack genere un <code>&lt;link rel="stylesheet"&gt;</code> que bloquea el renderizado, nuestro loader compila el SCSS y lo exporta como string JS. Resultado: <strong>solo el CSS de las secciones que realmente se renderizan llega al HTML de la página</strong>.</p>
<h2>La solución</h2>
<h3>1. Loader personalizado de Turbopack</h3>
<p>Un script Node.js de ~70 líneas como paquete yarn workspace (<code>@promova/scss-to-inline-loader</code>):</p>
<pre><code>const { createHash } = require(&apos;node:crypto&apos;)
const sass = require(&apos;sass&apos;)
const postcss = require(&apos;postcss&apos;)
const postcssModules = require(&apos;postcss-modules&apos;)
const path = require(&apos;path&apos;)

const SHARED_STYLES = path.resolve(__dirname, &apos;../shared/styles&apos;)

// Same prependData as sassOptions.prependData in next.config.ts
const PREPEND_DATA = `
@use &quot;sass:math&quot; as math;
@use &quot;sass:list&quot; as list;
@use &quot;${SHARED_STYLES}/_variables.scss&quot; as *;
@use &quot;${SHARED_STYLES}/_breakpoints.scss&quot; as *;
@use &quot;${SHARED_STYLES}/_mixins.scss&quot; as *;
`

module.exports = function scssToInlineLoader(source) {
  const callback = this.async()
  processScss(source, this.resourcePath)
    .then((js) =&gt; callback(null, js))
    .catch((err) =&gt; callback(err))
}

async function processScss(source, resourcePath) {
  // 1. Compile SCSS → CSS (with global variables, mixins, breakpoints)
  const sassResult = sass.compileString(PREPEND_DATA + &apos;\n&apos; + source, {
    loadPaths: [path.dirname(resourcePath), SHARED_STYLES],
    style: &apos;compressed&apos;,
    sourceMap: false,
    url: new URL(&apos;file://&apos; + resourcePath),
  })

  // 2. Scope class names with postcss-modules (CSS Modules compatible)
  let classNames = {}
  const result = await postcss([
    postcssModules({
      generateScopedName(name, filename) {
        const file = path.basename(filename, path.extname(filename))
          .replace(&apos;.module&apos;, &apos;&apos;)
        const hash = createHash(&apos;md5&apos;)
          .update(filename + name)
          .digest(&apos;base64url&apos;)
          .slice(0, 6)
        return `${file}__${name}__${hash}`
      },
      getJSON(_, json) { classNames = json },
    }),
  ]).process(sassResult.css, { from: resourcePath })

  // 3. Export as JS module — same interface as CSS Modules
  return [
    `export const cssText = ${JSON.stringify(result.css)};`,
    `export const styles = ${JSON.stringify(classNames)};`,
    `export default styles;`,
  ].join(&apos;\n&apos;)
}</code></pre>
<p>Qué hace: <strong><code>styles</code></strong> — el mismo mapa de nombres de clase con scope que CSS Modules estándar. <strong><code>cssText</code></strong> — el CSS compilado como string.</p>
<h3>2. Componente InlineStyle</h3>
<p>Usa la API integrada de React 19 <code>&lt;style href precedence&gt;</code> para deduplicación automática:</p>
<pre><code>export function InlineStyle({ css, id }: { css: string; id: string }) {
  return (
    &lt;style href={id} precedence=&quot;default&quot;&gt;
      {css}
    &lt;/style&gt;
  )
}</code></pre>
<p>React 19 garantiza: mismo <code>href</code> → solo un <code>&lt;style&gt;</code> en el DOM.</p>
<h3>3. Migración por componente (~6 líneas por sección)</h3>
<pre><code>// BEFORE: Turbopack → &lt;link rel=&quot;stylesheet&quot;&gt; (render-blocking)
import styles from &apos;./hero_section.module.scss&apos;</code></pre>
<pre><code>// AFTER: Custom loader → JS module → inline &lt;style&gt; (non-blocking)
import { cssText, styles } from &apos;./hero_section.module.scss&apos; with {
  turbopackLoader: &apos;@promova/scss-to-inline-loader&apos;,
  turbopackAs: &apos;*.js&apos;
}
import { InlineStyle } from &apos;@promova/scss-to-inline-loader/InlineStyle&apos;

export function HeroSection() {
  return (
    &lt;section className={styles.hero}&gt;
      &lt;InlineStyle css={cssText} id=&quot;hero-section&quot; /&gt;
      {/* ... component JSX, className usage is identical */}
    &lt;/section&gt;
  )
}</code></pre>
<p><strong>Los archivos <code>.module.scss</code> permanecen exactamente iguales.</strong> Sin reescritura de CSS.</p>
<h2>Por qué esto es mejor que inlineCss: true</h2>
<p>Esta es la diferencia crítica:</p>
<table><thead><tr><th></th><th>&lt;code&gt;inlineCss: true&lt;/code&gt;</th><th>Import Attributes + Loader</th></tr></thead><tbody><tr><td>What gets inlined</td><td>&lt;strong&gt;ALL CSS on the page&lt;/strong&gt; (94 files)</td><td>&lt;strong&gt;Only CSS for rendered sections&lt;/strong&gt; (5-8 files)</td></tr><tr><td>SSR overhead</td><td>CSS rebuilt on every request</td><td>CSS compiled at build time</td></tr><tr><td>Per-route control</td><td>No (all-or-nothing)</td><td>Yes (per-import)</td></tr><tr><td>SSR pages safety</td><td>Risky (overloads server)</td><td>Safe (component-level)</td></tr></tbody></table>
<p>Con <code>inlineCss: true</code>, una página sigue obteniendo TODOS los 94 estilos inline. Con nuestro enfoque, <strong>solo el CSS que realmente se renderiza llega al HTML</strong>.</p>
<h2>La trampa de Turbopack: sin reglas globales para .module.scss</h2>
<p>Una trampa en la que caí: podrías pensar que puedes agregar una regla de Turbopack en <code>next.config.ts</code> para aplicar el loader globalmente:</p>
<pre><code>// ❌ THIS CAUSES A FATAL PANIC
turbopack: {
  rules: {
    &apos;**/components/**/*.module.scss&apos;: {
      loaders: [&apos;@promova/scss-to-inline-loader&apos;],
      as: &apos;*.js&apos;
    }
  }
}</code></pre>
<p><strong>No lo hagas.</strong> El pipeline integrado de módulos CSS de Turbopack intercepta archivos <code>.module.scss</code> <strong>antes</strong> de que se apliquen reglas personalizadas, causando:</p>
<pre><code>FATAL PANIC: inner asset should be CSS processable</code></pre>
<p>Los atributos <code>with {}</code> funcionan porque instruyen a Turbopack <strong>en el sitio de importación</strong> a omitir completamente el pipeline de módulos CSS.</p>
<h2>Resultados</h2>
<p>Migrados 127 componentes de sección en el Landing Builder. Build de producción verificado.</p>
<table><thead><tr><th>Metric</th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>Render-blocking CSS files</td><td>94</td><td>0 (for landing sections)</td></tr><tr><td>CSS in HTML per page</td><td>~330 KB (all sections)</td><td>~20-40 KB (rendered sections only)</td></tr><tr><td>CSS delivery</td><td>&lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; (network request, blocking)</td><td>&lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; (inline, non-blocking)</td></tr><tr><td>Sass features</td><td>Full</td><td>Full (variables, mixins, nesting, @use)</td></tr><tr><td>CSS Modules scoping</td><td>Built-in</td><td>postcss-modules (compatible)</td></tr><tr><td>Code change per section</td><td>—</td><td>~6 lines (import + InlineStyle)</td></tr></tbody></table>
<h2>Limitaciones</h2>
<ul><li><strong><code>with {}</code> por import es verboso</strong> — cada import necesita 3 líneas extra.</li><li><strong>Solo Turbopack</strong> — los atributos <code>with {}</code> no son soportados por Webpack.</li><li><strong>Hashing de nombres de clase</strong> — nuestro loader usa un algoritmo de hashing diferente al de Turbopack.</li><li><strong>El tamaño del HTML aumenta</strong> — CSS inline en HTML en lugar de archivos cacheados separados.</li></ul>
<h2>Cuándo usar esto</h2>
<p>Esta técnica es más efectiva cuando:</p>
<ol><li><strong>Tienes un patrón registry/barrel</strong> — un archivo importa muchos componentes, pero solo unos pocos se renderizan por página</li><li><strong>Estás en Turbopack</strong> — Import Attributes son específicos de Turbopack</li><li><strong>Quieres control por componente</strong> — no un flag de todo o nada</li><li><strong>Tu SCSS es complejo</strong> — variables, mixins, breakpoints, anidación — todo soportado</li><li><strong>No puedes usar <code>experimental.inlineCss</code></strong> — porque tienes páginas SSR o quieres control granular</li></ol>
<h2>Issues de GitHub relacionados</h2>
<p>Si te afecta el CSS que bloquea el renderizado en Next.js App Router — no estás solo:</p>
<h3>El problema central</h3>
<ul><li><a href="https://github.com/vercel/next.js/issues/62485" target="_blank" rel="noopener noreferrer"><strong>#62485</strong></a> — Render blocking CSS (maintainers: "expected behavior")</li><li><a href="https://github.com/vercel/next.js/issues/61066" target="_blank" rel="noopener noreferrer"><strong>#61066</strong></a> — Dynamic imports from Server Components are not code-split</li><li><a href="https://github.com/vercel/next.js/issues/54935" target="_blank" rel="noopener noreferrer"><strong>#54935</strong></a> — Server-side dynamic imports don't split client modules</li><li><a href="https://github.com/vercel/next.js/issues/61574" target="_blank" rel="noopener noreferrer"><strong>#61574</strong></a> — JS/CSS code splitting doesn't work as documented</li><li><a href="https://github.com/vercel/next.js/issues/57634" target="_blank" rel="noopener noreferrer"><strong>#57634</strong></a> — Add support for critical CSS inlining with App Router</li><li><a href="https://github.com/vercel/next.js/issues/50300" target="_blank" rel="noopener noreferrer"><strong>#50300</strong></a> — next/dynamic on server component does not build CSS modules</li></ul>
<h3>Feature inlineCss y problemas</h3>
<ul><li><a href="https://github.com/vercel/next.js/pull/72195" target="_blank" rel="noopener noreferrer"><strong>PR #72195</strong></a> — experimental: css inlining (the implementation)</li><li><a href="https://github.com/vercel/next.js/pull/73182" target="_blank" rel="noopener noreferrer"><strong>PR #73182</strong></a> — Don't inline CSS in RSC payload for client navigation</li><li><a href="https://github.com/vercel/next.js/issues/75648" target="_blank" rel="noopener noreferrer"><strong>#75648</strong></a> — WhatsApp preview broken with inlineCss + Tailwind</li><li><a href="https://github.com/vercel/next.js/issues/83612" target="_blank" rel="noopener noreferrer"><strong>#83612</strong></a> — Turbopack wrong font URL with inline CSS</li></ul>
<h3>La comunidad busca soluciones</h3>
<ul><li><a href="https://github.com/vercel/next.js/discussions/82894" target="_blank" rel="noopener noreferrer"><strong>Discussion #82894</strong></a> — How to prevent render-blocking with CSS Modules? (2025)</li><li><a href="https://github.com/vercel/next.js/discussions/70526" target="_blank" rel="noopener noreferrer"><strong>Discussion #70526</strong></a> — Ideas for reducing render-blocking CSS</li><li><a href="https://github.com/vercel/next.js/discussions/59814" target="_blank" rel="noopener noreferrer"><strong>Discussion #59814</strong></a> — Render blocking styles with Tailwind</li><li><a href="https://github.com/vercel/next.js/discussions/49691" target="_blank" rel="noopener noreferrer"><strong>Discussion #49691</strong></a> — How to deal with render-blocking modular CSS</li><li><a href="https://github.com/vercel/next.js/discussions/59989" target="_blank" rel="noopener noreferrer"><strong>Discussion #59989</strong></a> — Critical CSS inlining with App Router</li><li><a href="https://github.com/vercel/next.js/discussions/80486" target="_blank" rel="noopener noreferrer"><strong>Discussion #80486</strong></a> — Is optimizeCss still in use?</li><li><a href="https://github.com/vercel/next.js/discussions/85465" target="_blank" rel="noopener noreferrer"><strong>Discussion #85465</strong></a> — Turbopack plugin API (doesn't exist)</li></ul>
<h3>Bugs de CSS en Turbopack</h3>
<ul><li><a href="https://github.com/vercel/next.js/issues/68412" target="_blank" rel="noopener noreferrer"><strong>#68412</strong></a> — Incorrect CSS modules load order with external components</li><li><a href="https://github.com/vercel/next.js/issues/76464" target="_blank" rel="noopener noreferrer"><strong>#76464</strong></a> — Turbopack sometimes strips SCSS imports</li><li><a href="https://github.com/vercel/next.js/issues/82497" target="_blank" rel="noopener noreferrer"><strong>#82497</strong></a> — SCSS module not loading when not-found.tsx is present</li><li><a href="https://github.com/vercel/next.js/issues/88544" target="_blank" rel="noopener noreferrer"><strong>#88544</strong></a> — Exporting Sass variables from CSS modules doesn't work with Turbopack</li></ul>
<p>Construido en <a href="https://promova.com" target="_blank" rel="noopener noreferrer">Promova</a> — una plataforma de aprendizaje de idiomas que sirve a millones de usuarios.</p>]]></content:encoded>
      <pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate>
      <category>Next.js</category>
      <category>CSS</category>
      <category>Performance</category>
      <category>Turbopack</category>
      <category>SCSS</category>
    </item>
    <item>
      <title>Next.js Memory Leak: Fetch + Modo Standalone — 2 años sin solución</title>
      <link>https://oleksiimazurenko.dev/es/blog/nextjs-memory-leak-fetch-standalone</link>
      <guid isPermaLink="true">https://oleksiimazurenko.dev/es/blog/nextjs-memory-leak-fetch-standalone</guid>
      <description>Next.js parchea el fetch global y añade una capa de caché que pierde memoria en cada petición. En Docker/K8s esto causa crashes OOM cada pocas horas. El bug existe desde Next.js 14 y sigue sin resolverse en 16.2.x.</description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Next.js parchea el <code>fetch</code> global y añade una capa de caché que mantiene referencias a los datos de respuesta después de que deberían haber sido liberados. Cada llamada a <code>fetch</code> añade memoria que nunca es devuelta al GC. En Docker/Kubernetes esto provoca crashes OOM cada pocas horas. El bug existe desde Next.js 14 (abril 2024) y sigue sin resolverse en 16.2.x (marzo 2026). En Vercel el problema no se manifiesta gracias a las funciones serverless efímeras.</p>
<h2>Cómo funciona fetch normalmente</h2>
<pre><code>Request → fetch → got data → response to user → GC cleans up → memory free</code></pre>
<h2>Cómo funciona fetch en Next.js</h2>
<p>Next.js intercepta el <code>fetch</code> global y lo envuelve con su propia capa de caché/tracking:</p>
<pre><code>Request → Next.js fetch wrapper → creates:
  - Performance entry (speed tracking)    ← not cleaned
  - Request metadata object               ← not cleaned
  - Internal promise wrapper              ← not cleaned
  - Cache lookup entry                    ← not cleaned
  - Response body (for unique URL)        ← not cleaned
→ Response to user
→ GC sees objects are still referenced → doesn&apos;t touch them
→ Memory grows indefinitely</code></pre>
<h2>Qué ocurre en producción</h2>
<pre><code>Self-hosted (Docker/K8s):
  Request 1:      fetch → +100KB → RAM: 100KB
  Request 2:      fetch → +100KB → RAM: 200KB
  Request 1000:   fetch → +100KB → RAM: 100MB
  Request 50,000: fetch → +100KB → RAM: 5GB → OOM Kill 💀
  → Kubernetes restarts the pod → cycle repeats

Vercel (Serverless):
  Request 1: [start] → fetch → +100KB → [process dies] → 0 KB ✅
  Request 2: [start] → fetch → +100KB → [process dies] → 0 KB ✅
  → Memory never accumulates</code></pre>
<h2>Versiones afectadas</h2>
<h3>Next.js — Todas las versiones con App Router</h3>
<table><thead><tr><th>Version</th><th>Issue</th><th>Date</th></tr></thead><tbody><tr><td>14.2.x</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/64212&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#64212&lt;/a&gt;</td><td>April 2024</td></tr><tr><td>14.x-15.x</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/68578&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#68578&lt;/a&gt;</td><td>August 2024</td></tr><tr><td>14.3.0-canary</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/79588&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#79588&lt;/a&gt;</td><td>May 2025</td></tr><tr><td>16.0.1</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/85914&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#85914&lt;/a&gt;</td><td>November 2025</td></tr><tr><td>16.1.0</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/discussions/88603&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#88603&lt;/a&gt;</td><td>January 2026</td></tr><tr><td>16.0.10</td><td>&lt;a href=&quot;https://github.com/vercel/next.js/issues/90433&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;#90433&lt;/a&gt;</td><td>February 2026</td></tr><tr><td>16.2.0-canary.51</td><td>Confirmed in #90433 comments</td><td>March 2026</td></tr></tbody></table>
<h3>Node.js</h3>
<p>Probado en Node.js 20, 22, 24, 25 — fuga en todos.</p>
<h2>Qué no funciona</h2>
<table><thead><tr><th>Attempt</th><th>Result</th></tr></thead><tbody><tr><td>&lt;code&gt;cacheMaxMemorySize: 0&lt;/code&gt;</td><td>Doesn&apos;t help — leak is not from this cache</td></tr><tr><td>Disable image optimization</td><td>Doesn&apos;t help</td></tr><tr><td>&lt;code&gt;--max-old-space-size=6144&lt;/code&gt;</td><td>Just crashes slower</td></tr><tr><td>Read response via &lt;code&gt;.json()&lt;/code&gt; / &lt;code&gt;.text()&lt;/code&gt;</td><td>Doesn&apos;t help</td></tr><tr><td>Remove React fetch patching (canary.45)</td><td>Doesn&apos;t help</td></tr><tr><td>Upgrade to latest version</td><td>Doesn&apos;t help (leaks on 16.2.0-canary.51 too)</td></tr></tbody></table>
<h2>Qué funciona (workarounds)</h2>
<table><thead><tr><th>Workaround</th><th>Why it helps</th></tr></thead><tbody><tr><td>Replace &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;axios&lt;/code&gt;</td><td>Bypasses Next.js wrapper</td></tr><tr><td>Replace &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;node-fetch&lt;/code&gt;</td><td>Same reason</td></tr><tr><td>Downgrade Node.js to 20.15.1</td><td>Older undici has fewer leaks (for some)</td></tr><tr><td>Docker image &lt;code&gt;node:20-alpine3.21&lt;/code&gt;</td><td>Helps in some cases</td></tr><tr><td>Deploy to Lambda (SST + OpenNext)</td><td>Ephemeral functions — memory doesn&apos;t accumulate</td></tr><tr><td>Deploy to Vercel</td><td>Same — serverless</td></tr></tbody></table>
<h3>Limitación del workaround con axios</h3>
<p>Tus propias llamadas API pueden reemplazarse con axios. Pero Next.js usa internamente el fetch parcheado para:</p>
<ul><li>ISR (Incremental Static Regeneration)</li><li><code>revalidatePath</code> / <code>revalidateTag</code></li><li>Data fetching de Server Components con deduplicación</li><li><code>use cache</code> (Next.js 16)</li></ul>
<p>Incluso sin un solo <code>fetch</code> en tu código — Next.js lo sigue usando internamente.</p>
<h2>Por qué Vercel no lo arregla</h2>
<h3>Lógica de negocio</h3>
<p>Vercel es una empresa que gana dinero alojando Next.js. En su plataforma el problema no se manifiesta (serverless = efímero). El bug solo afecta a self-hosted (Docker, K8s, VPS) — los que no pagan a Vercel.</p>
<h3>Posición oficial</h3>
<p>Tim Neutkens (mantenedor de Vercel) analizó el problema y declaró que es un problema de <strong>undici</strong> (biblioteca fetch de Node.js), no de Next.js. El issue #90433 fue cerrado. A pesar de que:</p>
<ul><li>axios y node-fetch en el mismo Node.js funcionan sin fugas</li><li>La fuga solo aparece cuando fetch pasa por el wrapper de Next.js</li><li>El bug lleva 2 años abierto sin solución</li></ul>
<h3>Prioridades</h3>
<p>En estos 2 años el equipo de Next.js lanzó:</p>
<ul><li>Turbopack (builds 2-5x más rápidos) — ventaja de marketing</li><li>Cache Components / <code>use cache</code> — reduce carga en servidores Vercel</li><li><code>proxy.ts</code> en lugar de middleware — simplifica el despliegue edge en Vercel</li><li>DevTools MCP — hype de IA</li></ul>
<p>¿Memory leak en self-hosted? No es prioridad.</p>
<h2>Solución: AWS Lambda (SST + OpenNext)</h2>
<h3>Qué es</h3>
<p>OpenNext es un adaptador open-source que convierte un build de Next.js en formato para AWS Lambda. SST es un framework que automatiza la infraestructura.</p>
<h3>Arquitectura</h3>
<pre><code>Next.js build
  → OpenNext
    → AWS Lambda (SSR, API routes)
    → S3 (static, assets)
    → CloudFront (CDN)
    → SQS + DynamoDB (ISR revalidation)</code></pre>
<h3>Por qué esto resuelve el memory leak</h3>
<p>Las funciones Lambda procesan peticiones y se reciclan tras 5-15 minutos de inactividad. La memoria no tiene tiempo de acumularse.</p>
<h3>Despliegue</h3>
<pre><code>npx sst@latest init
npx sst deploy --stage production</code></pre>
<h3>Comparación</h3>
<table><thead><tr><th></th><th>Vercel</th><th>AWS Lambda (SST)</th><th>Docker self-hosted</th></tr></thead><tbody><tr><td>Memory leak</td><td>Not felt</td><td>Not felt</td><td>Critical</td></tr><tr><td>Cold starts</td><td>Yes</td><td>Yes (~200-500ms)</td><td>No</td></tr><tr><td>Price (~medium traffic)</td><td>$20-150/mo</td><td>$5-30/mo</td><td>$5-50/mo</td></tr><tr><td>Control</td><td>Minimal</td><td>Full</td><td>Full</td></tr><tr><td>ISR/Revalidation</td><td>Works</td><td>Works (SQS)</td><td>Works (with leak)</td></tr><tr><td>Vendor lock-in</td><td>Vercel</td><td>AWS</td><td>None</td></tr></tbody></table>
<h3>Matices de Lambda</h3>
<ul><li><strong>Cold starts</strong> — la primera petición es más lenta (~200-500ms)</li><li><strong>Seguridad</strong> — activar OAC (Origin Access Control), sino la URL de Lambda es pública</li><li><strong>OpenNext</strong> — proyecto comunitario, no oficial de Vercel. Nuevas funciones de Next.js pueden romperse</li><li><strong>Ataque al monedero</strong> — durante DDoS, el auto-scaling de Lambda puede generar una factura grande</li></ul>
<h2>Por qué una solución real es poco realista</h2>
<h3>1. Problema arquitectónico</h3>
<p>La fuga no es un bug accidental sino consecuencia de una decisión de diseño: Next.js intercepta el <code>fetch</code> global y añade caché/tracking encima. Para arreglarlo hay que rediseñar cómo App Router interactúa con fetch. Esto afecta ISR, revalidation, data cache, request deduplication — el núcleo del framework.</p>
<h3>2. Conflicto de intereses</h3>
<p>Vercel no está motivado a arreglar lo que no afecta su plataforma. Self-hosted compite con su negocio. Cuantos más problemas en self-hosted — más personas migran a Vercel.</p>
<h3>3. Echar la culpa</h3>
<p>La posición oficial es "es undici, no nosotros". Hasta que eso cambie — no trabajarán en una solución.</p>
<h3>4. Sin solución comunitaria</h3>
<p>La licencia AGPL-3.0 de Next.js permite forks, pero la base de código es enorme y está estrechamente acoplada con la infraestructura de Vercel. Un PR comunitario para arreglar el wrapper de fetch requeriría un profundo conocimiento de la arquitectura interna y la aprobación de los mantenedores — que ya cerraron el issue.</p>
<h2>Conclusiones</h2>
<ol><li><strong>Si estás en Vercel</strong> — no hay problema, nada que hacer</li><li><strong>Si self-hosted y necesitas serverless</strong> — SST + OpenNext en AWS Lambda</li><li><strong>Si self-hosted Docker</strong> — reemplazar fetch con axios donde sea posible, monitorear RAM, configurar reinicios automáticos de pods</li><li><strong>Si empiezas un proyecto nuevo</strong> — considerar SvelteKit o Nuxt como alternativas sin este problema</li></ol>
<h2>Fuentes</h2>
<ul><li><a href="https://github.com/vercel/next.js/issues/64212" target="_blank" rel="noopener noreferrer">Issue #64212</a> — Memory Leak with global fetch (April 2024)</li><li><a href="https://github.com/vercel/next.js/issues/68578" target="_blank" rel="noopener noreferrer">Issue #68578</a> — Possible memory leak in Fetch API (August 2024)</li><li><a href="https://github.com/vercel/next.js/issues/85914" target="_blank" rel="noopener noreferrer">Issue #85914</a> — Memory Leak with fetch + standalone (November 2025)</li><li><a href="https://github.com/vercel/next.js/issues/90433" target="_blank" rel="noopener noreferrer">Issue #90433</a> — OOM in 16.0.10 (February 2026)</li><li><a href="https://github.com/vercel/next.js/discussions/88603" target="_blank" rel="noopener noreferrer">Discussion #88603</a> — OOM in Docker/K8s (January 2026)</li><li><a href="https://github.com/vercel/next.js/discussions/88078" target="_blank" rel="noopener noreferrer">Discussion #88078</a> — cacheMaxMemorySize behavior</li><li><a href="https://opennext.js.org/" target="_blank" rel="noopener noreferrer">OpenNext</a></li><li><a href="https://sst.dev/docs/start/aws/nextjs/" target="_blank" rel="noopener noreferrer">SST — Next.js on AWS</a></li><li><a href="https://www.flightcontrol.dev/blog/secret-knowledge-to-self-host-nextjs" target="_blank" rel="noopener noreferrer">Secret knowledge to self-host Next.js</a></li><li><a href="https://nextjs.org/docs/app/guides/memory-usage" target="_blank" rel="noopener noreferrer">Next.js Memory Usage Guide</a></li></ul>]]></content:encoded>
      <pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate>
      <category>Next.js</category>
      <category>Memory Leak</category>
      <category>Docker</category>
      <category>AWS Lambda</category>
      <category>Node.js</category>
    </item>
  </channel>
</rss>