The short version: I gave an agent a GTM container ID and a GA4 measurement ID. Two days later we had an owned Model Context Protocol server, three isolated OAuth grants, an immutable publication gate, eighteen semantic events, four-surface browser QA, and a much healthier distrust of anything that says “Tag fired.”

The original request sounded tiny:

The starting material was two identifiers:

That is enough to paste two snippets into a page. It is not enough to build a measurement system.

Howdy Notes is an Astro site containing articles, a QR generator, an organizational simulator, and a browser game. Some pages share an Astro layout. Three products are standalone HTML applications under public/ and inherit nothing from that layout. The QR tool accepts arbitrary destination data and uploaded logos. The game has a replay flow. Internal links can unload the page before analytics finishes sending.

Then there is the control plane: Google Cloud API enablement, OAuth client configuration, OAuth scopes, Google account permissions, access to a particular GTM container, GTM workspaces, version creation, publication, CDN propagation, GA4 processing, and reporting.

“Add analytics” turned out to mean “design, authorize, mutate, test, publish, and operate a small distributed system.”

The useful result is not just the live implementation. It is the sequence of mistakes that revealed the actual architecture.

The first implementation worked—and we rejected it

The first version collected real GA4 packets.

Site code loaded GTM, configured the Google tag directly with gtag(), sent an explicit page view, and exposed a shared event helper for the products. Browser QA saw valid page_view, select_content, QR, simulator, and game events. Query strings did not leak into page_location.

Technically, it worked.

Architecturally, it was wrong.

GTM was acting as a JavaScript loader while the website owned the real analytics configuration. The tags were not managed through GTM, which was the point of the exercise. Worse, a future Google tag added inside GTM could quietly create two owners:

site code → gtag config + page_view
GTM       → Google tag + automatic page_view

That is how duplicate measurement grows: not through one dramatic mistake, but through two individually reasonable implementations overlapping.

So we rolled the direct GA4 runtime back. The site kept only the standard GTM container shell. Production collection intentionally went dark while the proper control plane was built.

This was the first durable rule:

Pick one owner for tag configuration and one owner for page views. If you cannot name the owner, you do not have an architecture.

In the final design, GTM owns the Google tag and page views. Site code owns only the privacy-safe data-layer contract.

“One website” was four runtime surfaces

The Astro layout covers normal pages such as the homepage and articles. It does not cover:

Each standalone product needed its own GTM loader, page context, consent defaults, data-layer bootstrap, and browser test.

Adding GTM to a shared layout and checking the homepage would have produced a beautiful green check over a partially unmeasured site.

The test matrix therefore used four representative surfaces:

/
/qr-code-generator/
/organizational-headwind-simulation.html
/neko-rogaru/

Every important change had to pass all four.

This sounds obvious after the fact. Most architecture does.

We used another MCP server as a reference, not as authority

The open-source Stape Google Tag Manager MCP server was a valuable reference. It showed how GTM API operations can become structured agent tools: list accounts, inspect workspaces, manage tags and variables, create versions, preview, and publish.

For a moment, its hosted MCP endpoint was registered locally.

Then we stopped.

A strong reference implementation does not automatically authorize its hosted service to hold production control-plane access. Connecting it would have placed Google OAuth and GTM authority across infrastructure we did not own. That may be acceptable for someone who deliberately chooses the service. It was not the architecture we wanted here.

The hosted entry was removed before Google authorization completed. No GTM token was stored there and no container data was read through it.

Instead, we built a small owned TypeScript MCP server:

MCP gave the agent a structured control surface. Ownership decided who was allowed to touch it.

OAuth was not one gate

The authentication sequence generated more useful lessons than the tag creation.

These are separate dependencies:

  1. the Tag Manager API is enabled in a Google Cloud project;
  2. an OAuth client exists;
  3. the redirect URI is valid;
  4. the signing-in account is allowed by the OAuth consent configuration;
  5. the token contains the required scopes;
  6. the Google identity has access to the target GTM account and container.

Passing one does not imply another.

We hit several failures:

None of these meant “the GTM API is broken.” They were failures at different gates.

The final authorization model split authority into three local grants:

Read profile

Only tagmanager.readonly.

