TL;DR: Construí un agente de IA para TenK (app de 10,000 horas) que analiza tus datos de práctica y te da coaching personalizado. Usa tool calling con 5 herramientas, OAuth PKCE para que cada usuario conecte su cuenta de Claude, y un agentic loop de hasta 5 iteraciones. Este post explica cómo funciona todo bajo el capó.

TL;DR: I built an AI agent for TenK (10,000 hours app) that analyzes your practice data and gives you personalized coaching. It uses tool calling with 5 tools, OAuth PKCE so each user connects their Claude account, and an agentic loop of up to 5 iterations. This post explains how it all works under the hood.

1 ¿Qué es un Agente de IA?What is an AI Agent?

Un agente de IA no es un chatbot. La diferencia fundamental es que un chatbot solo responde preguntas con su conocimiento general, mientras que un agente puede tomar acciones y consultar datos en tiempo real para resolver un problema.

An AI agent is not a chatbot. The fundamental difference is that a chatbot only answers questions with its general knowledge, while an agent can take actions and query real-time data to solve a problem.

Pensemos en una analogía simple:

Let's think of a simple analogy:

🤖 Chatbot

Le preguntas "¿cuántas horas practiqué esta semana?" y responde "No tengo acceso a tus datos" o inventa algo.

You ask "how many hours did I practice this week?" and it replies "I don't have access to your data" or makes something up.

🧠 AgenteAgent

Recibe la pregunta, decide que necesita consultar tu historial, ejecuta la consulta a la base de datos, analiza los resultados y responde con datos reales.

Receives the question, decides it needs to check your history, executes the database query, analyzes the results, and responds with real data.

El agente tiene autonomía para decidir qué herramientas usar y en qué orden, similar a como un desarrollador decide qué queries ejecutar para resolver un ticket. No hay un flujo hardcodeado — el modelo razona en cada paso.

The agent has autonomy to decide which tools to use and in what order, similar to how a developer decides which queries to run to resolve a ticket. There's no hardcoded flow — the model reasons at each step.

2 El Agentic Loop: El Corazón del SistemaThe Agentic Loop: The Heart of the System

El patrón central es un loop de razonamiento-acción (conocido como ReAct pattern). Veamos un ejemplo real de cómo funciona en TenK:

The core pattern is a reasoning-action loop (known as the ReAct pattern). Let's see a real example of how it works in TenK:

Usuario: "¿Cómo voy con mi práctica de guitarra?"

→ Iteración 1:
  Claude PIENSA: "Necesito ver los skills del usuario"
  Claude LLAMA: get_user_skills(userId)
  Sistema RETORNA: [{skill: 'Guitarra', hours: 127, level: 'Aprendiz'}]

→ Iteración 2:
  Claude PIENSA: "Necesito las sesiones recientes"
  Claude LLAMA: get_practice_sessions(skillId, days: 30)
  Sistema RETORNA: [{date: '2026-02-10', duration: 45min}, ...]

→ Iteración 3:
  Claude PIENSA: "Quiero analizar patrones"
  Claude LLAMA: analyze_practice_patterns(skillId)
  Sistema RETORNA: {bestDay: 'Sábado', trend: 'growing', plateau: false}

→ Iteración 4:
  Claude PIENSA: "Ya tengo toda la info, puedo responder"
  Claude RESPONDE: "Llevas 127h de guitarra, nivel Aprendiz, racha de 5 días..."

Flujo real del agente: 4 iteraciones de razonamiento-acciónReal agent flow: 4 reasoning-action iterations

Lo clave: Claude decide en cada iteración si necesita más datos o si ya puede responder. El modelo razona sobre qué herramientas usar según la pregunta del usuario.

The key: Claude decides in each iteration whether it needs more data or can already respond. The model reasons about which tools to use based on the user's question.

3 Tool Calling: Cómo el LLM Ejecuta FuncionesTool Calling: How the LLM Executes Functions

El tool calling es el mecanismo que permite al LLM invocar funciones definidas por nosotros. Funciona en 3 pasos:

Tool calling is the mechanism that allows the LLM to invoke functions we define. It works in 3 steps:

Paso 1: Definición de Tools (JSON Schema)Step 1: Tool Definition (JSON Schema)

Le decimos al modelo qué herramientas tiene disponibles. El modelo nunca ejecuta código — solo emite un JSON estructurado indicando qué función quiere llamar:

We tell the model which tools are available. The model never executes code — it only emits a structured JSON indicating which function it wants to call:

{
  "name": "get_practice_sessions",
  "description": "Obtiene las sesiones de práctica de un skill específico",
  "input_schema": {
    "type": "object",
    "properties": {
      "skillId": { "type": "string", "description": "ID del skill" },
      "days":    { "type": "number", "description": "Últimos N días", "default": 30 }
    },
    "required": ["skillId"]
  }
}

Paso 2: Ejecución en el Backend (Tool Executor)Step 2: Backend Execution (Tool Executor)

Nuestro backend recibe la petición del modelo, valida los parámetros y ejecuta la query real contra PostgreSQL. Usamos un patrón TOOL_MAP que mapea nombres de herramientas a funciones importadas, envolviendo todo en try/catch con resultados tipados:

Our backend receives the model's request, validates the parameters, and executes the real query against PostgreSQL. We use a TOOL_MAP pattern that maps tool names to imported functions, wrapping everything in try/catch with typed results:

// tool-executor.ts — Implementación real
import { getUserSkills } from '../tools/query-skills.tool';
import { getPracticeSessions } from '../tools/query-sessions.tool';
import { getJourneyStats } from '../tools/get-stats.tool';
import { analyzePracticePatterns } from '../tools/analyze-patterns.tool';
import { saveWeeklyPlan } from '../tools/create-plan.tool';

type TToolFn = (userId: string, args: Record<string, unknown>) => Promise<TAgentToolResult>;

const TOOL_MAP: Record<string, TToolFn> = {
  get_user_skills: getUserSkills,
  get_practice_sessions: getPracticeSessions,
  get_journey_stats: getJourneyStats,
  analyze_practice_patterns: analyzePracticePatterns,
  save_weekly_plan: saveWeeklyPlan,
};

export async function executeTool(
  name: string, userId: string, args: Record<string, unknown>,
): Promise<TAgentToolResult> {
  const toolFn = TOOL_MAP[name];
  if (!toolFn) return { success: false, data: null, error: `Unknown tool: ${name}` };
  try {
    return await toolFn(userId, args);
  } catch (error) {
    return { success: false, data: null, error: String(error) };
  }
}

