My GTM container had 282 undocumented objects
I gave an agent a dependency graph to make it write notes on my GTM containers
TL/DR: I documented a GTM estate with 11 client-side web containers and one server-side container... the workflow developed on one 282-object web container gave an agent enough structure to write useful notes without letting it touch tracking logic.
I have maintained Google Tag Manager (GTM) containers long enough to know how this goes. The team adds a tag for a campaign, someone wires a variable into a trigger, a vendor leaves, and three years later the container still works. Nobody is fully certain why. Even if I was the one creating those objects myself, more often than not did I simply skip adding a note to each object for my future-self to understand what I did there π
The setup has 11 client-side web containers feeding one server-side container. I used one web container as the sample: 55 tags, 58 triggers, and 169 variables. That is 282 objects before considering folders and templates. Some already had notes from people (mostly me) who were there when the implementation was fresh. Many did not. Others had a changelog but no explanation.
The GTM UI tells me that a tag fires on a trigger. It does not tell me, in one place, which variables feed it, which tags use the same trigger, or how that object fits into the wider tracking setup. So why not reverse-engineer my GTM containers and extend on the notes I have to finally get a full picture? π€
I gave an agent a bounded job: write a one-sentence purpose and dependency summary for every tag, trigger, and variable. I kept the existing changelog. I kept every actual GTM change out of scope. The result was a documentation pass I could dry-run, review, and rerun after a failed API call (because Google APIs also have moods).
GTM is code with a clicky UI
I personally find the βGTM is for marketers, code is for engineersβ split a little silly. A production container has event flows, inputs, dependencies, sequencing, legacy branches, and runtime behaviour. That is software, even if it happens to be edited through a friendly UI.
A tag alone does not explain much. It can fire on one or several triggers, be blocked by consent or bot-detection triggers, use variables hidden in nested parameters, or require another tag to run before or after it. Variables can refer to other variables. Folders give the objects an operational home. The interesting context lives in the edges.
Here is the kind of note I wanted the script to produce:
Sends the Snowplow link_click event after client-side enrichment.
Folder: 1000 analytics
Fires on: 1010 user-interaction w/ tgtUrl. Uses: event_element_tgtUrl_url, 1000 snowplow - settings, 1010 snowplow - customContext.That is enough for me to decide whether to open the tag. It does not pretend to replace a tracking specification. It gives the next person a useful starting point.
First, make the container readable
I worked in a dedicated project with a GTM CLI (one of multiple available, cf. link below). The first step was always the same: resolve the current Default Workspace, then export it to JSON. Workspace IDs can change, so I did not bake one into a script.
$workspace = gtm workspaces list `
--account-id <account-id> `
--container-id <container-id> `
--format json |
ConvertFrom-Json |
Where-Object { $_.name -eq 'Default Workspace' } |
Select-Object -ExpandProperty workspaceId
gtm workspaces export `
--account-id <account-id> `
--container-id <container-id> `
--workspace-id $workspace `
-o container.jsonThe export is a much better substrate than a pile of screenshots. It contains folders, tags, triggers, variables, custom templates, clients, and their parameters. More importantly, it is versionable and inspectable outside the UI.
The agent needed a graph
I first built lookup maps for tags, triggers, variables, and folders. Then I walked the references in both directions.
foreach ($tag in $export.tags) {
foreach ($triggerId in $tag.firingTriggerId) {
$triggerUsedBy[$triggerId].Add($tag.name)
}
foreach ($variableName in (Extract-VarRefs $tag.parameter)) {
$variableUsedBy[$variableName].Add($tag.name)
}
}The complete pass also covers blocking triggers, setup and teardown tags, trigger filters, and nested list or map parameters. I filtered GTM built-in variables from the output because βUses: Page URLβ gets old after about four entries.

This was the useful bit. An agent handed only a tag name invents a plausible sentence. An agent handed the tag type, parameters, folder, trigger names, variable references, reverse dependencies, and existing notes can write something specific enough to review.
One sentence from the model
I made the modelβs task aggressively small. Every call returned a single JSON object with a single description field. No Markdown. No code fence. No explanation of what it did.
Return a single JSON object, nothing else.
Use exactly: {"description":"one short sentence"}
Describe this GTM entity in plain English. Prefer event names, vendor names, and data layer keys visible in the supplied context.The script invoked opencode run (using my local OpenCode CLI, obviously) in parallel for that first phase. It started asynchronous stdout and stderr reads before closing standard input, because a child process can fill its output buffer and stall at the finish line. Ask me how I found that one out...
I rejected invalid JSON, markdown fences, angle brackets, and overlong responses. A failed call did not stop the update. The script used a deterministic fallback based on the GTM object type: a gaawe item became a GA4 event tag, smm became a lookup table variable, and so forth.
Existing first lines won by default. The agent only replaced them with -ForceAI, because an old human-written note can be much better than a fresh synthetic one.
Google API gets the final say
The GTM CLI can create and edit many resources, but it does not expose the top-level notes field. Passing notes to --params would alter tag parameters, which is a deeply wrong way to βdocumentβ a tag.
I used the GTM REST API for this narrow part. Each update reads the live entity, adds the regenerated notes field, then writes the complete object back with PUT.
$current = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get
$current | Add-Member -MemberType NoteProperty -Name notes -Value $newNotes -Force
$body = $current | ConvertTo-Json -Depth 10 -Compress
Invoke-RestMethod -Uri $uri -Headers $headers -Method Put `
-Body $body -ContentType 'application/json'PATCH returned 404 for this endpoint. PUT worked with the full object returned by the preceding GET. Some trigger responses did not expose a writable notes property, hence the explicit Add-Member.

