Mi uso de Claude en el Stream Deck: por qué lo armé y qué aprendí en el camino
Dos botones del Stream Deck que muestran el uso de Claude.ai en vivo. Más los 6 gotchas que me hicieron renegar: Cloudflare, sandbox de Flatpak, caché de Python y otras yerbas.
- streamdeck
- claude
- devops
- plugins
- flatpak
- homelab
- ai
- linux
TL;DR
Tengo un Stream Deck MK.2 con dos botones físicos que me muestran, en vivo, cuánto uso de Claude llevo en la sesión de 5h y cuánto en la cuota semanal, con porcentaje, color por umbral y tiempo restante hasta el próximo reset. Llegué ahí después de pelearme con tres cosas en paralelo: el sandbox de Flatpak, el caché agresivo de Python en StreamController y un detallecito divertido del API de Claude.ai protegido por Cloudflare.
Este post cuenta el por qué, el cómo, y los gotchas que me hicieron perder más tiempo del que debería haber perdido. Si trabajás con Claude todos los días o estás escribiendo plugins para StreamController, te ahorra unas horas.

El por qué
Uso Claude todo el día — para escribir código, redactar, repasar tickets, lo que aparezca. La cuenta tiene dos cuotas:
- Sesión — una ventana de 5h que arranca con tu primer mensaje del día.
- Semanal — un total que se resetea fijo (en mi caso, jueves 16:00 ART).
El problema: la UI de claude.ai te muestra los porcentajes solo si te metés a claude.ai/settings/usage. Y yo, mientras estoy programando, no quiero abrir una pestaña para chequear si me queda margen para una conversación larga o si conviene cortar y volver a la mañana.
Quería el dato a la vista, sin abrir nada. El Stream Deck estaba ahí, encima del teclado, con botones libres.
Qué quedó armado
Dos botones, ambos cambian de color según el porcentaje:
┌──── Session ─────┐ ┌──── Weekly ─────┐
│ 2h 47m │ │ 3d 12h │ ← top_label: tiempo al reset
│ │ │ │
│ 34% │ │ 67% │ ← center_label: %
│ │ │ │
│ Session │ │ Weekly │ ← bottom_label
└──────────────────┘ └─────────────────┘
cyan < 50% amarillo < 80% rojo ≥ 80%

