You are most likely here because someone handed you this repo and said "set this up for me". This file is the procedure: do the steps in order and verify each one before moving on. Everything here is English; much of the deeper documentation is Swedish, which is fine to read as-is.
Work through Preflight first — half of all setup failures are decided there.
AGENTS.md, README.sv.md and some code comments refer to things the
maintainer has locally and you almost certainly do not have. None of them
are required, and the build gates on their absence:
| Reference | What to do |
|---|---|
~/Solelkollen/components, ~/Buddy/components |
Ignore. These are separate products in their own repos. Absent → the build prints saknas and builds VibePulse only. Expected, and what you want. |
The Solceller repo, docs/roadmap-hyllskarmen.md, "P-numbers" |
Ignore. Project history you cannot open. |
spec/*.yaml hardware registries |
Read, never edit. They are validated evidence with their own tests. |
- Never flash the board without the user explicitly telling you to. A build is free; writing firmware to their hardware is not. Ask, then flash.
- Never invent numbers. Missing data renders as dashes, everywhere. If you touch UI code, keep that property.
- Don't promote anything to "physically verified" in
spec/— that requires the user looking at the actual panel. - For any UI/visual change, read
.claude/skills/iterating-esp32-amoled-ui/SKILL.mdfirst. Setup work (this file) does not need it.
Confirm all five before touching anything. Ask the user for anything you cannot determine yourself.
- macOS or Windows? Both serve data. On Windows the Claude token comes
from
%USERPROFILE%\.claude\.credentials.jsoninstead of the keychain, and state and logs live under%LOCALAPPDATA%\VibePulse\. The suppliedinstall-windows-task.ps1adds autostart through Task Scheduler. On Linux the firmware still builds and the simulator still runs, but the service finds no Claude token at all — there is no keychain and the credential file is read only on Windows (#2). - Do they have the board? Waveshare ESP32-S3-Touch-AMOLED-2.16. No board → skip to Simulator only.
- Is their WiFi 2.4 GHz? The ESP32-S3 cannot see 5 GHz at all. This is the single most common "it won't connect" cause. Ask; don't assume.
- ESP-IDF 5.5 installed?
idf.py --version. If missing, point them at the install guide; it is a large download, so let them start it before you continue. - Python 3.11+?
python3 --version. Needed for the tokenserver.
cp secrets.h.example secrets.hThen edit secrets.h. Two separate things must be right:
TG_WIFI_SSID/TG_WIFI_PASS— their 2.4 GHz network. This one still belongs here: it is the immutable floor the panel falls back to, and the only network it knows before it has ever been anywhere. Every network after the first is taught to the panel at the place itself, with no rebuild — see wifi.md.- Replace
DIN-MACinTK_VIBEPULSE_BASE_URLwith their Mac's Bonjour name.
Those #defines ship active on purpose, with an obvious placeholder. Do not
comment them out or delete them: components/app_tokens/net.c guards every
fetch behind #ifdef TK_TOKENS_URL, so an undefined URL compiles the fetch
out entirely and the firmware then compiles cleanly, boots cleanly, connects
to WiFi cleanly — and shows dashes forever, with no error anywhere to tell
you why. A wrong hostname at least shows up in the serial log.
Use the Bonjour name, not an IP, so the same binary works at home and on a phone hotspot:
scutil --get LocalHostName # e.g. "Niclas-MacBook" -> Niclas-MacBook.localVerify: secrets.h has a non-empty SSID, and
grep -q 'DIN-MAC' secrets.h && echo "PLACEHOLDER STILL THERE" || echo "hostname set"
grep -nE 'http://[0-9]' secrets.h && echo "WARNING: raw IP in a URL" || echo "no raw IPs"prints hostname set and no raw IPs. If the placeholder is still there,
the board will look for a host that does not exist and every page will stay
on dashes. If the second line finds a raw IP: that is a snapshot of a DHCP
lease, and it will go stale — it cost an entire evening of network
debugging in the wrong direction before anyone read what the URL actually
contained (docs/lessons.md 2026-08-17). Use the Bonjour name.
Ask the user for the WiFi password. Do not guess it, and do not commit
secrets.h — it is gitignored, keep it that way.
. ~/esp/esp-idf/export.sh # their install path may differ
idf.py set-target esp32s3
idf.py buildVerify: the build ends with a torget.bin size/partition summary and no
error (the CMake project is torget, the platform; VibePulse is an app
inside it). Status lines saying Solelkollen saknas and ~/Buddy saknas
are expected on a fresh clone — that is the build telling you it found none
of the maintainer's companion apps, which is exactly what you want.
Ask first. Then:
idf.py -p /dev/cu.usbmodem101 flash # confirm the real port firstFind the port with ls /dev/cu.usbmodem*. Two hardware facts decide whether
this works, both learned the hard way:
- Flash in download mode, with the panel dark. Hold BOOT, tap RESET, release BOOT. The board re-enumerates as a ROM device.
- A computer USB port often cannot power the running firmware. The AMOLED panel's current draw makes the board bounce off the bus or hang. This looks exactly like a bad cable and is not one. After flashing, run the screen from its own USB power supply.
Verify: the flash log says Hash of data verified, then the panel lights
up after reset.
Pure stdlib, nothing to install:
python3 tools/tokenserver/tokenserver.pyThe computer must be on for the panel to receive fresh data. On the same LAN, the panel talks directly to it. On WiFi with client isolation, the optional numbers relay can still carry quota data as long as this service is running; it does not publish agent activity or Needs You prompts.
Verify, from the same Mac:
curl -s localhost:8737/ | python3 -m json.toolRead claudeProbe in that output — it is the single most useful diagnostic
in the whole system, and it tells you exactly why Claude's numbers are or
are not arriving:
claudeProbe |
Meaning | What to do |
|---|---|---|
usage_http_200 + ok |
Working. Limits parsed. | Nothing |
not_run |
Probe has not fired yet | It runs every 120 s — wait |
no_claude_oauth_token |
No Claude Desktop / Claude Code token found | Have them sign in to Claude Code on this Mac |
token_expired_… |
Token found but expired | Re-authenticate in Claude Code |
usage_http_401 / usage_http_403 |
Every token source rejected (on macOS the probe tries Claude Desktop's process token, then the keychain, and falls back automatically; on Windows there is only %USERPROFILE%\.claude\.credentials.json) |
Re-authenticate in Claude Code |
usage_http_200 + no_mapped_limits |
Authenticated, but nothing in the usage response mapped (a ; fallback_… suffix records the header-probe outcome) |
Plan may not expose limits; Codex half still works |
usage_request_failed: … |
Network/DNS failure from the Mac | Check the Mac's own connectivity |
usage_http_429 + backoff_until_HH:MM |
Rate-limited by the API; the probe rests until the shown time | Wait — it retries by itself |
probe_crashed: <Type> |
The probe itself hit a bug (crash before it could classify the failure) | Read the traceback in ~/Library/Logs/torget-tokenserver.log; worth filing |
Codex is read separately from its local app-server, so a bad claudeProbe
never explains missing Codex numbers, and vice versa.
Then check the endpoints the screen polls:
curl -s localhost:8737/api/tokens
curl -s localhost:8737/api/agent-status
curl -s localhost:8737/api/max-trackerOptional plan badges: --claude-plan {pro,max5x,max20x}, --codex-plan {plus,pro}. Cosmetic labels only, never used in any percentage maths.
For autostart on login, see ../tools/tokenserver/README.md.
The screen polls every 30 seconds, so wait that long before judging. Then confirm with the user that real numbers replaced the dashes.
This turns the panel from a monitor into an input device. It is all off by default. Installing the Codex plugin does not enable Codex interactions, and enabling one provider does not enable the other, the numbers relay, the encrypted interaction relay, or GitHub.
The computer must be awake and running the tokenserver. Direct answers use the LAN. The separate encrypted interaction relay can work across unrelated internet Wi-Fi using outbound HTTPS only; it remains default-off and is not enabled by these steps or by installing the Codex plugin.
One shared secret authenticates answers. Generate one value and put the same value on the panel and computer; never paste the real value into an issue or commit it:
python3 -c "import secrets; print(secrets.token_hex(32))"- In the gitignored
secrets.h, set#define TK_VIBEPULSE_DEVICE_KEY "…64 hex…", then rebuild and flash only after the user approves Step 3. Without it, the sender is display-only. - On the computer, write the value to
~/.vibepulse-device-keyand runchmod 600 ~/.vibepulse-device-key, or exportVIBEPULSE_DEVICE_KEY.
Run the guided setup from the repo root:
python3 tools/vibepulse_setup.py installChoose off, claude, codex, or both. Then choose whether bounded
question/command detail may reach the local panel; the safe default is no.
This saves the choices for the tokenserver and installs the optional Codex
adapter. It does not start a cloud relay. A non-interactive install with no
provider choice leaves both providers off.
Useful lifecycle commands:
python3 tools/vibepulse_setup.py status
python3 tools/vibepulse_setup.py doctor
python3 tools/vibepulse_setup.py disable codex
python3 tools/vibepulse_setup.py uninstall codexdisable codex turns off only Codex. uninstall codex removes the VibePulse
Codex plugin/MCP and disables Codex; it preserves Claude, relay, GitHub,
device-key, and unrelated Codex settings. It does not delete the repository or
the shared device key.
To opt in to encrypted decisions across isolated Wi-Fi, first read the exact privacy boundary in interaction-relay.md. Enable at least one provider and detail above, install the pinned Worker dependencies, then run:
cd tools/interaction-relay && npm ci && npx wrangler login && cd ../..
python3 tools/vibepulse_setup.py relay install \
--url https://vibepulse-interaction-relay.YOUR-SUBDOMAIN.workers.dev \
--yes-e2e-cloud
python3 tools/vibepulse_setup.py relay status
python3 tools/vibepulse_setup.py relay doctorThe installer generates the mailbox and role credentials; it does not print
them or flash the board. Enable TK_VIBEPULSE_INTERACTION_RELAY separately in
idf.py menuconfig, rebuild, and ask before flashing. Restart a running
tokenserver after changing saved choices.
Disable traffic without deleting credentials, or remove only this relay:
python3 tools/vibepulse_setup.py relay disable
python3 tools/vibepulse_setup.py relay uninstall --keep-worker
python3 tools/vibepulse_setup.py relay uninstall --delete-workerThese commands preserve Claude/Codex provider choices, the Codex package, GitHub, numbers relay, repository, and shared device key. Captive portals, offline networks, and blocked Worker domains still fall back to the computer.
The service command remains plain:
python3 tools/tokenserver/tokenserver.pyIt loads the saved choices. Do not put provider or detail switches into a
launchd/Task Scheduler command, where they can go stale. --interactions is a
legacy alias for Claude only. Current installations should use the setup tool.
For Codex, open Codex and run /hooks. Review the VibePulse SessionStart and
PermissionRequest command hooks and explicitly trust them. Then Start a new
Codex task so the newly trusted hooks, skill, and MCP tool are loaded. Run
python3 tools/vibepulse_setup.py doctor again; doctor reports the review
state but never bypasses it.
Codex permissions use a narrow safe-command tier. Only recognized read-only, test, and build commands can offer ALLOW ONCE. Unknown commands, mutations, secrets, truncated text, free-form questions, and questions without exactly one explicit recommendation use the computer fallback. Timeout and silence also return to the computer; nothing is approved by silence.
For an old Claude-only panel, --legacy-claude-panel-v1 is available solely
as an explicit compatibility switch. legacy Claude v1 is insecure because
its verdict is not bound to provider and exact rendered view; it is off by
default and must never be used for Codex.
Point Claude Code's hooks at the bridge on loopback (Claude Code blocks HTTP hooks that resolve to the LAN, which is why the bridge splits loopback-in and LAN-out). In Claude Code settings:
{
"hooks": {
"PreToolUse": [{
"matcher": "AskUserQuestion",
"hooks": [{
"type": "http",
"url": "http://127.0.0.1:8737/api/hook/question",
"timeout": 120,
"statusMessage": "Waiting for VibePulse…"
}]
}],
"PermissionRequest": [{
"matcher": ".*",
"hooks": [{
"type": "http",
"url": "http://127.0.0.1:8737/api/hook/permission",
"timeout": 120,
"statusMessage": "Waiting for VibePulse…"
}]
}]
}
}Claude Code can emit both hooks for one AskUserQuestion: the dedicated
question hook contains the choices the panel should show, while the broad
permission hook may repeat the same internal tool. VibePulse keeps the
dedicated question and immediately returns no decision for that duplicate
permission. This prevents a second generic AskUserQuestion card from
replacing or queueing behind the real question; every unrelated permission
still follows the normal approval path.
Fail-safe by design: a held hook that times out or is left alone renders no
decision, so Claude Code falls back to its normal terminal prompt. This is the
same computer fallback as Codex. A managed/enterprise allowedHttpHookUrls
policy can silently block HTTP hooks — if the panel never reacts, check that
first.
On the glass: a tap opens the decision; APPROVE / DENY / LEAVE IT answer it; on the private screen a tap hands it to the terminal. KEY3 held ~1.5–3 s and released is the panic — deny everything parked; the 3 s hold still opens OTA.
After the first USB flash, day-to-day updates go over the air — the full workflow, consent model and troubleshooting live in ota.md.
| Symptom | Cause | Fix |
|---|---|---|
| Screen boots, everything is dashes, forever | DIN-MAC never replaced in secrets.h, or the TK_* defines were removed |
Set the real Bonjour name, rebuild, reflash |
| Dashes, and the Mac's URL is set | tokenserver not running, or Mac asleep, or firewall | Start it; check curl localhost:8737/ |
| Dashes only for Claude, Codex fine (or vice versa) | That provider's source is unavailable | Check claudeProbe; the other half working is by design |
| Never joins WiFi | Network is 5 GHz | 2.4 GHz only. iPhone hotspot: enable "Maximize Compatibility". The glass names the reason itself after 60 s |
| Moved to a new place; panel finds nothing | The new network was never taught to it | It raises VibePulse-setup after 90 s (or a 3 s KEY3 hold). Run tools/wifi-here.sh on the Mac, or join the AP from a phone. Remembered afterwards — docs/wifi.md |
wifi-here.sh cannot join the setup AP |
The window is closed, or TG_OTA_TOKEN is missing so the password is random |
Check the glass says WIFI SETUP; without a token run TG_AP_PASS=<what the glass shows> tools/wifi-here.sh |
| Panel joined the venue WiFi but still shows dashes | Client isolation, or a captive portal the panel cannot pass | Not fixable on the device. Use the phone hotspot instead |
| "This project has no OTA" / partitions.csv shows one factory partition | Reading a tree from before the OTA foundation (A/B slots + otadata + components/torget_ota/) |
Check which branch/commit the checkout is on; read partitions.csv in THAT tree before concluding. OTA workflow: tools/ota-flash.sh <ip> + a 3 s KEY3 hold |
| Panel shows stale quota / empty Fable weekly in the morning | Upstream 429 penalty from the shared account bucket | Self-heals: dead tokens are never resent, the penalty persists across restarts, deltas serve from cache. Check claudeProbe on curl localhost:8737/ |
| Panel shows stale while powered from the computer USB port | The Mac port cannot feed WiFi TX bursts — fetches time out | Expected on Mac USB; run from wall power. Logs stay valid on Mac USB, data does not |
| OTA boots always show state 0xffffffff and the health gate always rests | sdkconfig generated before the rollback line landed in sdkconfig.defaults (defaults only apply on fresh generation) |
grep BOOTLOADER_APP_ROLLBACK sdkconfig — set =y, rebuild, and USB-flash ONCE (the bootloader carries the logic; OTA never writes it) |
No /dev/cu.usbmodem* |
Not in download mode | Hold BOOT, tap RESET, release BOOT |
| Flash starts then dies; board hangs | USB port cannot power the panel | Download mode to flash; own PSU to run |
| Numbers freeze and go stale | Service or LAN dropped | Last good values are kept deliberately; restart the service |
./test/run.sh refuses to start |
Unpinned PyYAML/Pillow | See Hardware knowledge |
The whole platform runs on the host against the real LVGL, fed by the
recorded fixtures in sim-fixtures/ through the same parsers the board uses:
brew install sdl2 cmake ninja # Debian/Ubuntu: apt-get install libsdl2-dev cmake ninja-build
cmake -S sim -B sim/build -G Ninja && ninja -C sim/build
./sim/build/torget-simKeys: [ / ] change page, S cycles agent status, M cycles Max Tracker
fixtures, T re-feeds tokens, L opens the launcher.
For a non-interactive check — useful in CI or over SSH — this writes the full 480×480 capture matrix and exits non-zero if any frame fails:
TORGET_CAPTURE_DIR=/tmp/caps ./sim/build/torget-sim --vibepulse-static-qaRun the host gate before you hand anything back. It needs pinned versions, so use the venv:
python3.12 -m venv .venv && . .venv/bin/activate
python -m pip install -r requirements-dev.txt
./test/run.shIt runs the C core tests, the Python suites, and exact-raster landmark checks that rebuild the simulator and compare real captures. No ESP-IDF needed.
Architecture, the app contract, and the full list of hardware traps are in
../README.sv.md (Swedish) and spec/.