<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Privatrak]]></title><description><![CDATA[Notes from building Privatrak, privacy-friendly analytics you set up without code.]]></description><link>https://privatrak.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ad244dc9f0b5aa81eb55287/8f761cb9-30fc-4b42-9035-4e3eedf56764.gif</url><title>Privatrak</title><link>https://privatrak.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 22 Oct 2026 05:06:56 GMT</lastBuildDate><atom:link href="https://privatrak.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[We gave our MCP server an undo button]]></title><description><![CDATA[Letting an AI agent change your product's settings is only reasonable if you can take it all back. So every change our MCP server makes goes into a run…]]></description><link>https://privatrak.hashnode.dev/we-gave-our-mcp-server-an-undo-button</link><guid isPermaLink="true">https://privatrak.hashnode.dev/we-gave-our-mcp-server-an-undo-button</guid><category><![CDATA[AI]]></category><category><![CDATA[mcp]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Developer]]></category><category><![CDATA[SaaS]]></category><dc:creator><![CDATA[Quentin Wendegass]]></dc:creator><pubDate>Wed, 21 Oct 2026 03:51:16 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad244dc9f0b5aa81eb55287/045738d7-1b1c-4293-8fcd-6253f528159f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Letting an AI agent change your product's settings is only reasonable if you can take it all back. So every change our MCP server makes goes into a run that can be undone in one step. This is how it works, and the decisions that turned out to be hard.</em></p>
<p>In July 2025, Replit's AI agent deleted a company's production database during a code freeze, then told the user the data couldn't be recovered. It could. Replit had a rollback, and the user restored everything from a checkpoint. The agent just <a href="https://incidentdatabase.ai/cite/1152/">didn't know the feature existed</a>. Two lessons from that story shaped what we built: the agent has to know that undo exists, and people have to be able to undo without asking the agent.</p>
<p>We build <a href="https://privatrak.com">Privatrak</a>, a cookieless analytics tool. Its MCP server lets Claude, Cursor or Codex do more than read your analytics. They can set them up: feature rules that name what people click, funnels, dashboards, and marking click problems as fixed. 17 of its 44 tools change something.</p>
<p>Most MCP servers with write access rely on the client asking before each call. We wanted one more thing: after an agent has made twenty changes, you see all of them in one list and undo them in one step, without losing what you or a colleague changed in between.</p>
<h2>Asking first isn't enough</h2>
<p>Claude asks before it runs a tool marked destructive. That's good, and we set our tool annotations carefully for it. But it depends on the server labelling its tools honestly, which is why the MCP specification tells clients to treat those labels as untrusted unless the server is trusted. And it protects one call at a time, while an agent's work isn't one call.</p>
<p>Setting up a project takes a dozen or twenty writes: rules, a funnel built on those rules, a dashboard whose widgets query them. Nobody reads the fifteenth confirmation as carefully as the first. Security people call this confirmation fatigue, and OWASP's list of risks for AI agents includes it. And the mistakes in a setup rarely show in a single call. They show later, when a dashboard counts the wrong thing.</p>
<p>What you need then isn't another confirmation. You need to see everything the agent did as one unit, and to take that unit back. Developers have that for code in version control. Product settings usually have nothing like it.</p>
<h2>Undo has to live in the server</h2>
<p>Coding agents already have undo. Claude Code and Cursor keep checkpoints you can go back to. But both only cover files the agent edited. <a href="https://code.claude.com/docs/en/checkpointing">Claude Code's docs</a> say changes made through commands aren't tracked, and <a href="https://docs.cursor.com/agent/chat/checkpoints">Cursor's</a> say restoring a checkpoint doesn't undo a database migration the agent ran. Once an agent calls an API, the client can't take it back. Only the service behind the API can.</p>
<p>MCP doesn't help here either. The <a href="https://modelcontextprotocol.io/specification/2025-11-25/server/tools">specification</a> asks clients to keep a person in the loop and to confirm tool calls, but it has nothing for transactions or rollback. So if you want undo for what an agent does in your product, you build it into your MCP server. That's what the rest of this article is about.</p>
<h2>One task, one run</h2>
<p>The unit of undo is a <strong>setup run</strong>. An agent starts one at the beginning of a task with <code>start_setup_run</code> and a label such as "Checkout tracking", then passes its id on every write. The run answers the question "what did the assistant do?"</p>
<p>Two rules make it reliable:</p>
<ul>
<li><p><strong>Nothing happens outside a run.</strong> A write tool called without a run id starts a run itself and returns its id. Even an agent that ignores the instructions leaves a trail that can be undone.</p>
</li>
<li><p><strong>Runs that changed nothing don't clutter the list.</strong> If the API rejects a write, the tool deletes the run it had just started for it.</p>
</li>
</ul>
<p>In the API, a write joins a run through a header, <code>X-Setup-Run-ID</code>, not a body field. DELETE has no body, and all fourteen write operations should join the same way. A write without the header takes exactly the code path it always took, so adding runs changed nothing that already worked.</p>
<p>The runs also show up in the dashboard under Settings, as <strong>AI assistant changes</strong>. The person who connected the assistant doesn't need the assistant to undo what it did.</p>
<img src="https://privatrak.com/blog/mcp-server-undo-2.png" alt="The AI assistant changes page in Privatrak's settings, listing the run &quot;Checkout tracking&quot; with what it changed and buttons for Details and Undo" style="display:block;margin:0 auto" />

