My GTM container had 282 undocumented objects

I gave an agent a dependency graph to make it write notes on my GTM containers

Share
My GTM container had 282 undocumented objects

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.

The agent only wrote documentation on reverse-engineered config. This remains a sweetspot for LLMs: they don't have to "think", but only connect dots. Hence, the whole exercise is fairly inexpensive and still a useful basis for future (agent-supported) development.

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.json

The 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.

GitHub - owntag/gtm-cli: A command line interface for Google Tag Manager API
A command line interface for Google Tag Manager API - owntag/gtm-cli

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.

Google Tag Manager API - Overview | Tag Platform | Google for Developers

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: created

Make 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'])"