A tiny desk display for your Claude Code and OpenAI Codex CLI sessions — live usage gauges and an at-a-glance list of what every session is doing, with silent "needs you" alerts you dismiss by tapping the screen.
Sessions tab — working / idle / waiting at a glance · Usage tab — Claude & Codex usage % with 5h + weekly bars and reset countdowns
VibeMonitor turns a ~$15 ESP32 touchscreen into an ambient status panel for your AI coding sessions:
- Usage gauges — your Claude usage % and reset countdown (plus Codex when available), so you can see how much headroom is left before a rate-limit window resets.
- Live session list — every active Claude Code and Codex session with a calm status symbol: working (actively running), idle (alive but quiet), or waiting (needs your input). Waiting sessions sort to the top and flash.
- Tap-to-dismiss alerts — when a session is waiting on you, its row flashes. Tap it to acknowledge and clear the highlight. No beeps, no LEDs — it's deliberately quiet.
- AI session summaries (optional) — a one-line "what they're working on" per session (e.g. "Refactor auth middleware"), generated from the latest prompt by a cheap model via OpenRouter. Opt-in. See Session summaries.
- Web dashboard — the bridge also serves the same view in a browser at
http://<bridge-host>:5151/, so you can glance at it without the hardware. See Web dashboard. - On-device settings — tap the SETTINGS tab to adjust screen brightness, switch light/dark theme, and set a sleep timeout (blank the display after N minutes with nothing waiting; tap to wake). Saved on the device.
- Usage analytics — burn-rate projection ("out ~3:40 PM" vs "resets first · 35% to spare"), both reset clocks, and daily peak history. See Usage analytics.
- Capacity advisor — a go/pace/throttle banner answering "can I start another agent right now?". See Capacity advisor.
- Phone push (optional) — ntfy or Pushover notification when a session starts waiting on you, with one high-priority escalation if it stays unanswered. See Phone push notifications.
- Cost attribution — today's token usage per project (with optional $ estimates), so you know which project is eating the budget. See Cost attribution.
- Home Assistant (optional) — a flat
GET /hasensor endpoint plus an outbound webhook that fires when a session needs you (and when it clears), so you can drive lights/notifications. See Home Assistant.
VibeMonitor is two pieces: a small Python "bridge" that runs on your PC, and firmware on the ESP32 display. The bridge reads your local Claude/Codex state and serves it over your LAN; the device polls it about once every 1.5 seconds.
┌─ PC: VibeMonitor bridge (Python / Flask) ─────┐ ┌─ ESP32 "Cheap Yellow Display" ─┐
│ │ │ (Arduino + LVGL) │
│ reads ~/.claude ──► collector_claude ─┐ │ HTTP / LAN │ │
│ reads ~/.codex ──► collector_codex ─┼─► Store ─► hub ◄──────┤ GET /state (~1.5 s poll) │
│ usage probe ──► usage / cache ─┘ (Flask) ──────► SESSIONS tab + USAGE tab │
│ │ ───────────► │ POST /ack (on tap) │
│ Claude Code hook ──POST /hook──────────────► │ X-VibeMonitor│ │
└──────────────────────────────────────────────── -Token header └────────────────────────────────┘
Everything runs in one Python process in v1. The collector → hub split is a clean module boundary, so a second machine could later run only a collector that POSTs into the same hub.
VibeMonitor targets the AITRIP ESP32-2432S028R, commonly sold as the "Cheap Yellow Display" (CYD) — a 2.8", 320×240 resistive-touch TFT bonded to an ESP32, around $15. This is the exact unit used here: Amazon — ASIN B0CKYVPWX9.
The device in the photos sits in a 3D-printed CYD case: ESP32 CYD case on MakerWorld (optional, but it makes a tidy desk toy).
Important board gotcha — ST7789, not ILI9341. Many CYD guides assume an ILI9341 controller. This AITRIP variant uses the ST7789 controller. Getting this wrong gives inverted colors or wrong geometry. The correct TFT_eSPI build flags (already set in
firmware/VibeMonitor/platformio.ini) are:
ST7789_DRIVER=1TFT_INVERSION_OFF=1TFT_RGB_ORDER=TFT_BGRTFT_WIDTH=240,TFT_HEIGHT=320(native portrait;setRotation(1)gives 320×240 landscape)These produce correct colors and geometry on this board. You should not need to change them.
VibeMonitor/
bridge/ Python/Flask bridge: reads ~/.claude + ~/.codex, serves /state, /ack, /hook
firmware/ PlatformIO + LVGL app for the ESP32 CYD (plus a mock hub and a smoke test)
icons/ Source Claude/Codex PNGs + make_lvgl_icons.py (generates firmware icons.c/.h)
docs/ Screenshots and media
Requires Python 3.11+ (uses tomllib).
cd bridge
python -m pip install -e ".[dev]" # installs flask, requests; pytest for tests
python -m vibemonitor.setup # guided web wizard — writes config.toml for you
python -m vibemonitor.main config.toml # starts the hub (default http://0.0.0.0:5151)Prefer to hand-edit? Skip the wizard and cp config.example.toml config.toml, then set a
random token — that's the only required field.
python -m vibemonitor.setup opens a small local web wizard (at http://127.0.0.1:5152/) that
writes config.toml for you — no hand-editing TOML. Only the first step (a shared token, which it
generates, plus which tools to watch) is required; every later step — AI summaries, Home Assistant,
phone push, per-project pricing — is optional and skippable. It can also test your OpenRouter key
and ntfy topic, install the Claude Code hooks, and print ready-to-paste Home Assistant YAML on the
final screen. Re-running it backs up any existing config.toml to config.toml.bak first.
The Claude OAuth token used for the usage gauge is auto-read from ~/.claude/.credentials.json on Windows and Linux; you only set [claude] oauth_token in config.toml to override it. On macOS Claude Code keeps that token in the system Keychain rather than a file, so the bridge can't auto-read it — set [claude] oauth_token manually there (see Platform support).
The bridge ships a hook installer that merges into your existing ~/.claude/settings.json (it backs the file up first and never clobbers your other hooks):
cd bridge
python hooks/install_hooks.py --token <same-token-as-config.toml> --url http://localhost:5151/hookRestart Claude Code so it picks up the new hooks. This wires Notification, Stop, UserPromptSubmit, and SessionStart events to the bridge for instant, accurate "waiting" detection.
cd bridge
python -m pytest -qThe bridge serves a browser version of the display at http://<bridge-host>:5151/ — the
same waiting-first session list and usage gauges, no hardware required. The page asks for your
token once (the same value as config.toml) and stores it locally in your browser; it never
travels in the URL. It polls /state every couple of seconds, shows per-session summaries when
enabled, flags the bridge as offline/stalled if it stops responding, and lets you click a waiting
row to acknowledge it — exactly like tapping the device. A header link opens an Analytics
view (see below).
VibeMonitor turns the usage gauge from "how much is left" into "what should I do about it."
The bridge logs each usage reading to a small SQLite db (bridge/usage_history.db, gitignored)
and derives:
- Burn rate + projection — "↯ ~9%/hr · out ~3:40 PM" when you're on pace to hit the cap before the window resets, or "✓ resets first · 35% to spare" when you've got headroom. This one line is the whole point: it tells you whether to push or pace.
- Both reset clocks — the 5h window and the weekly window, shown as exact countdowns (the weekly cap is the real constraint for heavy users, and answers "when's the next reset").
- Daily peaks — a per-day chart of how high you drove usage, with the days you capped out marked red → "which days do I run out." (Builds up over the first week or two of history.)
These appear three places: a one-line projection under each gauge on the dashboard's main panel,
a full Analytics view (trend sparkline + daily chart + reset clocks), and a compact projection
line on the device's USAGE tab. The math (usageanalytics.py) is a linear projection of your
recent burn — i.e. "if you keep developing at this rate."
API: GET /analytics (token) returns the full bundle (per-provider projection, sparkline
samples, and daily history); /state and /ha carry the compact projection fields too.
A one-line answer to "can I start another agent right now?", derived from current usage, the burn-rate projection, and how many sessions are idle:
- go — "Plenty of budget · 3 idle — safe to start another"
- pace — "Budget tight (78% used) — pace yourself" (any provider above ~70%)
- throttle — "claude will cap out before reset — hold off on new work" (projected to exhaust before the window resets)
It shows as a colored banner at the top of the web dashboard and rides along in /state,
/analytics, and /ha (capacity_status) so automations can use it. No configuration —
it's always computed.
The bridge can push to your phone when a session starts waiting on you, with one
escalation re-fire (high priority) if it's still waiting after escalate_sec —
so a permission prompt you missed at your desk finds you in the kitchen. Two providers:
[push]
provider = "ntfy" # "ntfy" | "pushover"
ntfy_url = "https://ntfy.sh/vibemon-<random>" # ntfy: subscribe to this topic in the app
escalate_sec = 600 # re-notify (high priority) after waiting this longor with Pushover:
[push]
provider = "pushover"
pushover_token = "..." # app token
pushover_user = "..." # your user keyPushes are edge-triggered (one on enter-waiting, at most one escalation per wait) and best-effort — network errors are swallowed and never disrupt the poll loop. Off by default; unset keys = no-op. With ntfy, anyone with the topic URL can read it, so use a long random topic name. Project names (not prompt contents) appear in the notification text.
"Which project is eating my budget?" — the bridge tallies today's token usage per
(tool, project) from the local session logs (since local midnight) and serves it at
GET /costs (token). Each row carries total tokens and its share of today's grand
total; the dashboard's Analytics view renders it as the "Today's usage by project"
breakdown.
Optionally, add a [pricing] table to get a USD estimate per project (key is a model-id
substring; values are USD per 1M tokens):
[pricing]
"claude-sonnet" = { input = 3.0, output = 15.0 }
"codex" = { input = 2.5, output = 10.0 }Without it, rows report tokens only (usd = null). Everything is computed locally from
files already on disk — nothing leaves your machine.
VibeMonitor can show a short "what they're working on" line under each session (e.g. "Refactor auth middleware"). The bridge reads the latest prompt from the session log and asks a cheap model — via OpenRouter — for a 3–6 word description. It only calls the model when a session's prompt changes, so cost stays minimal, and it degrades silently (no summary shown) if the model is unavailable or rate-limited.
Enable it in config.toml:
[summary]
enabled = true
model = "google/gemma-4-31b-it:free" # any OpenRouter model id; :free models cost nothing
[openrouter]
api_key = "sk-or-v1-..." # from https://openrouter.ai/keysPrivacy: with summaries enabled, the latest prompt text of each active session is sent to OpenRouter (and its upstream model provider). It's off by default for that reason. Your OpenRouter key lives only in
config.toml, which is gitignored. Everything else about VibeMonitor stays local.
The bridge exposes a flat, automation-friendly endpoint and can push events, so you can surface your sessions in Home Assistant and drive lights/notifications off them.
Sensor (GET /ha) — token-protected, flat scalars plus a waiting list:
# configuration.yaml — poll the bridge as a REST sensor
sensor:
- platform: rest
name: VibeMonitor
resource: http://<bridge-ip>:5151/ha
headers:
X-VibeMonitor-Token: !secret vibemonitor_token
scan_interval: 10
value_template: "{{ value_json.waiting_count }}"
json_attributes:
[claude_pct, codex_pct, claude_reset_min, waiting_count, working_count,
any_waiting, stale_sec, waiting]Fields: claude_pct / codex_pct (0–100), claude_week_pct, claude_reset_min,
*_ok, waiting_count / working_count / idle_count / session_count,
any_waiting (bool), stale_sec, and waiting — a list of {tool, project, summary}
for the sessions that need you.
Ready-made entities — turn that one REST sensor's attributes into proper named entities (so they show up in dashboards, automations, and the logbook):
# configuration.yaml — derives entities from the `sensor.vibemonitor` REST sensor above
template:
- sensor:
- name: Claude Usage
unique_id: vibemonitor_claude_pct
unit_of_measurement: "%"
state: "{{ state_attr('sensor.vibemonitor', 'claude_pct') }}"
- name: Codex Usage
unique_id: vibemonitor_codex_pct
unit_of_measurement: "%"
state: "{{ state_attr('sensor.vibemonitor', 'codex_pct') }}"
- name: VibeMonitor Waiting Count
unique_id: vibemonitor_waiting_count
state: "{{ state_attr('sensor.vibemonitor', 'waiting_count') }}"
- binary_sensor:
- name: VibeMonitor Needs Me
unique_id: vibemonitor_needs_me
device_class: problem # on = a session is waiting on you
state: "{{ state_attr('sensor.vibemonitor', 'any_waiting') | bool(false) }}"
- name: VibeMonitor Bridge Online
unique_id: vibemonitor_bridge_online
device_class: connectivity # off = bridge stalled/unreachable
state: >
{% set s = state_attr('sensor.vibemonitor', 'stale_sec') %}
{{ s is not none and s >= 0 and s < 30 }}That gives you binary_sensor.vibemonitor_needs_me (flip a light off it),
binary_sensor.vibemonitor_bridge_online (alert if the bridge dies), and
sensor.claude_usage / sensor.codex_usage for dashboards. The webhook below is still
the lowest-latency way to react the instant a session needs you; these entities are the
always-available polled view.
Webhook (push) — set a Home Assistant webhook URL in config.toml:
[homeassistant]
webhook_url = "http://homeassistant.local:8123/api/webhook/vibemonitor-xxxxx"The bridge POSTs {"event":"waiting","tool":...,"project":...,"summary":...,"ts":...}
the moment a project starts waiting on you, and {"event":"cleared",...} when it
resolves — edge-triggered, so one event per transition. Example automation:
automation:
- alias: VibeMonitor needs me
trigger:
- platform: webhook
webhook_id: vibemonitor-xxxxx
local_only: true
action:
- choose:
- conditions: "{{ trigger.json.event == 'waiting' }}"
sequence:
- service: light.turn_on
target: { entity_id: light.office }
data: { rgb_color: [245, 166, 35] } # amber
- conditions: "{{ trigger.json.event == 'cleared' }}"
sequence:
- service: light.turn_off
target: { entity_id: light.office }Both are optional and local-network only; the webhook URL stays in the gitignored config.toml.
Requires PlatformIO (the CLI or the VS Code extension).
cd firmware/VibeMonitor
pio run -e cyd -t upload # build + flash over USBOn first boot the device starts a WiFi captive portal (SoftAP named VibeMonitor-setup). Join it from a phone or laptop and enter:
- your WiFi SSID + password,
- the bridge host (your PC's LAN IP),
- the bridge port (default
5151), - the token (the same value as
config.toml).
These are stored in NVS (the token is obfuscated), so subsequent boots connect straight to WiFi. Holding the BOOT button (GPIO 0) at power-on triggers a factory reset back into the portal.
Build is PlatformIO + Arduino-ESP32 + LVGL 8.3 + TFT_eSPI, configured entirely via build_flags in platformio.ini (no User_Setup.h needed). See the Hardware section for the ST7789 flags.
Tip:
firmware/mock_hub/is a tiny Flask server that serves canned/statefixtures, so you can develop the UI without a live bridge.
The bridge is pure Python and the firmware is built with PlatformIO, so both run on Windows, macOS, and Linux. The session list and Codex usage work the same everywhere; the only difference is how the Claude usage gauge gets its OAuth token:
| OS | Claude usage gauge | Notes |
|---|---|---|
| Windows | Auto — reads %USERPROFILE%\.claude\.credentials.json |
Turnkey. |
| Linux | Auto — reads ~/.claude/.credentials.json |
Turnkey. |
| macOS | Manual — set [claude] oauth_token in config.toml |
Claude Code stores the token in the Keychain, not a file, so the bridge can't auto-read it (yet). |
A few extra notes:
- macOS token: grab the value from the Keychain (Keychain Access → search "Claude", or
security find-generic-password -s "Claude Code-credentials" -w) and paste it into[claude] oauth_token. The token rotates every few hours, so this is best treated as a temporary workaround until a native Keychain reader lands — contributions welcome. - Hook installer uses
python. If your macOS/Linux setup only haspython3onPATH, the installed hook command will silently no-op (waiting status still works, just inferred from file activity instead of instant). Adjust the command in~/.claude/settings.jsontopython3if so. CLAUDE_CONFIG_DIR: if you've relocated your Claude config via this env var, the bridge currently still looks in the default~/.claudelocation.- Auto-start at login is OS-specific and not bundled: Windows uses a Scheduled Task, macOS a
launchdplist, Linux asystemd --userservice. The bridge itself is justpython -m vibemonitor.main config.toml.
The data endpoints (/state, /ha, /analytics, /ack, /hook) require the header X-VibeMonitor-Token: <token> (a missing or wrong token returns 401). The dashboard shell at GET / is unauthenticated (it carries no data; its JS supplies the token when it fetches /state). The hub binds to the LAN; the token is the only gate, which is appropriate for a desk toy on a trusted network. No provider secrets are ever sent to the device — they stay on the PC.
Everything is local by design:
- Your data stays on your machine. The bridge reads local session files and rate-limit headers; nothing is sent to any third party. The only exceptions are features you opt into: session summaries (prompt text → OpenRouter) and phone push / HA webhook (project names → your chosen endpoint).
- No secrets in the repo.
config.toml(token, API keys) is gitignored; onlyconfig.example.tomlwith placeholders is committed. The Claude OAuth token is read from your local~/.claude/.credentials.jsonat runtime and never stored or transmitted. - No real session data in the repo. The dashboard/analytics screenshots in
docs/media/are rendered from mock fixture data (the same fixtures asfirmware/mock_hub/); the setup-wizard screenshot shows a placeholder token and default host/port; and device photos are redacted and metadata-stripped.
Returns the current usage and the deduped, waiting-first session list (sessions past their TTL are already dropped server-side). staleSec is seconds since the last successful scan (-1 before the first scan), so a stalled bridge is detectable from the device:
{
"ts": 1780000000,
"usage": {
"claude": { "ok": true, "pct": 0.24, "window": "5h", "resetSec": 8040, "weekPct": 0.04 },
"codex": { "ok": false, "pct": null, "window": "5h", "resetSec": null, "weekPct": null }
},
"sessions": [
{ "id": "c21ef32d", "tool": "claude", "project": "WebApp",
"status": "waiting", "ageSec": 3, "waiting": true, "count": 1,
"summary": "Refactor auth middleware" }
],
"staleSec": 2
}{ "id": "c21ef32d" }Clears that session's waiting highlight (sent by the device when you tap a waiting row).
{ "id": "...", "project": "...", "tool": "claude", "event": "Notification", "ts": 1780000000 }Received from the Claude Code hook; event is one of Notification, Stop, UserPromptSubmit, SessionStart.
- working — the session's log file changed within
working_sec(default 60s). - waiting — the session is handing back to you. For Claude, this means a real
permission/input prompt (the
Notificationhook); a finished turn (Stop) is treated as activity, not a waiting alert. For Codex (which has no "blocked on you" signal in its logs), a finished turn (task_complete) is shown as waiting. A waiting session is highlighted forwaiting_ttl_sec(default 30 min), then quietly decays to idle — it is never pinned forever. - idle — quiet, but not long enough to hide.
- gone (hidden) — quiet longer than
gone_ttl_sec(default 4h); dropped from the list.
Tapping a waiting row on the device clears the alert for every window of that project.
Thresholds live in config.toml under [thresholds].
MIT — see LICENSE. Copyright (c) 2026