<h2>Record before and after, in the same transaction</h2>
<p>Every write that belongs to a run is saved together with a record of what it changed: the state before and the state after. Both are saved in one database transaction, so either both happen or neither does. The run is locked meanwhile, so nobody can undo it halfway through a write.</p>
<p>The recorded state is the whole definition: a rule's name and conditions, a funnel's steps, a dashboard with its widgets. Everything undo needs to rebuild it. The database enforces the shape: a creation has no "before", a deletion has no "after".</p>
<p>One detail cost us more time than the rest. Undo later compares these records with what is in the database now. If the same thing can be saved in two ways that mean the same, the comparison fails, and every undo looks like a conflict. So every write saves its input in exactly one form.</p>
<h2>Undo walks backwards</h2>
<p>Undo replays the run's changes newest first, in one transaction:</p>
<ul>
<li><p><strong>Created</strong> becomes deleted.</p>
</li>
<li><p><strong>Updated</strong> gets back the fields the run changed.</p>
</li>
<li><p><strong>Deleted</strong> is recreated, with its original id.</p>
</li>
</ul>
<p>Newest first matters. An agent that created a rule and then renamed it has to unwind the rename before the creation. And if the run created something and later deleted it itself, there is nothing to do for it at all.</p>
<p>The original id matters more than it sounds. Dashboards, funnels and links refer to things by id. A recreated rule even gets its original creation time back, because the order of rules decides which name the journey view shows for a click. Undo should leave the project reading exactly as before, not just something equivalent.</p>
<p>Afterwards the run is marked as undone and its report is stored. Asking again returns the same report, and a write that tries to join an undone run is refused.</p>
<img src="https://privatrak.com/blog/mcp-server-undo-1.png" alt="Left, the agent's four changes from oldest to newest. Right, what undo does with each, from the newest up: the dashboard is deleted, the renamed funnel is skipped, the rule's old name is put back, the rule is deleted" style="display:block;margin:0 auto" />

<p><em>The undo starts with the agent's last change and works back to its first. The funnel you renamed stays, and everything else goes.</em></p>
<h2>Never overwrite someone else's work</h2>
<p>This was the hardest decision. Between the agent's run and the undo, people keep working. Someone renames a widget the agent made, or fixes a rule's condition. Undo must not destroy that.</p>
<p>So every change is checked before it is reverted:</p>
<ul>
<li><p>Undoing a creation requires the thing to still be exactly what the run created.</p>
</li>
<li><p>Undoing an update requires every field the run changed to still hold the run's value. Fields the run didn't touch may have changed since. They are none of its business.</p>
</li>
<li><p>A dashboard the run created that now holds someone else's widgets is not deleted.</p>
</li>
<li><p>Recreating something that would go over the plan's limits is skipped, not forced through.</p>
</li>
</ul>
<p>When a check fails, that one change is skipped and reported with the reason, and the rest of the run is still undone. We considered stopping the whole undo instead. But then one widget someone renamed would keep an agent's entire setup in place. Skipping and reporting gives people the undo they asked for, minus exactly the things they have since made their own.</p>
<p>What counts as someone's work was a decision too. Moving or resizing a widget doesn't count. Someone who drags an agent's widget aside hasn't made it theirs, and deleting it destroys nothing they did.</p>
<h2>The preview is the real undo, rolled back</h2>
<p>You want to see an undo before it happens. The MCP tool <code>undo_setup_run</code> does a dry run unless told otherwise, and the server's instructions tell the agent to show you the result first. In the dashboard, the dialog says "This is what undoing will do. Nothing has been changed yet." before the button.</p>
<img src="https://privatrak.com/blog/mcp-server-undo-3.png" alt="The &quot;Undo these changes?&quot; preview: the dashboard will be removed, the feature rule gets its old name back and is removed, and the renamed funnel stays as it is" style="display:block;margin:0 auto" />

