Muestra la salud en vivo de Juanito dentro de Claude Code.

Asistente IA personal conectado directamente a WhatsApp via Baileys. Vive en un VPS, escucha grupos y chats, responde cuando lo mencionan, crea recordatorios y recuerda cosas importantes del jefe.
Teléfono (SIM del bot)
│ Baileys (WebSocket persistente)
▼
Node.js app (Docker, VPS)
├── Message router
│ ├── Grupo + mención → Claude responde en el grupo
│ └── DM del jefe → Claude responde al jefe
├── Claude API (tool use: recordatorios, memoria, resúmenes)
├── SQLite (historial, memoria, grupos, recordatorios, contactos)
└── Scheduler (recordatorios cada 1min, resúmenes cada 4h, limpieza 3am)
Un solo container. Sin Meta API. Sin servicios externos.
git clone <repo> juanito && cd juanito
cp .env.example .env
nano .env # completar ANTHROPIC_API_KEY, BOSS_PHONE, AGENT_PHONE
Variables obligatorias:
| Variable | Descripción |
|---|---|
ANTHROPIC_API_KEY | API key de Anthropic |
BOSS_PHONE | Número del jefe sin +, ej: 573001234567 |
AGENT_PHONE | Número del bot con +, ej: +573009998877 (solo para el primer vinculado) |
docker compose up -d --build
docker compose logs -f agent
# Esperar hasta ver el código de 8 dígitos:
# 📱 Código de vinculación: XXXX-XXXX
En el teléfono físico: WhatsApp → Dispositivos vinculados → Vincular con número de teléfono → ingresar el código
La sesión queda guardada en el volumen agent-data. No hace falta repetir este paso en reinicios.
Desde el teléfono del jefe, agregar el número del bot a cada grupo relevante. El bot comenzará a escuchar y guardar mensajes automáticamente.
El jefe le escribe al número del bot:
"recuérdame llamar al banco mañana a las 10am"
"¿qué pasó en el grupo de proveedores hoy?"
"guarda que mi cuenta de banco es 1234567"
"¿qué tengo pendiente esta semana?"
"recuérdale a Juan el lunes a las 9 que tiene reunión"
En grupos, mencionar al bot por su nombre (BOT_NAME):
"@Juanito ¿qué se decidió sobre el presupuesto?"
# Local
npm run contact -- "Juan" "+57 300 111 2222"
npm run contact -- --list
# En el VPS
docker compose exec agent node scripts/add-contact.js "Juan" "573001112222"
src/
├── index.js Entry point: conecta Baileys y wira handlers
├── whatsapp/index.js Cliente Baileys (connect, sendMessage, listGroups, ...)
├── bot/index.js Router: DM del jefe vs. mención en grupo
├── claude/index.js Claude con tool use y memoria
├── scheduler/ Cron jobs: recordatorios, resúmenes, limpieza
├── db/ SQLite: schema, migración, funciones de acceso
├── contacts/ Directorio de contactos (resolver nombre → número)
└── common/utils.js Normalización de teléfonos, HMAC
entrypoint.sh Backoff exponencial entre reinicios (evita softban)
docker compose logs -f agent # logs en tiempo real
docker compose restart agent # reiniciar sin bajar la sesión de WA
docker compose down # bajar (la sesión queda en el volumen)
entrypoint.sh implementa backoff exponencial: 30s → 60s → 120s → 240s → 300s. Después de 8 fallos consecutivos, Docker aplica su propia política de restart. Esto evita el loop rápido de reconexiones que puede triggerear detección de WhatsApp.
La sesión de Baileys persiste en el volumen agent-data:/app/data/wa-session. No se pierde en reinicios normales.
hooks/register.tsx 153 lines1import { atom, read } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HealthCheck, Level, Snapshot } from '../types/index'
5import { bandLines, icon, statusText, truncate } from './format'
6
7const BASE = 'https://juanito.tail2df10b.ts.net'
8const PANE = 'juanito'
9const POLL_MS = 60_000
10const TIMEOUT_MS = 10_000
11const UNREACHABLE = 'No alcanzo el dashboard (¿Tailscale prendido?)'
12
13const initial: Snapshot = {
14 nivel: 'ok',
15 generadoAt: '',
16 checks: [],
17 sha: '',
18 error: null,
19 fetchedAt: 0,
20}
21
22const snapshotState = atom(
23 { plugin: 'juanito-status', key: 'snapshot' } as const,
24 initial,
25)
26
27type HealthResponse = {
28 nivel: Level
29 generadoAt: string
30 checks: HealthCheck[]
31}
32
33type MetaResponse = { sha: string }
34
35function withTimeout<T>($: EngineInterface, work: Promise<T>): Promise<T> {
36 return new Promise<T>((resolve, reject) => {
37 const timeout = $.clock.after(TIMEOUT_MS, () => reject(new Error('timeout')))
38 work.then(
39 value => {
40 timeout.cancel()
41 resolve(value)
42 },
43 error => {
44 timeout.cancel()
45 reject(error)
46 },
47 )
48 })
49}
50
51async function getJson<T>($: EngineInterface, path: string): Promise<T> {
52 const response = await $.http.fetch(`${BASE}${path}`, { method: 'GET' })
53 if (!response.ok) throw new Error(`HTTP ${response.status}`)
54 return JSON.parse(response.text) as T
55}
56
57async function refresh($: EngineInterface): Promise<void> {
58 const previous = await read($, snapshotState)
59
60 try {
61 const [health, meta] = await withTimeout(
62 $,
63 Promise.all([
64 getJson<HealthResponse>($, '/api/salud'),
65 getJson<MetaResponse>($, '/api/meta'),
66 ]),
67 )
68 const next: Snapshot = {
69 nivel: health.nivel,
70 generadoAt: health.generadoAt,
71 checks: health.checks,
72 sha: meta.sha,
73 error: null,
74 fetchedAt: await $.clock.now(),
75 }
76 await $.state.set(snapshotState, next)
77 $.ui.status(statusText(next))
78 } catch {
79 const next = { ...previous, error: UNREACHABLE, fetchedAt: await $.clock.now() }
80 await $.state.set(snapshotState, next)
81 $.ui.status(statusText(next))
82 }
83}
84
85function generatedTime(value: string): string {
86 if (!value) return '--:--'
87 const date = new Date(value)
88 if (Number.isNaN(date.getTime())) return '--:--'
89 return date.toLocaleTimeString('es-CO', {
90 hour: '2-digit',
91 minute: '2-digit',
92 hour12: false,
93 })
94}
95
96export const register: Register = on => {
97 on('session.start', async ($, e, next) => {
98 await $.command.register({
99 name: 'juanito',
100 description: 'Muestra el estado de salud de Juanito',
101 })
102 // Sin await: si el dashboard no responde, la sesión no espera los 10 s del timeout.
103 void refresh($)
104 $.clock.every(POLL_MS, () => {
105 void refresh($)
106 })
107
108 return next(e)
109 })
110
111 on('command.run', { command: 'juanito' }, async $ => {
112 await $.ui.open({ id: PANE, title: 'Juanito' })
113 return { text: 'Panel de Juanito abierto.' }
114 })
115
116 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
117 if (e.props.hasSurvey) return next(e)
118
119 const lines = bandLines(await read($, snapshotState))
120 if (lines.length === 0) return next(e)
121
122 const { Box, Text } = $.ui.resolve(e)
123 return (
124 <Box flexDirection="column">
125 {lines.map((line, index) => (
126 <Text key={`juanito-${index}`}>{line}</Text>
127 ))}
128 </Box>
129 )
130 })
131
132 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
133 const { Box, Text } = $.ui.resolve(e)
134 const snapshot = await read($, snapshotState)
135 const overall = snapshot.error ? '⚪' : icon(snapshot.nivel)
136
137 return (
138 <Box flexDirection="column">
139 <Text>
140 {overall} generado {generatedTime(snapshot.generadoAt)} · deploy{' '}
141 {snapshot.sha ? snapshot.sha.slice(0, 7) : '-------'}
142 </Text>
143 {snapshot.error && <Text>⚪ {snapshot.error}</Text>}
144 {snapshot.checks.map(check => (
145 <Text key={check.key} dimColor={check.level === 'ok'}>
146 {icon(check.level)} {check.label} — {truncate(check.detail)}
147 </Text>
148 ))}
149 </Box>
150 )
151 })
152}
153hooks/format.ts 44 lines1import type { Level, Snapshot } from '../types/index'
2
3export function icon(level: Level): string {
4 if (level === 'error') return '🔴'
5 if (level === 'warn') return '🟡'
6 return '🟢'
7}
8
9export function truncate(text: string, max = 100): string {
10 if (text.length <= max) return text
11 return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}…`
12}
13
14export function statusText(snapshot: Snapshot): string {
15 if (snapshot.error) return '⚪ Juanito: sin conexión'
16
17 const errors = snapshot.checks.filter(check => check.level === 'error').length
18 if (snapshot.nivel === 'error' || errors > 0) {
19 return `🔴 Juanito: ${errors} error(es)`
20 }
21
22 const warnings = snapshot.checks.filter(check => check.level === 'warn').length
23 if (snapshot.nivel === 'warn' || warnings > 0) {
24 return `🟡 Juanito: ${warnings} aviso(s)`
25 }
26
27 return '🟢 Juanito ok'
28}
29
30export function bandLines(snapshot: Snapshot, max = 4): string[] {
31 if (!snapshot.error && snapshot.nivel === 'ok') return []
32
33 const lines = snapshot.checks
34 .filter(check => check.level !== 'ok')
35 .map(check => `${icon(check.level)} ${check.label} — ${truncate(check.detail)}`)
36
37 if (snapshot.error) {
38 lines.unshift(`⚪ Sin conexión — ${truncate(snapshot.error)}`)
39 }
40
41 if (lines.length <= max) return lines
42 return [...lines.slice(0, max), `… y ${lines.length - max} más`]
43}
44types/index.d.ts 25 lines1export type Level = 'ok' | 'warn' | 'error'
2
3export type HealthCheck = {
4 key: string
5 label: string
6 level: Level
7 count: number
8 detail: string
9}
10
11export type Snapshot = {
12 nivel: Level
13 generadoAt: string
14 checks: HealthCheck[]
15 sha: string
16 error: string | null
17 fetchedAt: number
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'juanito-status': { snapshot: Snapshot }
23 }
24}
25