El tipo TAgentToolResult estandariza la respuesta: { success: boolean, data: unknown, error?: string }. Esto evita que un error en una herramienta crashee todo el loop — el agente recibe el error como dato y puede decidir qué hacer (reintentar, usar otra herramienta, o avisar al usuario).

The TAgentToolResult type standardizes the response: { success: boolean, data: unknown, error?: string }. This prevents an error in a tool from crashing the entire loop — the agent receives the error as data and can decide what to do (retry, use another tool, or notify the user).

⚠️ Nota de seguridad: Siempre filtramos por userId extraído del JWT — nunca del input del modelo. Esto previene ataques de prompt injection donde un usuario intente acceder a datos ajenos.

⚠️ Security note: We always filter by userId extracted from the JWT — never from the model's input. This prevents prompt injection attacks where a user might try to access other users' data.

Paso 3: Respuesta al ModeloStep 3: Response to the Model

El resultado de la función se inyecta como un mensaje tool_result en la conversación y el modelo continúa razonando con los datos reales.

The function result is injected as a tool_result message in the conversation and the model continues reasoning with the real data.

4 Arquitectura del Agent RunnerAgent Runner Architecture

El agent-runner.ts es el orquestador central. Su responsabilidad es manejar el loop de iteraciones:

agent-runner.ts is the central orchestrator. Its responsibility is to manage the iteration loop:

┌──────────────────────────────────────────────────┐
│                  AGENT RUNNER                     │
│                                                   │
│  ┌───────────┐    ┌──────────┐    ┌────────────┐ │
│  │  Messages  │──▶│  Claude  │──▶│   Parse     │ │
│  │   Array    │    │   API    │    │  Response   │ │
│  └───────────┘    └──────────┘    └─────┬──────┘ │
│       ▲                                  │        │
│       │          ┌──────────┐            │        │
│       │          │   Tool   │◀───────────┘        │
│       └──────────│ Executor │   tool_calls?       │
│     tool_result  └──────────┘                     │
│                                                   │
│  Si no hay tool_calls → return response           │
│  Si iteraciones > 5  → force return               │
└──────────────────────────────────────────────────┘

Arquitectura del Agent Runner con loop de máximo 5 iteracionesAgent Runner architecture with max 5 iteration loop

La implementación real en TypeScript. Nota los detalles que no aparecen en tutoriales: Promise.race para timeout global, tracking de tokens consumidos, y una llamada final SIN herramientas cuando se alcanza el límite de iteraciones para forzar una respuesta de texto:

The real TypeScript implementation. Note the details you won't find in tutorials: Promise.race for global timeout, token usage tracking, and a final call WITHOUT tools when the iteration limit is reached to force a text response:

// agent-runner.ts — Implementación real con timeout y tracking
const MAX_TOOL_ITERATIONS = 5;
const AGENT_TIMEOUT_MS = 150_000; // 2.5 minutos máximo total

export async function runAgent(userId: string, messages: IChatMessage[]) {
  // Timeout global — Promise.race mata el proceso si tarda mucho
  return Promise.race([
    runAgentLoop(userId, messages),
    agentTimeout(),
  ]);
}

async function agentTimeout(): Promise<never> {
  await new Promise((r) => setTimeout(r, AGENT_TIMEOUT_MS));
  throw new Error('El agente tardó demasiado. Intenta de nuevo.');
}

async function runAgentLoop(userId: string, messages: IChatMessage[]) {
  const toolsUsed: string[] = [];
  let lastProvider = 'unknown';
  const totalUsage = { inputTokens: 0, outputTokens: 0 };

  for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
    const result = await chatCompletion(messages, AGENT_TOOLS, userId);
    lastProvider = result.provider;

    // Acumular tokens consumidos en cada iteración
    if (result.usage) {
      totalUsage.inputTokens += result.usage.inputTokens;
      totalUsage.outputTokens += result.usage.outputTokens;
    }

    // Sin tool_calls = respuesta final
    if (!result.toolCalls.length) {
      return { response: result.content, toolsUsed, provider: lastProvider, usage: totalUsage };
    }

    // Agregar respuesta del asistente con tool_calls al historial
    messages.push({
      role: 'assistant', content: result.content ?? '',
      tool_calls: result.toolCalls,
    });

    // Ejecutar las herramientas y agregar resultados
    for (const tc of result.toolCalls) {
      const args = JSON.parse(tc.function.arguments);
      const toolResult = await executeTool(tc.function.name, userId, args);
      messages.push({
        role: 'tool',
        content: JSON.stringify(toolResult),
        tool_call_id: tc.id,
      });
      toolsUsed.push(tc.function.name);
    }
  }

  // Guardrail: max iteraciones alcanzado → una llamada final SIN tools
  // Esto fuerza al modelo a generar texto en vez de pedir más tools
  const finalResult = await chatCompletion(messages, undefined, userId);
  if (finalResult.usage) {
    totalUsage.inputTokens += finalResult.usage.inputTokens;
    totalUsage.outputTokens += finalResult.usage.outputTokens;
  }
  return {
    response: finalResult.content ?? 'No pude completar el análisis.',
    toolsUsed, provider: lastProvider, usage: totalUsage,
  };
}

💡 Detalle clave: Cuando el agente alcanza el máximo de 5 iteraciones, la mayoría de implementaciones simplemente devuelven el último contenido. Nosotros hacemos una llamada extra sin herramientas — esto obliga a Claude a sintetizar toda la información recopilada en una respuesta coherente, en vez de cortarse a mitad de un razonamiento.

💡 Key detail: When the agent reaches the maximum 5 iterations, most implementations simply return the last content. We make an extra call without tools — this forces Claude to synthesize all collected information into a coherent response, instead of cutting off mid-reasoning.

El array de messages crece con cada iteración: pregunta del usuario → respuesta con tool_calls → resultados de las tools → nueva respuesta → etc. Claude tiene contexto completo de toda la conversación para generar la respuesta final.

The messages array grows with each iteration: user question → response with tool_calls → tool results → new response → etc. Claude has full context of the entire conversation to generate the final response.

5 Las 5 Herramientas del TenK CoachThe 5 TenK Coach Tools

# Tool Input Output Uso típicoTypical use
1 get_user_skills includeArchived?: boolean Skills con horas, emoji, color, nivel de maestría, sesionesSkills with hours, emoji, color, mastery level, sessions Inicio de conversaciónConversation start
2 get_practice_sessions skillId (required), days (default: 30) Historial de sesiones con fecha, horas, notasSession history with date, hours, notes Análisis de skill específicoSpecific skill analysis
3 get_journey_stats Promedio semanal, mejor día/semana, proyecciones, rachasWeekly average, best day/week, projections, streaks Resumen general del journeyOverall journey summary
4 analyze_practice_patterns skillId?: string Distribución por día, tendencia semanal, gaps, consistenciaDay distribution, weekly trend, gaps, consistency Diagnóstico profundo de hábitosDeep habit diagnosis
5 save_weekly_plan content + insights[] (typed) Confirmación + fecha de la semanaConfirmation + week date Persistir plan generadoPersist generated plan