<p>The preview isn't a separate prediction. It runs the actual undo, every check and every write, inside the transaction, and then rolls the transaction back. A prediction written as separate code drifts away from what undo really does, and then the preview is wrong exactly when it matters. One code path can't disagree with itself.</p>
<h2>Say what can't come back</h2>
<p>Some things can't be undone exactly, and pretending otherwise is worse than saying so.</p>
<p>In Privatrak, a funnel counts from the day it is created. The per-visitor data that could recount earlier days is deleted every night, for privacy. That has two consequences for undo:</p>
<ul>
<li><p>Undoing a funnel the agent created also deletes the days it has counted since. The preview says how many.</p>
</li>
<li><p>A funnel the agent deleted comes back with its name and steps, but it counts again from the undo. Its old days are gone, and the report says so.</p>
</li>
</ul>
<p>The rule we took from this: when undo can't be exact, the preview has to say what will be lost, before anyone confirms.</p>
<h2>Undo is the last line, not the only one</h2>
<p>Undo is what makes write access reasonable, but it sits behind other guards:</p>
<ul>
<li><p><strong>Tools go through our own API.</strong> Each tool sends an ordinary request to our existing API, with the same authentication, validation, plan limits and project scoping as any other client. There is no second path to the data, so there is no second set of rules to get wrong.</p>
</li>
<li><p><strong>Write access is a fixed list.</strong> A read-only connection can reach a fixed list of API endpoints. Write access adds exactly the endpoints the write tools need, and nothing else. Tests fail if the tools and the lists disagree, in either direction.</p>
</li>
<li><p><strong>People choose at sign-in.</strong> Which projects the connection may see, and whether it may only read or also make changes.</p>
</li>
<li><p><strong>Every call names its project.</strong> The server keeps no "current project" that one chat could switch for another.</p>
</li>
<li><p><strong>The hints are honest.</strong> Creating isn't destructive. Updating, deleting and undoing are, so Claude asks before them.</p>
</li>
</ul>
<p>When we submitted Privatrak to <a href="https://claude.ai/directory/privatrak">Claude's connector directory</a>, the undo made one more thing possible: a test plan that calls all 44 tools on a shared review account and ends by undoing everything it changed.</p>
<h2>If you build this yourself</h2>
<ol>
<li><p>Pick the unit: one task, one run. Make every write join a run, even when the agent forgets to start one.</p>
</li>
<li><p>Record the whole state before and after, in the same transaction as the write, saved in one fixed form so you can compare it later.</p>
</li>
<li><p>Undo newest first, in one transaction, and recreate deleted things with their original ids.</p>
</li>
<li><p>Check every change for conflicts. Skip and report; never force, and never give up on the whole run.</p>
</li>
<li><p>Make the preview the real undo, rolled back.</p>
</li>
<li><p>Say what can't come back, before it is lost.</p>
</li>
<li><p>Put undo where people are: in the tool for the agent, and in your product's UI for everyone else.</p>
</li>
</ol>
<p>Privatrak's MCP server works with Claude, Cursor and Codex. <a href="https://privatrak.com/docs/ai-assistant">Our docs</a> show how to connect it.</p>
]]></content:encoded></item><item><title><![CDATA[Our analytics counted US visitors twice every evening]]></title><description><![CDATA[A tester in the US saw more visits than there should be. The cause was one assumption about what a day is, and it hides in more than analytics.]]></description><link>https://privatrak.hashnode.dev/our-analytics-counted-us-visitors-twice-every-evening</link><guid isPermaLink="true">https://privatrak.hashnode.dev/our-analytics-counted-us-visitors-twice-every-evening</guid><category><![CDATA[analytics]]></category><category><![CDATA[privacy]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Programming Tips]]></category><category><![CDATA[Developer]]></category><dc:creator><![CDATA[Quentin Wendegass]]></dc:creator><pubDate>Fri, 16 Oct 2026 15:52:16 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad244dc9f0b5aa81eb55287/ba6ac2ee-3616-4443-a8fe-32de5d025371.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>We build <a href="https://privatrak.com">Privatrak</a>, a cookieless analytics tool. In September one of our testers, who lives in the US, told us something was off. His data looked weird. There were more visits every day than there should have been.</p>
<p>He was right. For visitors in the US we were counting some people twice, every day, around dinner time. The reason is obvious in hindsight and was invisible before: we had never decided what a "day" is.</p>
<img src="https://privatrak.com/blog/visitor-counted-twice-1.png" alt="Two timelines for the same New York visitor at 7:50 pm and 8:10 pm. With the day cut at midnight UTC, the visitor identifier changes at 8 pm and the person counts as two visitors. With the day cut at local midnight, they count as one." style="display:block;margin:0 auto" />

