A Hypervisor for Products

This morning I opened a Claude Code session in the Monument Labs site repo and, before I typed a word, the session was handed three things it should already know about this product. One: only about one session in eight on this site emits a page_viewed event, so that event cannot be used as a traffic denominator. Two: 14 of the 16 inquiries we have ever received carried a Google Ads click id, so with ads off the organic inquiry rate is effectively zero. Three: a snapshot's referrer list is a truncated top-N, so a sibling product's absence from it proves nothing. Below those sat three open recommendations, one of which was "submit the inquiry form on production by hand, because nothing has fired since July 18 while CTA clicks continue."
No human wrote any of that. It came from a system that watches ten products at once, and it was injected into the session by a hook that runs in about half a second.
Last month I wrote about the apparatus that grew around Daylight: an agent that wakes at 6am, reads the event stream and the source code, and keeps a ledger of beliefs with the SQL to re-check them. That post ended with a ladder of affordances and a promise that the next rung, proposing changes rather than recommending them, would open once the containment existed. This post is about what happened when I lifted that engine off one product and set it over all of them.
Attention is the resource being scheduled
Monument Labs has ten products in production: Daylight, Margin, Camera Shy, Gentle Way, Riff, Call Compass, Clean Markdown, this site, my personal site, and one client app. Eleven repos, seven Supabase projects, ten Vercel deployments. My available attention for all of that is dropping to roughly ten minutes a day, and ten minutes split ten ways is one minute per product, which rounds to nothing.
The framing that unlocked the design was the one operating systems use. A hypervisor runs many guests on one host. Each guest gets a config file, an isolation boundary, and a share of a scarce resource the host schedules. The guests do not know about each other. The host knows about all of them, and the host is where the policy lives.
Here the guests are products, the scarce resource is attention (mine and the model's), and the config file is one row in a registry table. That row carries everything the system is allowed to know about a product: a one-paragraph brief, schema notes for its event vocabulary, its repos, its probes, the maximum tier of action it may take, and the short list of tables it may ever write to. The prompts that drive the agent contain zero product facts. The system prompt says it plainly:
If the registry does not say it, you do not know it, and you say so instead of guessing.
That sentence is the whole reason the design generalizes. Adding a product is an insert statement. Tightening what the system may do to a product is an update on one row. Nothing in the reasoning layer has to change.
The day
Everything runs on Inngest crons out of this site's repo, and nothing runs on preview deployments.
| When (ET) | What happens |
|---|---|
| Every 5 min | HTTP and WebSocket probes on every product. An incident opens after three consecutive failures and closes on the first success. |
| 05:30 | Collectors write one snapshot row per product: visitors and top paths from Vercel, deploy state and 5xx counts, database advisors and health, signups and revenue through a per-product read-only SQL adapter. |
| 06:00 | A Vercel Sandbox clones the repo, gets the last 14 snapshots per product, both ledgers, open incidents, and the objectives, and runs the daily report. Monday runs the weekly retro instead. |
| On a fire | A scoped investigation of one product and one question, with that product's repos mounted read-only. Capped at 6 a day and $15 a day. |
| 07:00 | A digest email. Subject line format: Aug 24 · 10/10 up · 2 need you. |
The runner is a Claude Agent SDK loop on Opus with Read, Grep, Glob, and nine purpose-built tools, most of them named for exactly what they do: run_sql, hub_sql, probe, git_query, write_report, upsert_finding, upsert_recommendation, upsert_objective_progress, propose_action. The sandbox's outbound traffic is narrowed to three hosts after setup. It receives a database URL for a writer role that cannot delete, a Supabase management token, and a gateway key. It never sees the GitHub key or the Vercel token.
A daily fleet report costs about $1.32 and takes 20 turns. The first live weekly ran 50 turns for $5.21. Since the system went live on August 23 it has written 8 reports, taken 30 snapshots, and logged 5,554 probe results, with zero incidents.
The ledger, one per product
The findings ledger was the part of the Daylight post I said I would rebuild first anywhere else. Rebuilding it for a fleet turned out to be mostly a matter of adding a project_id column and then getting far stricter about what is allowed in.
A finding is a named, typed belief (structural, causal, definitional, or risk) with a lifecycle: candidate, then confirmed once a later run re-verifies it, then retired with a reason. Every finding carries a recipe: the SQL that produced it, the expected result, and the date it was last checked. The prompt's rule for what qualifies is the rule I wish every analytics team had written down:
Discipline is enforced in three places, and only one of them is the prompt. Before writing, the model must name which existing slug a new belief touches; a new slug asserts that no active finding covers the subject, and the unique (project, slug) constraint plus a cap of 25 active findings per product will reject it otherwise. Recipes have to be run through run_sql before they can be saved, never written dry. And the database role the runner uses has no DELETE grant at all. Retirement is the delete. Rows never disappear; they get a retired_reason and, if something replaced them, a pointer to what did.
The daily run's job is maintenance. After the report is written, it re-checks up to three findings, oldest first, and closes up to two recommendations. The prompt is explicit that most dailies write zero new findings. The weekly adds and grades. That division keeps the ledger from becoming a diary.
The first thing it found
The first real daily ran on August 23 and reported everything up except a database flag on Margin. That flag fired a scoped investigation, which cost $4.67 and produced a risk finding I did not enjoy reading: a SELECT policy on public.profiles with USING (true), which meant the anon key shipped in the browser could read all 15 columns of every profile, email included. The recommendation attached to it was specific down to the migration.
Finding margin-profiles-anon-readable · kind: risk · status: retired
Retired reason: fixed in migration 0060_restrict_anon_profile_columns.sql, commit 53f58b7.
Last result: anon_readable_columns = 9 (was 15)
Paired recommendation: margin-profiles-anon-email-exposure · status: acted · outcome: worked
Found in the morning, fixed the same day, retired with the query that proves the fix and the number it now returns. That row is the whole architecture in miniature. The belief did not get deleted when it stopped being true. It got a reason, a commit hash, and a measurement.
My other favorite row is one where the system filed a bug against itself. A definitional finding on Margin says the database advisor count tracks the 67 row-level security policies, which is lint debt rather than degraded health. The paired recommendation is aimed at the collector: stop calling that state degraded. The watcher noticed its own instrument was miscalibrated and wrote it down where the next run would read it.
The gate the model cannot talk its way past
This is the rung the Daylight post promised. The runner can now propose actions: delete rows it wrote itself, open a pull request, redeploy, roll back, merge. The rule that makes this safe is that the model never decides how dangerous its own proposal is.
propose_action inserts a row with status = 'proposed' and tier = null, and that is all the model can do. The tool's response tells it as much: submitted, the harness decides the tier, do not assume it ran. Classification happens on the launch side, in plain TypeScript, from what the action touches.
Tier 0: watching
Reports, findings, recommendations. No action row exists. This is where the system spends almost all of its time.
Tier 1: executed, then reported
Deleting rows the system tagged as its own, under 500 of them. Opening a pull request. It runs, and the 07:00 digest tells me it ran.
Tier 2: queued for one click
Any other mutation, a merge, a redeploy, a rollback. I get an email with the full SQL or payload and Approve and Decline links that expire in 72 hours. At most one per product per day.
Tier 3: refused
A table not on the product's allowlist. A product marked watch-only, which today means the client app and two products I have not opened up yet. SQL that fails the guard. A redeploy of the hub itself. The refusal becomes a process recommendation so it is not silently lost.
The SQL guard parses the statement into an AST and permits exactly one UPDATE or DELETE with a WHERE clause built only from column references, literals, equality, IN over a literal list, AND, and OR. No functions, no subqueries, no bare true, no comments. The comment rule exists because the parser strips comments, which would have waved through WHERE x = 1 -- AND owner_id = 42. Twenty-five test fixtures cover the ways I could think of to be clever.
The part I am most attached to: when a queued action is finally executed, the executor classifies it again, with a fresh row count, before running it. A runner that had been prompt-injected into proposing a Tier 2 action dressed as Tier 1 gets caught at the moment of execution, which is the only moment that matters. The gate does not trust the row it wrote itself.
Approval links are HMAC-signed, single-use, and land on a confirm page that shows the SQL, the precount, and the deployment id before anything happens. A reused link gets a 409. A bad token gets the same 404 a missing row would.
In testing, the pipeline opened a real PR against a fleet repo, 13 turns, $0.47, a one-line diff, squash-merged behind a green preview. A real redeploy of Camera Shy went through the approval path and came back READY. In production the action layer is still dark: the kill switch is unset and dry-run mode is on until early September, and the number of actions the system has executed against a live product is zero. The containment shipped before the autonomy did, which is the order the Daylight post argued for.
Closing the loop with the thing that builds
The opening scene of this post is the piece I built last. A watcher that observes production continuously is blind to the work that happens inside the repos it watches. When a coding session ships the fix for something the watcher recommended, the recommendation stays open forever and gets recommended again the next morning. That is a slow way to lose trust in a system.
So there is a bridge. A SessionStart hook in every fleet repo calls a 110-line CLI that identifies the project from git remote get-url origin, fetches the open findings and recommendations for that product, and prints them into the session's context. When the session finishes, a Stop hook prints a one-line reminder to log anything durable. Closing a recommendation posts automatically. Adding a new finding or a decision waits for me to confirm it.
That asymmetry comes from a threat model I did not have at the start of the week. Everything in the ledgers is injected into the prompt of an agent that can open pull requests without asking. Anything that can write to the ledgers can therefore put text in front of an agent with commit authority across eleven repositories. So the read token and the write token are different secrets, the read token is the only one the unattended hook gets, and session-authored rows get their own smaller caps inside the runner's caps. The ledger is memory, and memory is an attack surface.
What I think this is
I do not know of anyone running a portfolio of separate businesses on one control plane like this, and I have looked. Plenty of teams have an agent that watches one product. The move that changes the economics is putting the product-specific knowledge in a table instead of a prompt, so the same reasoning loop serves a legal documentation app and a judo poster without knowing anything about either until it reads the row.
The honest caveat is the same one as last time, sharpened. A control plane over ten products only works if each product is legible: an event schema, a documented vocabulary, an AGENTS.md at the repo root, a registry row that someone keeps fresh. The weekly run flags drift itself when a query errors on a column the notes claim exists, but the notes still have to be written. Half the effort in this build went into making ten products describable in one paragraph each.
Three things transfer even if you never build the agent. Give each product a single row of truth that a program could read. Put the policy for what an automated system may do outside the system, in code you can unit-test, and re-check it at execution time. And when a belief stops being true, retire it with a reason instead of deleting it, so the next person to ask the question finds the answer and the date it changed.
This morning's digest read: ten up, 359 visitors, no fires. It took me under a minute, and then I went and did something else.
If you are running more than one product and the watching is what does not scale, I would like to compare notes: kyle@monumentlabs.io.