Las herramientas 1-4 son de lectura (consultan datos). La herramienta 5 es de escritura (modifica la base de datos). El modelo decide cuáles usar y en qué orden según la pregunta del usuario.

Tools 1-4 are read tools (query data). Tool 5 is a write tool (modifies the database). The model decides which ones to use and in what order based on the user's question.

El Schema de los Insights (Tool #5)The Insights Schema (Tool #5)

La herramienta save_weekly_plan recibe un array de insights tipados — no es un string libre, sino datos estructurados que el modelo debe generar siguiendo un schema estricto:

The save_weekly_plan tool receives a typed array of insights — not a free string, but structured data that the model must generate following a strict schema:

// La definición real del tool le dice a Claude exactamente qué estructura generar
"insights": {
  "type": "array",
  "items": {
    "type": "object",
    "properties": {
      "type": {
        "type": "string",
        "enum": ["progress", "plateau", "streak", "suggestion", "milestone"]
      },
      "title": { "type": "string" },
      "description": { "type": "string" },
      "skillId": { "type": "string", "description": "Optional skill reference" }
    },
    "required": ["type", "title", "description"]
  }
}

Esto le da al modelo un vocabulario tipado para clasificar sus observaciones. Cada insight tiene un type semántico que permite renderizar diferentes estilos en el UI (badges de color, iconos, etc.).

This gives the model a typed vocabulary to classify its observations. Each insight has a semantic type that allows rendering different styles in the UI (color badges, icons, etc.).

💡 Decisión de diseño: Las herramientas son granulares a propósito. Podríamos haber creado una sola herramienta get_all_data que devuelva todo, pero eso enviaría datos innecesarios en cada consulta. Con herramientas granulares, Claude solo pide lo que necesita — consumiendo menos tokens y reduciendo la latencia.

💡 Design decision: The tools are intentionally granular. We could have created a single get_all_data tool that returns everything, but that would send unnecessary data on every query. With granular tools, Claude only requests what it needs — consuming fewer tokens and reducing latency.

6 El System Prompt: La Personalidad del AgenteThe System Prompt: The Agent's Personality

El system prompt define quién es el agente y cómo debe comportarse. Es probablemente el componente más importante — la diferencia entre un agente útil y uno genérico.

The system prompt defines who the agent is and how it should behave. It's probably the most important component — the difference between a useful agent and a generic one.

Nuestro prompt incluye reglas muy específicas que hacen la diferencia entre un chatbot genérico y un coach especializado:

Our prompt includes very specific rules that make the difference between a generic chatbot and a specialized coach:

Sin instrucciones claras, el modelo tiende a alucinar — decir "llevas X horas" sin verificar. El prompt fuerza al agente a siempre consultar herramientas primero.

Without clear instructions, the model tends to hallucinate — saying "you have X hours" without verifying. The prompt forces the agent to always query tools first.

// system-prompt.ts — Implementación real
export function buildSystemPrompt(): string {
  return `Eres el **TenK Coach** — entrenador de práctica deliberada basado en la 
regla de las 10,000 horas.

PERSONALIDAD:
- Motivador pero realista. Si el usuario no practica, dilo con tacto.
- Directo y conciso. Sin rodeos.
- Basado en datos. SIEMPRE usa las herramientas antes de opinar.
- Responde SIEMPRE en español.

HERRAMIENTAS:
- Llama TODAS las herramientas que necesites en UNA sola respuesta.
- NUNCA inventes estadísticas. Si no tienes datos, pídelos con las tools.

NIVELES DE MAESTRÍA:
🌱 Semilla (0-100h) — Apenas comenzando
📘 Aprendiz (100-500h) — Fundamentos sólidos
⚡ Intermedio (500-2,000h) — Competencia real
🔥 Avanzado (2,000-5,000h) — Dominio profundo
👑 Master (5,000-10,000h) — Élite

PLAN SEMANAL (cuando lo pidan):
1. 📊 Resumen — datos clave de la semana anterior
2. 📅 Plan — qué skill, qué día, cuántas horas
3. 🎯 Foco — 1 recomendación basada en patrones
4. 🏆 Meta — horas objetivo realistas

REGLAS:
- Máximo 300 palabras por respuesta
- Reconoce logros: rachas, sesiones largas, nuevos niveles
- No uses fuentes externas, solo los datos del usuario
`;
}

El Truco del Parallel Tool CallingThe Parallel Tool Calling Trick

La instrucción "Llama TODAS las herramientas que necesites en una sola respuesta" es clave para la performance. Sin ella, Claude pide una herramienta por iteración (3-4 API calls). Con ella, Claude puede pedir get_user_skills, get_journey_stats y analyze_practice_patterns en un solo turno — reduciendo las iteraciones de 4 a 2.

The instruction "Call ALL the tools you need in a single response" is key for performance. Without it, Claude requests one tool per iteration (3-4 API calls). With it, Claude can request get_user_skills, get_journey_stats and analyze_practice_patterns in a single turn — reducing iterations from 4 to 2.

7 OAuth PKCE: Cada Usuario, Su Propia CuentaOAuth PKCE: Each User, Their Own Account

En lugar de usar una API key centralizada (que comparte rate limits entre todos los usuarios), implementamos OAuth PKCE donde cada usuario conecta su propia cuenta de Claude Pro o Max.

Instead of using a centralized API key (which shares rate limits across all users), we implemented OAuth PKCE where each user connects their own Claude Pro or Max account.

¿Por qué Per-User?Why Per-User?

❌ API Key Centralizada❌ Centralized API Key

  • Rate limits compartidos entre todosShared rate limits across all users
  • Costo de API para nosotrosAPI cost for us
  • Un usuario heavy bloquea a todosOne heavy user blocks everyone
  • Escalabilidad limitadaLimited scalability

✅ OAuth Per-User✅ OAuth Per-User

  • Rate limits individualesIndividual rate limits
  • Zero API cost para nosotrosZero API cost for us
  • Cada usuario tiene su cuotaEach user has their own quota
  • Escalabilidad naturalNatural scalability

El Flujo CompletoThe Complete Flow

┌─────────┐     ┌──────────┐     ┌──────────┐     ┌───────────┐
│  Flutter │     │  Backend │     │ Anthropic│     │  Browser  │
│   App    │     │   API    │     │  OAuth   │     │  (Login)  │
└────┬─────┘     └────┬─────┘     └────┬─────┘     └─────┬─────┘
     │                │                │                  │
     │ 1. Init OAuth  │                │                  │
     │───────────────▶│                │                  │
     │                │                │                  │
     │                │ 2. Generate    │                  │
     │                │ code_verifier  │                  │
     │                │ + code_challenge                  │
     │                │ (SHA-256)      │                  │
     │                │                │                  │
     │  3. Auth URL   │                │                  │
     │◀───────────────│                │                  │
     │                │                │                  │
     │ 4. Open browser│                │                  │
     │────────────────────────────────────────────────────▶
     │                │                │    5. User logs  │
     │                │                │◀─────────────────│
     │                │                │                  │
     │                │                │  6. code#state   │
     │  7. Paste code │                │─────────────────▶│
     │◀───────────────────────────────────────────────────│
     │                │                │                  │
     │ 8. Exchange    │                │                  │
     │───────────────▶│                │                  │
     │                │ 9. POST /token │                  │
     │                │ (JSON!)        │                  │
     │                │───────────────▶│                  │
     │                │                │                  │
     │                │ 10. Tokens     │                  │
     │                │◀───────────────│                  │
     │                │                │                  │
     │  11. Connected │                │                  │
     │◀───────────────│                │                  │
     │                │                │                  │

Flujo OAuth PKCE completo: de la app al tokenComplete OAuth PKCE flow: from app to token

PKCE: Proof Key for Code Exchange

PKCE resuelve el problema de seguridad de OAuth en apps móviles (donde no puedes guardar un client_secret de forma segura). En vez de un secreto estático, cada flujo genera un par criptográfico único:

PKCE solves the OAuth security problem in mobile apps (where you can't store a client_secret securely). Instead of a static secret, each flow generates a unique cryptographic pair:

// claude-oauth.service.ts

// 1. Generar un string aleatorio de 64 caracteres
function generateRandomString(length: number): string {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
  const bytes = crypto.getRandomValues(new Uint8Array(length));
  return Array.from(bytes, (b) => chars[b % chars.length]).join('');
}

// 2. Derivar el challenge con SHA-256
async function generateCodeChallenge(verifier: string): Promise<string> {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

// El verifier se guarda en DB, el challenge va en la URL de auth
const codeVerifier = generateRandomString(64);
const codeChallenge = await generateCodeChallenge(codeVerifier);

El code_challenge viaja en la URL de autorización (público). El code_verifier se guarda en nuestra base de datos y se envía en el token exchange. El servidor de Anthropic verifica que SHA-256(verifier) === challenge — si no coincide, rechaza el request.

The code_challenge travels in the authorization URL (public). The code_verifier is stored in our database and sent during the token exchange. Anthropic's server verifies that SHA-256(verifier) === challenge — if it doesn't match, it rejects the request.

Las Trampas de Anthropic que Nadie DocumentaAnthropic's Undocumented Gotchas

🔥 Detalle #1: Anthropic devuelve el código de autorización en formato code#state como un solo string. Hay que parsear el # manualmente:

🔥 Gotcha #1: Anthropic returns the authorization code in code#state format as a single string. You have to parse the # manually:

// El usuario pega: "abc123xyz#state-789def"
const hashIndex = rawCode.indexOf('#');
const code = rawCode.substring(0, hashIndex);   // "abc123xyz"
const state = rawCode.substring(hashIndex + 1);  // "state-789def"

🔥 Detalle #2: Anthropic es el único proveedor OAuth que requiere Content-Type: application/json en el token exchange. Google, GitHub, todos usan application/x-www-form-urlencoded.

🔥 Gotcha #2: Anthropic is the only OAuth provider that requires Content-Type: application/json in the token exchange. Google, GitHub, everyone else uses application/x-www-form-urlencoded.

// Token exchange — JSON, NO form-urlencoded
const res = await fetch('https://console.anthropic.com/v1/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },  // ← único en la industria
  body: JSON.stringify({
    grant_type: 'authorization_code',
    client_id: CLAUDE_CLIENT_ID,
    code,
    state,               // ← también va en el body (no documentado)
    redirect_uri: REDIRECT_URI,
    code_verifier: codeVerifier,
  }),
});

8 Stealth Headers: Haciéndose Pasar por Claude Code CLIStealth Headers: Impersonating the Claude Code CLI

Los tokens OAuth de Claude solo funcionan si el request parece venir del CLI oficial. Sin los headers correctos, Anthropic rechaza con 403 Forbidden.

Claude's OAuth tokens only work if the request looks like it's coming from the official CLI. Without the correct headers, Anthropic rejects with 403 Forbidden.

Descubrimos esto investigando el código fuente de OpenClaw (cliente open source de Claude Code) y capturando las llamadas reales del CLI:

We discovered this by investigating OpenClaw's source code (open source Claude Code client) and capturing the real CLI calls:

// ai-client.ts — Stealth headers para tokens OAuth
const isOAuth = credential.accessToken.startsWith('sk-ant-oat');

if (isOAuth) {
  headers['Authorization'] = `Bearer ${credential.accessToken}`;
  headers['anthropic-dangerous-direct-browser-access'] = 'true';
  headers['anthropic-beta'] = 'claude-code-20250219,oauth-2025-04-20,' +
    'fine-grained-tool-streaming-2025-05-14';
  headers['user-agent'] = 'claude-cli/2.1.2 (external, cli)';
  headers['x-app'] = 'cli';

  // El system prompt DEBE abrir con la identidad de Claude Code
  body.system = [
    { type: 'text', text: "You are Claude Code, Anthropic's official CLI." },
    { type: 'text', text: actualSystemPrompt },
  ];
} else {
  // API key normal — headers estándar
  headers['x-api-key'] = credential.accessToken;
  body.system = actualSystemPrompt;
}

Son 5 headers específicos + un system prompt de identidad. Sin cualquiera de ellos, el token es rechazado.

That's 5 specific headers + an identity system prompt. Without any one of them, the token is rejected.

9 Rate Limits: De Error Críptico a Banner EleganteRate Limits: From Cryptic Error to Elegant Banner

Cuando el usuario agota su sesión de Claude, la API devuelve un 429 con un header retry-after. Sin manejo adecuado, el usuario veía un error técnico incomprensible.

When the user exhausts their Claude session, the API returns a 429 with a retry-after header. Without proper handling, the user would see an incomprehensible technical error.

La Cadena Completa de ManejoThe Complete Handling Chain

429 API Response (retry-after: 2520s)
         │
         ▼
TooManyRequestsError(minutes: 42)     ← Backend
         │
         ▼
JSON: { retryAfterMinutes: 42 }       ← HTTP Response
         │
         ▼
DioException (statusCode: 429)        ← Flutter HTTP
         │
         ▼
RateLimitFailure(minutes: 42)         ← Repository
         │
         ▼
AiCoachStatus.rateLimited             ← BLoC State
         │
         ▼
🟡 RateLimitBanner                    ← UI Widget
   "Intenta en ~42 min"

El error 429 transformándose de API response a UI amigableThe 429 error transforming from API response to user-friendly UI

Backend: Retry con BackoffBackend: Retry with Backoff

// ai-client.ts — 3 reintentos con backoff
if (res.status === 429) {
  const retryAfter = res.headers.get('retry-after');
  const rawWaitMs = retryAfter ? Number(retryAfter) * 1000 : (attempt + 1) * 5000;
  const minutes = Math.max(1, Math.ceil(rawWaitMs / 60000));

  // Si hay que esperar más de 1 minuto, no tiene sentido reintentar
  if (rawWaitMs > 60000) {
    throw new TooManyRequestsError(
      `Has alcanzado el límite de uso de Claude. Intenta en ~${minutes} min.`,
      minutes,
    );
  }
  // Si es poco tiempo, esperar y reintentar
  await sleep(Math.min(rawWaitMs, 15000));
}

Flutter: Error Tipado → UIFlutter: Typed Error → UI

// ai_coach_repository_impl.dart
if (statusCode == 429) {
  final retryMinutes = errorData?['retryAfterMinutes'] as int?;
  return RateLimitFailure(
    message: message,
    retryAfterMinutes: retryMinutes,
  );
}

// ai_coach_bloc.dart
if (failure is RateLimitFailure) {
  emit(state.copyWith(
    status: AiCoachStatus.rateLimited,
    retryAfterMinutes: failure.retryAfterMinutes,
  ));
}

El resultado: en vez de un crash, el usuario ve un banner con gradiente amber, ícono de timer y los minutos exactos de espera.

The result: instead of a crash, the user sees a banner with an amber gradient, timer icon, and the exact minutes to wait.

10 Optimización: 39 Segundos → 8 SegundosOptimization: 39 Seconds → 8 Seconds

El plan semanal inicialmente tomaba 39 segundos porque el agente ejecutaba 6 llamadas secuenciales a la API (una por iteración del tool loop). Cada iteración: Claude decide qué tool → ejecuta → recibe resultado → decide otra → repite.

The weekly plan initially took 39 seconds because the agent executed 6 sequential API calls (one per tool loop iteration). Each iteration: Claude decides which tool → executes → receives result → decides another → repeats.

❌ Agent Loop (39s)

// 6 API calls secuenciales:
// Claude pide tool 1 → ejecuta
// Claude pide tool 2 → ejecuta
// Claude pide tool 3 → ejecuta
// Claude escribe plan → save tool
// 4 rondas ida y vuelta

✅ Pre-fetch (8s)

// 3 queries en paralelo (~50ms)
// + 1 API call con todo
// = 4.7x más rápido

La implementación real. Nota cómo reutilizamos las mismas funciones de las tools del agente, pero las llamamos directamente sin el loop — las tools son funciones puras que reciben userId y args:

The real implementation. Note how we reuse the same tool functions from the agent, but call them directly without the loop — the tools are pure functions that receive userId and args:

// weekly-plan.service.ts — Implementación real
import { getUserSkills } from '../tools/query-skills.tool';
import { getJourneyStats } from '../tools/get-stats.tool';
import { analyzePracticePatterns } from '../tools/analyze-patterns.tool';
import { saveWeeklyPlan } from '../tools/create-plan.tool';

const PLAN_TIMEOUT_MS = 60_000; // 1 minuto máximo

export async function generateWeeklyPlanDirect(userId: string) {
  return Promise.race([
    generatePlan(userId),
    planTimeout(), // misma técnica que el agent runner
  ]);
}

async function generatePlan(userId: string) {
  // 1. Pre-fetch: 3 queries en paralelo (~50ms total)
  const [skills, stats, patterns] = await Promise.all([
    getUserSkills(userId, {}),
    getJourneyStats(userId, {}),
    analyzePracticePatterns(userId, {}),
  ]);

  // 2. Construir prompt con TODOS los datos embebidos como JSON
  const userPrompt = buildWeeklyPlanPrompt(skills.data, stats.data, patterns.data);

  // 3. UNA sola llamada a la API — sin tools, sin loop
  const result = await chatCompletion(
    [{ role: 'system', content: PLAN_SYSTEM_PROMPT },
     { role: 'user', content: userPrompt }],
    undefined, // ← sin tools
    userId,
  );

  // 4. Parsear y guardar
  const { planMarkdown, insights } = parsePlanResponse(result.content);
  await saveWeeklyPlan(userId, { content: planMarkdown, insights });

  return { response: result.content, toolsUsed: ['get_user_skills',
    'get_journey_stats', 'analyze_practice_patterns', 'save_weekly_plan'] };
}
// weekly-plan.prompt.ts — Los datos van como JSON en el prompt
export function buildWeeklyPlanPrompt(
  skillsData: unknown, statsData: unknown, patternsData: unknown,
): string {
  return `Genera mi plan semanal basándote en mis datos reales:

## Mis Skills
\`\`\`json
${JSON.stringify(skillsData, null, 2)}
\`\`\`

## Estadísticas del Journey
\`\`\`json
${JSON.stringify(statsData, null, 2)}
\`\`\`

## Patrones de Práctica
\`\`\`json
${JSON.stringify(patternsData, null, 2)}
\`\`\`

Con estos datos, genera mi plan semanal personalizado.`;
}

Para el chat libre mantenemos el agent loop porque cada pregunta es impredecible. Para el plan semanal pre-cargamos todos los datos con Promise.all (~50ms) y hacemos una sola llamada con todo embebido en el prompt.

For free chat we keep the agent loop because each question is unpredictable. For the weekly plan we pre-load all data with Promise.all (~50ms) and make a single call with everything embedded in the prompt.

Resultado: 4.7x más rápido — de 39 segundos a ~8 segundos.

Result: 4.7x faster — from 39 seconds to ~8 seconds.

11 Arquitectura CompletaComplete Architecture

Backend (Bun + Hono + Prisma)

modules/
├── ai/
│   ├── ai.controller.ts        # POST /chat, POST /weekly-plan
│   ├── ai.service.ts            # Orquesta el agent runner
│   ├── agent/
│   │   ├── agent-runner.ts      # Loop de tool calling (max 5 iteraciones)
│   │   ├── system-prompt.ts     # Prompt del TenK Coach
│   │   ├── tool-definitions.ts  # 5 tools con JSON Schema
│   │   └── tool-executor.ts     # Dispatcher de tools
│   └── tools/
│       ├── query-skills.tool.ts
│       ├── query-sessions.tool.ts
│       ├── get-stats.tool.ts
│       ├── analyze-patterns.tool.ts
│       └── create-plan.tool.ts
├── claude-oauth/
│   ├── claude-oauth.controller.ts  # POST /init, POST /exchange, GET /status
│   ├── claude-oauth.service.ts     # PKCE, token exchange, refresh
│   └── claude-oauth.repository.ts  # Prisma ops
lib/
└── ai-client.ts                 # Per-user tokens → stealth headers → retry

Flutter (Clean Architecture)

features/ai_coach/
├── data/
│   ├── datasources/
│   │   ├── ai_coach_remote_datasource.dart
│   │   └── claude_oauth_datasource.dart
│   ├── models/
│   └── repositories/
│       └── ai_coach_repository_impl.dart  # _handleDioError → RateLimitFailure
├── domain/
│   ├── entities/
│   ├── repositories/
│   └── usecases/
└── presentation/
    ├── bloc/
    │   ├── ai_coach_bloc.dart     # initial | sending | success | error | rateLimited
    │   ├── ai_coach_event.dart
    │   └── ai_coach_state.dart    # retryAfterMinutes field
    ├── pages/
    │   └── ai_coach_screen.dart
    └── widgets/
        ├── rate_limit_banner.dart
        ├── claude_connect_sheet.dart
        ├── chat_message_bubble.dart
        └── coach_empty_state.dart

12 Modelos de Base de DatosDatabase Models

// schema.prisma — Modelos reales para OAuth, Conversaciones y Planes

model AiConversation {
  id        String   @id @default(cuid())
  userId    String   @map("user_id")
  title     String   @default("Nueva conversación")
  messages  Json     @default("[]")     // IChatMessage[] serializado
  context   Json     @default("{}")
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  @@map("ai_conversations")
  @@index([userId])
}

model AiWeeklyReport {
  id             String   @id @default(cuid())
  userId         String   @map("user_id")
  weekStart      DateTime @map("week_start") @db.Date
  content        String   @db.Text          // Markdown del plan
  insights       Json     @default("[]")    // IWeeklyInsight[] tipado
  skillsAnalyzed Json     @default("[]") @map("skills_analyzed")
  createdAt      DateTime @default(now()) @map("created_at")
  user           User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  @@unique([userId, weekStart])  // Un plan por semana por usuario
  @@map("ai_weekly_reports")
  @@index([userId])
}

model ClaudeCredential {
  id           String   @id @default(cuid())
  userId       String   @unique @map("user_id")
  accessToken  String   @map("access_token") @db.Text
  refreshToken String   @map("refresh_token") @db.Text
  expiresAt    DateTime @map("expires_at")
  createdAt    DateTime @default(now()) @map("created_at")
  updatedAt    DateTime @updatedAt @map("updated_at")
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  @@map("claude_credentials")
}

model OAuthState {
  id            String   @id @default(cuid())
  userId        String   @map("user_id")
  state         String   @unique
  codeVerifier  String   @map("code_verifier")
  expiresAt     DateTime @map("expires_at")
  createdAt     DateTime @default(now()) @map("created_at")
  @@map("oauth_states")
  @@index([state])
  @@index([expiresAt])
}

Detalles clave del schema:Key schema details:

13 Provider Fallback: La Cadena de 3 NivelesProvider Fallback: The 3-Tier Chain

El sistema de credenciales tiene 3 niveles de fallback para maximizar la disponibilidad. Si un nivel falla o no tiene credenciales, intenta el siguiente automáticamente:

The credential system has 3 fallback levels to maximize availability. If a level fails or has no credentials, it automatically tries the next one:

chatCompletion(messages, tools, userId)
         │
         ▼
┌─ Nivel 1: Per-User OAuth (DB) ─────────────────────┐
│  claudeOAuthService.getUserCredential(userId)       │
│  → ClaudeCredential table → accessToken             │
│  → Auto-refresh si expirado                         │
│  → Headers: stealth (OAuth tokens)                  │
└──────────┬──────────────────────────────────────────┘
           │ null? (no conectado)
           ▼
┌─ Nivel 2: System Anthropic (Keychain/File) ─────────┐
│  readClaudeCredentials()                             │
│  → macOS Keychain: "Claude Code-credentials"        │
│  → Fallback: ~/.claude/.credentials.json             │
│  → Auto-refresh si expirado via OAuth refresh_token  │
│  → Headers: stealth (same OAuth format)              │
└──────────┬──────────────────────────────────────────┘
           │ null? (no disponible)
           ▼
┌─ Nivel 3: OpenAI Codex (File) ──────────────────────┐
│  readCodexCredentials()                              │
│  → ~/.codex/auth.json → access_token                │
│  → Model: gpt-4o (en vez de claude-sonnet-4)        │
│  → Headers: estándar OpenAI                          │
└─────────────────────────────────────────────────────┘

3-tier provider fallback: per-user DB → system Keychain → OpenAI

// ai-client.ts — Implementación real del fallback chain
export async function chatCompletion(
  messages: IChatMessage[], tools?: IToolDefinition[], userId?: string,
): Promise<IChatResponse> {

  // Nivel 1: Credencial del usuario (Claude OAuth via DB)
  if (userId) {
    const userCred = await claudeOAuthService.getUserCredential(userId);
    if (userCred) {
      return callAnthropic(
        { provider: 'anthropic', accessToken: userCred.accessToken,
          refreshToken: userCred.refreshToken, expiresAt: userCred.expiresAt },
        messages, tools,
      );
    }
  }

  // Nivel 2: Credencial del sistema (Keychain/archivo)
  const systemCred = await readClaudeCredentials();
  if (systemCred) return callAnthropic(systemCred, messages, tools);

  // Nivel 3: OpenAI como último recurso
  const openaiCred = readCodexCredentials();
  if (openaiCred) return callOpenAI(openaiCred, messages, tools);

  throw new Error('No AI credentials available');
}

Lectura del macOS KeychainReading from macOS Keychain

En servidores macOS, las credenciales de Claude Code se almacenan en el Keychain del sistema. El backend las lee ejecutando security — el mismo CLI que usa Claude Code internamente:

On macOS servers, Claude Code credentials are stored in the system Keychain. The backend reads them by executing security — the same CLI that Claude Code uses internally:

// ai-credentials.ts — Leer del Keychain de macOS
function readClaudeFromKeychain(): IOAuthCredential | null {
  if (platform() !== 'darwin') return null;

  // Claude Code guarda en diferentes "accounts"
  const accounts = [process.env.USER ?? 'root', 'Claude Code'];

  for (const account of accounts) {
    try {
      const raw = execSync(
        `security find-generic-password -s "Claude Code-credentials" -a "${account}" -w`,
        { encoding: 'utf8', timeout: 5000 },
      ).trim();

      const data = JSON.parse(raw);
      const oauth = data.claudeAiOauth;
      if (!oauth?.accessToken) continue;

      // Normalizar expiresAt (puede venir en segundos o milisegundos)
      const expiresAt = oauth.expiresAt < 10_000_000_000
        ? oauth.expiresAt * 1000  // era segundos
        : oauth.expiresAt;        // ya era ms

      return { provider: 'anthropic', accessToken: oauth.accessToken,
        refreshToken: oauth.refreshToken ?? null, expiresAt };
    } catch { continue; }
  }
  return null;
}

Auto-Refresh de TokensAuto-Refresh Tokens

Si un token está expirado pero tiene refresh_token, el sistema lo refresca automáticamente y persiste el nuevo token en la misma ubicación (Keychain o DB según el nivel):

If a token is expired but has a refresh_token, the system automatically refreshes it and persists the new token in the same location (Keychain or DB depending on the level):

// ai-token-refresh.ts
const CLAUDE_OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const TOKEN_ENDPOINT = 'https://console.anthropic.com/v1/oauth/token';

export async function refreshClaudeToken(refreshToken: string): Promise<IOAuthCredential | null> {
  const res = await fetch(TOKEN_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'refresh_token',
      client_id: CLAUDE_OAUTH_CLIENT_ID,
      refresh_token: refreshToken,
    }),
  });
  if (!res.ok) return null;

  const data = await res.json();
  const SAFETY_BUFFER = 5 * 60 * 1000; // 5 min antes de expiración

  return {
    provider: 'anthropic',
    accessToken: data.access_token,
    refreshToken: data.refresh_token,
    expiresAt: Date.now() + data.expires_in * 1000 - SAFETY_BUFFER,
  };
}