<h2>How we count people without a cookie</h2>
<p>A cookie is a label stuck on a browser. Every page view carries it, so counting unique visitors is easy.</p>
<p>We don't store anything on the visitor's device, so we compute an identifier on the server from things every request already contains:</p>
<pre><code class="language-plaintext">visitor_id = HMAC(daily_key, IP address + browser + site)
</code></pre>
<p>The same person on the same device produces the same identifier on every page view, so we count them once. We never keep the raw IP address or browser string, only the hash.</p>
<p>The <code>daily_key</code> is what keeps this private. If it never changed, the identifier would be a stable fingerprint that follows someone for months, which is exactly what cookie rules exist to prevent. So the key changes once a day. After the change, yesterday's identifiers can't be matched to today's.</p>
<p>What we hadn't thought about: "once a day" means "at some clock time". We had picked midnight UTC without realising it was a choice.</p>
<h2>Midnight UTC is not midnight</h2>
<p>Midnight UTC arrives at very different times of day depending on where your visitors are:</p>
<table>
<thead>
<tr>
<th>Your visitors are in</th>
<th>Midnight UTC is, for them</th>
</tr>
</thead>
<tbody><tr>
<td>Los Angeles</td>
<td>4–5 pm</td>
</tr>
<tr>
<td>New York</td>
<td>7–8 pm</td>
</tr>
<tr>
<td>São Paulo</td>
<td>9 pm</td>
</tr>
<tr>
<td>London</td>
<td>midnight–1 am</td>
</tr>
<tr>
<td>Berlin</td>
<td>1–2 am</td>
</tr>
<tr>
<td>Dubai</td>
<td>4 am</td>
</tr>
<tr>
<td>Mumbai</td>
<td>5:30 am</td>
</tr>
<tr>
<td>Singapore</td>
<td>8 am</td>
</tr>
<tr>
<td>Tokyo</td>
<td>9 am</td>
</tr>
<tr>
<td>Sydney</td>
<td>10–11 am</td>
</tr>
</tbody></table>
<p><em>(The ranges are the two sides of daylight saving time.)</em></p>
<p>For a European audience that moment is the middle of the night, so almost nobody is browsing and almost nobody gets split. For a US audience it lands in the early evening. Someone reads your pricing page at 7:50 pm, goes to dinner and comes back at 8:10 pm. The key changed at 8:00 (in winter it is 7:00). Same person, new identifier, two visitors.</p>
<img src="https://privatrak.com/blog/visitor-counted-twice-2.png" alt="Hourly traffic bars for a New York audience over one day, rising through the afternoon to a peak around 7 to 8 pm. A red dashed line at 8 pm marks where the visitor identifier changes, so everyone browsing across it counts twice." style="display:block;margin:0 auto" />