Tap en cualquiera de los dos fuerza un refresh. El refresh automático corre cada 5 minutos vía systemd timer.
La arquitectura
Tres componentes:
fetch_usage.py— script Python que vive en el host. Lee cookies de Firefox, llama al API de Claude.ai y escribeusage.jsonconsession.percent,weekly.percenty losresets_at.claude-usage.timer— systemd timer conOnCalendar=*:0/5que corre el script cada 5 minutos.com_sauay_ClaudeUsage— plugin de StreamController con dos actions (SessionUsage,WeeklyUsage). Cada una leeusage.jsonenon_tick, calcula color y tiempo restante, y renderiza.
Diagrama:
Firefox (cookies)
│
▼
fetch_usage.py ───── HTTP ───▶ claude.ai/api/.../usage
│ (Cloudflare-protected)
▼
usage.json ◀──── reads ──── StreamController plugin
▲ │
│ ▼
systemd timer Stream Deck button
(cada 5 min)
La separación host-script + sandbox-plugin no fue un capricho de diseño: fue la única forma de que funcionara. Ahí van los gotchas.
Gotcha 1: El API de Claude.ai está detrás de Cloudflare
Lo primero que probé fue requests.get(...) con las cookies de sesión. Cloudflare devuelve 403 porque mi cliente Python no tiene el TLS fingerprint de Firefox.
La solución: curl_cffi. Es una librería que envuelve libcurl-impersonate y hace que tu request luzca exactamente igual que un Firefox real, incluyendo el handshake TLS.
from curl_cffi import requests as cffi_requests
resp = cffi_requests.get(
url,
headers=headers,
cookies=cookies,
impersonate="firefox", # ← la magia
timeout=15,
)
Las cookies las saco directamente de la base SQLite de Firefox:
shutil.copy2(FIREFOX_COOKIES_DB, tmp_path) # copy para evitar el lock
conn = sqlite3.connect(tmp_path)
cursor = conn.execute(
"SELECT name, value FROM moz_cookies WHERE host LIKE ?",
("%claude.ai%",),
)
cookies = {row[0]: row[1] for row in cursor.fetchall()}
(El copy2 es importante: si Firefox está abierto, el .sqlite está locked y no podés leerlo en vivo.)
El endpoint es claude.ai/api/organizations/{org_id}/usage. Devuelve un JSON con five_hour, seven_day, seven_day_sonnet y extra_usage. Cada uno trae utilization (0–100) y resets_at (ISO timestamp con timezone).
⚠️ Esto es un endpoint interno de claude.ai, no documentado. Puede cambiar sin aviso. Yo lo banco porque es para mi homelab y si se rompe lo arreglo.
Gotcha 2: Flatpak no ve los paquetes Python del host
StreamController es un Flatpak (com.core447.StreamController). El sandbox no tiene acceso a paquetes pip del host, así que curl_cffi desde dentro del plugin no funciona. Tampoco puedo instalar dentro del sandbox sin manifest changes.
La solución: separar el fetch del plugin. El script Python corre en el host (donde sí tengo curl_cffi), escribe usage.json a disco, y el plugin solo lee el archivo. El plugin no sabe de HTTP, no sabe de Cloudflare, no sabe de cookies. Lee JSON y dibuja.
Cuando el usuario tapea el botón y quiero refresh manual, uso flatpak-spawn --host:
subprocess.run(
["flatpak-spawn", "--host", "python3", str(script_path)],
cwd=str(Path.home()), # ← clave, ver gotcha 3
timeout=30,
)
Gotcha 3: flatpak-spawn --host hereda el cwd del sandbox
Este me hizo perder una tarde entera. flatpak-spawn --host ejecuta el comando en el host real, pero hereda el cwd del proceso que lo invocó. Y el cwd del Flatpak StreamController es /app/bin/StreamController — un path que solo existe dentro del sandbox, no en el host.
Resultado: el subprocess arranca, intenta resolver cwd, falla, y muere silenciosamente sin error visible en logs.
Fix: pasar cwd explícito a algo que sí exista en el host:
subprocess.run(
[...],
cwd=str(Path.home()), # ~/ existe en el host
)
Una línea. Una tarde.
Gotcha 4: StreamController cachea Python como si no hubiera mañana
Este es el más insidioso. StreamController carga los módulos del plugin una vez y los cachea. Si vos editás SessionUsage.py, reiniciás StreamController, y el cambio no aparece, no es que tu código está mal. Es que el __pycache__ viejo está mandando.
Lo que probé y no funciona del todo:
- Borrar
__pycache__antes de reiniciar — a veces alcanza, a veces no. flatpak kill com.core447.StreamController(mejor que cerrar la ventana, que no mata el proceso).
Lo que funciona siempre:
Versionar el módulo en el nombre del archivo. Cuando hago un cambio que no estoy seguro de que se va a tomar, renombro SessionUsage.py a SessionUsage_v2.py, actualizo el import, reinicio. Python no tiene de dónde sacar caché para un módulo que no existía antes, así que carga el nuevo seguro.
# main.py
from .actions.SessionUsage.SessionUsage_v2 import SessionUsage
Es feo. Funciona. La memoria me agradece a las 11 de la noche.
Gotcha 5: set_background_color no siempre cambia el color
Acá perdí un par de horas debugueando por qué el botón se quedaba en rojo aunque mi código corría set_background_color(cyan) correctamente. Logueaba, el método se ejecutaba, action_color cambiaba en el BackgroundManager. Y el botón seguía rojo.
El motivo: StreamController tiene dos colores en cada botón — action_color (el que setea tu plugin) y page_color (el que el usuario puede tocar desde el sidebar UI). Y get_composed_color() prioriza page_color sobre action_color. Si alguna vez tocaste el color desde la UI, queda persistido y le gana siempre a tu plugin.
Fix:
# Mal — lo escribe pero no se ve
self.set_background_color(color)
# Bien — escribe el page_color directo
self.get_state().background_manager.set_page_color(
color, update=True, update_ui=False
)
Esto no está documentado en ningún lado obvio. Lo encontré leyendo el código fuente de StreamController (Con IA claro!!!). Si alguna vez te pasa, sabés.
Gotcha 6: Cálculo de tiempo restante con timezones
El resets_at del API viene como "2026-05-04T18:42:30.123Z". Me interesa mostrar “2h 14m” hasta ese momento. Una resta datetime.now() da error porque uno es naive y el otro es aware.
from datetime import datetime, timezone
resets_at = datetime.fromisoformat(iso.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
delta = resets_at - now
if delta.total_seconds() <= 0:
label = "--" # el reset ya pasó pero el JSON no se actualizó
elif delta.days >= 1:
label = f"{delta.days}d {delta.seconds // 3600}h"
else:
h = delta.seconds // 3600
m = (delta.seconds % 3600) // 60
label = f"{h}h {m}m"
Detalle bonito: el resets_at de la sesión de 5h es dinámico — depende de cuándo arrancaste la ventana, no es fijo. El de la cuota semanal sí es fijo (en mi caso, jueves 19:00 UTC). El plugin trata a los dos igual, solo lee y resta.
Por qué el systemd timer cada 5 minutos
Probé varias frecuencias:
- 30 segundos: el dato no cambia tan rápido, malgastás CPU/red.
- 1 minuto: igual, overkill.
- 5 minutos: balance bueno. La sesión se mueve en saltos visibles, la semanal mucho más lento.
Importante: cuando armé el timer al principio usé OnUnitActiveSec=5min. Como el script es Type=oneshot, nunca se vuelve a disparar — OnUnitActiveSec cuenta desde el último activo, y un oneshot no queda activo. El timer mostraba Trigger: n/a y el usage no se refrescaba.
Fix: usar OnCalendar=*:0/5 (cada 5 minutos del reloj, independiente del estado de la unit).
[Timer]
OnCalendar=*:0/5
Persistent=true
[Install]
WantedBy=timers.target
Lección general: OnCalendar para oneshot, OnUnitActiveSec para servicios long-running.
Lecciones generales
- Cuando una librería no entra en un sandbox, sacala del sandbox. No pelees con manifests si lo podés resolver con un script en el host y un archivo JSON compartido.
flatpak-spawn --hostsiempre concwd=explícito. Asumir que el host tiene tucwdactual es un bug esperando.- Cuando un cambio “no se toma”, sospechá del caché antes que de tu código. Versionar archivos por nombre es feo pero te ahorra horas.
- Leé el código fuente del framework cuando una API no se comporta como esperás. En este caso, StreamController es Python puro y el bug del color era visible en 3 funciones.
- El Stream Deck es ridículamente útil para datos a la vista. No es solo para streamers. Cualquier cosa que quieras mirar muchas veces al día — uso de cuotas, estado de servicios, % de batería del homelab — entra en un botón.
El código
Repo público con el plugin, el fetch_usage.py, las units de systemd y un install.sh que lo deja andando:
github.com/pereyra-carlos/claude-usage-streamdeck-linux
Si lo probás y rompe en tu setup, abrí un issue y lo miramos.
Próximos pasos
- Cambio de color del botón cuando faltan <30 min al reset semanal, para que sea más visible.
- Sumar quota de Sonnet por separado (el API ya devuelve
seven_day_sonnet). - Soporte Chrome/Chromium — hoy el script asume Firefox por la SQLite de cookies.
El Stream Deck es solo una de las salidas posibles
Si no tenés un Stream Deck, no te vayas todavía. La pieza realmente reusable de este proyecto es fetch_usage.py — un script de ~150 líneas que te devuelve el % de uso y el resets_at en un JSON. Lo que hagas con eso es decisión tuya. Algunas ideas:
- Notificación a Telegram cuando pasás un umbral. Un cron que corra
fetch_usage.pycada 15 min y, siweekly.percent >= 80, te dispare un mensaje vía bot. Te enterás antes de que el chat empiece a fallar. - Dashboard web personal. Un endpoint Flask/FastAPI que sirva
usage.jsoncon gráficos de Chart.js, embebible en una pestaña pinneada o en Homepage / Homarr / Grafana. - Indicador en la barra del sistema (i3blocks / Polybar / Waybar / GNOME extension). Un módulo que lea el JSON y muestre
34% / 2h47men la status bar — útil si vivís en tiling WMs. - Métrica Prometheus. Exponer un
/metricsconclaude_session_utilization{}yclaude_weekly_utilization{}, scrapeable por tu Prometheus de homelab. Alertas en Grafana cuando cruzás umbrales. - Webhook a Slack/Discord para equipos. Si compartís la cuenta entre varios, un mensaje al canal cuando se acerca el cap evita el clásico “che, ¿pueden parar de usar Claude que estoy en algo importante?”.
- Auto-pausa de jobs. Un wrapper que antes de invocar Claude desde un script (batch, agente, lo que sea) chequea el JSON y aborta si estás >95%, en vez de quemar requests que van a fallar igual.
Todas comparten la misma forma: el script escribe un JSON, vos lo leés desde donde quieras. El Stream Deck es un consumidor más entre muchos posibles.
Más cosas del homelab y del flujo con Claude en mi LinkedIn — carlos@sauay.com si querés que charlemos algo en particular.