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:
- Identidad: "Eres el TenK Coach, un entrenador experto en práctica deliberada."
- Identity: "You are TenK Coach, an expert coach in deliberate practice."
- Personalidad: Motivador pero realista, directo, basado en datos, siempre en español.
- Personality: Motivating but realistic, direct, data-driven, always in Spanish.
- Parallel tool calling: "Llama TODAS las herramientas que necesites en una sola respuesta" — reduce iteraciones."Call ALL the tools you need in a single response" — reduces iterations.
- Niveles de maestría: 5 niveles con emojis y rangos de horas exactos.
- Mastery levels: 5 levels with emojis and exact hour ranges.
- Formato de plan semanal: 4 secciones obligatorias con estructura definida.
- Weekly plan format: 4 mandatory sections with defined structure.
- Restricciones: Máximo 300 palabras, nunca inventar datos, no usar fuentes externas.
- Constraints: Max 300 words, never fabricate data, don't use external sources.
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:
AiConversation.messageses unJsonque almacena el array completo deIChatMessage[]— incluyendo los tool_calls — para poder reanudar conversaciones.AiConversation.messagesis aJsonthat stores the completeIChatMessage[]array — including tool_calls — to be able to resume conversations.AiWeeklyReporttiene un@@unique([userId, weekStart])que permite hacer upsert: si generas un nuevo plan la misma semana, reemplaza el anterior.AiWeeklyReporthas a@@unique([userId, weekStart])that enables upsert: if you generate a new plan the same week, it replaces the previous one.ClaudeCredential.userIdes@unique— un usuario, una credencial. Conectar de nuevo reemplaza la anterior.ClaudeCredential.userIdis@unique— one user, one credential. Reconnecting replaces the previous one.OAuthState.expiresAtcon index permite limpiar estados expirados eficientemente.OAuthState.expiresAtwith index allows cleaning up expired states efficiently.
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
- El agent loop es poderoso pero costoso — cada iteración es una API call. Usa pre-fetch cuando el flujo es predecible.
- The agent loop is powerful but expensive — each iteration is an API call. Use pre-fetch when the flow is predictable.
- 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.
- 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.
- Tool calling > RAG para datos estructurados — en vez de embeber todo en el contexto, deja que el modelo pida solo lo que necesita.
- Tool calling > RAG for structured data — instead of embedding everything in context, let the model request only what it needs.
- Siempre aislar por userId en el backend, nunca confiar en el input del modelo para autenticación.
- Always isolate by userId in the backend, never trust model input for authentication.
- Los guardrails importan — límite de iteraciones, timeouts con
Promise.race, y manejo de rate limits son esenciales en producción. - Guardrails matter — iteration limits, timeouts with
Promise.race, and rate limit handling are essential in production. - Anthropic tiene sus particularidades — JSON en token exchange,
code#state, stealth headers. Documenta todo porque no lo encontrarás en su docs oficiales. - Anthropic has its quirks — JSON in token exchange,
code#state, stealth headers. Document everything because you won't find it in their official docs. - Cada usuario con su token = escalabilidad natural — zero API cost para el developer y rate limits individuales.
- Each user with their own token = natural scalability — zero API cost for the developer and individual rate limits.
- 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.
- Normalize formats across providers — Anthropic and OpenAI return tool calls differently. An intermediate parser makes the agent runner provider-agnostic.
- 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. - Tools as pure functions — the same
getUserSkills()is used in the agent loop AND in the weekly plan pre-fetch. Real reuse. - 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.
- 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