El SAFETY_BUFFER de 5 minutos asegura que el token se refresca antes de expirar realmente, evitando ventanas donde el token podría estar "casi expirado" y fallar a mitad de una llamada.

The 5-minute SAFETY_BUFFER ensures the token is refreshed before it actually expires, avoiding windows where the token could be "almost expired" and fail mid-call.

14 Implementación Real de las HerramientasReal Tool Implementation

Cada herramienta es una función pura que recibe (userId, args) y retorna TAgentToolResult. Veamos qué hace cada una realmente bajo el capó:

Each tool is a pure function that receives (userId, args) and returns TAgentToolResult. Let's see what each one really does under the hood:

get_user_skills — Nivel de Maestría Calculadoget_user_skills — Computed Mastery Level

// query-skills.tool.ts
export async function getUserSkills(userId: string, args: Record<string, unknown>) {
  const includeArchived = (args.includeArchived as boolean) ?? false;
  const skills = await skillRepository.findAllByUserId(userId, includeArchived);

  const data = skills.map((skill) => {
    const totalHours = skill.sessions.reduce((sum, s) => sum + Number(s.hours), 0);
    const mastery = getMasteryLevel(totalHours); // 🌱📘⚡🔥👑

    return {
      id: skill.id,
      name: skill.name,
      emoji: skill.emoji || '📚',
      totalHours: Math.round(totalHours * 10) / 10,
      goalHours: skill.goalHours,
      masteryLevel: `${mastery.emoji} ${mastery.name}`,
      sessionsCount: skill.sessions.length,
    };
  });
  return { success: true, data };
}

