Claude Code Dentro de Minecraft: Cómo Metí un Agente de IA Real en un Bloque de Computadora
Un análisis técnico de cómo conectar Claude Code CLI a un ordenador CC:Tweaked mediante un servidor WebSocket PTY en Python, un emulador xterm-lite en Lua y tmux como capa de persistencia, convirtiendo cualquier bloque de Minecraft en una terminal de IA funcional.
Claude Code Dentro de Minecraft: Cómo Metí un Agente de IA Real en un Bloque de Computadora
No es un mod que habla a una API. No es una ventana de chat pegada a una GUI. Es una sesión claude CLI real, corriendo en el host, renderizada en la pantalla de un ordenador CC:Tweaked, con teclado que manejas desde dentro del juego. El mismo pipe sirve para lynx, htop, vim o cualquier otro programa de terminal. Claude Code es solo el inquilino más interesante. Este post desglosa la arquitectura, los momentos raros del proceso, y la pieza de la que estoy más orgulloso: el traductor de stream que convierte un PTY xterm real en algo que una pantalla Lua de 16 colores puede renderizar.
El Stack en un Diagrama
Son cinco partes móviles que trabajan en conjunto:
┌──────────────────┐ RCON ┌──────────────────┐ spawn ┌──────────────────┐
│ Minecraft Fabric │◀─────────▶│ DevChefs Panel │────────────▶│ tmux sessions │
│ Server 1.21.1 │ │ (Next.js :3000)│ │ dev1-5 / browser │
└──────────────────┘ └──────────────────┘ └──────────────────┘
▲ │ ▲
│ bloque in-game │ sirve startup.lua │ attach
│ ▼ │
┌──────────────────┐ WebSocket ┌──────────────────┐ │
│ CC:Tweaked │──────────────▶│ ptyws.py :7680 │──────────────────┘
│ computer │ ttyd proto │ WebSocket PTY │
└──────────────────┘ └──────────────────┘- Minecraft Fabric 1.21.1 + CC:Tweaked: provee el bloque de computadora programable y su runtime Lua con
term,httpyperipheral. - DevChefs Panel: una app Next.js como plano de control — cliente RCON, gestor de jugadores, catálogo de ítems (~1.368 ítems), consola en vivo, y un endpoint público
/api/setupque sirve el programa Lua de bootstrap. - ptyws.py: un servidor WebSocket PTY de ~300 líneas escrito sobre el módulo
socketpuro (sin librería websockets). Implementa suficiente del protocolo de cable ttyd para reenviar bytes PTY y eventos de resize. - tmux: la capa de persistencia. Las sesiones
dev1..dev5ybrowser1..browser5sobreviven a reconexiones, crashes y descargas de chunks. Tu sesiónclaudesigue donde la dejaste tres días después. - startup.lua: el lado CC:Tweaked del bridge. Se auto-descarga en cualquier computadora que bootea, obtiene el roster de sesiones, abre el WebSocket, y corre un pequeño emulador xterm en Lua.
El Flujo de Usuario
Colocas una computadora (o una tortuga, o una pared de monitores avanzados junto a una computadora). En el primer boot, el runtime Lua baja startup.lua desde http://panel:3000/api/setup. El script renderiza un selector de sesiones: dev1..5 para shells, browser1..5 para lynx preconfigurado con una paleta brillante de 16 colores afinada para monitores CC:Tweaked.
Seleccionas dev1. La computadora abre ws://panel:7680/session/dev1, ptyws.py spawnea (o re-adjunta) una sesión tmux, y bash cae al PTY. Escribes claude. El agente corre dentro de Minecraft. Si hay un Monitor Avanzado adyacente, startup.lua lo autodetecta vía peripheral.find("monitor") y espeja cada term.write en él, convirtiendo una pared de monitores 5×3 en una pantalla de ~70 columnas.
La Parte Interesante: Traducir un PTY Real a CC:Tweaked
Aquí es donde el proyecto dejó de ser "código de pegamento" y se volvió genuinamente difícil.
CC:Tweaked te da una grilla de caracteres. Puedes setCursorPos(x, y), write("texto"), clearLine(), y elegir uno de 16 colores con nombre. Eso es todo. No es una terminal. Nunca ha oído hablar de ANSI. No sabe qué significa \x1b[2J.
Del otro lado del cable, una sesión bash real adjunta a tmux está disparando:
- Secuencias CSI:
\x1b[H,\x1b[2J,\x1b[31;1m,\x1b[6;14H - Modos privados DEC:
\x1b[?25lpara ocultar cursor,\x1b[?1049hpara cambiar a la pantalla alternativa - Secuencias OSC:
\x1b]0;title\x07para títulos de ventana - Códigos de color SGR en formas de 8, 16, 256 y 24 bits
- Runs UTF-8 multibyte para cada carácter de box-drawing que Claude Code ama usar
Algo tiene que estar entre esos dos mundos. Ese algo es un parser xterm-lite en streaming escrito en Lua, corriendo dentro de la computadora de Minecraft.
Cómo Funciona el Parser
processOutput(text) recorre el chunk entrante byte a byte. El loop de nivel superior tiene dos ramas: "estamos viendo un ESC" y "estamos viendo texto plano".
Rama ESC. Al encontrar 0x1B, mira el siguiente byte:
[→ secuencia CSI. Escanea hacia adelante hasta encontrar un byte en el rango0x40..0x7E. Extrae el string de parámetros, detecta y salta prefijos privados DEC (?,>,=), luego despacha en el byte final:H/f= posición del cursor,A/B/C/D= movimientos relativos,J= borrar pantalla,K= borrar línea.]→ OSC. Salta hastaBEL(0x07) oST(ESC \). Los títulos de ventana no son útiles en Minecraft.(,),*,+→ selects de charset. Skip de tres bytes.
¿CSI incompleto al final del buffer? Retorna temprano y deja el resto para el siguiente frame.
Rama de texto plano. Itera carácter por carácter y despacha códigos de control contra un cursor virtual (cx, cy):
LF→cy++; sicy > CY2llamaterm.scroll(1)y clampea. Esta es la línea más importante de todo el parser.CR→cx = 1;BS→cx--;TAB→ redondea al siguiente stop de 8 columnas.- Bytes ≥
0x80→ emite?, luego consume bytes de continuación para no desincronizarse. - Todo lo demás → agrupa ASCII imprimible en un run y emite vía
pwrite().
-- Parser xterm-lite en Lua (fragmento)
local function processOutput(text)
local i = 1
local len = #text
while i <= len do
local byte = text:byte(i)
if byte == 0x1B then -- ESC
i = i + 1
local next = text:byte(i)
if next == 0x5B then -- CSI: ESC[
i = i + 1
local csi_start = i
while i <= len and (text:byte(i) < 0x40 or text:byte(i) > 0x7E) do
i = i + 1
end
if i <= len then
local params = text:sub(csi_start, i - 1)
dispatch_csi(params, text:byte(i))
i = i + 1
end
else
i = i + 1 -- 2-byte escape, skip
end
elseif byte == 0x0A then -- LF: la linea mas importante
cy = cy + 1
if cy > CY2 then term.scroll(1); cy = CY2 end
i = i + 1
else
local run_start = i
while i <= len and text:byte(i) >= 0x20 and text:byte(i) < 0x7F do
i = i + 1
end
if i > run_start then pwrite(text:sub(run_start, i-1))
else i = i + 1 end
end
end
endPor Qué No Stripear los Escapes en el Servidor
Lo intenté. s:gsub("\27%[[%d;]*[A-Za-z]", "") es rápido y obvio y arruina todo. Claude Code es un TUI fullscreen: si strippeas las secuencias de posicionamiento y dejas solo el texto, obtienes cada frame apilado verticalmente hasta que la pantalla hace scroll más allá de la parte útil en ~400ms. Tienes que honrar los movimientos de cursor y los borrados, o la UI es inutilizable.
La Race Condition del Handshake que Nadie Documenta
Primer bug que me costó una tarde: el cliente CC:Tweaked se conectaba, el servidor enviaba los frames de bienvenida, y el cliente nunca los veía. La causa: http.websocket() de CC:Tweaked bloquea dentro de un loop pullEvent durante su propio handshake y silenciosamente se come cualquier evento websocket_message que llegue antes de que retorne.
La solución en el lado Python: después del upgrade HTTP, no enviar el saludo. Bloquear con timeout de 10 segundos hasta que el cliente mande su primer frame de resize, luego spawnear el PTY y enviar el saludo. El resize es siempre lo primero que emite startup.lua después de que http.websocket() retorna, así que es una señal perfecta de cliente listo.
# Python: esperar el primer resize antes de spawnear el PTY
cols, rows = 80, 24
conn.settimeout(10.0)
while True:
opcode, payload = ws_recv_frame(conn)
if opcode in (0x1, 0x2) and payload and chr(payload[0]) == '1':
info = json.loads(payload[1:])
cols = int(info.get('columns', cols))
rows = int(info.get('rows', rows))
break
master_fd, pid = spawn_shell(cols, rows, session_name)
ws_send(conn, f'1DevChefs {session_name}', send_lock)Por Qué tmux en el Medio
Tres razones, todas sobre persistencia. Primero, sobrevivir descargas de chunks: sin tmux, alejarte de tu base mataría tu sesión claude. Con tmux, el PTY vive en el host y se readjunta en la reconexión exactamente donde lo dejaste. Segundo, compartir sesiones entre bloques: dos computadoras pueden adjuntarse a dev1 simultáneamente. Tercero, límite de seguridad limpio: ptyws.py usa un allowlist regex (^(dev|browser)[1-5]$) y lo único que el WebSocket puede hacer es adjuntarse a una de diez sesiones tmux predeclaradas.
El Wrapper de Espejado para Monitores
startup.lua no usa term.redirect() para pushear output al monitor porque ocultaría el output de la GUI propia. En cambio, sombrea term.setCursorPos, term.write, term.clear, term.clearLine, term.scroll y los setters de color con wrappers que llaman tanto el original como el periférico monitor. Escala de texto double-density (0.5) en una pared de monitores avanzados 5×3 da ~70 columnas — suficiente para el TUI completo de Claude Code.
Límite de Seguridad
El panel está ligado a 127.0.0.1 detrás de Basic Auth. La contraseña RCON vive en .env.local (gitignoreado). ptyws.py solo acepta conexiones en 127.0.0.1 y solo spawnea sesiones cuyo nombre hace match con ^(dev|browser)[1-5]$. Las sesiones tmux corren como usuario sin privilegios. No lo corras como root.
Conclusión
Lo que construí son básicamente tres archivos: ptyws.py (~320 líneas de Python, sockets crudos, sin dependencias), public/startup.lua (~990 líneas de Lua con bootstrap, selector de sesiones, parser xterm-lite y programa de tortuga), y los routes de API de Next.js como plano de control delgado sobre RCON y tmux. Sin mods. Sin código Fabric personalizado. El truco es que una vez que tienes un PTY en un extremo y una grilla de caracteres en el otro, un emulador xterm suficientemente bueno cabe en unos cientos de líneas de Lua. Y una vez que tienes eso, todo programa de terminal se convierte en un programa de Minecraft, incluido el que más me importa: Claude Code. Coloca una computadora. Bootéala. Elige dev1. Escribe claude. Empieza a construir.