Its job was to prove the identity, resolve the real account/container IDs, list workspaces, inspect the live version, and establish the baseline. No mutation tool needed to exist yet.

Manage profile

Read plus the minimum scopes for container editing and container-version creation.

It could create an isolated workspace, reconcile managed resources, compile a preview, and freeze an unpublished candidate. It had no publish scope.

Publish profile

Read plus publish authority, isolated from edit authority.

It existed only for the final exact-version publication path.

That separation matters even on a personal project. The safest token is the token that cannot perform the next dangerous action.

The MCP server was intentionally narrow

The server was not a generic “do anything in any GTM account” robot.

Mutation was bound to one resolved account/container pair. Writes defaulted off. Publishing required both the write gate and a separate publish gate. Every write required the exact public container ID as confirmation.

Publication required a stronger phrase:

PUBLISH GTM-MP5H8XJB VERSION 3

The version ID was part of the approval. “Yes, publish” was not enough, because it does not say what will be published.

The reconciliation operations were designed to be idempotent:

An agent should be able to rerun the desired state safely. It should not be able to guess which of three similarly named tags the human “probably meant.”

We designed an event contract, not a click landfill

The useful questions came before the event names:

That produced a small semantic contract:

Writing:
page_view → content_engaged → select_content

QR generator:
page_view → qr_customize → qr_export

Organizational Headwind:
page_view → simulation_configure → simulation_run

Neko Rogaru:
page_view → game_start → game_complete → replay_generate → replay_share

The full contract contains eighteen allowlisted semantic events and thirty-six data-layer variables. One exact-regex custom-event trigger accepts only those event names. One GA4 event tag uses {{Event}} and the allowlisted variables.

This avoided a small forest of nearly identical tags.

The naming rule was simple:

Name events after user intent or product state, not the DOM mechanism that happened to reveal it.

qr_export is useful. download_button_click is implementation trivia.

game_complete is useful. results_modal_open mistakes a UI side effect for a product outcome.

Attempts, successes, and failures also remain separate. A click is not a confirmed export. An export attempt is not an export. A replay button press is not a successfully generated replay.

Good measurement is controlled information loss. It keeps the distinctions required for decisions and throws away the rest.

Privacy lived in the interface

The QR generator can contain exactly the data analytics should not casually collect:

The data-layer interface does not accept those values.

It excludes:

page_location and page_referrer are reduced to safe origins and paths before GTM reads them. Unknown events and unknown parameters are rejected. The tracking specification carries its own version so a future schema change is visible in the data.

Consent defaults are queued before GTM:

Google Signals and ad-personalization signals are disabled in the Google tag configuration.

This later prevented a bad diagnosis. When no request appeared in DevTools, it was tempting to blame Consent Mode. The network consent signal showed analytics was allowed. The real problem was elsewhere.

Privacy is much stronger when it is structural:

Do not collect a dangerous field and rely on future discipline. Build an interface that cannot accept it.

Draft first, candidate second, production last

The manage-scoped MCP created an isolated GTM workspace and reconciled:

GTM quick preview reported:

At the same time, the matching site data-layer contract was built across all four runtime surfaces. Static ownership checks and the Astro production build passed. A local browser saw one GTM load per surface and zero direct GA4 requests.

That “zero” was a feature. The site contract was inert until GTM owned the destination.

The inert site code was deployed first. Then the isolated workspace was packet-tested in Preview. Each surface produced exactly:

Only after that did the MCP create immutable candidate version 3.

GTM removes the source workspace when it creates a version, so the candidate ID and resulting workspace state had to be recorded. The same four-surface packet suite was then rerun against the immutable candidate environment.

Preview of a mutable workspace was not the publication gate. Candidate-version QA was.

Publishing was its own transaction

The candidate existed. Production still served version 2, which contained no GA4 tags.

This produced a wonderfully confusing state:

dataLayer works
→ GTM container loads
→ GTM Preview works
→ normal production sends nothing

The explanation was boring and decisive:

production loaded live version 2
candidate version 3 contained the tags
version 3 was not published

Not Consent Mode. Not a broken data layer. Not a CSP problem. The tag existed in the wrong state.

After the exact publication approval, the process:

  1. authorized the isolated publish profile;
  2. re-read the candidate;
  3. validated the target container and fingerprint;
  4. published only version 3;
  5. re-read the live version;
  6. tested a normal production browser.