El nivel de maestría se calcula en tiempo real sumando todas las sesiones — no es un campo guardado. Esto asegura que siempre refleje el estado actual.

The mastery level is calculated in real time by summing all sessions — it's not a stored field. This ensures it always reflects the current state.

analyze_practice_patterns — El Diagnóstico Profundoanalyze_practice_patterns — The Deep Diagnosis

Esta es la herramienta más compleja. Analiza hasta 500 sesiones y calcula 4 métricas:

This is the most complex tool. It analyzes up to 500 sessions and calculates 4 metrics:

// analyze-patterns.tool.ts — 4 análisis sobre hasta 500 sesiones
export async function analyzePracticePatterns(userId: string, args: Record<string, unknown>) {
  const sessions = await prisma.session.findMany({
    where: { skill: { userId, isArchived: false } },
    orderBy: { practiceDate: 'desc' },
    take: 500, // últimas 500 sesiones
  });

  return {
    success: true,
    data: {
      dayDistribution: analyzeDayDistribution(sessions),
      //→ [{day: 'Lun', sessions: 12, totalHours: 8.5}, ...]

      weeklyTrend: analyzeWeeklyTrend(sessions),
      //→ {weeks: [...], trend: 'improving'|'declining'|'stable',
      //   recentAvg: 4.2, olderAvg: 3.1}

      gaps: analyzeGaps(sessions),
      //→ {longestGap: 5, averageGap: 1.8, gapsOver3Days: 3}

      consistency: analyzeConsistency(sessions),
      //→ {activeDaysLast30: 22, consistencyScore: 'buena'}
    },
  };
}