<p>That is why a tester in the US saw more visits than expected, and why a tester in Europe would never have noticed. We can't give you a percentage, because it depends on how long your visitors stay, how many come back the same evening and when your traffic peaks. What we can say is what happened after the fix: the difference was big, and his funnels improved as well.</p>
<p>The funnels make sense once you see it. A funnel follows one visitor from step to step, so someone who looked at the pricing page at 7:50 pm and signed up at 8:10 pm was two visitors, and neither of them ever completed the funnel. Once the day was cut in the right place, his funnel numbers also matched what the insights in the dashboard showed.</p>
<h2>Who this affects</h2>
<p>Not everyone. It takes distance: if your visitors live close to the timezone where the day is cut, almost nobody is browsing when it turns over, and nobody gets split. It bites when visitors are far from that boundary. For a product like ours, where customers and visitors can be in every timezone, any single fixed boundary can land in somebody's evening, so one boundary for everyone can't be right.</p>
<p>A site whose visitors are mostly in one region can fix it by choosing that region's timezone. A site with a truly worldwide audience can't make it go away, because some part of the world is always in the evening when the day turns over. The best you can do is cut the day where your own traffic is lowest.</p>
<h2>It wasn't only visitors</h2>
<p>When we looked, the visitor key was only one of two places that used the UTC day. The other was every daily bucket in the reports. We had two separate pieces of code that each decided independently that a day starts at midnight UTC. They happened to agree with each other, so the mistake didn't show up as a disagreement anywhere.</p>
<p>For a New York project, the bar labelled "Tuesday" actually held Monday 8 pm to Tuesday 8 pm. So the problem wasn't just a few extra visitors. Every day in the report was shifted. A newsletter sent at 9 pm on Monday would have appeared to work on Tuesday.</p>
<h2>The fix: decide once, in one place</h2>
<p>We gave every project a timezone and made it decide both things: the moment the key changes and the edges of every daily bucket. Now there is only one value, so the two can't disagree.</p>
<p>It looked like a small change. It wasn't.</p>
<p><strong>A counted day is frozen.</strong> We delete the raw rows of a day once we've counted it, for privacy. So when someone changes a project's timezone, we can't re-count old days on the new boundary; the rows that would answer it are gone. A timezone change applies from then on, and the first day after a change is longer or shorter than 24 hours.</p>
<p><strong>Daylight saving makes days 23 or 25 hours long.</strong> An hourly chart has 25 buckets on the day the clocks go back and 23 when they go forward. We label each bucket with the UTC offset at that moment, so there's one bucket per real hour.</p>
<h2>This is not only an analytics bug</h2>
<p>Once we'd seen it, we started spotting the same bug elsewhere. Plenty of software never decides whose day it is or when things reset, and just uses whatever clock the server has. Places we'd look:</p>
<ul>
<li><p><strong>Daily quotas and rate limits</strong> that reset at midnight UTC, so users elsewhere get their fresh allowance at an odd hour.</p>
</li>
<li><p><strong>Streaks and daily goals.</strong> A streak that breaks because the server's midnight arrived mid-evening is a classic.</p>
</li>
<li><p><strong>"Today" in dashboards and reports,</strong> especially when you compare it with another system's day, such as your payment provider's.</p>
</li>
<li><p><strong>Daily budgets and billing periods</strong> that pace or reset by an account's day.</p>
</li>
<li><p><strong>Scheduled digests and cron jobs.</strong> "Every day at 9 am" in whose 9 am?</p>
</li>
<li><p><strong>Anything that rotates an identifier on a schedule:</strong> session IDs, rate-limit buckets, A/B test bucket seeds. If it changes at a fixed time, it splits whoever is mid-action, exactly like ours did.</p>
</li>
<li><p><strong>Daylight saving,</strong> for everything that assumes a day has 24 hours.</p>
</li>
</ul>
<h2>What we'd do differently</h2>
<p>The first time <code>DATE(timestamp)</code> or "reset at midnight" shows up in code, ask whose midnight it is. We didn't, and we found out when a tester in another timezone noticed.</p>
]]></content:encoded></item></channel></rss>