I preserved the existing changelog lines and rebuilt only the generated section:
Sends the Snowplow page_view event to the server-side collector.
Folder: 1000 analytics
Fires on: 1000 pageview-physical, 1001 pageview-virtual. Uses: 1000 snowplow - settings.
2025-02-28 martin: changed trigger
2023-01-03 martin: createdMake failure boring too
I ran the agent pass in parallel. I ran GTM writes sequentially. Google started returning 429s when I got greedy across several containers, so I raised the delay to 1,500 ms. The script retries 429, 503, and 504 responses with exponential backoff. A 502 still means waiting a little and rerunning.
The run is idempotent. It gets the current entity again, retains date-prefixed changelog entries, and rebuilds the description and relationship block. A partial run is annoying, not a disaster.
Authentication had a fun failure mode too. The CLI could list containers while the OAuth token on disk was already expired, then direct REST calls got 403. A non-interactive shell cannot complete the browser refresh. I had to run gtm auth login in a real terminal and try again.
A smaller archaeology job
The 11 web containers, server-side routing, and the 282-object sample container are still there. GTM does not become simple because a model wrote a sentence about each one. But I now have a repeatable way to turn every container into something I can navigate.
I think agentic engineering works well here because the division of labour is very clear. The script calculates relationships from structured data. The model turns that context into compact prose. I decide if the description is honest, and the API only receives the narrow documentation update after a dry run.
Next time somebody asks why a conversion tag fires after a particular custom event, I might even know before opening seven GTM tabs. Small victories. π
The script
And just in case anyone would like to replicate or build upon this:
#PowerShell version 7
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ExportJson,
[Parameter(Mandatory)][string]$AccountId,
[Parameter(Mandatory)][string]$ContainerId,
[Parameter(Mandatory)][string]$WorkspaceId,
[string]$Author = "martin",
[int]$RateLimitMs = 250,
[switch]$DryRun,
[switch]$ShowVerbose,
[switch]$NoAI,
[string]$AIModel = "llmbase-agent-api/deepseek/deepseek-v4-flash",
[int]$AIConcurrency = 4,
[int]$AITimeoutSec = 45,
[ValidateSet('all','tags','triggers','variables')][string]$Scope = 'all',
[switch]$ForceAI
)
$ErrorActionPreference = "Stop"
# ββ Load token (with refresh capability) ββ
$tokenFile = Join-Path $env:USERPROFILE ".config\gtm\token.json"
if (-not (Test-Path $tokenFile)) { throw "Token file not found: $tokenFile" }
function Refresh-Token {
# Force the CLI to actually exercise the token by making a real API
# request. `gtm auth status` is a no-op that doesn't trigger refresh
# even when it reports "will refresh on next request". A list call does.
$null = & gtm containers list --account-id $AccountId --format compact 2>$null
$script:token = Get-Content $tokenFile -Raw | ConvertFrom-Json
$script:headers = @{ "Authorization" = "Bearer $($script:token.access_token)" }
}
# ββ opencode subagent invocation ββ
# Must use Process directly. The `opencode run` call operator from PowerShell
# stalls because the child expects a TTY for stdin. Piping prompt via stdin
# avoids that. Used by the parallel AI pass; safe to call from runspaces.
function Invoke-OpencodeRun {
param(
[Parameter(Mandatory)][string]$Prompt,
[Parameter(Mandatory)][string]$Model,
[int]$TimeoutSec = 45
)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "opencode"
$psi.Arguments = "run --model `"$Model`""
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.StandardOutputEncoding = [System.Text.UTF8Encoding]::new($false)
$psi.StandardErrorEncoding = [System.Text.UTF8Encoding]::new($false)
$p = [System.Diagnostics.Process]::Start($psi)
# Start async reads BEFORE closing stdin; otherwise the child fills the
# OS pipe buffer (~26s) and blocks even after the model finishes.
$stdoutTask = $p.StandardOutput.ReadToEndAsync()
$stderrTask = $p.StandardError.ReadToEndAsync()
$p.StandardInput.Write($Prompt)
$p.StandardInput.Close()
$exited = $p.WaitForExit($TimeoutSec * 1000)
if (-not $exited) {
try { $p.Kill($true) } catch {}
return @{ ok = $false; error = "timeout" }
}
$stdout = $stdoutTask.Result
$stderr = $stderrTask.Result
if ($p.ExitCode -ne 0) {
return @{ ok = $false; error = "exit=$($p.ExitCode)"; stdout = $stdout; stderr = $stderr }
}
return @{ ok = $true; stdout = $stdout }
}
function Extract-JsonField {
# Pulls {"description":"..."} from a string that may contain noise.
param([string]$Text, [string]$Field)
if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
try {
$j = $Text | ConvertFrom-Json -ErrorAction Stop
if ($j.PSObject.Properties.Name -contains $Field) {
$v = [string]$j.$Field
if ($v.Trim()) { return $v.Trim() }
}
} catch {}
$clean = $Text -replace '(?s)```[a-zA-Z]*\s*', '' -replace '```', ''
try {
$j = $clean | ConvertFrom-Json -ErrorAction Stop
if ($j.PSObject.Properties.Name -contains $Field) {
$v = [string]$j.$Field
if ($v.Trim()) { return $v.Trim() }
}
} catch {}
$m = [regex]::Match($Text, '"' + [regex]::Escape($Field) + '"\s*:\s*"((?:[^"\\]|\\.)*)"')
if ($m.Success) {
$v = $m.Groups[1].Value -replace '\\"', '"' -replace '\\n', "`n" -replace '\\\\', '\'
if ($v.Trim()) { return $v.Trim() }
}
return $null
}
Refresh-Token
# ββ Load export JSON ββ
$export = Get-Content $ExportJson -Raw -Encoding UTF8 | ConvertFrom-Json
# ββ Build lookup maps ββ
$tagMap = @{} # tagId β tag object
$trigMap = @{} # triggerId β trigger object
$varMap = @{} # variableId β variable object
$tagByName = @{}
$trigByName = @{}
$varByName = @{}
$folderMap = @{}
foreach ($t in $export.tags) { $tagMap[$t.tagId] = $t; $tagByName[$t.name] = $t }
foreach ($t in $export.triggers) { $trigMap[$t.triggerId] = $t; $trigByName[$t.name] = $t }
foreach ($v in $export.variables) { $varMap[$v.variableId] = $v; $varByName[$v.name] = $v }
foreach ($f in $export.folders) { $folderMap[$f.folderId] = $f }
# ββ Build reverse dependency maps ββ
# referredBy: which tags use this trigger / variable
$trigReferredBy = @{} # triggerId β [tag names]
$varReferredBy = @{} # variableId/name β [entity names that reference it]
function Get-VarName {
param([string]$ref)
# Extract variable name from {{variableName}} reference
if ($ref -match '^\{\{(.+)\}\}$') { return $Matches[1] }
return $null
}
function Extract-VarRefs {
param($obj)
$refs = [System.Collections.Generic.HashSet[string]]::new()
if ($null -eq $obj) { return @() }
function Walk-Params($params) {
if ($null -eq $params) { return }
foreach ($p in $params) {
if ($p.value -is [string] -and $p.value -match '\{\{(.+?)\}\}') {
$matches = [regex]::Matches($p.value, '\{\{(.+?)\}\}')
foreach ($m in $matches) {
[void]$refs.Add($m.Groups[1].Value)
}
}
if ($p.map) { Walk-Params $p.map }
if ($p.list) {
foreach ($item in $p.list) {
if ($item.map) { Walk-Params $item.map }
if ($item.value -is [string] -and $item.value -match '\{\{(.+?)\}\}') {
$mm = [regex]::Matches($item.value, '\{\{(.+?)\}\}')
foreach ($m2 in $mm) { [void]$refs.Add($m2.Groups[1].Value) }
}
}
}
}
}
Walk-Params $obj
return $refs
}
# Tags: firingTriggerId, blockingTriggerId, parameter refs, teardownTag, setupTag
foreach ($tag in $export.tags) {
if ($tag.firingTriggerId) {
foreach ($tid in $tag.firingTriggerId) {
if (-not $trigReferredBy.ContainsKey($tid)) { $trigReferredBy[$tid] = [System.Collections.Generic.List[string]]::new() }
$trigReferredBy[$tid].Add($tag.name)
}
}
if ($tag.blockingTriggerId) {
foreach ($tid in $tag.blockingTriggerId) {
if (-not $trigReferredBy.ContainsKey($tid)) { $trigReferredBy[$tid] = [System.Collections.Generic.List[string]]::new() }
$trigReferredBy[$tid].Add($tag.name)
}
}
$vrefs = Extract-VarRefs $tag.parameter
foreach ($vr in $vrefs) {
$key = $vr
if (-not $varReferredBy.ContainsKey($key)) { $varReferredBy[$key] = [System.Collections.Generic.List[string]]::new() }
$varReferredBy[$key].Add($tag.name)
}
}
# Triggers: parameter refs in filter, customEventFilter
foreach ($trig in $export.triggers) {
$vrefs = Extract-VarRefs $trig.filter
$vrefs2 = Extract-VarRefs $trig.customEventFilter
$allRefs = $vrefs + $vrefs2
foreach ($vr in $allRefs) {
if (-not $varReferredBy.ContainsKey($vr)) { $varReferredBy[$vr] = [System.Collections.Generic.List[string]]::new() }
$varReferredBy[$vr].Add($trig.name)
}
}
# Variables: parameter refs
foreach ($var in $export.variables) {
$vrefs = Extract-VarRefs $var.parameter
foreach ($vr in $vrefs) {
if (-not $varReferredBy.ContainsKey($vr)) { $varReferredBy[$vr] = [System.Collections.Generic.List[string]]::new() }
$varReferredBy[$vr].Add($var.name)
}
}
# ββ Folder name lookup ββ
function Get-FolderName($folderId) {
if ($folderId -and $folderMap.ContainsKey($folderId)) { return $folderMap[$folderId].name }
return $null
}
# ββ Build dependency info string ββ
function Get-Dependencies {
param($entity, [string]$entityType)
$deps = [System.Collections.Generic.List[string]]::new()
if ($entityType -eq 'tag') {
# Firing triggers
if ($entity.firingTriggerId) {
$tnames = @($entity.firingTriggerId | Where-Object { $trigMap.ContainsKey($_) } | ForEach-Object { $trigMap[$_].name })
if ($tnames.Count -gt 0) { $deps.Add("Fires on: $($tnames -join ', ')") }
}
# Blocking triggers
if ($entity.blockingTriggerId) {
$bnames = @($entity.blockingTriggerId | Where-Object { $trigMap.ContainsKey($_) } | ForEach-Object { $trigMap[$_].name })
if ($bnames.Count -gt 0) { $deps.Add("Blocked by: $($bnames -join ', ')") }
}
# Teardown tag
if ($entity.teardownTag) {
$tdnames = @($entity.teardownTag | ForEach-Object { $_.tagName })
if ($tdnames.Count -gt 0) { $deps.Add("Teardown: $($tdnames -join ', ')") }
}
# Setup tag
if ($entity.setupTag) {
$sunames = @($entity.setupTag | ForEach-Object { $_.tagName })
if ($sunames.Count -gt 0) { $deps.Add("Setup: $($sunames -join ', ')") }
}
}
if ($entityType -eq 'trigger') {
# Custom event filter
if ($entity.customEventFilter) {
$evs = @($entity.customEventFilter | ForEach-Object {
$arg1 = $_.parameter | Where-Object { $_.key -eq 'arg1' } | Select-Object -First 1
if ($arg1) { $arg1.value } })
if ($evs.Count -gt 0) { $deps.Add("Custom event: $($evs -join ', ')") }
}
}
# Variable references from parameters
$vrefs = Extract-VarRefs $entity.parameter
if ($vrefs.Count -gt 0) {
# Resolve to names (already names, but filter out built-in vars like Event, _event)
$builtinVarNames = @('Event','_event','Page URL','Page Path','Page Hostname','Click Element','Click Classes','Click ID','Click Target','Click Text','Click URL','Container ID','Container Name','Container Version','Debug Mode','DOM Element','Environment Name','Form Classes','Form Element','Form ID','Form Target','Form Text','Form URL','HTML ID','Lookup Table','New Value','Old Value','Redirect URL','Referrer','Scroll Depth Threshold','Scroll Depth Units','Video Duration','Video Percent','Video Provider','Video Status','Video Title','Video URL','Video Visible','Window URL')
$realVars = $vrefs | Where-Object { $_ -notin $builtinVarNames -and $varByName.ContainsKey($_) }
if ($realVars.Count -gt 0) {
$list = ($realVars | Select-Object -First 10) -join ', '
if ($realVars.Count -gt 10) { $list += ", +$($realVars.Count - 10) more" }
$deps.Add("Uses: $list")
}
}
if ($deps.Count -eq 0) { return $null }
return $deps -join '. '
}
function Get-ReferredBy {
param($entity, [string]$entityType)
$refs = [System.Collections.Generic.List[string]]::new()
if ($entityType -eq 'trigger') {
$tid = $entity.triggerId
if ($trigReferredBy.ContainsKey($tid)) {
$list = ($trigReferredBy[$tid] | Select-Object -First 10) -join ', '
if ($trigReferredBy[$tid].Count -gt 10) { $list += ", +$($trigReferredBy[$tid].Count - 10) more" }
$refs.Add("Used by tags: $list")
}
}
elseif ($entityType -eq 'variable') {
$vname = $entity.name
if ($varReferredBy.ContainsKey($vname)) {
$list = ($varReferredBy[$vname] | Select-Object -First 10) -join ', '
if ($varReferredBy[$vname].Count -gt 10) { $list += ", +$($varReferredBy[$vname].Count - 10) more" }
$refs.Add("Used by: $list")
}
}
if ($refs.Count -eq 0) { return $null }
return $refs -join '. '
}
# ββ Generate description line ββ
function Get-Description {
param($entity, [string]$entityType, [string]$existingNotes)
# If existing notes has a first line that's a description (not a changelog entry), keep it
if ($existingNotes) {
$firstLine = ($existingNotes -split "`n" | Select-Object -First 1).Trim()
if ($firstLine -and $firstLine -notmatch '^\d{4}-\d{2}-\d{2}' -and $firstLine.Length -gt 10) {
return $firstLine
}
}
$name = $entity.name
$type = $entity.type
# Context-aware fallback. AI descriptions are produced earlier in Phase 1 and
# injected directly when building notes; this path is reached only for --NoAI
# runs or when the AI call failed.
$desc = switch -Wildcard ($type) {
'cvt_*' {
# Custom template - try to identify by name pattern
if ($name -match 'agnosticalyze') { "Agnosticalyze: $name" }
elseif ($name -match 'isEmployee') { "Detects employee sessions via cookie" }
elseif ($name -match 'snowplow') { "Snowplow tracker: $name" }
elseif ($name -match 'consent') { "Consent management: $name" }
elseif ($name -match 'isBot') { "Bot detection: $name" }
elseif ($name -match 'TMSProcessing') { "TMSProcessing orchestrator: $name" }
else { "Custom template: $name" }
}
'html' { "Custom HTML tag: $name" }
'googtag' { "Google Tag: $name" }
'gaawe' { "GA4 event tag: $name" }
'hjtc' { "Hotjar tracking code: $name" }
'gclidw' { "Conversion linker: $name" }
'pntr' { "Pinterest tag: $name" }
'flc' { "Floodlight tag (deprecated): $name" }
'ua' { "Universal Analytics tag (deprecated): $name" }
'sgtmgaaw' { "GA4 server-side tag: $name" }
'sgtmadsct' { "Google Ads conversion server-side tag: $name" }
'sgtmadsremarket' { "Google Ads remarketing server-side tag: $name" }
'sgtmadscl' { "Conversion linker server-side tag: $name" }
'jsm' { "JavaScript variable: $name" }
'v' { "Data layer variable: $name" }
'j' { "Data layer variable: $name" }
'u' { "URL variable: $name" }
'k' { "First-party cookie variable: $name" }
'c' { "Constant variable: $name" }
'smm' { "Lookup table variable: $name" }
'remm' { "Regex match variable: $name" }
'ed' { "Event data variable: $name" }
'sgtmk' { "Server-side cookie variable: $name" }
'd' { "DOM element variable: $name" }
default { "$type`: $name" }
}
return $desc
}
# ββ Parse existing changelog ββ
function Get-Changelog {
param([string]$notes)
if (-not $notes) { return @() }
$lines = $notes -split "`n"
$changelog = @()
foreach ($line in $lines) {
$trimmed = $line.Trim()
if ($trimmed -match '^\d{4}-\d{2}-\d{2}') {
$changelog += $trimmed
}
}
return $changelog
}
# ββ Compose new notes ββ
function Compose-Notes {
param($entity, [string]$entityType)
$existingNotes = $entity.notes
$description = Get-Description $entity $entityType $existingNotes
$changelog = Get-Changelog $existingNotes
$deps = Get-Dependencies $entity $entityType
$referredBy = Get-ReferredBy $entity $entityType
$folder = Get-FolderName $entity.parentFolderId
$parts = [System.Collections.Generic.List[string]]::new()
$parts.Add($description)
# Folder info
if ($folder) {
$parts.Add("Folder: $folder")
}
# Dependencies
if ($deps) {
$parts.Add($deps)
}
# Referred by
if ($referredBy) {
$parts.Add($referredBy)
}
# Changelog
if ($changelog.Count -gt 0) {
$parts.Add("")
foreach ($entry in $changelog) {
$parts.Add($entry)
}
}
return ($parts -join "`n")
}
# ββ API PUT ββ
function Update-Entity {
param($entityObj, [string]$entityType, [string]$entityId)
$uri = "https://tagmanager.googleapis.com/tagmanager/v2/accounts/$AccountId/containers/$ContainerId/workspaces/$WorkspaceId/$entityType/$entityId"
$body = $entityObj | ConvertTo-Json -Depth 10 -Compress
$result = Invoke-RestMethod -Uri $uri -Headers $script:headers -Method Put -Body $body -ContentType "application/json"
return $result
}
# ββ Process entities ββ
$stats = @{ tags=0; triggers=0; variables=0; errors=0; aiUsed=0; aiSkipped=0; aiFailed=0 }
$idFieldMap = @{ tags='tagId'; triggers='triggerId'; variables='variableId' }
# Build flat work list
$workItems = [System.Collections.Generic.List[object]]::new()
foreach ($type in @('tags','triggers','variables')) {
if ($Scope -ne 'all' -and $type -ne $Scope) { continue }
$collection = $export.$type
$idField = $idFieldMap[$type]
$entityType = $type.TrimEnd('s')
for ($i = 0; $i -lt $collection.Count; $i++) {
$entity = $collection[$i]
$entityId = $entity.$idField
$entityName = $entity.name
if (-not $entityId) {
Write-Warning " [$type/$i] No ID for $entityName, skipping"
continue
}
$workItems.Add([pscustomobject]@{
Index = $i
Type = $type
EntityType = $entityType
EntityId = $entityId
EntityName = $entityName
Entity = $entity
})
}
}
Write-Host "`nTotal entities: $($workItems.Count) (across all types)"
# ββ Phase 1: AI description generation (parallel) ββ
$aiCache = @{} # entityId -> description
$entitiesNeedingAI = [System.Collections.Generic.List[object]]::new()
if (-not $NoAI) {
foreach ($item in $workItems) {
$existing = $item.Entity.notes
$firstLine = $null
if ($existing) {
$firstLine = ($existing -split "`n" | Select-Object -First 1).Trim()
}
$hasExisting = $firstLine -and $firstLine -notmatch '^\d{4}-\d{2}-\d{2}' -and $firstLine.Length -gt 10
if ($hasExisting -and -not $ForceAI) {
$aiCache[$item.EntityId] = $firstLine
$stats.aiSkipped++
} else {
$entitiesNeedingAI.Add($item)
}
}
if ($entitiesNeedingAI.Count -gt 0) {
Write-Host "`nPhase 1: generating AI descriptions for $($entitiesNeedingAI.Count) entities (concurrency=$AIConcurrency, model=$AIModel)..."
# Runspace pool for parallel opencode calls
$sessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
$runspacePool = [runspacefactory]::CreateRunspacePool(1, [Math]::Max(1, $AIConcurrency), $sessionState, $host)
$runspacePool.Open()
$jobs = @()
$counter = 0
foreach ($item in $entitiesNeedingAI) {
$counter++
$powershell = [powershell]::Create()
$powershell.RunspacePool = $runspacePool
# Capture per-script state
$powershell.AddScript({
param($ent, $entType, $folder, $deps, $refBy, $firstLine, $model, $timeout)
$aiSystemGuard = @"
OUTPUT RULES (must follow exactly):
- Return a single JSON object, nothing else.
- No markdown code fences (no ```).
- No XML or HTML or angle brackets (< >).
- No tool-call syntax of any kind.
- No commentary before or after the JSON.
- Use this exact shape: {"description":"<one short sentence>"}
- The description must be one short sentence, plain English.
"@
function Build-Prompt {
param($entity, $entityType, $folder, $deps, $referredBy, $existingFirstLine)
$name = $entity.name
$type = $entity.type
$paramJson = ''
if ($entity.parameter) {
$paramJson = ($entity.parameter | ConvertTo-Json -Depth 8 -Compress)
if ($paramJson.Length -gt 2000) { $paramJson = $paramJson.Substring(0, 2000) + '...[truncated]' }
}
$trigFilter = ''
if ($entityType -eq 'trigger') {
if ($entity.filter) { $trigFilter = "`nFilter: " + (($entity.filter | ConvertTo-Json -Depth 6 -Compress) -replace '"', '\"') }
if ($entity.customEventFilter) { $trigFilter += "`nCustom event filter: " + (($entity.customEventFilter | ConvertTo-Json -Depth 6 -Compress) -replace '"', '\"') }
}
$existingClause = ''
if ($existingFirstLine) { $existingClause = "`nExisting first line (use as reference, do not copy verbatim): `"$existingFirstLine`"" }
return $aiSystemGuard + "`n`n" + @"
Entity type: $entityType
Name: $name
GTM type code: $type
Folder: $(if ($folder) { $folder } else { '<root>' })
$(if ($deps) { "Dependencies: $deps" } else { "Dependencies: <none>" })
$(if ($referredBy) { "Referenced by: $referredBy" } else { "Referenced by: <none>" })
Parameters (JSON, may be truncated): $paramJson$trigFilter$existingClause
Write a one-sentence description of what this entity does in plain English. Prefer specific terms (event names, vendor names, data layer keys visible above) over generic phrasing.
"@
}
function Invoke-Run {
param([string]$Prompt, [string]$Model, [int]$TimeoutSec)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'opencode'
$psi.Arguments = "run --model `"$Model`""
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.StandardOutputEncoding = [System.Text.UTF8Encoding]::new($false)
$psi.StandardErrorEncoding = [System.Text.UTF8Encoding]::new($false)
$p = [System.Diagnostics.Process]::Start($psi)
# Start async reads BEFORE closing stdin to avoid the OS
# pipe buffer deadlocking the child process.
$stdoutTask = $p.StandardOutput.ReadToEndAsync()
$stderrTask = $p.StandardError.ReadToEndAsync()
$p.StandardInput.Write($Prompt)
$p.StandardInput.Close()
$exited = $p.WaitForExit($TimeoutSec * 1000)
if (-not $exited) {
try { $p.Kill($true) } catch {}
return @{ ok = $false; error = 'timeout' }
}
$stdout = $stdoutTask.Result
if ($p.ExitCode -ne 0) {
return @{ ok = $false; error = "exit=$($p.ExitCode)"; stderr = $stderrTask.Result }
}
return @{ ok = $true; stdout = $stdout }
}
function Try-Parse {
param([string]$Text, [string]$Field)
if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
try {
$j = $Text | ConvertFrom-Json -ErrorAction Stop
if ($j.PSObject.Properties.Name -contains $Field) {
$v = [string]$j.$Field
if ($v.Trim()) { return $v.Trim() }
}
} catch {}
$clean = $Text -replace '(?s)```[a-zA-Z]*\s*', '' -replace '```', ''
try {
$j = $clean | ConvertFrom-Json -ErrorAction Stop
if ($j.PSObject.Properties.Name -contains $Field) {
$v = [string]$j.$Field
if ($v.Trim()) { return $v.Trim() }
}
} catch {}
$m = [regex]::Match($Text, '"' + [regex]::Escape($Field) + '"\s*:\s*"((?:[^"\\]|\\.)*)"')
if ($m.Success) {
$v = $m.Groups[1].Value -replace '\\"', '"' -replace '\\n', "`n" -replace '\\\\', '\'
if ($v.Trim()) { return $v.Trim() }
}
return $null
}
$prompt = Build-Prompt -entity $ent -entityType $entType -folder $folder -deps $deps -referredBy $refBy -existingFirstLine $firstLine
for ($attempt = 1; $attempt -le 2; $attempt++) {
$res = Invoke-Run -Prompt $prompt -Model $model -TimeoutSec $timeout
if ($res.ok) {
$desc = Try-Parse -Text $res.stdout -Field 'description'
if ($desc -and $desc.Length -lt 300 -and $desc -match '[A-Za-z]' -and $desc -notmatch '```' -and $desc -notmatch '[<>]') {
return $desc
}
} elseif ($res.error -eq 'timeout') {
return $null
}
}
return $null
}).AddArgument($item.Entity).AddArgument($item.EntityType)
$folder = Get-FolderName $item.Entity.parentFolderId
$deps = Get-Dependencies $item.Entity $item.EntityType
$refBy = Get-ReferredBy $item.Entity $item.EntityType
$powershell.AddArgument($folder).AddArgument($deps).AddArgument($refBy).AddArgument($firstLine).AddArgument($AIModel).AddArgument($AITimeoutSec)
$handle = $powershell.BeginInvoke()
$jobs += [pscustomobject]@{
Item = $item
PS = $powershell
Handle = $handle
}
}
$completed = 0
foreach ($job in $jobs) {
$result = $job.PS.EndInvoke($job.Handle)
$job.PS.Dispose()
$completed++
$desc = if ($result) { [string]$result[0] } else { $null }
if ($desc) {
$aiCache[$job.Item.EntityId] = $desc
$stats.aiUsed++
} else {
$stats.aiFailed++
}
if ($completed % 10 -eq 0 -or $completed -eq $jobs.Count) {
Write-Host " AI: $completed/$($jobs.Count) done (used=$($stats.aiUsed) failed=$($stats.aiFailed))"
}
}
$runspacePool.Close()
$runspacePool.Dispose()
}
} else {
Write-Host "`nPhase 1: AI disabled (--NoAI)"
foreach ($item in $workItems) {
$aiCache[$item.EntityId] = $null # force fallback path
}
}
# ββ Phase 2: PUT to GTM (sequential, rate-limited) ββ
Write-Host "`nPhase 2: updating GTM workspace entities..."
foreach ($type in @('tags','triggers','variables')) {
if ($Scope -ne 'all' -and $type -ne $Scope) { continue }
$itemsOfType = @($workItems | Where-Object { $_.Type -eq $type })
if ($itemsOfType.Count -eq 0) { continue }
Write-Host "`nProcessing $($itemsOfType.Count) $($type)..."
for ($i = 0; $i -lt $itemsOfType.Count; $i++) {
$item = $itemsOfType[$i]
$entity = $item.Entity
$entityId = $item.EntityId
$entityName = $item.EntityName
$entityType = $item.EntityType
try {
# GET current entity from API (with retry)
$uri = "https://tagmanager.googleapis.com/tagmanager/v2/accounts/$AccountId/containers/$ContainerId/workspaces/$WorkspaceId/$type/$entityId"
$maxRetries = 3
$retryDelay = 2000
$current = $null
for ($retry = 0; $retry -le $maxRetries; $retry++) {
try {
$current = Invoke-RestMethod -Uri $uri -Headers $script:headers -Method Get
break
} catch {
$status = $_.Exception.Response.StatusCode
if (($status -eq 429 -or $status -eq 503 -or $status -eq 504) -and $retry -lt $maxRetries) {
Start-Sleep -Milliseconds $retryDelay
$retryDelay *= 2
continue
}
if (($status -eq 401 -or $status -eq 403) -and $retry -eq 0) {
Refresh-Token
$retry--
continue
}
throw
}
}
# Compose new notes. If we have an AI description, use it as the
# description line and preserve the changelog from existing notes.
# Otherwise fall through to the regular Compose-Notes (which
# reuses the existing description if present).
$aiDesc = if ($aiCache.ContainsKey($entityId)) { $aiCache[$entityId] } else { $null }
if ($aiDesc) {
$existingNotes = $entity.notes
$changelog = Get-Changelog $existingNotes
$deps = Get-Dependencies $entity $entityType
$referredBy = Get-ReferredBy $entity $entityType
$folder = Get-FolderName $entity.parentFolderId
$parts = [System.Collections.Generic.List[string]]::new()
$parts.Add($aiDesc)
if ($folder) { $parts.Add("Folder: $folder") }
if ($deps) { $parts.Add($deps) }
if ($referredBy) { $parts.Add($referredBy) }
if ($changelog.Count -gt 0) {
$parts.Add("")
foreach ($entry in $changelog) { $parts.Add($entry) }
}
$newNotes = ($parts -join "`n")
} else {
$newNotes = Compose-Notes $entity $entityType
}
if ($DryRun) {
Write-Host " [$i] DRY RUN: $entityName ($entityId)"
Write-Host " Notes preview (first 100 chars): $($newNotes.Substring(0, [Math]::Min(100, $newNotes.Length)))..."
$stats[$type]++
Start-Sleep -Milliseconds $RateLimitMs
continue
}
# Update notes on current entity object.
# Some entity types (notably triggers) have `notes` as a read-only
# deserialized property, so direct assignment throws. Force-add
# always - cheap and idempotent.
$current | Add-Member -MemberType NoteProperty -Name notes -Value $newNotes -Force
# PUT back (with retry)
$result = $null
for ($retry = 0; $retry -le $maxRetries; $retry++) {
try {
$result = Update-Entity $current $type $entityId
break
} catch {
$status = $_.Exception.Response.StatusCode
if (($status -eq 429 -or $status -eq 503 -or $status -eq 504) -and $retry -lt $maxRetries) {
Start-Sleep -Milliseconds $retryDelay
$retryDelay *= 2
continue
}
if (($status -eq 401 -or $status -eq 403) -and $retry -eq 0) {
Refresh-Token
$retry--
continue
}
throw
}
}
$stats[$type]++
if ($ShowVerbose) {
Write-Host " [$i] OK: $entityName ($entityId)"
} else {
if ($i % 10 -eq 0) { Write-Host " [$i/$($itemsOfType.Count)] $entityName" }
}
# Rate limit
Start-Sleep -Milliseconds $RateLimitMs
} catch {
$stats.errors++
Write-Warning " [$($i.ToString().PadLeft(3))] ERROR: $entityName ($entityId): $($_.Exception.Message)"
}
}
}
Write-Host "`nDone. Stats:"
Write-Host " Tags updated: $($stats['tags'])"
Write-Host " Triggers updated: $($stats['triggers'])"
Write-Host " Variables updated: $($stats['variables'])"
Write-Host " AI used: $($stats['aiUsed'])"
Write-Host " AI skipped (existing good description): $($stats['aiSkipped'])"
Write-Host " AI failed (fell back to rules): $($stats['aiFailed'])"
Write-Host " Errors: $($stats['errors'])"