// La tendencia compara las últimas 4 semanas vs las 4 anteriores
function analyzeWeeklyTrend(sessions) {
  // ... agrupa por semana ...
  const recent = weeks.slice(0, 4).average();
  const older = weeks.slice(4).average();
  const trend = recent > older * 1.1 ? 'improving'
              : recent < older * 0.9 ? 'declining' : 'stable';
  return { weeks, trend, recentAvg: recent, olderAvg: older };
}

// Consistencia: días activos en los últimos 30 días
function analyzeConsistency(sessions) {
  const activeDays = uniqueDaysInLast30(sessions);
  const score = activeDays >= 25 ? 'excelente'
              : activeDays >= 18 ? 'buena'
              : activeDays >= 10 ? 'regular' : 'baja';
  return { activeDaysLast30: activeDays, consistencyScore: score };
}

El modelo recibe estos datos estructurados y puede hacer observaciones como "Tu tendencia es creciente — promediabas 3.1h/semana y ahora estás en 4.2h. ¡Sigue así!" — toda basada en datos reales, no en intuición.

The model receives this structured data and can make observations like "Your trend is growing — you averaged 3.1h/week and now you're at 4.2h. Keep it up!" — all based on real data, not intuition.

save_weekly_plan — Upsert Inteligentesave_weekly_plan — Smart Upsert