The first post-publication browser fetch briefly received the old GTM object. GTM’s CDN response advertises a private cache lifetime, so publication and edge delivery are not instantaneous. After propagation, production sent a page_view and a semantic event to G-W6ND3K69NY, with the expected consent signal and no test query or fragment leakage.

The live container later advanced to version 4, which still contains the same managed Google tag, event tag, allowlist trigger, and variables. The durable lesson is to read the live API state rather than assume the version you last published is still current.

Preview mode told the truth and still misled us

The next bug was better.

In GTM Preview, tags fired. In normal browsing, DevTools appeared empty. The console also showed:

Executing inline script violates the following Content Security Policy directive...
chrome-extension://...

That message came from a Chrome extension. Howdy Notes served no CSP header. Extension code violating the extension’s own policy was noisy, but irrelevant to the site-owned GTM loader.

A clean Chrome profile with extensions disabled showed normal production behavior:

The first hit took about 8.5 seconds to appear because the Google scripts loaded slowly. An immediate DevTools check could look empty.

But there was also a real bug.

For internal links, the site pushed select_content and immediately navigated. In a normal browser, page unload sometimes won the race before GA4 dispatched the request. When navigation was prevented, the identical event returned 204.

Preview mode adds debugging work and changes timing. That extra delay was enough to hide the race.

The repair uses GTM’s event callback plus an independent bounded timeout:

push semantic event
→ navigate when GTM calls eventCallback
→ or navigate after 750 ms no matter what

Modified clicks, downloads, external links, new-tab targets, and same-page anchors are not delayed.

Three tests matter:

After deployment, a clean normal Chrome run confirmed that the click now creates the expected select_content POST before landing on the article. Chrome cancelled the first fetch during unload and immediately opened a replacement request for the same event. DevTools Protocol did not expose a terminal 204 for that replacement, so this is evidence that the production tag fires during real navigation—not evidence of downstream GA4 processing. That distinction belongs in the record.

This is the sharpest browser lesson from the project:

Preview is a diagnostic environment. It is not a faithful performance model of normal navigation.

The agent forgot the project

One failure had nothing to do with Google.

During the build, the agent’s heartbeat routine drifted into low-value maintenance work and stopped advancing this explicitly active project. It had logs. It had code. It had a pending OAuth and implementation milestone. It still lost the work queue.

That is a control-plane failure too.

The repair was not “remember harder.” Session memory is not project state.

The project now keeps:

The workspace priority model was also corrected: active user-directed work outranks routine housekeeping. Only urgent safety, security, data-loss, or critical-service incidents may preempt it.

An agent-operated control plane needs continuity controls around the agent.

Collection is not reporting

A browser request returning 204 proves dispatch. It does not prove the entire analytics system is complete.

There are at least four layers:

  1. Collection — did the browser send the correct packet?
  2. Processing — did GA4 accept, transform, model, or filter it?
  3. Reporting — are the required fields usable in the intended reports?
  4. Activation — are key events, audiences, Ads links, or warehouse exports configured?

This project proved production collection.

It did not silently promote that evidence into claims about custom definitions, key events, attribution, Realtime reliability, or BigQuery parity. Those require separate checks.

“The request left the browser” is a useful fact. It is not a dashboard strategy.

The reusable operating model

The entire process became an OpenClaw skill: Owned GTM MCP Operator.

It captures the repeatable parts:

Install the public Gist as a git-backed OpenClaw skill:

openclaw skills install \
  git:https://gist.github.com/khw0710/6b74125d09fd0f0b0d816060f5d548bc.git \
  --as owned-gtm-mcp-operator

Read it before using it. It is deliberately conservative because GTM publication changes production measurement.

The skill links back to this article as its homepage, so the operating procedure and the evidence trail remain connected.

What I would do the same way

What I would change next time

Final verdict

The final GTM container is not impressive because it has many tags. It has two managed tags, one allowlist trigger, and a controlled set of variables.

The impressive part is everything surrounding them:

Analytics is not installed when a snippet appears in source.

It is installed when the right event fires once, at the right moment, from every relevant runtime, survives real navigation, reaches the intended endpoint without leaking prohibited data, and can be explained by evidence rather than vibes.