This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Pulse (formerly Phils Home) — a 15-card dashboard plugin for Even Realities G2 smart glasses. Replaces the Even home screen for the user: a Today aggregate glance card, sports scores, Duck Ops business status, Google Tasks, GitHub PRs / CI, Gmail, Now Playing, Calendar, Messages. Tap to expand to detail; swipe for carousel navigation; tap on an actionable item in detail view to open a full-screen action picker (approve / reject / dry-run / complete / skip / etc.), and list-tap the chosen option to execute it. Ring-double-tap in dashboard opens the card-selector modal for jumping anywhere in the carousel without swiping through every card.
npm run dev # Vite dev server on :5174 (host 0.0.0.0 for LAN)
npm run build # tsc + vite build → dist/
npm run pack # evenhub pack app.json dist -o pulse.ehpk
npm run deploy # build + pack in one step (preferred)
npx evenhub qr --url http://<mac-lan-ip>:5174 # hot-reload loop on glassesUpload the .ehpk manually to the com.philtullai.pulse project at https://hub.evenrealities.com/application — no CLI upload exists.
When adding a data source, classify it FIRST:
- Public web APIs (ESPN, open-meteo, etc.) →
fetch()directly fromsrc/api.ts. No new server needed. - Per-app private data → the app that owns the state also owns its endpoint. Duck Ops data lives at
~/ai-agents/duck-ops/runtime/widget_api.pyon :8780. Do not mirror it into this repo — extend the widget_api there. - General Mac/phone data with no natural home (iMessages, Google Calendar/Tasks/Gmail, GitHub API, Now Playing) → add a route to
~/ai-agents/phils-bridge/server.pyon :8790.
Rationale: single auth/CORS surface per service, per-repo ownership of app-specific state, easier future migration to Tailscale. See memory file project_glasses_data_architecture.md for the full architectural decision record.
Every card is a CardDefinition from src/cards/_types.ts. Minimal card:
{ id, title, pollMs,
load(): Promise<unknown>,
format(data, error): string, // dashboard view, dense
formatDetail?(data, error): string } // full-screen detail viewItem-paginated cards (Approvals, Tasks) also provide:
getItems(data): unknown[],
formatItem(item, index, total): string,
// Simple two-option picker: main.ts synthesizes [confirmLabel, rejectLabel]
// from these fields. Use for cards that just have APPROVE/REJECT or similar.
confirmAction?(item): Promise<ActionResult>,
rejectAction?(item): Promise<ActionResult>,
confirmLabel?: string,
rejectLabel?: string,
// For richer flows (3+ options, undo, read-only preview), declare getActions
// directly. Takes precedence over confirmAction/rejectAction.
getActions?(item): PickerOption[]PickerOption is { label: string; run: () => Promise<ActionResult>; undo?: () => Promise<ActionResult> }. If a chosen option has undo, the app flashes a 3-second UNDO prompt after success; a tap during that window fires the undo closure. Used by Tasks for un-complete / un-skip. Not used by Approvals APPROVE (email irreversible); the DRY RUN option exists as the preview-first alternative.
To add a new card: copy src/cards/_template.ts → src/cards/<id>.ts, implement, add one import + one entry to the CARDS array in src/cards/index.ts (order in that array = carousel order). _template.ts documents every option.
Shared helpers live in src/cards/_shared.ts (formatError/formatLoading) and src/cards/_sports.ts (ESPN card factory).
The Today card (src/cards/today.ts) is the first card in the carousel and aggregates counts from other data sources. The left column's attention-badges line reads from Today's loaded state (cardStates.get('today').data) — Today must have loaded once before badges appear. Keep the Today snapshot shape stable; formatAttentionBadges in main.ts reads it by field.
The G2 temple + optional R1 ring give us 6 usable inputs. Current assignments:
| Gesture | Action |
|---|---|
| Single tap (dashboard) | Enter detail view for the current card |
| Single tap (detail, non-actionable item) | Back to dashboard |
| Single tap (detail, actionable item) | Open the action picker modal (full-screen list of card-declared options) |
| Single tap (while UNDO window is open) | Fire the undo closure of the last action |
| Double tap (detail, EITHER source) | Back to dashboard. Overrides the Even "glasses-double-tap = exit" convention because the user prefers not to lose their place when reading a card. Exit is still reachable from dashboard via glasses-2-tap. |
| Glasses double tap (dashboard) | System exit dialog |
| Ring double tap (dashboard) | Open the card-selector modal (jump to any card in the carousel) |
| Swipe up/down (dashboard) | Prev/next card |
| Swipe up/down (detail, item-paginated card) | Prev/next item within card |
| Swipe up/down (detail, non-paginated) | Prev/next card |
| Picker: list-tap an option | Execute the chosen action, flash result (with optional UNDO window), return to dashboard |
| Picker: double-tap | Cancel picker (stay in detail view / same card) |
Ring-tap and glasses-tap are treated identically for the single-tap primary action; source only matters for the double-tap (ring-double = back/card-selector, glasses-double = exit).
Ring vs glasses is distinguished by event.sysEvent.eventSource (TOUCH_EVENT_FROM_RING = 2, glasses = 1 or 3). See src/even.ts:classifySource.
Display sleep — there's no formal documentation of the firmware's display-sleep behavior (the "~15s no-update" rule used to be folklore in the comments and didn't match observed reality). What we DO know empirically:
- Each
textContainerUpgradeBLE write is read by the firmware as "the app is active, keep the display lit". Frequent writes = display never sleeps. onDeviceStatusChangedfires more often than expected on real glasses (battery percent, charging, connection-keepalive). If you callpaint()unconditionally inside that callback, the display stays lit forever — seemain.tsdevice-status handler for the bucket+flip dedupe pattern. This was the root cause of v0.10.x display-stays-on bug.- Card polling intervals (60s+ for the shortest card) and the 60s service-health probe are too infrequent to keep the display continuously lit on their own.
| Service | Port | Where | Purpose |
|---|---|---|---|
| widget_api | 8780 | ~/ai-agents/duck-ops/runtime/widget_api.py |
Duck Ops read + POST /approvals/approve |
| phils-bridge | 8790 | ~/ai-agents/phils-bridge/server.py |
Calendar, Tasks, Gmail, GitHub, Now Playing, iMessages. Write routes: POST /tasks/complete, POST /tasks/skip, POST /tasks/uncomplete (undo complete), POST /tasks/unskip (undo skip). |
| Vite dev | 5174 | this repo | Hot-reload dev |
All LAN-only, no auth. Before ANY non-LAN exposure (Tailscale, tunnel, public deploy): add bearer-token auth to the POST endpoints. User explicitly chose this trust model — see memory feedback_duckops_widget_auth.
- Google OAuth — refresh token at
~/ai-agents/phils-bridge/.env, scopes:tasks+calendar.readonly+gmail.readonly. Re-runpython3 ~/ai-agents/phils-bridge/oauth_setup.pyif revoked or new scope needed. - SMTP (for approval emails) — read from
~/ai-agents/duckAgent/.envby widget_api.py. Already working. - GitHub PAT — set
GITHUB_TOKEN+GITHUB_REPOS=owner/repo,...env vars on phils-bridge start; otherwise PR/CI cards show "unconfigured". - iMessages (Full Disk Access) — the Python interpreter that runs phils-bridge needs FDA granted in System Settings → Privacy & Security. Binary path:
/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/bin/python3.14.
- Serialize all bridge writes. The SDK docs warn that concurrent
textContainerUpgradecalls crash the BLE link.src/even.tsuses anenqueue()mutex — all renders go through it. - SDK is v0.0.10.
setBackgroundState/onBackgroundRestoredocumented in the plugin'sbackground-stateskill do NOT exist in this version. Re-fetch onFOREGROUND_ENTER_EVENTinstead. - One active card polls.
startActiveCardPoll()restarts the per-card timer on card change; swiping away stops the previous card's polling. - Cache shared data sources.
loadDuckOps()caches for 30s so the 6+ Duck Ops cards don't each hit/widget-status.jsonon swipe. - Update
app.jsonpermissions.network.whitelistwhen you add a new host. Packaged.ehpkblocks non-whitelisted fetches. - Approvals safety.
POST /approvals/approverequires{confirm: true}or{dryRun: true}in body — this prevents accidental LAN requests from triggering real publishes. The picker modal on glasses is the user-side half of the safety: a single tap opens the picker, the list-tap selects the action, and double-tap-cancel dismisses without firing anything.
| Task | Where |
|---|---|
| New card | Copy src/cards/_template.ts, register in src/cards/index.ts. Also see /new-card skill. |
| New phils-bridge route | Add handler + route in ~/ai-agents/phils-bridge/server.py. Update route list in / root handler. |
| New widget_api field | Add helper in ~/ai-agents/duck-ops/runtime/widget_api.py, include in build_widget_status(). Update types in src/types.ts. |
| New env var | Add to vite-env.d.ts's ImportMetaEnv, .env.local, and app.json permissions if it's a URL. |
| Restart both backend services | See /restart-services skill. |
| Dry-run test an approval | See /dry-run-approve skill. |
ROADMAP.md (in this repo) tracks pending user actions, queued work, and detailed plans for unstarted features (image containers, audio + wake-word, plus three standalone apps: lyrics overlay, live captions, recipe assistant). Check it before starting a new direction.
Key memory files that apply here (in ~/.claude/projects/-Users-philtullai-Documents-even/memory/):
project_glasses_data_architecture.md— three-bucket rule + Duck Ops update cadenceproject_phils_home_data_setup.md— services, OAuth state, Google Tasks list namesfeedback_duckops_widget_auth.md— LAN-only trust decision, auth gate before any wider exposure