// create-plan.tool.ts — Calcula el lunes de la semana actual
export async function saveWeeklyPlan(userId: string, args: Record<string, unknown>) {
  const content = args.content as string;
  const insights = args.insights as IWeeklyInsight[];

  // Calcular inicio de semana (lunes)
  const now = new Date();
  const dayOfWeek = now.getDay();
  const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
  const weekStart = new Date(now);
  weekStart.setDate(now.getDate() + mondayOffset);
  weekStart.setHours(0, 0, 0, 0);

  // Upsert: si ya hay plan esta semana, actualizarlo
  const existing = await aiRepository.findWeeklyReportByWeek(userId, weekStart);
  if (existing) {
    await prisma.aiWeeklyReport.update({ where: { id: existing.id }, data: { content, insights } });
  } else {
    await aiRepository.createWeeklyReport({ userId, weekStart, content, insights, skillsAnalyzed: [] });
  }

  return { success: true, data: { message: 'Plan guardado', weekStart } };
}

15 Parsers: Formato Unificado entre ProvidersParsers: Unified Format Across Providers

Anthropic y OpenAI retornan tool calls en formatos completamente diferentes. Nuestros parsers normalizan ambos a una interfaz común IChatResponse:

Anthropic and OpenAI return tool calls in completely different formats. Our parsers normalize both to a common IChatResponse interface:

