This is the complete setup for PostHog on any client site: what it does, what silently breaks, and the exact order to build it so the Hive Mind can eventually read the data and act on it without us. Proven end to end on siriscore.com in one day. Every gotcha below cost real time to find. Follow the order and none of them cost you anything.
Every analytics tool with a heatmap can tell you what happened. That is not the job. The job is a loop: read the data, form a hypothesis, ship a variant, measure it, keep the winner. An agent can only close that loop if the platform lets it create things, not just read them. So the entire evaluation collapsed into one test: does the API have a working create endpoint for experiments.
| Platform | Heatmaps | Can an agent create things | Verdict |
|---|---|---|---|
| PostHog | Yes: click, scroll depth, rageclick, mousemove, dead clicks | Yes. Experiments, feature flags, dashboards, insights, alerts, cohorts, annotations. Official MCP with roughly 80 tools. | Chosen |
| Contentsquare (Hotjar) | Yes, mature | No. Every object endpoint is a GET. Creating a goal returns 404, which means the route does not exist, not that we lacked permission. No plan upgrade changes this. | Cannot close the loop |
| Microsoft Clarity | Yes, free and unlimited | No. Read only, and the export API allows 10 requests per day, 3 days of history, 3 dimensions per request. Unusable for an automated loop. | Free archive only |
Clarity is still worth running beside PostHog on any site where you want an unlimited free recording archive for a human to scrub through. It just never gets a vote in the loop.
Create one PostHog project per client site. Pick the region at creation and never guess it later: US and EU are separate hosts and a key from one returns nothing on the other. Then collect four values.
| Value | Shape | Where it lives | Secret |
|---|---|---|---|
| Project API key | phc_... | In the page HTML, visible to anyone | No. It is designed to be public. It can only write events, never read them. |
| Personal API key | phx_... | .env AND a User scoped OS env var | Yes. Never commit it. This is the key that reads and creates. |
| Project ID | Numeric | .env | No. Every REST path needs it. |
| Host | us.posthog.com or eu.posthog.com | .env | No. |
heatmap:read scope specifically, and it is easy to miss because nothing in the UI reminds you.Ad blockers and browser privacy modes block us.i.posthog.com by name. Most people assume the cost of that is some missing events. The real cost is much worse: the analytics bundle itself is loaded from that same host, so a blocked request does not lose some events, it loses the entire visitor. No pageview, no heatmap, no recording, nothing. Those visitors are invisible and you never see a gap where they should be.
A reverse proxy fixes this by serving everything from a subdomain of the client's own site. PostHog hosts and manages it at no cost. Blockers do not block the client's own domain.
signal.clientsite.com works. clientsite.com does not.analytics, tracking, telemetry, stats, metrics, posthog, or ph in the name. Blocklists match hostnames, not just domains, so a first party subdomain called analytics gets blocked anyway and you have done the work for nothing.xxxx.cf-prod-us-proxy.proxyhog.com).valid.script-src and connect-src or every request fails silently.curl -sI https://signal.clientsite.com/static/array.js must return 200. If it returns anything else, stop here. Nothing downstream works.What the proxy does not do. It does not change where ads land, it does not touch the Meta Pixel or GA4, and it does not move any page. It changes exactly one thing: the hostname PostHog's own script and events travel over. Every other tag on the page is unaffected. Expect event volume to step up after cutover, and label that step in an annotation, because it is recovered data and not growth. Anyone reading the chart a week later will otherwise call it a win.
Do not hand roll a loader. PostHog's generated snippet installs a stub that queues any event fired before the bundle finishes loading, so early events survive. A hand rolled loader with a readiness flag silently drops them, and the events fired earliest are usually the ones at the top of the funnel where the traffic is.
Copy the snippet from the PostHog UI, then add the config below. Two of these options are not in the generated snippet and must be added manually every single time.
| Option | What happens if you skip it |
|---|---|
| api_host set to the proxy | You lose whole visitors to ad blockers, invisibly. |
| ui_host | The PostHog toolbar cannot attach, so nobody can visually inspect heatmaps on the live page. |
| defaults pinned to a date | A future PostHog default change quietly alters what you capture, and the data shifts under a running experiment. |
| person_profiles: identified_only | Every anonymous ad click bills a person profile. See the trade below before changing it. |
| capture_heatmaps | No scroll depth, no click maps, no rageclicks. This is off by default and it is the single most valuable CRO signal. |
| capture_dead_clicks | No record of people tapping things that do nothing, which is the most common mobile stuck point. |
With identified_only, anonymous visitors never get a person profile. The consequence is real and you should know it before someone reports it as a bug: person based cohorts will calculate cleanly and return zero. That is correct behavior, not a broken config. Funnels, trends, heatmaps, replays, and every breakdown work perfectly on anonymous events, and that is effectively all of the CRO value. Setting it to always bills person processing on every anonymous ad click for a feature we do not use. Keep identified_only unless a client genuinely needs person level analysis.
Setting capture_heatmaps: true in your code is not enough to collect heatmaps. There is a second, separate toggle at the project level in the PostHog UI, and it is off by default. When the code flag is on and the project toggle is off, the browser looks completely correct: the bundle loads, the config reads true, no console errors, events flow normally. Every heatmap query returns zero datapoints and no error message.
This burned a full day on the reference build. The code flag was live and verified in browser for hours while the project was quietly discarding every heatmap payload.
The only source of truth is PostHog's remote config. Open this URL in a browser and read the four values. The browser side flag lies. This one does not.
https://us-assets.i.posthog.com/array/<phc_YOUR_KEY>/config.js
| Field in config.js | What it turns on |
|---|---|
| heatmaps | Scroll depth, click maps, rageclicks. The core CRO signal. |
| captureDeadClicks | Taps on things that do nothing. Where people get stuck. |
| capturePerformance.web_vitals | LCP and INP from real visitors. Answers whether a bad number is a speed problem or a page problem. |
| autocaptureExceptions | Front end errors tied to the session that hit them. |
The heatmap API is REST only. There is no MCP tool for it, so this is the one part of the loop that needs a direct call with the personal key.
Read cumulative_count, not bucket_count. The cumulative number is the one that answers the owner's actual question: how many people ever saw the offer. Plot it against the page height and the cliff is obvious.
Autocapture handles clicks for free. These three patterns cover everything autocapture cannot see, and together they took the reference site from raw pageviews to a full purchase funnel in a very small diff.
Most of our sites already push funnel events to a dataLayer through a single helper. Add PostHog inside that helper rather than at each call site. On the reference build this covered nine funnel events with one edit, and every future event added to the helper is instrumented automatically.
This one is mandatory, not optional. Any flow that runs on a single URL, which describes every modern scan tool, quiz, wizard, or checkout drawer we build, looks to PostHog like one pageview and nothing else. Without virtual pageviews no funnel can be built at all. Map each step to a fake path and emit it.
Register a value once and PostHog attaches it to every subsequent event from that visitor. This is how you answer questions that span the whole session, like whether a low scoring lead buys more often than a high scoring one. Register the variant, the score, the band, anything you will later want as a breakdown.
Two deliberate refusals, both worth copying.
Do not call posthog.identify(email) on lead capture. Under identified_only it bills a person profile for every lead and puts customer PII into the analytics tenant, and it buys nothing for CRO.
Send the purchase event from the server, not the browser. A client side purchase fires in the instant before a redirect, which is the least reliable moment on the whole internet, for the single event that is actually money. Fire it from the payment webhook and it cannot be lost, blocked, or double counted.
PostHog ships an official MCP with roughly 80 tools. This is what gives an agent hands: it can query insights, read funnels, create dashboards, and most importantly create feature flags and experiments. Add it as an http server with the secret referenced by variable so nothing is ever committed.
The trap: ${VAR} expands from the operating system environment, not from your project .env file. Claude Code does not read .env for this. A key that exists only in .env expands to nothing, sends a bare Bearer header, and the server returns 401. The token was valid the entire time. This has now happened with four separate MCP servers, so treat it as the default failure mode for any new keyed MCP, not as a surprise.
Writing the key to .env is necessary but never sufficient. Set the User scoped environment variable in the same step, then restart, because the environment is read once at launch.
Note that this is a per machine setup step. A teammate who pulls the repo gets the .mcp.json and the gitignored .env is absent, so their MCP will 401 until they run the same command. Put it in the client handoff notes.
The reason PostHog won is that it can be built by code, so build it by code. A hand clicked dashboard cannot be replicated to the next client, cannot be version controlled, and cannot be repaired by an agent. A script can stamp the same command center onto every client site in seconds.
| Object | What it answers |
|---|---|
| Full funnel | Ad click through to purchase, every step, with the drop between each one. |
| Funnel broken down by variant | Which headline or landing variant actually converts, not which gets clicks. |
| Funnel broken down by ad creative | Uses utm_content. Ties spend to outcome instead of to traffic. |
| Rage and dead clicks by page | Where people get stuck. The fastest fix list you will ever get. |
| Revenue blocking failures | Any error event on a path a paying customer is already on. |
| Alert on those failures | So a broken checkout pages someone instead of waiting for a weekly review. |
| Annotations | Go live, proxy cutover, price change. Without these, every future chart reading is guesswork. |
The insight creation schema is fiddly and undocumented in places. This shape works and is worth copying verbatim.
One schema gotcha to save you a search: in cohort definitions, type: 'event' is invalid. The valid tags are behavioral, cohort, person, person_metadata, AND, and OR. Read the validation error text before assuming the problem is your config, because a wrong guess here sends you debugging the wrong thing entirely.
This section exists because three separate things in this build reported success while doing nothing. If you skip everything else in this playbook, do not skip this. Each rule below is one specific failure that actually happened.
| The lie | What is really happening | The check that catches it |
|---|---|---|
| A 200 status code | A JSON-RPC error and a valid response both return HTTP 200. Checking the status alone proves the server is reachable, nothing more. | Read the response body and assert on its content. Never use curl -o /dev/null -w "%{http_code}" as proof of anything. |
| An empty browser test | PostHog drops events from automated browsers because navigator.webdriver is true. Wiring can be perfect and capture shows zero. The tell is GA4 firing on the same page load while PostHog does not. | Mask the flag before navigating, then confirm the capture requests appear. Never conclude "broken" from an automated browser alone. |
| A working config flag | Heatmaps were on in code and off at the project level for hours. Queries returned zero rows with no error. | Read config.js for the project. It is the only authority. |
So the final check for any client is always the same, and it is server side. Ask the API what it actually stored:
Keep your own test traffic out of the read. Verification sessions land in the same project as real visitors. Name probe events distinctly so they can be filtered, and never let the first few sessions after a cutover be read as customer behavior. On the reference build the first day's data was almost entirely our own verification.
This is the part that decides whether autonomous CRO works or quietly destroys things, and it has nothing to do with analytics. PostHog is a sensor. It reports behavior on a page with no idea what that page is for, what the client agreed to, what is deliberate, or what we already tried and buried. An agent holding only PostHog data is confident and blind at the same time, which is the worst combination we can build.
The brain is not a convenience layer that makes the agent better informed. It is the refusal layer. It is the only thing standing between a plausible optimization and a broken client site.
| What the data shows | What the agent would do | What the brain knows |
|---|---|---|
| A URL parameter maps almost one to one with an ad, so the variants never mix | Flag it as a broken test and replace the mechanism with a feature flag | It is message match, not a test. The parameter changes the headline to match the ad that was clicked, on purpose, to hold continuity and lift conversion. Replacing it removes the lift. |
| A client page has thin content and no citations | Generate content and submit citations | The client is in a regulated vertical and citations are deliberately held. Shipping them creates a compliance problem, not a win. |
| An old client report page converts poorly | Rebuild the page | Post 90 day reports are never rebuilt. They are a frozen record. Marking as sent is the only allowed action. |
| A headline underperforms and an obvious alternative exists | Test the obvious alternative | We tested it in March and it lost. Without recall, the loop re runs failed experiments forever and calls each one a fresh idea. |
Every agent in this workspace inherits CLAUDE.md, which means every agent already has the retrieval rule. Before touching prior decisions, client history, tool gotchas, or past lessons, it queries rather than guesses. Never grep the notes, because the corpus moves and the query does not.
This is the same mechanism the self heal loop already uses. Its propose agent receives a PRIOR LESSONS block built from the top brain matches for the issue signature, so it argues from history instead of from a blank page. A CRO propose agent gets the identical treatment, with the client slug and the page as the signature.
A finding that lives only in a chat window is lost. The write path is already built and already runs nightly, so a CRO loop plugs into it rather than inventing a new one.
| The learning | Where it is written | Who picks it up |
|---|---|---|
| A confirmed rule. "Long form scan pages lose mobile buyers past screen four" | A feedback memory, with the why and how to apply | Every future agent, through brain-search |
| Client context. Goals, constraints, what was approved and by whom | A project memory for that client | Every future agent working that account |
| A completed work block. What ran, the decision, what is next | Three lines appended to Reports/Projects/<project>.md | The nightly Insight Agent, which turns it into the morning brief |
| Follow up work | A Beads issue, bd create "..." -p 1 | The ready queue, so nothing depends on someone remembering |
The poisoning firewall, and it is the most important rule in this section. A lesson learned on one client does not automatically apply to another. A headline that won for an HVAC company in Austin is not evidence for a med spa in Charleston, and an agent that generalizes across clients will spread one bad conclusion to the whole book of business at machine speed.
The self heal loop settled this already: cross surface lesson promotion is an Owner click and nothing else. A lesson stays local to the client that produced it until a human promotes it. Automatic promotion is not a feature to add later. It is the specific failure mode this design exists to prevent.
Once the setup above is done, an agent can technically read the funnel, create a feature flag, launch an experiment, and ship the winner with no human involved. That capability arrives on day one. It should not be used on day one. An agent with write access and forty sessions of data will confidently optimize a site into noise, and it will produce a very convincing report while doing it.
So autonomy is earned in rungs, and the gate between rung two and rung three is traffic volume, not confidence.
The instinct here is to design an autonomous CRO system from scratch. Resist it. This workspace already operates a loop that diagnoses a problem, writes an exact fix, has that fix independently reviewed, gates it, applies it, commits it to production, and reverts it if it regresses. That is the Sentry self heal loop, it has been live since July, and it has shipped unattended commits. Every hard problem in autonomous CRO was already solved there, in production, under real failure.
So CRO is not a new system. It is the second franchise of an existing one, with PostHog swapped in as the sensor. The heal loop watches errors. The CRO loop watches behavior. Everything between the sensor and the commit is the same machinery, and it is machinery that has already been attacked on purpose and held.
| Stage | Self heal loop (live) | CRO loop (this design) |
|---|---|---|
| 1 · Sense | Sentry poller finds a persistent error | PostHog finds a drop off, a dead click cluster, a bad vital, a losing variant |
| 2 · Recall | Propose prompt gains a PRIOR LESSONS block from brain-search | Identical. Client history, constraints, and past experiments before any hypothesis |
| 3 · Propose | Read only agent writes root cause, exact diff, test plan, risk | Read only agent writes the hypothesis, the exact change, the metric it moves, the risk |
| 4 · Review | Independent fail closed reviewer that genuinely tries to refute the diagnosis | Identical, and it must be able to answer "is this sample big enough to conclude anything" |
| 5 · Gate | Severity ring, then Slack emoji approval or auto commit at risk low | Identical ring, identical Slack card, identical approval path |
| 6 · Act | Audited executor applies the diff, runs the full suite, commits, pushes | PostHog MCP creates the flag and the experiment, or the executor ships the page change |
| 7 · Watch | Seven day regression watchdog, auto revert, breaker on strikes | Identical. A shipped winner that later regresses marks its own lesson SUSPECT |
These are not theoretical. Each one exists because something went wrong once and the fix got pinned as a permanent check.
| Guardrail | In the CRO loop |
|---|---|
| The severity ring. Money, client, secret, and deploy paths demote to a human click at any autonomy tier | Checkout, pricing, offer copy, legal pages, claims and proof numbers, and any message match parameter are always a human click. No tier ever touches them. |
| Grounded self gating. Stale cache, blind state, or unreadable data means autonomy refuses rather than guesses | Refuse when PostHog data is stale, when the sample is under the gate, when heatmaps were off for part of the window, or when the page was deployed mid window, because the before and after are not comparable. |
| Two strike breaker, cooldowns, daily budgets, kill switch | Unchanged. A CRO loop that can ship twice an hour is a CRO loop that can break a site twice an hour. |
| Recurrence to test ratchet. A second fix on the same issue with no pinning test gets demoted | A second experiment on the same element with no recorded lesson from the first gets demoted to propose only. Repeated attempts are a signal the loop is not learning. |
| Crash budget. Too many new error classes in a window forces propose only | Conversion rate trending down across the account forces propose only. A loop that is losing does not get to move faster. |
| Every guarantee is a test. Guardrails are conformance checks in the suite, not documentation | Unchanged, and non negotiable. A guardrail that only exists in this playbook is not a guardrail. |
This was the breakthrough when the heal loop was extended to a second surface, and it is the single most useful idea to carry into CRO. You do not grant a client site autonomy by pointing the code at it. The site earns autonomy by passing an exam against its own configuration, and until it passes, it runs propose only.
The exam is a red team drill: plant scenarios that should be refused and confirm the loop refuses all of them. On the heal loop this ran four scenarios and refused four, twice, and designing the drill exposed two real holes in the protections that nobody had noticed. Expect the same here. The drill is not a formality, it is how you find out what your ring actually covers.
| Rung | What the agent does | Gate to reach it |
|---|---|---|
| 1 · Read | Reads heatmaps, funnels, web vitals, dead clicks. Writes nothing anywhere. | Setup complete and verified server side. |
| 2 · Recommend | Writes findings to the brain, files tasks, proposes specific changes with the data behind them. A human ships. | Roughly two weeks of real traffic, so weekday and weekend both appear. |
| 3 · Experiment | Creates the feature flag and the experiment itself. A human approves the ship or the revert. | About 1,000 conversion relevant sessions per variant. This is the real gate. |
| 4 · Autonomous | Ships winners and reverts losers inside written guardrails, and reports what it did. | Rung 3 has run clean for several cycles with no human override needed. |
Do not assume every variant on a site is an experiment. A URL parameter that changes a headline to match the ad that was clicked is message match, and it exists to hold continuity from ad to page. It is a deliberate conversion technique, not an A/B test, and swapping it for a feature flag would remove the continuity and lower conversions. Before an agent touches any variant mechanism, confirm what it is actually for. Ask, do not infer.
Status, stated plainly so nobody reads this section as a description of something that exists.
Live today: the Sentry self heal loop, with all seven stages, the ring, the reviewer, the breaker, the drills, and the Slack approval path. PostHog itself, fully instrumented and verified on the reference build. The brain, with retrieval and the nightly intake.
Designed here, not yet built: the CRO propose agent, its reviewer, and the CRO surface registration. That is the work. The point of this section is that it is a franchise of a proven machine and a config seam, not a greenfield build, so scope it accordingly and reuse the exam rather than reinventing the guardrails.
The platform can close the loop on day one. The data cannot, and the brain decides whether it should. Ship the reading before you ship the writing.
PostHog's free tier is permanent and it survives upgrading, so adding a paid feature later does not remove the free allowance. There is no platform fee, which means every client site can have its own project without a per seat cost.
| Allowance | Free tier | Then |
|---|---|---|
| Events | 1,000,000 | $0.00005 each |
| Session recordings | 5,000 | $0.005 each. The only real cost driver. Sample them. |
| Feature flag requests | 1,000,000 | $0.0001 each |
| Survey responses | 1,500 | Rarely reached |
The order matters. Toggles before traffic, because heatmaps never backfill. Proxy before the tag, so you ship the final snippet once. Verification before the dashboard, so you are not building charts on a broken pipe.
Once a site is live, the recurring work is small and should stay small.
cumulative_count fall off, and is the offer above or below that point.