← blog

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 Stream Deck montado entre los dos monitores, ahí viven los dos botones de Claude.

El por qué

Uso Claude todo el día — para escribir código, redactar, repasar tickets, lo que aparezca. La cuenta tiene dos cuotas:

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%

Stream Deck en vivo: abajo en naranja, los dos botones de Claude (4% session, 4h 33m al reset; 4% weekly, 3d 15h al reset). Los demás son monitores de GPU/RAM y switches del homelab.

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:

  1. fetch_usage.py — script Python que vive en el host. Lee cookies de Firefox, llama al API de Claude.ai y escribe usage.json con session.percent, weekly.percent y los resets_at.
  2. claude-usage.timer — systemd timer con OnCalendar=*:0/5 que corre el script cada 5 minutos.
  3. com_sauay_ClaudeUsage — plugin de StreamController con dos actions (SessionUsage, WeeklyUsage). Cada una lee usage.json en on_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:

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:

Importante: cuando armé el timer al principio usé OnUnitActiveSec=5min. Como el script es Type=oneshot, nunca se vuelve a dispararOnUnitActiveSec 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

  1. 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.
  2. flatpak-spawn --host siempre con cwd= explícito. Asumir que el host tiene tu cwd actual es un bug esperando.
  3. 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.
  4. 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.
  5. 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


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:

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.