// Interfaz unificada que usa todo el sistema
interface IChatResponse {
  content: string | null;
  toolCalls: IToolCall[];
  provider: 'anthropic' | 'openai';
  usage?: { inputTokens: number; outputTokens: number };
}

Anthropic Format

// Tool calls vienen como "content blocks"
{
  content: [
    { type: "text", text: "..." },
    { type: "tool_use",
      id: "toolu_xxx",
      name: "get_user_skills",
      input: { includeArchived: false }
    }
  ]
}

OpenAI Format

// Tool calls son un campo separado
{
  choices: [{
    message: {
      content: "...",
      tool_calls: [{
        id: "call_xxx",
        type: "function",
        function: {
          name: "get_user_skills",
          arguments: "{\"includeArchived\":false}"
        }
      }]
    }
  }]
}
// ai-parsers.ts — Normalizar a formato unificado
export function parseAnthropicResponse(data: AnthropicResponse): IChatResponse {
  const content = data.content
    ?.filter((b) => b.type === 'text')
    .map((b) => b.text)
    .join('') || null;

  const toolCalls = data.content
    .filter((b) => b.type === 'tool_use')
    .map((b) => ({
      id: b.id,
      type: 'function' as const,
      function: { name: b.name, arguments: JSON.stringify(b.input) },
    }));

  return { content, toolCalls, provider: 'anthropic',
    usage: data.usage ? { inputTokens: data.usage.input_tokens,
      outputTokens: data.usage.output_tokens } : undefined };
}

// Los tool_results también se convierten en sentido inverso:
// Nuestro formato → Anthropic "user message con tool_result content block"
export function convertToAnthropicMsg(msg) {
  if (msg.role === 'tool') {
    return {
      role: 'user',
      content: [{ type: 'tool_result', tool_use_id: msg.tool_call_id,
        content: msg.content }],
    };
  }
  // ... assistant messages con tool_calls → content blocks
}

Sin los parsers, tendríamos que escribir código diferente para cada provider en cada parte del sistema. Con ellos, el agent-runner es completamente agnóstico del provider — funciona igual con Claude o GPT-4o.

Without the parsers, we'd have to write different code for each provider in every part of the system. With them, the agent-runner is completely provider-agnostic — it works the same with Claude or GPT-4o.

💡 Lecciones Aprendidas💡 Lessons Learned

  1. El agent loop es poderoso pero costoso — cada iteración es una API call. Usa pre-fetch cuando el flujo es predecible.
  2. The agent loop is powerful but expensive — each iteration is an API call. Use pre-fetch when the flow is predictable.
  3. El system prompt es el 80% del comportamiento — un buen prompt convierte un LLM genérico en un experto especializado. Incluye parallel tool calling para reducir iteraciones.
  4. The system prompt is 80% of the behavior — a good prompt turns a generic LLM into a specialized expert. Include parallel tool calling to reduce iterations.
  5. Tool calling > RAG para datos estructurados — en vez de embeber todo en el contexto, deja que el modelo pida solo lo que necesita.
  6. Tool calling > RAG for structured data — instead of embedding everything in context, let the model request only what it needs.
  7. Siempre aislar por userId en el backend, nunca confiar en el input del modelo para autenticación.
  8. Always isolate by userId in the backend, never trust model input for authentication.
  9. Los guardrails importan — límite de iteraciones, timeouts con Promise.race, y manejo de rate limits son esenciales en producción.
  10. Guardrails matter — iteration limits, timeouts with Promise.race, and rate limit handling are essential in production.
  11. Anthropic tiene sus particularidades — JSON en token exchange, code#state, stealth headers. Documenta todo porque no lo encontrarás en su docs oficiales.
  12. Anthropic has its quirks — JSON in token exchange, code#state, stealth headers. Document everything because you won't find it in their official docs.
  13. Cada usuario con su token = escalabilidad natural — zero API cost para el developer y rate limits individuales.
  14. Each user with their own token = natural scalability — zero API cost for the developer and individual rate limits.
  15. Normaliza los formatos entre providers — Anthropic y OpenAI retornan tool calls de forma diferente. Un parser intermedio hace que el agent runner sea agnóstico del provider.
  16. Normalize formats across providers — Anthropic and OpenAI return tool calls differently. An intermediate parser makes the agent runner provider-agnostic.
  17. Las tools como funciones puras — el mismo getUserSkills() se usa en el agent loop Y en el pre-fetch del plan semanal. Reutilización real.
  18. Tools as pure functions — the same getUserSkills() is used in the agent loop AND in the weekly plan pre-fetch. Real reuse.
  19. El fallback chain con auto-refresh — 3 niveles de credenciales con refresh automático. El usuario nunca ve un error de autenticación si hay cualquier credencial disponible.
  20. The fallback chain with auto-refresh — 3 credential levels with automatic refresh. The user never sees an authentication error if any credential is available.

📱 La App en AcciónThe App in Action

TenK App — Vista principal con grid de progreso
Vista principal de la appApp main view
TenK App — Técnica Pomodoro
Técnica Pomodoro integradaIntegrated Pomodoro technique
TenK — Landing page web
Landing page enat tenk.oventlabs.com