@nytka/plugin-gtm
v0.1.2
Published
Google Tag Manager connector for nytka projects. Reads container configuration into datasets/ and registers it with provenance. Read-only.
Downloads
24
Readme
@nytka/plugin-gtm
Google Tag Manager connector for nytka projects. Reads one container's live configuration
into the project's datasets/ and registers it in datasets/index.json with provenance.
This is the READ half only. Creating and editing tags is a separate, later task
(PLG-011's write half), gated on a second, write-capable service
account that does not exist yet
(OPS-003). This package requests only tagmanager.readonly and
is read-only by construction — see Read-only, and how it is held
below, not merely by convention.
Install
npm install @nytka/plugin-gtmTwo dependencies. @googleapis/tagmanager, 2.16 MB unpacked — the same per-API split
@nytka/plugin-gsc uses for Search Console, verified 2026-07-28 against the npm registry.
It depends only on googleapis-common: no google-gax, so none of the
rimraf/glob/minimatch/brace-expansion advisory chain PLG-005 could not fully clear
for @nytka/plugin-ga4. And @nytka/core, which has no dependencies of its own.
Setup
Same credential every other Google connector in this line shares — if a project already
has @nytka/plugin-gsc or @nytka/plugin-ga4 configured, the only new step is granting
this service account access inside Tag Manager itself (step 3 below).
1. Enable the API
https://console.cloud.google.com/apis/library/tagmanager.googleapis.com → Enable.
Per Cloud project, same as every other Google API used here. A project already enabled for Search Console or Analytics has not enabled this one — it is a separate switch, and the first symptom of skipping it is a 403 that reads like a permissions problem when it is really "this API is off" (see decisions/0008, which found the identical trap while confirming GTM accepts service accounts at all).
2. Reuse or create the service account
If GOOGLE_SERVICE_ACCOUNT_KEY is already set for this project (GSC or GA4 is installed),
reuse that same key file — do not create a second one. Skip to step 3.
Otherwise: IAM & Admin → Service accounts → Create service account. Name it something
like <project>-google. Skip both optional steps — no IAM role is needed. Download the
key, move it into the project and rename it, exactly as @nytka/plugin-gsc's README
describes in full.
3. Grant it access in Tag Manager
Cloud IAM does not grant Tag Manager access — the account must be added inside GTM itself.
grep client_email <project>/private/google-service-account.jsonhttps://tagmanager.google.com/ → Admin → User Management → + → paste the email. Grant Read permission on the account (or on one container, if that is all this project needs). Never grant Publish or Edit to this account — it is the read credential, and 0008 requires the two to stay separable by more than a scope request.
4. Configure
One .env at the project root. Every key for the project lives there and nowhere else.
The name below ships with the package too, so recovering it later never means re-reading this guide:
cat node_modules/@nytka/plugin-gtm/.env.example >> .envGOOGLE_SERVICE_ACCOUNT_KEY=private/google-service-account.jsonA path, resolved from the project root, not the cwd — so it works from any subdirectory, same as every other Google connector here.
5. Verify
npx nytka-gtm discoverListing an account and its containers means every step above worked.
Use
npx nytka-gtm discover # auth smoke test — accounts and containers
npx nytka-gtm collect --account 6009123456 --container 123456789
npx nytka-gtm collect --account 6009123456 --container 123456789 --workspace 9
npx nytka-gtm collect --account 6009123456 --container 123456789 --no-registerdiscover
Lists every account and container this service account can read. Writes no files and
registers no dataset — it exists purely to prove the credential works and to hand you the
account and container ids collect needs.
collect
Collects one container's live configuration — not an observation over a date range,
because there is none. This connector reports what is configured to happen, not what
happened; that is also why there is no monthly command here the way there is in
@nytka/plugin-gsc and @nytka/plugin-ga4 — nothing here aggregates over time.
What it collects:
- the container record itself (name, public id)
- every workspace on the container
- for the default workspace — the numerically lowest
workspaceId, which the API never flags explicitly but is, for the overwhelming majority of containers, the original "Default Workspace" GTM created it with.--workspace IDoverrides this for a project that deliberately tracks a different one.- every tag
- every trigger
- every variable
- every built-in variable enabled on the container
- the container's version headers — its full publish history, by header only (name, numbers of each entity type, whether it was deleted) — plus, separately, whichever version is currently live, so the registry entry can name the published version number without walking the whole history to find it.
Programmatic:
import { run, discoverAccounts, collectContainer } from '@nytka/plugin-gtm'
const { id, counts, rawPath } = await run({ accountId: '6009123456', containerId: '123456789' })run() returns counts and paths, never the tags, triggers or variables themselves —
the same rule every connector in this line holds, because a container's tag configuration
is exactly the kind of thing a script should query, not something an agent should hold in
context.
What it writes
| Path | Committed? |
|---|---|
| datasets/payloads/<id>.json | no — the directory ships its own .gitignore |
| datasets/index.json | yes — one entry, added or replaced |
The dataset id — question-keyed, not version-keyed
gtm-6009123456-123456789-config
└────┬────┘ └───┬───┘
accountId containerIdThe id names the question this dataset answers — "what is configured on this
container" — never the answer. That is the same rule @nytka/plugin-sanity's
datasetId established for a content lake with no reporting period, applied here for the
same reason: a container's configuration is one fact that changes, not a series of
observations, so there is no reporting period to key on the way @nytka/plugin-gsc and
@nytka/plugin-ga4's monthly ids do.
registerDataset matches on id, so re-collecting the same container after a publish
replaces this entry rather than adding a second one describing the same container a
moment apart.
GTM does have something Sanity's content lake does not: a published container version is
a real, immutable, user-declared number — not an incidental hash of whatever happened to
be in the lake at request time. That number is worth keeping, and it is recorded in the
registry entry's publishedVersionId field, not in the id. Putting it in the id would
fragment one container's history into one registry entry per publish, the same drift
PLG-007 exists to prevent for monthly series, just approached
from the opposite direction — an id that would move too often rather than one that used to
move too little.
Read-only, and how it is held
@googleapis/tagmanager's generated client is one object carrying every RPC the API has —
including tags.create, tags.delete, workspaces.create_version and versions.publish.
Object.freeze()-ing that object would not remove any of those methods; freezing only
stops new properties being added; it has no opinion about the ones already there.
So this package never hands that generated client back to a caller, frozen or otherwise.
client() returns a new object, built once inside src/gtm.mjs, exposing only the
calls discover and collect need — every one of them a list or a get. The generated
client is a variable closed over inside that function and is never a property of the
returned object, so there is no path from the handle back to anything that could spell a
mutation. The returned object is frozen on top of that, so a caller cannot bolt a write
method back on either.
This is the same shape @nytka/plugin-sanity uses for a fetch-based client with no method
surface to hide — a frozen handle exposing only what a read needs, with the real
machinery closed over rather than attached. test/readonly.test.mjs checks it from both
directions, matching that package's suite: every key on the handle is a function whose
name reads as a read, nothing on it is itself an object a caller could reach into, and the
two scope literals tagmanager.publish and tagmanager.edit.containers do not appear
anywhere in src/ — not even in a comment, so there is nothing here that could spell them
by accident either.
Missing credential
If GOOGLE_SERVICE_ACCOUNT_KEY is not set, both discover and collect name the variable
and exit 0 — this project has not configured Google Tag Manager access, which is a
normal, unconfigured state, not a broken package
(decisions/0008 §0:
capability is configuration presence, and absence must produce a plain answer, not a stack
trace).
Rules it follows
- Payloads never enter agent context. Query them with a script; write conclusions to
research/. - The project is found by walking up for
project.yaml, so it works at any install depth. - Pagination follows
nextPageTokento exhaustion on every list call — accounts, containers, workspaces, tags, triggers, variables, built-in variables and version headers all page independently. - No YAML parsing, no config file. Secrets from
.env, everything else from flags.
Tests
npm testnode --test against fake handles matching the same shape client() returns — no
network, no credential, no client data. Covers pagination to exhaustion on every list
call, the dataset id rule (question-keyed, replacing rather than accumulating across a
publish), the missing-credential exit-0 behaviour against the actual CLI process, the
registry entry shape, and the read-only construction itself.
