尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗Benny is the Penguin Palace assistant — a shared app for Mer and Michael to run their household: calendar, chores, mutual to-dos, bills, and pet care.
This repo is also Michael's hands-on build for the Overclock Accelerator 10-week AI program, so it's deliberately built in small, explained steps rather than generated all at once.
benny/
├── index.js # Express app entry point — start here
├── server/
│ ├── lib/
│ │ ├── supabaseClient.js # two Supabase clients: anon (RLS-limited) and admin (service_role)
│ │ ├── googleClient.js # builds the Google OAuth2 client
│ │ ├── tokenStore.js # reads/writes your Google tokens in Supabase
│ │ ├── anthropicClient.js # one shared Claude client
│ │ ├── todoParser.js # turns free text into {title, assignee, due_date} via Claude tool use (was choreParser.js)
│ │ ├── todos.js # To-Do rules: what a change may do, when a repeating to-do comes due again
│ │ ├── people.js # which of us an email is (PERSON_EMAILS), for To-Do's "✓ Mer" (Oct 2026)
│ │ ├── billParser.js # turns free text into {category, amount, billing_month} via Claude tool use
│ │ ├── rentcastClient.js # calls RentCast's AVM endpoint for a home value + comps
│ │ ├── compsSummary.js # turns that data into a short plain-language paragraph via Claude
│ │ ├── resideoClient.js # talks to Resideo's thermostat API directly (no SDK)
│ │ ├── resideoTokenStore.js # reads/writes the one Resideo token row in Supabase
│ │ ├── authSession.js # cookie-based Supabase Auth session + requireUser middleware (Stage 17)
│ │ ├── agentAuth.js # agent bearer token, route allowlist, agent_actions audit log (Stage 18)
│ │ ├── actionError.js # an Error that carries its HTTP status, for shared write paths (Stage 21)
│ │ ├── recurringBills.js # next-due-date math for recurring bills, in Chicago time (Stage 20)
│ │ ├── moveTasks.js # move tracker rules: repeats, the Summit agenda, decision warnings (Stage 22)
│ │ ├── comments.js # comment rules: what one may say, who may change it (workspace, phase 1)
│ │ ├── petCare.js # pet care rules: record fields, Pets-list reminders, the EU travel checklist (Stage 23)
│ │ └── weatherClient.js # today's forecast from Open-Meteo, no API key (Stage 20)
│ └── routes/
│ ├── hello.js # GET /api/hello — the hello-world endpoint
│ ├── auth.js # /auth/google + /auth/google/callback — the Google sign-in handshake
│ ├── calendar.js # /api/calendar/status + /api/calendar/events
│ ├── todos.js # /api/todos + /api/lists — To-Do: chores, groceries and any other list (Oct 2026)
│ ├── bills.js # /api/bills (GET/POST) — includes the per-category summary
│ ├── comps.js # /api/comps (GET) + /api/comps/refresh (GET, cron-only)
│ ├── resideoAuth.js # /auth/resideo + /auth/resideo/callback — the Resideo sign-in handshake
│ ├── smarthome.js # /api/smarthome/status, /thermostats (GET+PATCH), /shades
│ ├── session.js # /auth/login, /verify, /confirm, /logout — signing in to Benny itself (Stage 17)
│ ├── recurringBills.js # /api/recurring-bills — what's due when (Stage 20)
│ ├── proposals.js # /api/proposals — "Benny suggests," approve or dismiss (Stage 21)
│ ├── projects.js # /api/projects, /tasks, /decisions, /key-dates, /log — the move tracker (Stage 22)
│ ├── comments.js # /api/tasks/:id/comments, /api/decisions/:id/comments, /api/comments/:id (workspace, phase 1)
│ └── pets.js # /api/pets + their vaccinations, visits, medications (Stage 23)
├── public/
│ ├── index.html # the page you see at localhost:3000
│ ├── app.js # browser-side JS that calls /api/hello
│ ├── calendar.html # the calendar page
│ ├── calendar.js # browser-side JS that calls /api/calendar/events
│ ├── todo.html / todo.js # To-Do: My Day, Important, Planned, Michael/Mer, and every list (Oct 2026)
│ ├── chores.html / lists.html # redirects to todo.html, for old bookmarks and home-screen icons
│ ├── bills.html # the bills page
│ ├── bills.js # browser-side JS for adding bills and rendering the summary
│ ├── comps.html # the home comps page
│ ├── comps.js # browser-side JS that renders the estimate + comps table
│ ├── smarthome.html # the smart home page
│ ├── smarthome.js # browser-side JS: thermostat status/control + shade status
│ ├── login.html / login.js # sign-in page: email → code (Stage 17)
│ ├── move.html / move.js # the move as a project: Overview, List, Timeline, Decisions, Log (workspace, phase 1)
│ ├── move-pane.js # the Move page's side pane: one task or decision, and its comments
│ ├── move-timeline.js # the Move page's Gantt chart (its layout is pure, and tested)
│ ├── move-shared.js # date labels + the Everyone/Michael/Mer filter, shared with the homepage card
│ ├── log.html # redirects to the Move page's Log tab, for old links
│ ├── pets.html / pets.js # pet records, reminders and the EU travel checklist (Stage 23)
│ ├── auth-guard.js # loaded on every signed-in page; bounces to /login.html on 401
│ ├── shell.js # the shared sidebar (computer) and tab bar (phone) on every signed-in page
│ └── escape.js # escapeHtml() for titles that came from outside (Stage 18)
├── agent/benny-plugin/ # Hermes plugin: Benny the agent's tools for this app's API
├── agent/scripts/ # scripts Hermes runs on the Pi (the morning brief)
├── scripts/ # one-time setup: the Stage 22, To-Do (two), Stage 23 and comments migrations + the roadmap importer
├── tests/ # `npm test`: node:test checks of the pet care, To-Do, move, timeline and comment rules
├── powerview-bridge/ # standalone script that runs on a Raspberry Pi at home,
│ # not part of the Vercel app — see its own README.md
├── .env.example # template for required environment variables
├── .env # your real values (never committed — see .gitignore)
├── vercel.json # tells Vercel explicitly how to build/route the app
└── package.json
Note:
index.jsused to live atserver/index.js. It moved to the project root in the "Deploying to Vercel" stage below — see that section for why.
npm install
.env has real values (it already does in this delivery — the
Supabase URL and anon key are filled in). If you ever need to recreate it,
copy .env.example to .env and fill it in.npm run dev
npm run dev uses nodemon, which restarts the server automatically
whenever you edit a file — useful while we're actively building.
Three layers, one round trip:
index.html and runs app.js.app.js calls /api/hello on the Express server.Once you can load the page and see "✅ Supabase is connected", every piece of the stack is proven end to end and we're ready to build real features on top of it — starting with the shared calendar.
This adds a "Connect Google Calendar" flow and a /calendar.html page that
lists upcoming events. Two setup steps are required beyond .env.example
that only you can do (they involve logging into your own accounts):
.env:
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback
service_role — click "reveal"). Add to .env:
SUPABASE_SERVICE_ROLE_KEY=...
This key bypasses Row Level Security entirely, which is exactly why the
google_tokens table (holding OAuth tokens) has RLS turned on with
no public policies — the regular anon key literally cannot read or write
it. Only trusted server code (this app, never a browser) should ever
hold this key.Once both are in .env, restart the server (npm run dev picks up new
env vars on restart, not automatically), open http://localhost:3000/calendar.html,
and click Connect Michael's calendar (or Mer's — see below).
Michael and Mer each connect their own Google account separately, and
Benny merges both calendars into one read-only, chronologically sorted
list (each event tagged with an owner so the UI can show a colored dot
per person). There's exactly one Google Cloud project and one OAuth app —
you don't create separate credentials per person — but two rows in
google_tokens (id = 'michael', id = 'mer'), one per connected
account.
The mechanism: /auth/google/:owner (michael or mer) redirects to
Google with that owner stashed in OAuth's state parameter; Google
echoes state back verbatim to /auth/google/callback, which is how a
single shared callback route knows whose tokens it just received. This
only works because the redirect URI is identical for both people — it's
registered once in Google Cloud Console and never changes.
One Google Cloud Console step this needs: if the OAuth consent screen is still in Testing mode (the default until you submit for verification), only email addresses explicitly added as test users can complete sign-in — so add Mer's Google account email under APIs & Services → OAuth consent screen → Test users before she tries to connect, or her attempt will fail at Google's consent screen before it ever reaches Benny.
Do not create a Gmail API connector or grant any Gmail scope for
this. Calendar and Gmail are separate Google APIs with separate
permissions — Benny only ever requests
https://www.googleapis.com/auth/calendar.readonly (see
server/lib/googleClient.js), which covers reading calendar events and
nothing about email. Reading email, or writing new events instead of
just displaying them, would each need their own, broader scope granted
deliberately later — neither is needed for read-only calendar display.
Replaced by To-Do (Oct 2026). Chores is now a list in To-Do, and
choreParser.jsbecametodoParser.js. See "To-Do: chores and lists in one place" below. This section is kept as the history of how it started.
Adds a /chores.html page: type something like "remind Mer to water the
plants Thursday", and Benny turns it into a real chore with a title,
an assignee, and a due date — this is the first feature where the app
itself calls Claude, not just Supabase or Google.
How it works: server/lib/choreParser.js sends your sentence to Claude
using a feature called tool use (function calling). Instead of asking
Claude to reply with some JSON and hoping it's formatted correctly, we hand
it a strict schema — { title, assignee, due_date }, with assignee
restricted to exactly "michael" or "mer" — and Claude is forced to fill
in those exact fields. We also tell it today's date in the prompt, so it
can resolve "Thursday" or "tomorrow" into a real calendar date.
Setup: add your Anthropic API key to .env:
ANTHROPIC_API_KEY=sk-ant-...
Get one from console.anthropic.com → API Keys → Create Key. Note this is a separate account/product from your regular Claude.ai chat login — the API bills per use (pay-as-you-go), not through a Claude subscription. You'll likely need to add a small amount of credit/billing there before requests succeed.
Update (Stage 8, security pass): chores originally shipped without
Row Level Security, on the reasoning that it doesn't hold credentials and
only our own server touches it anyway. That reasoning was true of the
code, but not enforced at the database level — RLS off means the
anon key (the one this app was already using for these tables) can
read and write the table directly against Supabase, completely bypassing
the app. Supabase's own security advisor flagged this, so chores,
bills, home_value_estimates, and home_comps all now have RLS
enabled with no public policies — same treatment as google_tokens and
resideo_tokens — and their routes (chores.js, bills.js, comps.js)
were switched from the anon supabase client to supabaseAdmin
(service_role), which bypasses RLS for trusted server code. Net effect:
zero behavior change for the app itself, but the anon key can no longer
touch any of these tables directly.
Worth noting: since every chore already has an assignee (Michael or Mer),
this single feature effectively covers "mutual to-do assignment" from the
original feature list too — there isn't a separate to-do system.
Adds a /bills.html page: type something like "electric bill was $142.50
for August", and Benny logs it, then shows a per-category summary (latest
amount, plus how it compares to the previous bill in that category) above
a full chronological history.
How it works: server/lib/billParser.js uses the same Claude tool-use
pattern as chores, extracting { category, amount, billing_month }. Unlike
chores.assignee, category is deliberately not restricted to a fixed
list — households have all kinds of bills, so Claude is instructed to
normalize synonyms into a consistent lowercase label (e.g. "power" or
"ComEd" → "electric") rather than being locked to a preset menu. The
"analyzer" part is summarizeByCategory() in server/routes/bills.js,
which groups entries by category and computes the percent change between
the two most recent bills in each one.
No new setup is needed for this stage — it reuses the same ANTHROPIC_API_KEY
from Stage 3.
Michael and Mer are planning a move abroad, with the condo likely going up for sale around
June/July 2027. This stage adds a /comps.html page that watches comparable home sales/listings
in Logan Square, so they can casually track the market well ahead of listing — a real once-a-week
"how's the neighborhood doing" glance, not something to obsess over daily.
Why not just use Zillow or Redfin directly? Neither has a public API anymore, and both explicitly prohibit automated collection of their listing data in their terms of service — that restriction is about the site, not about which tool does the fetching, so there's no compliant way to have Benny pull data from them automatically.
What Benny uses instead: RentCast's free developer tier (50
requests/month, $0/mo) — specifically their /v1/avm/value endpoint. One call returns both an
estimated home value and the actual comparable properties RentCast used to calculate it
(address, price, sqft, beds/baths, status, days on market, distance, and a similarity score) —
this is effectively an automated realtor CMA (comparative market analysis) in a single request.
How it works:
server/lib/rentcastClient.js calls RentCast with your house's own details — address, property
type, bedrooms/bathrooms, square footage — all read from .env, not hardcoded, so remeasuring
the place or fixing a typo is a config edit, not a code change. HOME_ADDRESS never appears in
the repo, this README, or anywhere else — same treatment as your other secrets.server/lib/compsSummary.js turns that data into a short plain-language paragraph via Claude.
This is a different pattern than choreParser.js/billParser.js: those force a strict schema
with tool use because we needed reliable structured fields out of messy sentences. Here it's the
opposite — the data's already clean and structured, and we want Claude to do what it's naturally
good at: turning numbers into a couple of readable sentences. No tool_choice needed.server/routes/comps.js has two routes: GET /api/comps (read-only — what the page calls,
reads the latest pull from Supabase, never talks to RentCast directly) and
GET /api/comps/refresh (does the actual RentCast pull, only meant to be triggered by the
weekly cron job below).home_value_estimates (one row per pull — this is the "trend over
time" series) and home_comps (one row per comparable property, linked to the pull it came
from). Same RLS-off treatment as chores/bills — no credentials in here.The weekly job: vercel.json now includes a crons entry that hits /api/comps/refresh
every Monday. Vercel Cron Jobs always invoke via GET (not POST), which is why refresh is a GET
route even though it changes data. It's protected by a CRON_SECRET environment variable — set
that one variable in Vercel, and Vercel automatically sends it back as an
Authorization: Bearer <CRON_SECRET> header whenever it triggers the job, so the route can verify
the request actually came from Vercel's scheduler and not a stranger poking the URL. No manual
pairing needed beyond setting the variable. (Cron jobs only run against your production
deployment, and — on Vercel's free Hobby plan — at most once a day, which weekly is well within.)
Why weekly, and when that should change: RentCast's free tier is 50 requests/month; weekly pulls use about 4-5 of those, leaving plenty of headroom. The data itself doesn't really reward checking more often than that this far from listing. Worth increasing to 2-3x/week once you're within a couple months of actually listing (around Q1-Q2 2027), to catch newly-listed competing homes faster while deciding on price and timing.
Comp matching: a ~0.4 mile radius (HOME_COMP_RADIUS_MILES) — the same "neighborhood-standard"
range a real appraiser or listing agent uses for a CMA — and 10 comps per pull
(HOME_COMP_COUNT). The condo is registered as HOME_PROPERTY_TYPE=Condo (not Townhouse) even
though it looks and lives like a rowhouse — it's legally a condominium (HOA, shared entry hallway),
and Chicago prices true townhouses differently, so getting this field right matters for comp
accuracy.
Setup:
RENTCAST_API_KEY=... # from app.rentcast.io — free tier, no credit card needed
HOME_ADDRESS=... # your full street address
CRON_SECRET=... # any random 16+ character string you generate yourself
HOME_PROPERTY_TYPE, HOME_BEDROOMS, HOME_BATHROOMS, HOME_SQUARE_FOOTAGE,
HOME_COMP_RADIUS_MILES, and HOME_COMP_COUNT all have sensible defaults already set in
.env.example — only override them if something changes. Once deployed with these set in Vercel's
Production environment, the first pull happens automatically on the next Monday, or you can
trigger one immediately by visiting /api/comps/refresh with the right Authorization header
(or just temporarily unset CRON_SECRET in .env for one local test run).
Scoped out of v1, on purpose: Cook County's official property sale records were considered as a free way to cross-check RentCast's numbers against actual recorded closings, but their open data doesn't support searching by address/radius directly — it would mean joining PIN numbers across three separate datasets (sales, property characteristics, address lookup). That's a lot of added complexity for what the single RentCast call already covers well. Worth revisiting later.
Adds a /smarthome.html page showing live thermostat status and
control (Resideo), plus shade status pushed in from a Raspberry Pi on
the home network (PowerView). Built in three slices — 8a proved the
Resideo connection read-only, 8b added control, 8c added shades.
Shows each thermostat's current indoor temperature, mode (heat/cool/auto), and setpoint, pulled live from your Resideo (Honeywell Home) account. The first slice of "smart home awareness" — just proving the connection, the same spirit as Stage 1's hello-world.
How it works: server/lib/resideoClient.js talks to Resideo's REST
API directly with fetch() (there's no SDK like googleapis for this
one). server/routes/resideoAuth.js handles the "Connect thermostat"
OAuth handshake — simpler than the Google flow since there's only ever
one household thermostat account to connect, not two people's separate
accounts, so there's no state-based owner routing needed.
server/routes/smarthome.js exposes /api/smarthome/status (connected
or not) and /api/smarthome/thermostats (the live data), refreshing the
access token first if it's expired or about to be.
A real gotcha that shaped resideoTokenStore.js: Google only issues
a new refresh_token the first time you consent, and quietly reuses the
old one after that (see tokenStore.js's merge logic). Resideo does the
opposite — every single exchange or refresh call returns a brand new
refresh_token, and the previous one stops working immediately. So
resideoTokenStore.js always overwrites the stored refresh token rather
than falling back to the old value. Getting this backwards would work
fine for the first ~few minutes (until the access token expires) and
then silently break the connection.
Data model: one new Supabase table, resideo_tokens — a single row
(id = 'household') rather than one-per-person like google_tokens,
since Resideo connects once for the whole house. Same RLS-on,
no-public-policies treatment as google_tokens, since it holds
credentials.
Setup:
RESIDEO_CLIENT_ID=...
RESIDEO_CLIENT_SECRET=...
RESIDEO_REDIRECT_URI=http://localhost:3000/auth/resideo/callback
Register an app at developer.honeywellhome.com
to get the client id/secret. There's no sandbox or simulator — this only
works against a real Resideo thermostat already on your account. Like
Google, the production RESIDEO_REDIRECT_URI needs to point at the live
domain once deployed, added as an environment variable in Vercel.
Adds mode (Off/Heat/Cool/Auto) and setpoint control to the same page —
PATCH /api/smarthome/thermostats/:deviceId, body
{ locationId, mode?, heatSetpoint?, coolSetpoint? }.
A real gotcha: Resideo's control endpoint takes a full
changeableValues object on every write and replaces it wholesale —
sending just { heatSetpoint: 70 } risks the API rejecting the request
or dropping fields you didn't mean to touch (autoChangeoverActive,
etc.). So the route does a read-modify-write: fetchThermostat() pulls
the device's current changeableValues fresh (not whatever the browser
last saw, which could be stale), merges in only the field(s) actually
changing, then submits the merged object. Auto mode needs both
heatSetpoint and coolSetpoint together, which is why the frontend
shows two setpoint fields only when Auto is selected.
Superseded (Oct 2026): Home Assistant on the Pi talks to the PowerView hub now, and agent Benny controls the shades through it. See "Smart home through Home Assistant". The bridge below was never deployed.
Adds a "Shades" section to the same page, showing each shade's position,
tilt (if applicable), and battery status. Unlike the thermostat, this
page never talks to the shade hub directly — Hunter Douglas's PowerView
hub only exposes its API on the home's local network, which a
Vercel-hosted app has no way to reach (unlike Resideo, which is a real
cloud API). Instead, a small Node script in powerview-bridge/ runs on
a Raspberry Pi on the home network, polls the hub, and pushes shade
status into a new Supabase table, powerview_shades — the exact same
shape as the RentCast weekly comps cron, just triggered from a device at
home instead of a Vercel cron job. GET /api/smarthome/shades just
reads whatever the Pi last wrote.
Status: the Pi bridge's hub client was written before a real Gen 3
hub was available to test against — PowerView's Gen 3 local API isn't
officially documented, so the endpoint paths and response shape in
powerview-bridge/hubClient.js are a best guess from community
reverse-engineering, not confirmed. powerview-bridge/README.md has a
npm run discover step specifically for verifying (and fixing, if
needed) that guess against the real hub once it's reachable.
Data model:
create table powerview_shades (
id text primary key,
name text not null,
room_name text,
primary_position integer,
tilt_position integer,
battery_status text,
updated_at timestamptz not null default now()
);
No RLS — same as chores/home_value_estimates/home_comps: it's
device status, not credentials, and only this app's own server and the
Pi bridge (both holding the key privately) ever touch it.
Setup: see powerview-bridge/README.md for the full walkthrough
(finding the hub's address, installing Node on the Pi, the discovery
step, and running it long-term via systemd).
Scoped out, on purpose:
Adds an /advice.html page: ask Benny any question and it answers with
two things — calm, grounded guidance for approaching the question
mindfully (not a direct answer or solution), plus a themed haiku with
the same reflective tone.
This feature has been through two shapes already. It originally returned three playful asides at once on every answer (a magic-eight-ball verdict, a haiku, and an absurd Great British Bake Off "egg wash" non sequitur) — that got noisy fast, so a second revision cut it to one random playful aside plus genuine guidance. After using it for real, the verdict and egg-wash bits didn't add much, and what people actually wanted was the guidance itself, refocused specifically on mindfulness and stress reduction rather than generic "how to think about this." So: gone are the verdict and egg-wash formats; the haiku stays, now always included rather than one-in-three, and now shares the same calm tone as the guidance instead of being "silly, wistful, or dramatic, whatever fits."
How it works: server/lib/adviceGenerator.js uses the same Claude
tool-use pattern as choreParser.js/billParser.js — a strict schema
({ guidance, haiku }) rather than one blob of text to parse. The
guidance field's instructions explicitly ask for real mindfulness/
stress-reduction technique (present-moment awareness, breath, self-
compassion, letting go of urgency) where it genuinely fits the
question — deliberately not forcing a breathing exercise onto something
that's really just a logistics question — and to help the person find
some peace with the question before problem-solving it, not hand them an
answer. The same medical/legal/financial/safety guardrail from the
original version carries over unchanged: for anything serious, gently
point toward a real professional while still helping the person feel
grounded about it. Unlike the extraction-style prompts in
choreParser.js/billParser.js, this one asks for creative output, so
temperature stays turned up to 1 for variety between asks. Same model
as everywhere else in the app (FAST_EXTRACTION_MODEL, Haiku) — no
reason to pay for a bigger model for this. server/routes/advice.js is a
single POST /api/advice route that just passes the { guidance, haiku }
shape through unchanged.
No new setup needed — this reuses the existing ANTHROPIC_API_KEY
from Stage 3. No new Supabase table either: answers aren't persisted,
this is meant to be a fun one-shot, not a record to keep.
The loading state: while Claude is thinking, three small penguins (in
the family colors) do a CSS keyframe waddle instead of a generic spinner
— .hl-penguin/.hl-waddle in public/theme.css.
Scoped out of v1, on purpose:
Adds a "Today" section to the homepage: a merged, at-a-glance view of
today's calendar events (both owners) and today's due-or-overdue open
chores — so the two lists that used to live on separate pages (/calendar.html,
/chores.html) finally answer "what's actually on my plate today" as one
glance instead of two page visits.
How it works: GET /api/digest (server/routes/digest.js) does pure
data-joining, no Claude involved — unlike chores/bills/advice, there's
nothing here for a language model to extract or generate. It reuses
fetchEventsForOwner() from calendar.js (now exported, and given a
maxResults option) rather than duplicating the Google Calendar call,
and a plain Supabase query — completed = false AND due_date <= today —
for chores, which naturally also picks up anything overdue (and just as
naturally excludes chores with no due date at all, since NULL <= anything
is never true in Postgres).
A real gotcha: the household is in Chicago, but Vercel runs
serverless functions in UTC — computing "today" from the server's own
clock would flip the digest over to tomorrow several hours before it
actually is locally. localDateString() in digest.js pins "today" to
America/Chicago explicitly (Intl/toLocaleDateString, no date
library needed) rather than trusting the server's own timezone.
Another one, found while testing this locally: a broken or expired
Google connection (an invalid_grant from a stale refresh token, in this
case) used to take down the entire digest, chores included, even
though chores have nothing to do with Google. Fixed by catching each
owner's calendar fetch independently inside digest.js and treating a
failed fetch the same way fetchEventsForOwner() already treats "not
connected yet" — contributes zero events, doesn't block the chores half.
This is specifically about the digest's own resilience; it does not fix
whatever is actually wrong with the stored Google token (see /calendar.html
if that page is also showing a calendar error — reconnecting there is the
real fix).
No new setup, no new table — reads from the two things Benny can
already see (google_tokens indirectly, via the existing calendar code
path, and chores).
Scoped out of v1, on purpose:
Adds a real "Add to Home Screen" experience on phones: a proper penguin icon instead of a generic globe/browser icon, and — when launched from the home screen — a standalone window (no browser address bar) themed to match Harbor Lights, instead of just a bookmark that reopens Chrome/Safari.
What was added:
public/icons/icon-{180,192,512}.png — the existing penguin mark,
rasterized onto a solid #211f2b square (Harbor Lights' --hl-bg-elev)
with generous padding, so Android's circular/squircle mask and iOS's
own rounded-corner mask don't clip it. Deliberately not pre-rounded —
the OS applies its own shape, and a doubly-rounded icon looks wrong.public/manifest.json — name, icons, display: "standalone", and
background_color/theme_color matching the app's dark theme.<head> now links the manifest and icons and sets
apple-mobile-web-app-* meta tags. This has to be on every page, not
just the homepage — iOS reads these from whichever page is open at the
moment someone taps "Add to Home Screen," not from index.html
specifically, so a page missing them would launch as a plain browser
bookmark instead of a themed standalone app.How the icons were generated: the project has no image-processing
dependency (and didn't need one for this) — the penguin SVG was rendered
onto an in-browser <canvas> at each size and posted to a one-off, since-
removed dev route that wrote the PNG bytes straight to disk. Avoids both
adding a native image dependency for a one-time task and hand-copying
large base64 strings through chat (error-prone at that length).
Setup: none — no new environment variables, no new Supabase table.
Adds a "Kitchen appliances" section to the Smart Home page: connection status for the Thermador range hood and the Bosch dishwasher. Both run through Home Connect, BSH's shared platform for Bosch/Siemens/ Gaggenau/Thermador appliances — one integration covers both, the same way one Resideo connection already covers thermostats.
How it works: server/lib/homeConnectClient.js mirrors
resideoClient.js's shape closely (no SDK, plain fetch()), with two
real differences from Resideo worth knowing:
client_id/
client_secret as regular POST body fields, not HTTP Basic auth like
Resideo.Accept: application/vnd.bsh.sdk.v1+json
header — a plain application/json Accept header gets rejected, and
nothing about the OAuth flow itself hints that this is needed.server/lib/homeConnectTokenStore.js and server/routes/homeConnectAuth.js
follow resideoTokenStore.js/resideoAuth.js exactly — one household
row (id = 'household'), no per-person state. New Supabase table,
home_connect_tokens, same RLS-on/no-public-policies treatment as
resideo_tokens.
This slice deliberately stops at status, not control — same progression as Stage 8a before Stage 8b added thermostat control. Two things aren't nailed down yet and are worth confirming before building control on top of them:
homeConnectClient.js requests
IdentifyAppliance Hood-Control Hood-Settings Dishwasher-Control Dishwasher-Settings Dishwasher-Monitor, based on Home Connect's public
docs, not verified against a real registered app yet. The authorize
call only ever grants whatever subset was selected when the OAuth
client was registered at developer.home-connect.com, so this needs a
real connection to confirm.homeConnectTokenStore.js currently
assumes Home Connect behaves like Google (keeps the same refresh token
across refreshes) rather than like Resideo (issues a new one every
time, invalidating the old one). Worth confirming empirically once
tokens are actually flowing — get this wrong in the Resideo direction
and the stored refresh token would silently go stale.A real advantage over Resideo: Home Connect runs a full simulator at
https://simulator.home-connect.com — set HOMECONNECT_API_BASE to it
to prove the whole OAuth + status flow works before testing against
the real hood and dishwasher. Resideo has no equivalent; this was the
first slice of "smart home awareness" that could be dry-run without
real hardware on the line.
Setup:
HOMECONNECT_CLIENT_ID=...
HOMECONNECT_CLIENT_SECRET=...
HOMECONNECT_REDIRECT_URI=http://localhost:3000/auth/homeconnect/callback
Register an app at developer.home-connect.com to get the client id/secret — same shape as the Resideo registration in Stage 8a. Both the hood and dishwasher need to already be paired in the Home Connect app itself first; this API only reads appliances that are already set up there, it doesn't pair new ones.
Found while building this, unrelated to Home Connect: the
powerview_shades table documented in Stage 8c as already created
doesn't actually exist in Supabase — /api/smarthome/shades fails with
"Could not find the table." The PowerView bridge code and README both
assume it's there; it needs to actually be created before that part of
Stage 8c can work. Tracked as an open item, not fixed here.
Adds a "New event" box to /calendar.html: type something like "dinner
with the Hansens Friday at 7," review a plain-language preview of what
Benny understood, then confirm before it actually lands on a real
calendar. Benny can now create events, not just display them.
Why the preview/confirm step, when chores don't have one: a chore
that's slightly wrong is easy to fix or ignore. A wrong event lands on a
calendar you and Mer both see, possibly with the wrong date, time, or
person's calendar — a worse class of mistake, so this feature gets a
review step chores never needed. POST /api/calendar/events/parse only
ever reads the sentence (via the same Claude tool-use pattern as
choreParser.js) and returns a draft — nothing touches Google until a
separate POST /api/calendar/events call, sent only after the person
clicks "Add to calendar" on the exact preview they saw.
Scope change, so everyone needs to reconnect: Benny requested
calendar.readonly only until now. Creating events needs
calendar.events too (requested alongside readonly, not instead of it —
see server/lib/googleClient.js). Existing connections don't
automatically gain the new permission; both Michael and Mer need to
click "Connect ___'s calendar" again on /calendar.html to re-consent.
Confirmed locally: an event created against an old, readonly-only
connection fails cleanly with "Insufficient Permission" rather than
silently doing nothing or crashing — reconnecting is what fixes it.
A real gotcha, sidestepped rather than solved: getting a wall-clock
time like "7pm" onto the right UTC instant normally means computing a
timezone offset, including DST. Google's Calendar API accepts a
timezone-naive dateTime plus a separate IANA timeZone field (e.g.
America/Chicago) instead of a full offset — Benny hands over the local
time exactly as typed and lets Google resolve it, sidestepping the DST
math entirely rather than getting it wrong in a subtle way once a year.
Setup: none beyond reconnecting — same GOOGLE_CLIENT_ID/
GOOGLE_CLIENT_SECRET as Stage 2, no new environment variables.
Scoped out of v1, on purpose:
Adds /gmail.html: a per-owner (not merged — email is personal in a way
a shared household calendar isn't) view of recent inbox messages, plus a
"create a test draft" form. The decision behind this stage: Benny gets
full read access, but is only ever allowed to create drafts, never
send. That's enforced in the code, not just as a scope choice —
server/lib/gmailClient.js has no function that calls Gmail's send
endpoint at all, only drafts.create. There is no path from Benny to a
sent email; a person always opens the draft in Gmail and sends it
themselves.
Reuses Calendar's connection — Gmail's scopes are requested
alongside calendar.readonly/calendar.events in the same Google OAuth
client, so there's no separate "Connect Gmail" flow or token table; it's
the same google_tokens rows Calendar already uses, just with a wider
scope grant once reconnected.
Refactored while building this: authenticatedClientFor() and
clearIfDeadToken() (the self-healing dead-token logic from the
calendar/chores digest work) moved from calendar.js into
googleClient.js, since both are generic to "any Google API call using
a household member's stored tokens" — Gmail needed the exact same
plumbing Calendar already had, so this avoided a second copy of it.
Status: fully wired up, waiting on Michael and Mer to reconnect. Verified locally against the real Gmail API (before the scopes existed) that both the read and draft-create paths work correctly end to end, failing cleanly with "Insufficient Permission" against the existing calendar-only-scoped tokens — exactly the expected state before the scope is actually granted. What it took to get here:
benny-app
— Google began enforcing this platform-wide on September 21, 2026,
for any new API enablement, not something specific to this project.gmail.compose.auth.js's authorize call alongside Calendar's,
so one reconnect grants everything (done — see googleClient.js).The scope actually used is narrower than originally planned.
gmail.compose ("Manage drafts and send emails," per Google's own
description) was the obvious choice, but Google Cloud Console's scope
picker surfaced gmail.drafts.create ("Compose new draft emails") —
exactly, and only, what gmailClient.js actually does. Used that
instead: GMAIL_DRAFTS_CREATE_SCOPE in googleClient.js, not
GMAIL_COMPOSE_SCOPE. Requesting the exact scope the code exercises,
when one exists, beats requesting a broader one "to be safe" — the
draft-only guarantee doesn't depend on Benny's own restraint alone
anymore, since the OAuth grant itself no longer includes send/delete
either way.
One thing worth knowing for later: Google Cloud Console classifies
gmail.readonly as a restricted scope (its highest sensitivity
tier — "highly sensitive user data"), one step above the "sensitive"
tier Calendar's scopes and gmail.drafts.create sit in. This doesn't
block anything while the app stays in "Testing" publishing status with
just the two of you as test users, but the "move to In production"
plan discussed for fixing Calendar's 7-day-refresh-token issue (see the
roadmap) may need a closer look at what restricted scopes require for
that transition — possibly more than the sensitive-scope-only
assumption that plan was based on.
Setup: none beyond the reconnect above — same GOOGLE_CLIENT_ID/
GOOGLE_CLIENT_SECRET as Calendar, no new environment variables, no new
Supabase table.
Scoped out of v1, on purpose:
Stage 14 originally included a "recent messages" list on the Gmail page,
reading straight from fetchRecentMessages(). Michael clarified after
using it: the ask was for Benny to have Gmail access for future
automation, not to display any inbox content in the app. This stage
walks that back.
What changed:
/drafts.html, was /gmail.html) no longer calls
fetchRecentMessages() at all. That function and the underlying
gmail.readonly scope are still there — kept specifically for future
server-side automation (an agent reading a vendor's reply, say) — but
nothing in the UI reads or displays inbox content anymore.gmail_drafts — RLS on, no public policies, same treatment as
chores/bills) rather than a live Gmail API call. POST /api/gmail/drafts records { id, owner, to_address, subject, created_at } there right after Gmail confirms the draft was created.
This is a deliberately stronger guarantee than "the UI just doesn't
call that endpoint" — even if something in a future feature did list
every draft in the mailbox, this page still only shows what's in
Benny's own table, never anything a person drafted themselves.mail.google.com/mail/u/0/#drafts) rather than deep-linking a
specific draft — Gmail doesn't have a documented, stable URL format
for that, so this doesn't try to be clever about it.Setup: none beyond what Stage 14 already required.
Adds /timeline.html with two toggled views, replacing the idea of one
"calendar+chores" view with two purpose-built ones — a scrolling
chronological list for "what's coming up," and a compact grid for "what
does the next month and a half look like."
Timeline view: open chores (including anything overdue, no matter
how late — an unresolved chore doesn't age out) merged chronologically
with timed calendar events only. All-day events are deliberately
excluded here — they don't have a specific time to sort by, and mixing
them into a time-ordered list would misrepresent them as happening at a
particular moment. New route: GET /api/timeline, distinct from
/api/digest (Stage 10) — the digest stays today-only for the homepage
glance; this is the fuller 60-day-ahead view for its own page.
Month view: a rolling 6-week grid — one week before the current
week, the current week, and four weeks ahead (42 days, Sunday-start).
Each day shows all-day event titles as text (expanded across every day
a multi-day event spans, not just its start date) and a small colored
dot per owner who has at least one timed event that day — presence
only, not details, matching how compact this view is meant to be. New
route: GET /api/calendar/month.
Shared plumbing: calendar.js's event-fetching was refactored —
fetchEventsForOwner() (next N upcoming, used by /calendar.html and
the homepage digest) and the new fetchEventsInRange() (everything in
an explicit date window, used by both new views here) now share one
listEvents() call underneath instead of duplicating the Google API
call and event-shaping logic.
A real gotcha, caught before it shipped: converting an all-day
event's date (e.g. "2026-09-25", no time component) through
new Date(...) and a timezone-aware formatter is exactly the bug
chores.js's formatDueDate() already has a comment warning about —
UTC midnight on that date, converted to Chicago time, lands on the
previous calendar day. The month grid avoids this entirely for
all-day events by using Google's date string directly rather than
round-tripping it through Date; only timed events (which carry a real
instant, not just a date) go through the timezone conversion.
Verified against real calendar data: cross-checked the month grid's
placement of a yearly-recurring event against the already-proven
/api/calendar/events endpoint to confirm no off-by-one day error, and
confirmed the timeline correctly excludes all 4 of the household's
current all-day events (two birthdays, an anniversary, a multi-day
vacation) while including all of its timed ones. Checked at mobile
width (375px) too — dense but legible, matching how real calendar apps
handle the same 7-column tradeoff.
Setup: none — no new environment variables, no new Supabase table,
reads from the same Google connection and chores table everything
else already uses.
Scoped out of v1, on purpose:
Until this stage, Benny had no login at all: every /api route was open
to anyone who knew the Vercel URL — adding chores, creating real Google
Calendar events, creating Gmail drafts, spending Claude credits. It's
also the prerequisite for the agent bridge (Stage 18): there's no point
giving agent Benny a scoped token while the front door is unlocked.
How it works:
/login.html asks for an email, and
POST /auth/login has Supabase send a one-time code plus a link.
Typing the code (POST /auth/verify) or tapping the link
(GET /auth/confirm) finishes sign-in. The code matters on phones:
the home-screen app keeps its own cookies, separate from the phone's
browser, so a tapped link would sign in the browser, not the app.@supabase/ssr
(server/lib/authSession.js). Cookies, not a bearer token, because
"Connect Michael's calendar" and the other OAuth buttons are plain
browser navigations, which can't carry an Authorization header. The
cookie rides along on those as well as on every fetch('/api/...').requireUser middleware guards every /api route and every
/auth route except sign-in itself. Signed-out API calls get a 401;
signed-out navigations (e.g. /auth/google/michael) redirect to the
login page and come back afterward. The one public API route is
/api/comps/refresh, which Vercel's cron calls with no session and
which checks CRON_SECRET itself.ALLOWED_EMAILS (comma-separated) is the list of who may sign in,
checked both before an email is sent and after sign-in. If it's unset,
nobody gets in: it fails closed, not open.PERSON_EMAILS (Oct 2026, michael:…,mer:…) says which of us each
of those emails is. To-Do uses it to name who ticked a to-do off and
who added it (server/lib/people.js); without it, it names nobody.public/*.html directly, so
the server can't gate the HTML itself, but nothing sensitive lives in
it. public/auth-guard.js (loaded first on every page except login and
privacy) calls /api/me on load and sends you to /login.html on any
401. The homepage has a Sign out button./api 401, "this integration isn't connected yet" (calendar,
Gmail, thermostat, kitchen appliances) is a 424 instead, and the
pages check for 424 to show their connect state.One-time setup (only you can do these, in the Supabase dashboard):
shouldCreateUser: false, so this is a second lock.https://benny-penguin-palace.vercel.app. Add these redirect URLs:
https://benny-penguin-palace.vercel.app/auth/confirm and
http://localhost:3000/auth/confirm. If the redirect isn't listed,
Supabase quietly swaps in the Site URL and the email's link breaks.<h2>Sign in to Benny 🐧</h2>
<p>Your code: <strong>{{ .Token }}</strong></p>
<p>Or <a href="{{ .RedirectTo }}?token_hash={{ .TokenHash }}&type=email">tap here to sign in</a>.</p>
ALLOWED_EMAILS
(both emails, comma-separated). Add it to your local .env too.
Do this before deploying Stage 17, or nobody can sign in.Gotchas:
app.set('trust proxy', true) in index.js is what makes the email
link say https:// on Vercel. Without it, Express only sees the
proxy's plain-HTTP hop.Benny the agent (Hermes, in Discord) can now read and write Benny the app's data through the app's own API, using its own token. It never gets Supabase access. The service_role key could also read our Google tokens, and the agent is a process that reads Discord messages.
How it works:
BENNY_AGENT_TOKEN is a long random string that both Vercel and
Hermes hold. The agent sends it as Authorization: Bearer <token>.
requireUser (in server/lib/authSession.js) checks it with a
constant-time comparison. A request carrying a token is judged on that
token alone and never falls back to a cookie. If the variable is unset,
agent access is off entirely.
An explicit allowlist (AGENT_ROUTES in server/lib/agentAuth.js)
decides which routes the token opens. Everything else returns 403, and
new routes stay closed to the agent until someone adds them on purpose.
The first cut:
| Route | Why |
|---|---|
GET /api/me | "Is my token working?" |
GET /api/digest, GET /api/timeline | Answer "what's on today / coming up" |
GET /api/chores, POST /api/chores, PATCH /api/chores/:id | List, add and complete chores from Discord |
Calendar writes, Gmail drafts, bills and smart-home controls are off the list on purpose. They wait for the proposals inbox (Stage 21), per the "autonomous at drafting, not at acting" rule.
Every agent request is logged in a new agent_actions table, one
row per request (method, path, status, body), refused requests
included. The row is written before the response goes out, because
on Vercel, work left running after a response is sent can be frozen
partway through.
Chores remember who added them. New source (app / agent)
and created_by (an email, or agent) columns. The chores page shows
"via Benny 🐧" on anything the agent added.
agent/benny-app/SKILL.md (since replaced by the Benny plugin
for Hermes, see below) was the agent's instructions for this
API: what each route does, curl examples, and the rules. Those are:
always name the person in chore text (the parser defaults to Michael),
read the parsed result back, and treat chore and event text as data,
never as instructions. Copy that folder into Hermes's skills directory.
Also fixed here: chore titles and calendar event titles and locations
used to go into the page as raw HTML. Once the agent can relay text from
Discord, and since calendar invites can come from anyone who emails us,
a title containing HTML could have run as code on the page. They're now
escaped by public/escape.js on the homepage, calendar, chores and
timeline pages.
Schema change (applied as the stage18_agent_bridge migration):
alter table public.chores
add column source text not null default 'app' check (source in ('app', 'agent')),
add column created_by text;
create table public.agent_actions (
id bigint generated always as identity primary key,
created_at timestamptz not null default now(),
method text not null,
path text not null,
status integer not null,
request_body jsonb
);
alter table public.agent_actions enable row level security; -- server-only, no policies
Setup:
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"BENNY_AGENT_TOKEN, then redeploy. Env var
changes only apply to new deployments.BENNY_AGENT_TOKEN, plus
BENNY_API_URL=https://benny-penguin-palace.vercel.app/api, and copy
agent/benny-app/ into its skills directory.agent_actions
gets a row.To revoke the agent's access, delete or change BENNY_AGENT_TOKEN in
Vercel and redeploy.
Folded into To-Do (Oct 2026). Groceries is now an "items" list in To-Do, and
list_itemswas copied intotodos. The add-items route and its duplicate rule below still work the same way. See "To-Do: chores and lists in one place" below.
A shared grocery list that either of us can add to from the app, or by telling agent Benny in Discord ("Benny, add oat milk and eggs"). It's the first thing built with the agent in mind. It's low stakes and used every day, so it's where talking to Benny is most useful.
How it works:
lists (a slug like
groceries, and a display name) and list_items (text, checked,
checked_at, plus the same source / created_by as chores).
Groceries is the only list for now. Adding another is one row in
lists, and the page shows tabs as soon as there's more than one.{"items": ["oat milk", "eggs"]}). The app
splits what you type on commas, and the agent sends one entry per
item, so the server never guesses where one item ends.visibilitychange), so switching back to Benny mid-shop picks up
whatever the other person or the agent added. It isn't live-updating
beyond that.Routes (server/routes/lists.js):
| Route | Agent? |
|---|---|
GET /api/lists: every list, with its open-item count | ✅ |
GET /api/lists/:slug/items | ✅ |
POST /api/lists/:slug/items: body { items: [...] } | ✅ |
PATCH /api/list-items/:id: body { checked } | ✅ |
DELETE /api/lists/:slug/items/checked | ❌ app only |
agent/benny-app/SKILL.md has a new "Shared lists" section for Hermes:
one array entry per item, report skipped duplicates, default to
groceries, and read items back before a bulk "got everything"
check-off. Re-copy it into Hermes's skills folder whenever it changes.
Schema change (applied as the stage19_shared_lists migration):
create table public.lists (
id uuid primary key default gen_random_uuid(),
slug text not null unique check (slug ~ '^[a-z0-9-]+$'),
name text not null,
created_at timestamptz not null default now()
);
create table public.list_items (
id uuid primary key default gen_random_uuid(),
list_id uuid not null references public.lists(id) on delete cascade,
text text not null check (length(trim(text)) > 0),
checked boolean not null default false,
checked_at timestamptz,
source text not null default 'app' check (source in ('app', 'agent')),
created_by text,
created_at timestamptz not null default now()
);
create index list_items_list_id_idx on public.list_items (list_id);
alter table public.lists enable row level security; -- server-only, no policies
alter table public.list_items enable row level security; -- server-only, no policies
insert into public.lists (slug, name) values ('groceries', 'Groceries');
Every morning, agent Benny posts a short summary to our Discord home channel. It covers weather, today's events for both of us, chores due or overdue, and bills coming due. The app assembles the data and the agent writes the message, so the app stays the data layer and the agent does the talking.
What changed in the app:
GET /api/digest gained two fields. weather has today's
summary, high/low in °F, and chance of precipitation. bills_due has
active recurring bills due in the next 7 days, today included. Both
are fetched alongside the calendar, and a failure just leaves that
piece out (null / []), the same way a broken Google connection
already worked. The homepage Today card shows both.server/lib/weatherClient.js),
which is free and needs no key or account. It uses HOME_LATITUDE /
HOME_LONGITUDE if set, otherwise downtown Chicago. These only need
to be neighborhood-close, not the house itself.recurring_bills table: name, amount
(blank for bills that vary), day of month due, autopay, and
active/paused. They're managed in a new "Recurring — what's due when"
section on the Bills page (/api/recurring-bills, app-only, not on the
agent's allowlist). This is separate from the existing bills log, which
records what each bill was for the analyzer. This records what comes
due when.server/lib/recurringBills.js. A bill due on
the 31st lands on the last day of shorter months, so it's never
skipped. Everything is computed from "today" in Chicago, using plain
date strings, so Vercel's UTC clock can't shift a due date by a day.The schedule lives in Hermes, not Vercel. It's a Hermes cron job (see
setup below) that fires at 7:00 Central and runs agent Benny with the
benny-app skill. The skill's "morning brief" section says how to write
the message: weather first, bills not on autopay that are due soon made
impossible to miss, no filler. Hermes ran on the laptop, and the brief
never once posted at 7:00: every run was a catch-up hours later, when
the laptop woke. Hermes moved to the Raspberry Pi on Oct 9, 2026, and
the app took over writing the brief (see Stage 20b).
Schema change (applied as the stage20_recurring_bills migration):
create table public.recurring_bills (
id uuid primary key default gen_random_uuid(),
name text not null check (length(trim(name)) > 0),
amount numeric(10, 2) check (amount is null or amount >= 0),
due_day integer not null check (due_day between 1 and 31),
autopay boolean not null default false,
active boolean not null default true,
created_at timestamptz not null default now()
);
alter table public.recurring_bills enable row level security; -- server-only, no policies
Setup:
HOME_LATITUDE / HOME_LONGITUDE in Vercel for a
closer forecast.hermes cron create "0 7 * * *" "Post the Penguin Palace morning brief: use the benny-app skill, call GET /digest, and write it up following the skill's morning brief section." --name "Morning brief" --skill benny-app --deliver discord
Stage 20b replaces this with a job that runs no model.Agent Benny used to write the brief: a full Sonnet run every morning, about $0.10 each, mostly spent loading the agent's ~17K-token setup just to reword JSON the app had already assembled. Now the app writes the brief and Hermes only delivers it, so it costs nothing and comes out the same way every day.
How it works:
GET /api/digest/brief returns the brief as finished Discord text
(server/lib/morningBrief.js), built from the same data as
/api/digest. It follows the rules the agent did: a bill that isn't
on autopay and is due today or tomorrow goes at the very top, then
weather (an umbrella only at 50%+), today's events by time, chores
with overdue ones first, then the rest of the week's bills. Empty
sections are left out. At most 8 chores are listed, with "…and N more
in Benny" after that. It's on the agent's allowlist.@everyone / <@id> mentions are broken so a title can't ping the
server.agent/scripts/morning-brief.sh
fetches the brief and Hermes posts whatever it prints, with no model
involved. If the fetch fails, the script exits non-zero and Hermes
posts a failure notice rather than a blank brief.Date, which is UTC midnight,
and that's still the previous day in Chicago. All-day events now
compare as plain dates and show on every day they cover. This also
fixes the homepage's Today card.The skill's "morning brief" section now just says to pass
/digest/brief along as-is when someone asks for today's rundown.
Setup (on the Pi):
ssh pi 'mkdir -p ~/.hermes/scripts'
scp agent/scripts/morning-brief.sh pi:~/.hermes/scripts/morning-brief.sh
6c657c92b7a2) to the script, with no model:
ssh pi 'chmod +x ~/.hermes/scripts/morning-brief.sh && ~/.local/bin/hermes cron edit 6c657c92b7a2 --script morning-brief.sh --no-agent --clear-skills'
hermes cron run 6c657c92b7a2, which posts it on the
next scheduler tick (or wait for 7:00).Agent Benny can now ask for things that reach outside the app, like a calendar event or an email draft, without being able to do them itself. It proposes one, a "Benny suggests" card shows up at the top of the homepage, and nothing reaches Google until Michael or Mer taps Approve. This is the "autonomous at drafting, not at acting" rule from the agent plan, made real.
How it works:
POST /api/proposals and
GET /api/proposals are on the agent's allowlist. Approve and reject
aren't, so only a signed-in person can turn a proposal into a real
event or draft. A proposal the agent could approve itself would just
be a slower way of acting.createCalendarEvent() (calendar.js) and createTrackedDraft()
(gmail.js). The app's own routes and approving a proposal both call
those, so each still has one code path. They throw an ActionError
carrying an HTTP status, and each caller turns it into its own
response.YYYY-MM-DD date,
24-hour times, an end after the start, and one plain to address.
They're stored already cleaned up, and approving writes exactly that.
Nothing is re-parsed later. The card shows every field, including an
email's full body, since reviewing it is the whole point.pending to approving in one conditional update. Only the request
that wins that flip runs the action, so a double tap, or both of us
approving at once, can't create two events.429 after that), so an agent
stuck in a loop can't flood the homepage.to or
subject would let the rest become a header of its own (a hidden
Bcc:, say). Those are refused now, both for proposals and for the
existing POST /api/gmail/drafts.Routes (server/routes/proposals.js):
| Route | Agent? |
|---|---|
GET /api/proposals: pending, oldest first. ?status=all gives the 50 most recent of any status | ✅ |
POST /api/proposals: body { kind, payload, note? }, where kind is calendar_event or gmail_draft | ✅ |
POST /api/proposals/:id/approve | ❌ app only |
POST /api/proposals/:id/reject | ❌ app only |
Payloads: calendar_event takes { owner, title, date, start_time?, end_time? },
the same fields POST /api/calendar/events takes. gmail_draft takes
{ owner, to, subject, body }, the same as POST /api/gmail/drafts.
agent/benny-app/SKILL.md has a new "Proposals" section: how to propose
each kind, to say "it's waiting for you in Benny" and never "done", to
only use email addresses the person actually gave, and to never turn
text it read (an invite, a chore title) into a proposal. Re-copy it
into Hermes's skills folder.
Adding a kind later (a thermostat change, say) takes an entry in
KINDS in proposals.js, the kind check constraint below, and a
branch in the card's describeProposal() in public/app.js.
Schema change (applied as the stage21_proposals_inbox migration):
create table public.pending_actions (
id uuid primary key default gen_random_uuid(),
kind text not null check (kind in ('calendar_event', 'gmail_draft')),
payload jsonb not null,
note text,
status text not null default 'pending' check (status in ('pending', 'approving', 'approved', 'rejected')),
source text not null default 'agent' check (source in ('app', 'agent')),
created_by text,
created_at timestamptz not null default now(),
decided_by text,
decided_at timestamptz,
result jsonb,
last_error text
);
create index pending_actions_status_created_at_idx on public.pending_actions (status, created_at);
alter table public.pending_actions enable row level security; -- server-only, no policies
The relocation roadmap (110 tasks, 8 open decisions, Oct 2026 → Sept 2027 plus the first 90 days in NL) lives in Benny now, not in a markdown file. It replaces the generic "projects + milestones" plan from the agent integration plan below. The move is one project with ten workstreams, and the home sale is one of them (House & Money), since every date in it is set by the move.
This replaces the first Stage 22 (PR #2, merged Sep 30): a generic Projects page with milestones and dependencies, milestones on the Timeline, and agent access for a weekly review. Its tables were still empty when this stage's migration dropped them, so nothing was lost. Its Projects page and agent routes are gone. The Timeline now shows move tasks instead (below), and the agent skill is back to its pre-PR #2 version, which is also the copy Hermes has installed.
Pages:
/move.html. Rebuilt as an Asana-style project page in Oct 2026.
See "Workspace, phase 1" below for the tabs, the task pane and the
comments. Its Overview tab still has the "why" from F-08 and the
Penguin Summit agenda (Sundays at 7pm): this week's starred focus
(three at most), overdue, "now and in the next 30 days", waiting on
someone, done in the last 7 days, and decisions coming up./log.html redirects there.GET /timeline sees these too; it's the only
move data it can read.How it works:
move_key_date) and is
logged.Where decisions get recorded (decided Oct 2, 2026). The household's
decisions and approvals go in the app (/log.html). They're already rows
in the database, both of us can see them, and recording one doesn't need a
code change. This README keeps decisions about how the app is built, like
the agent rules below.
Agent rules for the move (agreed Oct 2, 2026). These take effect when the agent gets access to the move data:
For now these routes are app-only: none are on the agent's allowlist. Agent calls are billed per use and the app does the job, so the agent waits until there's a reason.
Changes made to the roadmap when importing it (all listed in
scripts/import-roadmap.js):
Setup (once):
scripts/stage22_move_tracker.sql as the migration
stage22_move_tracker, using the Supabase SQL editor or apply_migration.
It replaces the unused tables from the first stage22_projects_module
migration and removes the separate home-sale project row.node scripts/import-roadmap.js path/to/netherlands-move-roadmap.md --dry-run
node scripts/import-roadmap.js path/to/netherlands-move-roadmap.md
Re-running it is safe: it skips anything that already exists and never
overwrites edits made in the app.Routes (server/routes/projects.js, all app-only):
| Route | Does |
|---|---|
GET /api/projects/:slug | Everything, plus the Summit agenda as lists of ids |
GET /api/projects/:slug/summary | Countdown, focus, overdue, next 30 days, decisions due (the homepage card) |
PATCH /api/projects/:slug | { why } |
POST /api/projects/:slug/tasks | { workstream, title, owner?, lead?, start_date?, due_date?, ... }. Gets the workstream's next code, e.g. L-07 |
PATCH /api/tasks/:id | Any task field. Marking a repeating task done rolls it forward (rolled: true) |
PATCH /api/workstreams/:id | { lead } |
PATCH /api/key-dates/:id | { date, dry_run? }. The dry run lists what would move |
PATCH /api/decisions/:id | Record (status: 'decided', outcome, decided_by, key_date?), reopen, or edit options / notes / decide_by |
GET /api/log | The decision log |
Schema: scripts/stage22_move_tracker.sql. It adds workstreams,
tasks, key_dates, decision_inputs and key_date_changes, adds
code, decide_by, options, notes and key_date_id to decisions,
adds why to projects, and creates the move_key_date() function
(service role only). Every new table has RLS on with no policies, so it's
server-only like the rest.
Not built yet: agent access (see the rules above), a documents tracker (apostilles start in December), a Summit-prep post in Discord, and exporting the plan as a Mermaid Gantt chart.
Agent Benny used to reach the app by reading the benny-app skill and
writing curl commands in the Pi's terminal. Now a Hermes plugin
(agent/benny-plugin/) gives it typed tools instead, and the
terminal is off Discord and Telegram entirely.
Why:
.env; it isn't in any command Benny writes, so it can't end up in a
reply.YYYY-MM-DD, times HH:MM), and
results come back trimmed to the fields Benny needs, with a short
reminder where there's a rule ("read it back", "it's waiting for
approval, not done").Who's asking. Hermes's session knows which Discord or Telegram
account sent the message. The plugin maps it to Michael or Mer with
BENNY_PEOPLE in the Pi's .env (kept out of this repo, which is
public), e.g. discord:<id>=michael,discord:<id>=mer,telegram:<id>=michael.
The model can't change it, so:
BENNY_PEOPLE, or a Hermes update that moves the session module),
anything tied to one person is refused rather than guessed.Tools (all in the benny toolset): benny_today (the brief, or the
digest as data), benny_upcoming, benny_todos, benny_add_todo,
benny_add_to_list, benny_set_todo_done (these four replaced the five
chore and list tools in Oct 2026, see "To-Do"), benny_search_email, benny_read_email,
benny_read_attachment (see below), benny_pets, benny_log_pet (see "Stage 23: Pet care"), benny_home, benny_home_control
(see "Smart home through Home Assistant" below),
benny_propose_event, benny_propose_email_draft,
benny_proposals. Apart from the email and smart-home tools, they call
the same allowlisted routes as before, so the app needed no changes for
them, and everything is still in agent_actions. The smart-home tools go
to Home Assistant on the Pi instead of the app.
Tests: python -m unittest discover -s agent/benny-plugin/tests -v
runs them against a fake app on localhost (standard library only).
Setup on the Pi (done Oct 9, 2026):
tar --exclude=__pycache__ -C agent -cf - benny-plugin | ssh pi 'mkdir -p ~/.hermes/plugins/benny && tar -xf - -C ~/.hermes/plugins/benny --strip-components=1'
ssh pi '~/.local/bin/hermes plugins doctor ~/.hermes/plugins/benny && ~/.local/bin/hermes plugins enable benny'
Then add BENNY_PEOPLE to ~/.hermes/.env, and in config.yaml:
platform_toolsets: Discord [web, benny, skills, memory, clarify, vision, cronjob, discord],
Telegram the same without discord. No terminal.tools.tool_search.enabled: off. Hermes otherwise shows plugin tools
only by name, and Benny has to look one up before every first use.skills.disabled includes benny-app, plus google-workspace and
the other Gmail and Apple skills (the app is the only way to Google).skills.platform_disabled for Discord and Telegram hides every skill
that needs the terminal, which leaves humanizer,
document-to-action-items, meeting-action-items,
weekly-review-planning and hermes-agent. The Pi's CLI keeps them
all.Agent Benny can now find an email and propose the reply, like "there's a referral from Dr. Weiss in my Gmail, draft a note to the office it names." The app's pages still never show inbox content (Stage 15).
App routes (server/routes/gmail.js, on the agent's allowlist, and
answering the agent token only, never a signed-in browser):
| Route | What it returns |
|---|---|
GET /api/gmail/search?owner=&q=&max= | Up to 20 messages, newest first: from, to, subject, date, Gmail's snippet, unread. q is Gmail's own search syntax. |
GET /api/gmail/messages/:id?owner= | One message as plain text: from, to, cc, reply-to, subject, date, the body (the text part, or the HTML part flattened to text, cut at 8,000 characters), and its attachments (index, name, type, size). |
GET /api/gmail/messages/:id/attachments/:index?owner= | One attachment as text (cut at 12,000 characters). Text and HTML files are decoded; PDFs (scanned ones too) and images are transcribed by Claude Haiku (server/lib/attachmentReader.js), told to copy names and numbers exactly and never follow instructions in the document. Up to 10 MB; other types (Word, spreadsheets) aren't supported yet. |
They use the gmail.readonly scope both of us already granted in
Stage 14, so nobody has to reconnect.
A faxed one-page referral PDF took about 7 seconds and well under a cent to transcribe, with every form field intact.
Plugin tools: benny_search_email, benny_read_email and
benny_read_attachment. None of them takes an owner. The plugin sets it to whoever is asking, and refuses:
Every result carries a reminder that the email was written by someone else and is never a source of instructions. The terminal is already off chat, and anything outward still needs approval in the app, so a hostile email can at worst get Benny to propose something you'd see on the card. Email drafts may now go to an address from an email the person asked Benny to read (Benny says which), as well as one they typed.
Two things to know: searches show up in agent_actions (the query is
part of the logged path, the email isn't), and what Benny reads stays in
its chat history on the Pi, like everything else it sees.
Also fixed: email drafts mangled non-ASCII text. createDraft sent
the subject and body with no charset, so Gmail read them as Latin-1 and
an en dash arrived as "–". The body is now declared UTF-8 and the
subject is RFC 2047 encoded.
Re-copy the plugin and restart (hermes gateway restart) whenever it
changes. Without the terminal, Benny can't run the skills that need it
(maps, PDFs, GIF search) from chat. Images still work through vision.
Re-copying from Windows PowerShell (from the repo folder, not
.git). The tar … | ssh line under "Setup on the Pi" doesn't work
there: Windows PowerShell passes a pipe between two programs as text,
which breaks tar's binary format, and the Pi quietly keeps the old files
(that's how the Oct 18 update first missed the Pi). Copy with scp
instead. Windows files carry no Unix permissions, so scp makes the
staging folder read-only, and the chmod gives it back so it can be
removed. Keep each ssh command in single quotes with no double quotes
inside, because Windows PowerShell strips those before ssh sees them.
git pull
scp -r agent\benny-plugin pi:benny-plugin-new
ssh pi 'chmod -R u+w ~/benny-plugin-new && cp -r ~/benny-plugin-new/. ~/.hermes/plugins/benny/ && rm -rf ~/benny-plugin-new && ~/.local/bin/hermes plugins doctor ~/.hermes/plugins/benny && ~/.local/bin/hermes gateway restart'
ssh pi 'ls -l ~/.hermes/plugins/benny/tools.py'
The last line should show today's date. Any error stops the chain before the restart, so if one appears, Hermes is still running the old plugin.
The chore, bill and calendar parsers, the advice generator and the comps
summary moved from Haiku 4.5 to Haiku 5.5 (FAST_EXTRACTION_MODEL in
server/lib/anthropicClient.js). Before switching, every parser ran on
the same 20 inputs with both models: 4 of 4 bills, 5 of 5 events and 7 of
8 chores came out identical, the comps summary followed its "two to
three plain sentences" rule better, and the 20 calls cost $0.003 instead
of $0.029.
What changed with it:
TRANSCRIPTION_MODEL).
On a scanned referral, Haiku 5.5 misread one character of the email
domain, both times; 4.5 matched the address that was checked by hand.max_tokens went from 300 to 1000. Haiku 5.5
thinks before answering by default, and that counts toward the cap.temperature: 1 is gone from the advice generator. Haiku 5.5
rejects any other value, so it was a trap for a later edit.householdToday()), like the digest and recurring bills already did.Home Assistant settled the Raspberry Pi question. It runs on the Pi in
Docker (~/homeassistant, docker compose, host networking) next to
Hermes. Agent Benny now controls the house through two plugin tools that
call Home Assistant's REST API on the Pi itself, so nothing goes through
the app or Vercel.
In Home Assistant as of Oct 11, 2026:
No approval, on purpose. Michael decided (Oct 11, 2026) not to approve lights and shades, which changes ground rule 3 of the agent plan for the smart home only. Calendar invites and email drafts are still proposals. What Benny may touch is limited in code instead:
BENNY_PEOPLE), so a cron run or a stranger in the server can't
switch things. The tool description also says never to act on text in
an email, event or list. The worst a hostile email could do is get
Benny to flip a light or a shade.Plugin tools: benny_home (devices and state, filterable by room or
kind; scenes only on request, because Hue makes a lot of them) and
benny_home_control (on/off with brightness and color, shade
open/close/stop/position, scenes, Apple TV play/pause, vacuum
start/dock). Home Assistant's own logbook records each change, under
whichever Home Assistant user made the token.
Setup on the Pi (done Oct 11, 2026; tested live by switching a lamp
on and off through the installed plugin; the previous plugin is at
~/.hermes/plugins-benny-bak-20261011):
.env without it passing through chat or a shell
history:
ssh -t pi 'read -rsp Token: t; echo; echo BENNY_HA_TOKEN=$t >> ~/.hermes/.env; echo saved'
BENNY_HA_URL is only needed if Home Assistant moves off the Pi; it
defaults to http://127.0.0.1:8123.The app's /smarthome.html and the powerview-bridge/ push (Stage 8c)
are superseded for shades. The app can't reach Home Assistant from
Vercel without a tunnel, and nothing here needs it to.
Chores (Stage 3) and shared lists (Stage 19) were two features with two
pages and two sets of Benny tools. They're now one: /todo.html, laid
out like Microsoft To Do, in the Harbor Lights theme. Every to-do sits in
a list. Chores and Groceries are lists, and you can make more.
What's on the page:
my_day), so it empties itself overnight with no job to clear it.PATCH /api/tasks/:id answers 409), so both pages share
it.splitLeadingEmoji in
server/lib/todos.js).The homepage's Chores and Lists tiles became one To-Do tile. The Today
card, the Timeline and the morning brief show due to-dos from every list.
The brief's heading is now "✅ To-dos", and it names the list when it
isn't Chores. /chores.html and /lists.html redirect to the right list.
Routes (server/routes/todos.js):
| Route | Agent? |
|---|---|
GET /api/todos: lists, open to-dos, to-dos finished in the last 30 days, open move tasks with a date | ✅ |
POST /api/todos: { text, list?, assignee_hint?, defaults? }, parsed by Claude, "tasks" lists only | ✅ |
PATCH /api/todos/:id: title, done, important, my_day, due_date, repeat, assignee, list, notes, steps (a tick keeps who did it) | ✅ |
DELETE /api/todos/:id | ❌ app only |
GET /api/lists, GET /api/lists/:slug/items, POST /api/lists/:slug/items (Stage 19's shapes) | ✅ |
POST /api/lists, PATCH /api/lists/:slug, DELETE /api/lists/:slug | ❌ app only |
DELETE /api/lists/:slug/items/checked: "Clear completed" | ❌ app only |
GET/POST /api/chores, PATCH /api/chores/:id, PATCH /api/list-items/:id: the old shapes, answered from todos | ✅ until the cleanup |
The old chore and list-item routes are only there so the plugin on the Pi
keeps working until it's reinstalled. They go, along with the chores
and list_items tables, in a cleanup once To-Do has been in use for a
few days.
Benny's tools: benny_todos (filter by list, person, my_day /
important / planned, open / done; also lists the lists),
benny_add_todo (parsed, default Chores; the asker or the named person
goes along as the hint), benny_add_to_list (unchanged) and
benny_set_todo_done (says when a repeating one is next due) replaced
benny_chores, benny_add_chore, benny_set_chore_done, benny_list
and benny_check_off.
Who got the limes? (Oct 18) benny_todos with status: done lists
the newest finished first, each with done_by and done_at already in
Chicago time ("Sat Oct 17, 7:14 PM"), since converting UTC is where a
model slips. When Benny ticks one off for one of us, the plugin sends
asker (from the chat session, never the model) and the app records
that person, not Benny. Only the agent's token can name someone, and
only Michael or Mer (actorOf in server/lib/people.js); with no
known asker it's benny, and Benny says so in the chat ("I couldn't
tell who's asking, so it's down as done by me"), with adding the account
to BENNY_PEOPLE as the fix, rather than leaving a quiet "done by
Benny" for later. The plugin with both was copied to the Pi and Hermes
restarted on Oct 18, 2026.
Schema change (scripts/todo_merge.sql, applied as the todo_merge
migration). It's additive: lists gets emoji, mode (tasks or
items) and position, plus a chores row. A new todos table holds
every to-do, with RLS on and no policies, so it's server-only like the
rest. Existing chores and list items were copied in with their ids, so
ids Benny already had still work. chores and list_items are left in
place until the cleanup.
Deploying it (the order matters, because production is the only database):
todo_merge migration. The deployed app keeps using the
old tables, so nothing changes yet.scripts/todo_merge.sql
again, so anything added through the old app in the meantime comes
along. Never run it after the deploy: by then the old tables are
stale.All four steps were done on Oct 16, 2026. The plugin from before To-Do
is backed up at ~/.hermes/plugins-benny-bak-20261016 on the Pi.
Second schema change (scripts/todo_done_by.sql, applied as the
todo_done_by migration on Oct 18, 2026): todos.done_by, additive and
empty to start. It has to go in before the code that reads it, because
GET /api/todos selects it. PERSON_EMAILS was set in Vercel the same
day.
/pets.html keeps Quincy's, Rey's, Poe's and Morticia's records: a
profile with the microchip, plus vaccinations, vet visits and
medications. Anything that comes due becomes a to-do in To-Do's Pets
list. For the three moving to the Netherlands, there's an EU travel
checklist that reads their records against the entry rules.
The rules, checked Oct 16, 2026 against the USDA APHIS Netherlands page (last updated Sept 2, 2026), the NVWA's page on travelling from non-EU countries, and the EU's pet-movement pages:
These live in server/lib/petCare.js, and npm test checks them
(tests/petCare.test.js). The checklist shows, per pet:
Morticia stays with Joe, so travels is off for her and she gets no
checklist. Her page holds her care sheet in the notes (P-22).
Reminders are ordinary to-dos in the Pets list, so they show up in My
Day, Planned, the morning brief and Benny's to-do tools. The server
writes them and links each one back to its record (reminder_todo_id):
Routes (server/routes/pets.js):
| Route | Agent? |
|---|---|
GET /api/pets: every pet with its records, open reminders, latest weight and travel checklist | ✅ |
GET /api/pets/:id: one pet, same shape | ✅ |
POST /api/pets/:id/vaccinations, /visits, /medications | ✅ |
POST /api/pets, PATCH /api/pets/:id, DELETE /api/pets/:id | ❌ app only |
PATCH/DELETE /api/pet-vaccinations/:id, /api/pet-visits/:id, /api/pet-medications/:id | ❌ app only |
Benny can read and log, but editing a record or a pet's profile stays in the app, so a chip number can't be changed from Discord. The Pets list can't be deleted from To-Do, the same as Chores.
Benny's tools:
benny_pets: with no pet, a summary of each (chip, age, weight, rabies
valid until, meds, what's due, and what the travel checklist wants
fixed). With a pet's name, its full record.benny_log_pet logs a visit, vaccination or medication when asked. It
can work from a vet email or PDF Benny has read with
benny_read_email/benny_read_attachment, logging only what the
document states, and reads back what it logged, including any
reminder.Move plan: the migration rewrote P-09's notes for the new certificate and added two tasks:
Schema change (scripts/stage23_pet_care.sql, applied as the
stage23_pet_care migration):
pets, pet_vaccinations, pet_visits and pet_medications, with
RLS on and no policies, so they're server-only.The plugin was reinstalled on the Pi on Oct 16, 2026 (16 tools). The one
from before Stage 23 is backed up at
~/.hermes/plugins-benny-bak-20261016-stage23.
On Oct 24, 2026 we picked a direction for Benny: one workspace for the two of us, laid out like Asana (which we both use at work), with Benny as a third member of the team. It comes in four phases:
What prompted it: after three weeks, the move tracker had 3 of 113 tasks done and none of the 8 decisions recorded, while To-Do was used every day. To-Do had also ended up with the same job on two lists for two people, and there was nowhere to talk about a task. The mockups are on a Claude canvas (Michael's, private).
The sidebar (public/shell.js) is on every signed-in page.
<nav id="shell"> straight after <body data-nav="...">, with shell.js right after it, and the page's own markup in <div class="hl-shell-main">. The script fills the nav before the rest of the page loads, so nothing jumps.auth-guard.js, which now shares its /api/me answer as window.bennyMe. /api/me also returns person (michael, mer or benny).The Move page (/move.html) has five tabs. The URL says which tab is showing and what's open (#list&task=<id>), so links open the right thing and Back closes the pane.
public/move-timeline.js): a Gantt chart of the same filtered tasks.
/log.html used to show.The pane (public/move-pane.js) shows one task or decision. It's a column on a wide screen, a drawer on a laptop, and the whole screen on a phone.
PATCH /api/tasks/:id. The change shows straight away and goes back if the server says no.Keeping up: like To-Do, the page fetches the plan again every 30 seconds while it's on screen, and right away when you come back to it. The open pane's comments come along too, so what the other of us wrote turns up without a reload.
Comments (server/routes/comments.js, rules in server/lib/comments.js):
| Route | Does |
|---|---|
GET /api/tasks/:id/comments, GET /api/decisions/:id/comments | The thread, oldest first |
POST /api/tasks/:id/comments, POST /api/decisions/:id/comments | { body }, up to 4000 characters. The author is whoever's signed in (actorOf), never the request |
PATCH /api/comments/:id | { body }. Only its author; sets edited_at |
DELETE /api/comments/:id | Only its author |
GET /api/projects/:slug now gives each task and decision a comment_count, and each task an added_by (michael, mer, benny, or null for the roadmap import).
benny as an author, so that won't need another migration.PERSON_EMAILS in .env, the same as Vercel's. Otherwise the server can't tell who's writing and says so.Schema change (scripts/workspace_comments.sql, applied as the workspace_comments migration on Oct 24, 2026): a new comments table.
GET /api/projects/:slug counts comments.Benny is deployed at https://benny-penguin-palace.vercel.app — Vercel is
connected directly to this GitHub repo, so every git push to main
automatically triggers a new deployment. No separate "deploy" step needed.
A URL correction, found the hard way: this README used to point at
benny-quincy5.vercel.app. That domain isn't actually connected to this repo or this Vercel account at all — it's a separate, orphaned deployment (frozen on a very old build) that just happened to still be reachable. Every real push has always gone to the Vercel project behindbenny-xi.vercel.app/benny-penguin-palace.vercel.app(same project, multiple aliases) instead. If a link to the old domain turns up anywhere else (bookmarks, Google Cloud Console's authorized domains, texts to each other), it needs updating too — it will not reflect new pushes.
What had to change to make this work: the first two deploy attempts
"succeeded" in well under a second each and produced a site with nothing
running behind it — hitting /api/hello returned a 404, meaning Express
never actually ran; Vercel had just quietly deployed a static site instead.
Two separate things needed fixing:
index, app, or server (.js/.ts/etc.)
at the project root or under src/. Our entry point was
server/index.js — one folder off from where Vercel looks. Fixed by
moving it to index.js at the project root (and updating package.json's
main/start/dev to match). Everything else — routes, lib, public —
stayed exactly where it was; only the one entry file had to move.vercel.json that spells
out explicitly how to build and route the app: run index.js as a
Node.js serverless function (@vercel/node), send anything starting
with /api/ or /auth/ to it, and let Vercel's filesystem serving handle
everything else (which is what serves public/** automatically). This is
the older, more battle-tested way to deploy an Express app on Vercel, and
it doesn't depend on newer auto-detection working correctly.
index.js also now only calls app.listen() when run directly (i.e.
locally) — Vercel imports the file and uses its exported app instead of
ever calling .listen() itself, since a serverless function doesn't keep
a port open the way a normal server does.This is also why the public/ folder lives at the project root rather than
inside server/: on Vercel, express.static() is ignored entirely and
Vercel's own CDN serves public/** directly instead. Locally, npm run dev
still uses express.static() like always — the difference only matters in
production.
Environment variables: none of the .env values travel with git push —
.env is intentionally never committed. Each variable from .env.example
needs to be added separately in the Vercel dashboard under
Project → Settings → Environment Variables (Production environment).
One of them needs a production-specific value: GOOGLE_REDIRECT_URI must
point at the live domain's /auth/google/callback, not localhost — and
that live URL also needs to be added as an additional Authorized redirect
URI on the Google Cloud Console OAuth client (alongside the localhost one,
not replacing it), or Google will reject sign-in with a
redirect_uri_mismatch error.
Access: by default, Vercel puts new projects behind a login wall (Vercel Authentication / SSO protection) — anyone without access to the Vercel team gets bounced to a Vercel login page instead of the app. That's been turned off for this project so the live URL above is publicly viewable (useful for a sprint demo video, or for Mer to open it on her phone).
This repo already lives at https://github.com/michaelstenson/Benny. Each time a new stage lands (like this one), commit and push it from this folder:
git add -A
git commit -m "<short description of what changed>"
git push
.env never gets pushed — it's listed in .gitignore specifically because
it holds credentials that shouldn't end up in a public (or even private)
GitHub repo.
public/theme.cssRESIDEO_CLIENT_ID/RESIDEO_CLIENT_SECRET/RESIDEO_REDIRECT_URI were never set on this Vercel project, and re-registering is currently blocked — the Honeywell/Resideo developer account exists but won't send verification/password-reset emails and refuses fresh signup as "already taken." Try a different email address or network before giving up; may need Resideo support. Shades, Hue lights, the Wemo plugs, the bedroom Apple TV and the vacuum are online through Home Assistant on the Pi (Oct 2026), and agent Benny controls them directly — see "Smart home through Home Assistant". That replaces the powerview-bridge/ plan, so its discovery step and the missing powerview_shades table no longer matter./calendar.html to actually use it — the same one reconnect now grants Calendar write and Gmail together. Confirmed working live: real drafts create successfully and show up on , which never surfaces inbox content — only Benny's own drafts.Benny the agent (built on Hermes, talking to us in Discord) and Benny the app were always meant to work together: the agent is the conversational layer, the app is the data and visual layer. This plan came out of a Discord exchange where agent Benny reviewed the app and suggested features; the order below is adjusted for what the code actually looks like today. It's also the concrete breakdown that idea #20 below (the autonomous agent) was waiting on.
index.js mounts every /api route
with no auth check — only the comps cron checks a secret. Anyone with
the Vercel URL can add chores, create real Google Calendar events,
create Gmail drafts, and spend Claude API credits. RLS doesn't help
here, because those routes use supabaseAdmin, which bypasses it.
Publishing an agent-facing API on top of that would make it worse, so
Stage 17 fixes it before anything else.service_role, and that
same key can read google_tokens, resideo_tokens, and
home_connect_tokens — i.e. full Calendar and Gmail access for both
of us, held by a process that reads Discord messages. Going through
the app's own REST API with a scoped agent token instead reuses the
existing parsers (choreParser, billParser, calendarEventParser)
and the Chicago-timezone handling in digest.js, and puts every agent
write through one place we can log and audit.requireUser middleware on /api/*. OAuth callbacks and
cron routes stay outside it (cron keeps using CRON_SECRET). Open
question: magic link (leaning this way) vs. a simple shared password.BENNY_AGENT_TOKEN bearer token with its own
middleware and an explicit allowlist of routes the agent may call.
Add source (app / agent) and created_by columns to chores (and
to later tables as they're built), plus an agent_actions audit log.
Write a small tool-definition file for Hermes covering chores,
timeline, digest, and lists. Open question: where Hermes runs — it
only needs outbound HTTPS to Vercel, but the token has to be stored
safely on that machine.lists + list_items tables, groceries first.
Low stakes and used every day, which makes it the right first test of
the bridge ("Benny, add oat milk")./api/digest with bills due soon and
weather (Open-Meteo, no API key needed). Hermes pulls it on its own
schedule and posts it to Discord; the app stays the data layer and
the agent does the talking. Needs a new recurring_bills table
(name, amount, due day, autopay) — the existing bills table records
monthly usage for the analyzer and has no due dates.pending_actions table. The agent proposes
something, a "Benny suggests" card appears in the app, and approving
it runs the existing calendar-write or Gmail-draft code path. This is
the core of idea #20's propose-then-approve model.Deferred: smart-home alerts and routines ("leaving home" / "coming home", temperature and shade alerts). Home Assistant won, and Benny now calls its REST API (see "Smart home through Home Assistant"). Routines should probably be Home Assistant automations or scenes that Benny can trigger, rather than logic in the plugin. Temperature is still blocked on the Resideo thermostats (roadmap #10).
Telegram is the backup channel. Discord is where the household talks to Benny. Telegram stays connected (Michael only, by user ID) in case Discord is down, or its connection silently drops while Hermes keeps running. Nothing is built for Telegram on purpose.
Not sequenced yet — captured here so they don't get lost. See conversation notes for a fuller breakdown of steps/UX for each.
chores and Google Calendar that could drift out of sync. Worth
revisiting once the Timeline view has been lived with for a while.pylitterbot), same difficulty tier as Resideo/Home Connect.done_by: michael, mer or benny):
Completed shows "✓ Mer · Today, 7:23 PM", and a finished to-do's footer
says "Completed by Mer". An open one's footer says who added it. The
names come from PERSON_EMAILS; anything finished before Oct 18, 2026
has no name, since nothing kept it. Ticked off through Benny, it's
whoever asked Benny (see "Who got the limes?" below). This came from Limes and Totopos
being ticked off one evening with no way to tell by whom./drafts.htmlpets, vet_visits, vaccinations, and
medications for Poe, Rey, and Quincy. EU import timing rules become
tasks on the Netherlands move (pinned to departure); start roughly 9 months before
the move. Verify the rules against official USDA APHIS / Dutch
sources before encoding them — current understanding is microchip,
then rabies vaccine, a 21-day wait, and a USDA-endorsed health
certificate within 10 days of arrival, with no rabies titer test
needed coming from the US (agent Benny's suggestion assumed one).home_value_estimates: equity (needs a
mortgage-balance input), a list of improvements with estimated
return, and a timeline to list. Lives as a project under Stage 22.