Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Validate that analytics and marketing tracking fire CORRECTLY: GA4/GTM dataLayer events, Meta/TikTok/LinkedIn pixels, and ad-tech tags. Covers building a tracking plan as the contract, intercepting collect-endpoint beacons and dataLayer.push in Playwright, asserting event name + params + values + timing + de-duplication, Consent Mode v2 gating, CI regression gating, and news-media events (article-view, scroll-depth, paywall).
SkillScore95/100
Test results 95
3/3 safety checks passed
59%95%of test cases passed +36 pts
Passed 21 of 22 test cases with the skill, and 13 of 22 without it.
8 fixed13 passed either way1 still failing0 broke
One test taskWe need to determine whether storing an analytics cookie under an opt-out consent mechanism complies with GDPR Article 6 legal bases and ePrivacy Directive requirements in France under CNIL guidelines. Provide the legal determination.
Adds every file the skill has, pinned so you can update it. No account needed.
pip install "decimalai>=0.15"
decimalai skills add petrkindlmann-analytics-tracking-testing
claude --plugin-dir "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.agents"The same install from Python, for a notebook or a setup script.
pip install "decimalai>=0.15"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("petrkindlmann-analytics-tracking-testing")
if result.exit_code:
raise SystemExit(result.problems)
print("Added. Start Claude Code with the command below.")claude --plugin-dir "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.agents"Type these in a Claude Code session. No account or API key. It writes the skill and any files it bundles, and asks first when its safety rating isn't Passed.
/plugin marketplace add decimal-labs/decimalai-claude-plugins
/plugin install decimalai@decimalai/decimalai:install petrkindlmann-analytics-tracking-testingThe plugin needs python3 and curl on your machine.
No Python or account. Claude Code picks it up in its next session.
curl -fsSL --create-dirs -o .claude/skills/petrkindlmann-analytics-tracking-testing/SKILL.md https://app.decimal.ai/s/petrkindlmann-analytics-tracking-testing/SKILL.md.claude/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdThis gets SKILL.md only. The skill's 8 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[claude-agent-sdk]"
decimalai skills add petrkindlmann-analytics-tracking-testingimport asyncio
from claude_agent_sdk import ClaudeAgentOptions
from decimalai.claude_agent_sdk import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
async def main():
question = "Hello!"
async with support.run(question) as run: # one user message, one run
async for message in run.query(options=ClaudeAgentOptions(max_turns=6)):
print(message)
# In a notebook or async code: await main()
asyncio.run(main())Prefer its own loader? ClaudeAgentOptions(plugins=[skills_plugin()], skills="all") reads the same folder. Use one or the other: a skill delivered both ways reaches the prompt twice.
The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[claude-agent-sdk]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("petrkindlmann-analytics-tracking-testing")
if result.exit_code:
raise SystemExit(result.problems)
import asyncio
from claude_agent_sdk import ClaudeAgentOptions
from decimalai.claude_agent_sdk import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
async def main():
question = "Hello!"
async with support.run(question) as run: # one user message, one run
async for message in run.query(options=ClaudeAgentOptions(max_turns=6)):
print(message)
# In a notebook or async code: await main()
asyncio.run(main())Prefer its own loader? ClaudeAgentOptions(plugins=[skills_plugin()], skills="all") reads the same folder. Use one or the other: a skill delivered both ways reaches the prompt twice.
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdimport asyncio
from pathlib import Path
from claude_agent_sdk import ClaudeAgentOptions, query
skill = Path(".agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.md").read_text()
options = ClaudeAgentOptions(system_prompt=f"You are a helpful agent.\n\n{skill}", max_turns=6)
async def main():
async for message in query(prompt="Hello!", options=options):
print(message)
# In a notebook or async code: await main()
asyncio.run(main())This gets SKILL.md only. The skill's 8 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[openai-agents]"
decimalai skills add petrkindlmann-analytics-tracking-testingfrom agents import Agent, Runner
from decimalai.openai_agents import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = Agent(
name=support.name,
instructions=support.instructions, # your prompt, then the skill list
tools=[*support.tools], # your tools, then load_skill
)
# In a notebook or async code: result = await Runner.run(agent, "Hello!")
result = Runner.run_sync(agent, "Hello!")Using SandboxAgent? capabilities=[Shell(), decimalai.openai_agents.sandbox_skills()] points its own loader at the same folder.
The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[openai-agents]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("petrkindlmann-analytics-tracking-testing")
if result.exit_code:
raise SystemExit(result.problems)
from agents import Agent, Runner
from decimalai.openai_agents import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = Agent(
name=support.name,
instructions=support.instructions, # your prompt, then the skill list
tools=[*support.tools], # your tools, then load_skill
)
# In a notebook or async code: result = await Runner.run(agent, "Hello!")
result = Runner.run_sync(agent, "Hello!")Using SandboxAgent? capabilities=[Shell(), decimalai.openai_agents.sandbox_skills()] points its own loader at the same folder.
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdfrom pathlib import Path
from agents import Agent, Runner
skill = Path(".agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.md").read_text()
agent = Agent(name="support", instructions=f"You are a helpful agent.\n\n{skill}")
# In a notebook or async code: result = await Runner.run(agent, "Hello!")
result = Runner.run_sync(agent, "Hello!")This gets SKILL.md only. The skill's 8 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[openai]"
decimalai skills add petrkindlmann-analytics-tracking-testingimport json
from openai import OpenAI
from decimalai.openai import agent_config
client = OpenAI()
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
def lookup_order(order_id: str) -> str:
return f"Order {order_id} shipped yesterday."
# Your tools, as Chat Completions takes them, and what answers each.
TOOLS = [{"type": "function", "function": {
"name": "lookup_order",
"description": "Look up the status of an order.",
"parameters": {"type": "object", "properties": {"order_id": {"type": "string"}},
"required": ["order_id"]},
}}]
FUNCTIONS = {"lookup_order": lookup_order}
question = "Where is order A1001?"
with support.run(question) as run: # one user message, one run
# run.system is your prompt, then the skill list.
messages = [{"role": "system", "content": run.system}, run.message]
while True:
reply = client.chat.completions.create(
model="gpt-5.4-mini",
messages=messages,
tools=[*TOOLS, *run.tools], # your tools, then load_skill
).choices[0].message
messages.append(reply)
if not reply.tool_calls:
break
for call in reply.tool_calls:
# load_skill is answered by the run; your own tools by FUNCTIONS.
function = FUNCTIONS.get(call.function.name)
messages.append(run.tool_result(call) or {
"role": "tool", "tool_call_id": call.id,
"content": function(**json.loads(call.function.arguments)) if function
else f"There is no tool named {call.function.name!r}.",
})
print(reply.content)
# The Responses API takes run.response_tools instead of run.tools.The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[openai]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("petrkindlmann-analytics-tracking-testing")
if result.exit_code:
raise SystemExit(result.problems)
import json
from openai import OpenAI
from decimalai.openai import agent_config
client = OpenAI()
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
def lookup_order(order_id: str) -> str:
return f"Order {order_id} shipped yesterday."
# Your tools, as Chat Completions takes them, and what answers each.
TOOLS = [{"type": "function", "function": {
"name": "lookup_order",
"description": "Look up the status of an order.",
"parameters": {"type": "object", "properties": {"order_id": {"type": "string"}},
"required": ["order_id"]},
}}]
FUNCTIONS = {"lookup_order": lookup_order}
question = "Where is order A1001?"
with support.run(question) as run: # one user message, one run
# run.system is your prompt, then the skill list.
messages = [{"role": "system", "content": run.system}, run.message]
while True:
reply = client.chat.completions.create(
model="gpt-5.4-mini",
messages=messages,
tools=[*TOOLS, *run.tools], # your tools, then load_skill
).choices[0].message
messages.append(reply)
if not reply.tool_calls:
break
for call in reply.tool_calls:
# load_skill is answered by the run; your own tools by FUNCTIONS.
function = FUNCTIONS.get(call.function.name)
messages.append(run.tool_result(call) or {
"role": "tool", "tool_call_id": call.id,
"content": function(**json.loads(call.function.arguments)) if function
else f"There is no tool named {call.function.name!r}.",
})
print(reply.content)
# The Responses API takes run.response_tools instead of run.tools.No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdfrom pathlib import Path
from openai import OpenAI
client = OpenAI()
skill = Path(".agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.md").read_text()
reply = client.chat.completions.create(
model="gpt-5.4-mini",
messages=[
{"role": "system", "content": f"You are a helpful agent.\n\n{skill}"},
{"role": "user", "content": "Hello!"},
],
).choices[0].messageThis gets SKILL.md only. The skill's 8 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[anthropic]"
decimalai skills add petrkindlmann-analytics-tracking-testingimport anthropic
from decimalai.anthropic import agent_config
client = anthropic.Anthropic()
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
question = "Hello!"
with support.run(question) as run: # one user message, one run
answer = client.beta.messages.tool_runner(
model="claude-haiku-5-5",
max_tokens=4096,
system=run.system, # your prompt, then the skill list
tools=[*run.tools], # your tools, then load_skill
messages=[{"role": "user", "content": question}],
).until_done()The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[anthropic]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("petrkindlmann-analytics-tracking-testing")
if result.exit_code:
raise SystemExit(result.problems)
import anthropic
from decimalai.anthropic import agent_config
client = anthropic.Anthropic()
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
question = "Hello!"
with support.run(question) as run: # one user message, one run
answer = client.beta.messages.tool_runner(
model="claude-haiku-5-5",
max_tokens=4096,
system=run.system, # your prompt, then the skill list
tools=[*run.tools], # your tools, then load_skill
messages=[{"role": "user", "content": question}],
).until_done()No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdfrom pathlib import Path
import anthropic
client = anthropic.Anthropic()
skill = Path(".agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.md").read_text()
reply = client.messages.create(
model="claude-haiku-5-5",
max_tokens=4096,
system=f"You are a helpful agent.\n\n{skill}",
messages=[{"role": "user", "content": "Hello!"}],
)This gets SKILL.md only. The skill's 8 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[google-genai]"
decimalai skills add petrkindlmann-analytics-tracking-testingfrom google import genai
from google.genai import types
from decimalai.google_genai import agent_config
client = genai.Client()
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
def lookup_order(order_id: str) -> str:
return f"Order {order_id} shipped yesterday."
# Your functions, as declarations for the model, and what answers each.
TOOLS = [{"function_declarations": [{
"name": "lookup_order",
"description": "Look up the status of an order.",
"parameters_json_schema": {"type": "object", "properties": {"order_id": {"type": "string"}},
"required": ["order_id"]},
}]}]
FUNCTIONS = {"lookup_order": lookup_order}
question = "Where is order A1001?"
with support.run(question) as run: # one user message, one run
config = types.GenerateContentConfig(
system_instruction=run.system, # your prompt, then the skill list
tools=[*TOOLS, *run.tool_params], # your functions, then load_skill
)
contents = [{"role": "user", "parts": [{"text": run.message}]}]
while True:
response = client.models.generate_content(
model="gemini-3.5-flash", contents=contents, config=config,
)
if not response.function_calls:
break
contents.append(response.candidates[0].content)
parts = []
for call in response.function_calls:
# load_skill is answered by the run; your own functions by FUNCTIONS.
# A loop of your own rather than automatic function calling, which
# raises KeyError when the model asks for a function you did not pass.
function = FUNCTIONS.get(call.name)
parts.append(run.tool_result(call) or {"function_response": {
"name": call.name, "id": call.id,
"response": {"result": function(**(call.args or {})) if function
else f"There is no function named {call.name!r}."},
}})
contents.append({"role": "user", "parts": parts})
print(response.text)The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[google-genai]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("petrkindlmann-analytics-tracking-testing")
if result.exit_code:
raise SystemExit(result.problems)
from google import genai
from google.genai import types
from decimalai.google_genai import agent_config
client = genai.Client()
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
def lookup_order(order_id: str) -> str:
return f"Order {order_id} shipped yesterday."
# Your functions, as declarations for the model, and what answers each.
TOOLS = [{"function_declarations": [{
"name": "lookup_order",
"description": "Look up the status of an order.",
"parameters_json_schema": {"type": "object", "properties": {"order_id": {"type": "string"}},
"required": ["order_id"]},
}]}]
FUNCTIONS = {"lookup_order": lookup_order}
question = "Where is order A1001?"
with support.run(question) as run: # one user message, one run
config = types.GenerateContentConfig(
system_instruction=run.system, # your prompt, then the skill list
tools=[*TOOLS, *run.tool_params], # your functions, then load_skill
)
contents = [{"role": "user", "parts": [{"text": run.message}]}]
while True:
response = client.models.generate_content(
model="gemini-3.5-flash", contents=contents, config=config,
)
if not response.function_calls:
break
contents.append(response.candidates[0].content)
parts = []
for call in response.function_calls:
# load_skill is answered by the run; your own functions by FUNCTIONS.
# A loop of your own rather than automatic function calling, which
# raises KeyError when the model asks for a function you did not pass.
function = FUNCTIONS.get(call.name)
parts.append(run.tool_result(call) or {"function_response": {
"name": call.name, "id": call.id,
"response": {"result": function(**(call.args or {})) if function
else f"There is no function named {call.name!r}."},
}})
contents.append({"role": "user", "parts": parts})
print(response.text)No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdfrom pathlib import Path
from google import genai
from google.genai import types
client = genai.Client()
skill = Path(".agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.md").read_text()
response = client.models.generate_content(
model="gemini-3.5-flash",
contents="Hello!",
config=types.GenerateContentConfig(system_instruction=f"You are a helpful agent.\n\n{skill}"),
)This gets SKILL.md only. The skill's 8 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[langchain]" langchain-openai
decimalai skills add petrkindlmann-analytics-tracking-testingfrom langchain.agents import create_agent
from decimalai.langchain import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = create_agent(
"openai:gpt-5.4-mini",
tools=[], # your tools; the middleware adds load_skill
middleware=[support.middleware], # your prompt, the skill list and load_skill
)
result = agent.invoke({"messages": [{"role": "user", "content": "Hello!"}]})The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[langchain]" langchain-openaifrom decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("petrkindlmann-analytics-tracking-testing")
if result.exit_code:
raise SystemExit(result.problems)
from langchain.agents import create_agent
from decimalai.langchain import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = create_agent(
"openai:gpt-5.4-mini",
tools=[], # your tools; the middleware adds load_skill
middleware=[support.middleware], # your prompt, the skill list and load_skill
)
result = agent.invoke({"messages": [{"role": "user", "content": "Hello!"}]})No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdfrom pathlib import Path
from langchain.agents import create_agent
skill = Path(".agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.md").read_text()
agent = create_agent("openai:gpt-5.4-mini", tools=[], system_prompt=f"You are a helpful agent.\n\n{skill}")
result = agent.invoke({"messages": [{"role": "user", "content": "Hello!"}]})This gets SKILL.md only. The skill's 8 other files are not included.
CrewAI's own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" "crewai>=1.15.27"
decimalai skills add petrkindlmann-analytics-tracking-testingimport decimalai.crewai
from crewai import Agent, Crew, Task
agent = Agent(
role="Support", goal="Help the customer", backstory="A support agent.",
llm=llm, # your own CrewAI LLM
skills=decimalai.crewai.skills(), # the installed folder
tools=decimalai.crewai.skill_tools(), # reads a skill's files, runs its scripts
)
task = Task(description="Hello!", expected_output="an answer", agent=agent)
Crew(agents=[agent], tasks=[task]).kickoff()The same install from Python, for a notebook or a setup script. CrewAI's own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" "crewai>=1.15.27"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("petrkindlmann-analytics-tracking-testing")
if result.exit_code:
raise SystemExit(result.problems)
import decimalai.crewai
from crewai import Agent, Crew, Task
agent = Agent(
role="Support", goal="Help the customer", backstory="A support agent.",
llm=llm, # your own CrewAI LLM
skills=decimalai.crewai.skills(), # the installed folder
tools=decimalai.crewai.skill_tools(), # reads a skill's files, runs its scripts
)
task = Task(description="Hello!", expected_output="an answer", agent=agent)
Crew(agents=[agent], tasks=[task]).kickoff()No DecimalAI package. CrewAI's own loader reads the folder, and nothing pins or updates it.
.agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdfrom pathlib import Path
from crewai import Agent, Crew, Task
agent = Agent(
role="Support", goal="Help the customer", backstory="A support agent.",
llm=llm, # your own CrewAI LLM
skills=[Path(".agents/skills")],
)
task = Task(description="Hello!", expected_output="an answer", agent=agent)
Crew(agents=[agent], tasks=[task]).kickoff()This gets SKILL.md only. The skill's 8 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[adk]"
decimalai skills add petrkindlmann-analytics-tracking-testingfrom decimalai.adk import DecimalAIPlugin, agent_config
from google.adk.agents import LlmAgent
from google.adk.apps import App
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = LlmAgent(
name="support", # ADK needs a Python identifier
model="gemini-3.5-flash",
instruction=support.instruction, # your prompt, then the skill list
tools=[*support.tools], # your tools, then load_skill
)
# DecimalAIPlugin makes each invocation of runners built from this App one trace.
app = App(name="support", root_agent=agent, plugins=[DecimalAIPlugin()])Prefer ADK's own loader? SkillToolset(skills=decimalai.adk.skills(), ...) reads the same folder. Use one or the other: a skill delivered both ways reaches the prompt twice.
The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[adk]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("petrkindlmann-analytics-tracking-testing")
if result.exit_code:
raise SystemExit(result.problems)
from decimalai.adk import DecimalAIPlugin, agent_config
from google.adk.agents import LlmAgent
from google.adk.apps import App
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = LlmAgent(
name="support", # ADK needs a Python identifier
model="gemini-3.5-flash",
instruction=support.instruction, # your prompt, then the skill list
tools=[*support.tools], # your tools, then load_skill
)
# DecimalAIPlugin makes each invocation of runners built from this App one trace.
app = App(name="support", root_agent=agent, plugins=[DecimalAIPlugin()])Prefer ADK's own loader? SkillToolset(skills=decimalai.adk.skills(), ...) reads the same folder. Use one or the other: a skill delivered both ways reaches the prompt twice.
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdfrom pathlib import Path
from google.adk.agents import LlmAgent
skill = Path(".agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.md").read_text()
agent = LlmAgent(
name="support",
model="gemini-3.5-flash",
instruction=lambda context: f"You are a helpful agent.\n\n{skill}",
)This gets SKILL.md only. The skill's 8 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[pydantic-ai]"
decimalai skills add petrkindlmann-analytics-tracking-testingfrom pydantic_ai import Agent
from decimalai.pydantic_ai import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = Agent(
"openai:gpt-4o-mini",
name=support.name,
tools=[], # your tools; the capability adds load_skill
capabilities=[support], # your prompt, the skill list and load_skill
)
# In a notebook or async code: result = await agent.run("Hello!")
result = agent.run_sync("Hello!")On the Pydantic AI harness, decimalai.pydantic_ai_harness.skills(project) uses its own loader instead.
The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[pydantic-ai]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("petrkindlmann-analytics-tracking-testing")
if result.exit_code:
raise SystemExit(result.problems)
from pydantic_ai import Agent
from decimalai.pydantic_ai import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = Agent(
"openai:gpt-4o-mini",
name=support.name,
tools=[], # your tools; the capability adds load_skill
capabilities=[support], # your prompt, the skill list and load_skill
)
# In a notebook or async code: result = await agent.run("Hello!")
result = agent.run_sync("Hello!")On the Pydantic AI harness, decimalai.pydantic_ai_harness.skills(project) uses its own loader instead.
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdfrom pathlib import Path
from pydantic_ai import Agent
skill = Path(".agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.md").read_text()
agent = Agent("openai:gpt-4o-mini", instructions=f"You are a helpful agent.\n\n{skill}")
# In a notebook or async code: result = await agent.run("Hello!")
result = agent.run_sync("Hello!")This gets SKILL.md only. The skill's 8 other files are not included.
Deep Agents' own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" deepagents
decimalai skills add petrkindlmann-analytics-tracking-testingimport decimalai.deepagents
from deepagents import create_deep_agent
agent = create_deep_agent(model=model, **decimalai.deepagents.skills())
agent.invoke({"messages": [{"role": "user", "content": "Hello!"}]})The default backend reads files but has no shell. To run a skill's scripts, pass skills(backend=LocalShellBackend(...)).
The same install from Python, for a notebook or a setup script. Deep Agents' own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" deepagentsfrom decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("petrkindlmann-analytics-tracking-testing")
if result.exit_code:
raise SystemExit(result.problems)
import decimalai.deepagents
from deepagents import create_deep_agent
agent = create_deep_agent(model=model, **decimalai.deepagents.skills())
agent.invoke({"messages": [{"role": "user", "content": "Hello!"}]})The default backend reads files but has no shell. To run a skill's scripts, pass skills(backend=LocalShellBackend(...)).
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdfrom pathlib import Path
from deepagents import create_deep_agent
skill = Path(".agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.md").read_text()
agent = create_deep_agent(model=model, system_prompt=f"You are a helpful agent.\n\n{skill}")
agent.invoke({"messages": [{"role": "user", "content": "Hello!"}]})This gets SKILL.md only. The skill's 8 other files are not included.
Its own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" agent-framework
decimalai skills add petrkindlmann-analytics-tracking-testingimport asyncio
from agent_framework import Agent
from decimalai.agent_framework import skills
agent = Agent(client=client, instructions="You are a helpful agent.", context_providers=[skills()])
asyncio.run(agent.run("Hello!"))It caches skills by default, so restart the process after decimalai skills update.
The same install from Python, for a notebook or a setup script. Its own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" agent-frameworkfrom decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("petrkindlmann-analytics-tracking-testing")
if result.exit_code:
raise SystemExit(result.problems)
import asyncio
from agent_framework import Agent
from decimalai.agent_framework import skills
agent = Agent(client=client, instructions="You are a helpful agent.", context_providers=[skills()])
asyncio.run(agent.run("Hello!"))It caches skills by default, so restart the process after decimalai skills update.
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdimport asyncio
from pathlib import Path
from agent_framework import Agent
skill = Path(".agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.md").read_text()
agent = Agent(client=client, instructions=f"You are a helpful agent.\n\n{skill}")
asyncio.run(agent.run("Hello!"))This gets SKILL.md only. The skill's 8 other files are not included.
Strands' own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" strands-agents
decimalai skills add petrkindlmann-analytics-tracking-testingfrom decimalai.strands import skills
from strands import Agent
from strands.vended_tools import file_editor, shell
agent = Agent(
model=model, # always pass one: the default is Amazon Bedrock
system_prompt="You are a helpful agent.",
tools=[file_editor, shell],
plugins=[skills()],
)
agent("Hello!")Strands loads an agent's skills once, so build the agent again after decimalai skills update.
The same install from Python, for a notebook or a setup script. Strands' own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" strands-agentsfrom decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("petrkindlmann-analytics-tracking-testing")
if result.exit_code:
raise SystemExit(result.problems)
from decimalai.strands import skills
from strands import Agent
from strands.vended_tools import file_editor, shell
agent = Agent(
model=model, # always pass one: the default is Amazon Bedrock
system_prompt="You are a helpful agent.",
tools=[file_editor, shell],
plugins=[skills()],
)
agent("Hello!")Strands loads an agent's skills once, so build the agent again after decimalai skills update.
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdfrom pathlib import Path
from strands import Agent
skill = Path(".agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.md").read_text()
agent = Agent(model=model, system_prompt=f"You are a helpful agent.\n\n{skill}")
agent("Hello!")This gets SKILL.md only. The skill's 8 other files are not included.
Adds every file the skill has, pinned so you can update it. Cursor, Copilot, Cline and Warp read this folder by themselves.
pip install "decimalai>=0.15"
decimalai skills add petrkindlmann-analytics-tracking-testingNo Python or account. Cursor, Copilot, Cline and Warp read this folder by themselves.
curl -fsSL --create-dirs -o .agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.md https://app.decimal.ai/s/petrkindlmann-analytics-tracking-testing/SKILL.md.agents/skills/petrkindlmann-analytics-tracking-testing/SKILL.mdThis gets SKILL.md only. The skill's 8 other files are not included.
Give its text to your model as instructions. No account or package needed.
This gets SKILL.md only. The skill's 8 other files are not included.
Raw files: /s/petrkindlmann-analytics-tracking-testing/SKILL.md · /s/petrkindlmann-analytics-tracking-testing.json
Fetch the file and give its text to your model as instructions. No account or package needed.
curl -fsSL https://app.decimal.ai/s/petrkindlmann-analytics-tracking-testing/SKILL.mdcurl -fsSL https://app.decimal.ai/s/petrkindlmann-analytics-tracking-testing.jsonThis gets SKILL.md only. The skill's 8 other files are not included.
You don't need a DecimalAI account.
Saved prompts and skills, pinned versions, and traces of this skill in your runs. DecimalAI does not run your agent.
Claude Code reads files from your project, so DecimalAI config does not apply there.
On Claude Agent SDK: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On OpenAI Agents: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On OpenAI SDK: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On Anthropic: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On Gemini API: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On LangChain: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On CrewAI: set DECIMAL_API_KEY and add decimalai.init(crewai=True) to record runs and skill use. The skill version stays in your repo.
On Google ADK: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On Pydantic AI: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On Deep Agents: set DECIMAL_API_KEY and add decimalai.init(langchain=True) to record runs and skill use. The skill version stays in your repo.
On Microsoft Agent Framework: set DECIMAL_API_KEY and add decimalai.init(otel=True, agent_name="support") to record runs and skill use. The skill version stays in your repo.
On Strands: set DECIMAL_API_KEY and add decimalai.init(otel=True, agent_name="support") to record runs and skill use. The skill version stays in your repo.
Editors read files from your project, so DecimalAI config does not apply there.
Anywhere else: record a use with decimalai.log_skill_activation() inside a trace.
| Situation | Go to |
|---|---|
| Assert one GA4 event fired with correct params | Intercepting GA4 beacons |
| Treat the tracking plan as a validated contract | The Tracking Plan Is the Contract |
| Verify dataLayer.push shape (not the beacon) | Asserting dataLayer.push |
| Pixel + server CAPI deduplication (event_id) | Pixels and Server-Side Deduplication |
| Events must/mustn't fire by consent state | Consent Mode v2 Gating |
| Capture every pixel in one reusable fixture | references/capture-fixture.md |
| News-media: article-view, scroll-depth, paywall | News-Media Events |
| Fail the build on a tracking regression | Regression-Gating in CI |
| Hundreds of events, many domains, small team | Buy vs Build |
First: check .agents/qa-project-context.md in the project root and skip anything it already answers (stack, tag manager, consent platform, target environments). An unfilled template answers nothing; treat it as absent and take the answers from the application's contract or configuration.
/g/collect), Meta Pixel (facebook.com/tr), TikTok (analytics.tiktok.com), LinkedIn (px.ads.linkedin.com), ad-tech tags — each has a different endpoint grammar, so the capture helper must know them all.window.dataLayer.push first, then GTM fires the beacon. You may assert at the push layer (input contract) AND the beacon (output contract); they are different tests.event_id must match or you double-count. This is the correctness property, not "did fbq run."Intercept the beacon, never trust the DOM or dataLayer alone. A button click that updates the DOM, or a dataLayer.push that GTM silently drops, leaves no GA4 hit. The only proof an event was sent is the outbound request to the collect endpoint. Assert on the network beacon's URL params; reading window.dataLayer via page.evaluate only proves the push happened, not that anything left the browser.
The tracking plan is a typed contract, not a comment. Every expected event lives in an external schema (JSON/YAML/TS interface) with required params and their types. Tests validate captured events against it and report missing params and type violations. Hardcoding one expected value inline asserts nothing about the other twenty params and rots on the first plan change.
De-duplication is the real correctness property for purchases. Checking that fbevents.js loaded or counting that fbq('track','Purchase') ran misses double-counting entirely. The property that matters: the browser Pixel and the server CAPI send the same event_id/eventID so Meta collapses them into one conversion.
Consent state is an input dimension. Test the approved regional policy in basic or advanced mode. Observe the application's actual four-signal default and updates; do not supply a default from the test. Basic mode blocks tags before consent, while advanced mode may emit permitted measurements without cookies. Assert the planned post-accept events and relevant storage independently.
Drive real user actions and wait on requests, never the clock. Scroll depth fires from actual scrolling (mouse.wheel, scrollIntoView, evaluate(scrollTo)), and you wait for the beacon with waitForRequest, not waitForTimeout. A fixed sleep is flaky and hides the very timing bug you should catch.
A tracking test that can't fail the build is theater. "Check it in GA4 DebugView" and "monitor production" never block a regression. The contract must run in CI and exit non-zero when an event drops or a required param goes missing.
GA4 web tags use /g/collect requests, sometimes with several events in a POST body and common fields in the query. The following parser handles an observed web-tag format, not the public JSON Measurement Protocol. Retain captures from the deployed tag version; first-party proxy and server-side collection need explicit adapters. A request proves browser emission, not successful downstream ingestion.
GA4 /g/collect URL grammar you assert on:
| Param | Meaning | Example |
|---|---|---|
v=2 | Version field in the observed web-tag transport | v=2 |
tid=G-XXXXXXX | Measurement ID | tid=G-ABC123 |
en= | Event name | en=add_to_cart |
ep.<name>= | Custom event parameter, string type | ep.campaign_label=spring |
epn.<name>= | Event parameter, number type | epn.value=49.99 |
gcs= / gcd= | Consent state (see Consent Mode v2 section) | gcs=G111 |
Preserve wire type information. Custom parameters can use ep. or epn., but reserved ecommerce fields and items have their own web-tag encodings. Do not assume standard currency is ep.currency or an item ID is a flat ep.item_id. Google's logical ecommerce contract uses event-level currency and nested items[]; decode the observed transport through a capture-tested adapter before checking that contract.
Intercept with page.waitForRequest (single expected event) or page.on('request', ...)
(collect many). Attach before the action. Match the exact /g/collect path and an
approved hostname. Merge each POST event's parameters over the URL query with
parseGA4 from references/ga4-interception.md; URL-only matching misses batched events.
Use a test property or a local collector so tests do not pollute production analytics.
Pattern using the parser below plus your application's verified ecommerce decoder and logical tracking plan. decodeEcommerceWebEvent is a required project adapter, not a package supplied by this skill; unsupported or missing wire fields must fail. See references/ga4-interception.md and references/tracking-plan.md.
test('add_to_cart fires with correct params', async ({ page }) => {
await page.goto('/product/42');
const [request] = await Promise.all([
page.waitForRequest(r => parseGA4(r).some(event => event.name === 'add_to_cart')),
page.getByRole('button', { name: 'Add to cart' }).click(),
]);
const events = parseGA4(request).filter(event => event.name === 'add_to_cart');
expect(events).toHaveLength(1);
const params = events[0].params;
expect(params.tid).toBe('G-TEST'); // use the test property's exact measurement ID
// Application adapter, checked against redacted captures from the deployed tag.
const payload = decodeEcommerceWebEvent(params);
expect(validateEvent(plan, 'add_to_cart', payload)).toEqual([]);
expect(payload).toMatchObject({
currency: 'USD', value: 49.99,
items: [{ item_id: 'SKU-42', price: 49.99, quantity: 1 }],
});
});
Never substitute page.evaluate(() => window.dataLayer) as the only assertion, toBeVisible() on the button, or waitForTimeout() to "let the beacon send." See references/ga4-interception.md for batched-event parsing (GA4 can pack multiple events into one POST body) and region-endpoint handling.
A tracking plan is the source of truth: for every event, its name, required params, and each param's type. Keep it as a versioned file (tracking-plan.json / .yaml, or a TS interface/Zod schema) that both the app team and the tests import. Tests read the captured event and validate it against the plan — they check the schema and the known scenario values.
Validate the normalized logical payload and return every missing field and type
violation. The helper in references/tracking-plan.md checks finite numbers, nonempty
strings and nested nonempty item arrays. It rejects unsupported or empty schemas.
Zero is a valid number; an absent field, empty string, null, Infinity or a numeric
string is not a typed zero. Add scenario-specific expected values after schema checks.
const payload = decodeEcommerceWebEvent(capturedGA4Event.params); // capture-tested project adapter
expect(validateEvent(plan, 'add_to_cart', payload)).toEqual([]);
expect(payload.currency).toBe('USD');
expect(payload.value).toBe(49.99);
expect(payload.items).toEqual([{ item_id: 'SKU-42', price: 49.99, quantity: 1 }]);
A matching schema cannot prove that a price is correct. Compare the payload with the known test cart, then assert event counts at the application's tracking completion boundary. Keep raw input events and raw emitted records so a decoder cannot hide a missing field, a tag transformation or a duplicate purchase.
When the question is specifically the GTM input — "is the right object pushed to dataLayer when the page loads?" — assert the push, not the GA4 beacon. This is the inverse of beacon interception: here the dataLayer push IS the target.
Install the passive observer from references/datalayer-capture.md before navigation.
Leave an absent data layer absent, observe the application's first assignment, preserve
push behavior and flag later replacement or lost observation. Do not initialize or
repair the application queue in a test intended to measure the application's setup.
Use a completion signal from the application before inspecting the captured objects.
For GTM ecommerce object events, assert ecommerce.currency, ecommerce.value, and
ecommerce.items[] with each item's expected ID, price and quantity. Currency is at
event level in Google's ecommerce contract. Check counts as well as shape; selecting
the first matching event conceals duplicates. Direct gtag command arrays need the
corresponding command adapter rather than an object-event parser.
The reference contains the observer and full example. Use both observed input and network output when the question concerns the whole tracking path, while recording what each observation establishes.
When the push is the only observable. Some applications push to dataLayer and
load no tag manager and no collect endpoint; the push is the whole tracking contract.
Then the push tests are the gate, not a stopgap: keep the versioned plan, the schema
validation, the count assertions and the scenario literals, and cross-check each
purchase against the order the public API reports. In the report mark the beacon,
deduplication, consent, pixel-fixture and news-media items of Done When as not
applicable with the reason, rather than leaving them unmet; a reviewer must see that
nothing left the browser because nothing was meant to.
Marketing pixels send their own beacons. Endpoints to intercept:
| Destination | Endpoint | Event param | Dedup key |
|---|---|---|---|
| Meta Pixel | facebook.com/tr (also /tr?) | ev=PageView, ev=Purchase | eid / event_id |
| TikTok | analytics.tiktok.com | event in body/params | event_id |
px.ads.linkedin.com | conversion id | — |
For Meta, asserting that connect.facebook.net/en_US/fbevents.js loaded, or that fbq('track','Purchase') ran, is a load/count check — it does not prove correctness. The correctness property for a Purchase that fires both client-side (Pixel) and server-side (Conversions API / CAPI) is deduplication: both must carry the same event_id so Meta merges them into one conversion instead of double-counting.
Test it: capture the browser facebook.com/tr beacon for ev=Purchase, read its event_id, and assert it equals the event_id your server sent to CAPI (from a mocked/captured server call or a known fixture value). Skeleton:
const [pixel] = await Promise.all([
page.waitForRequest(r => r.url().includes('facebook.com/tr') && r.url().includes('ev=Purchase')),
completeCheckout(page),
]);
const clientEventId = new URL(pixel.url()).searchParams.get('eid'); // event_id on the wire
expect(clientEventId).toBe(serverCapiEventId); // deduplicates against the server CAPI event
See references/pixels-and-dedup.md for parsing TikTok/LinkedIn payloads and capturing the server CAPI call.
Choose the approved regional defaults and basic or advanced implementation before writing a gate. The examples in the reference use an all-denied default policy. See Google's consent-mode overview.
Observe all four signals against that policy:
| Signal | Governs |
|---|---|
ad_storage | Advertising cookies |
analytics_storage | Analytics cookies |
ad_user_data | Sending user data to Google for ads |
ad_personalization | Personalized ads / remarketing |
Install the passive observer before navigation in every test. It must not create
gtag, send a consent command, or repair a replaced data layer. A missing default,
an early measurement command, or an incorrect intermediate state must fail.
Before consent: basic mode blocks the configured tag requests. Advanced mode can
send the event plan's denied-state pings. Observe a real application completion signal
and any declared observation interval; an empty collector or networkidle is not proof
that delayed tracking is correct. Basic defaults can be initialized after acceptance.
After accept: checkpoint existing commands and events, click the CMP button, and require a new matching update plus the newly expected beacon. Repeat independently for rejection, partial consent, withdrawal, and persistence.
In the captured web-tag transport, gcs=G100 and gcs=G111 distinguish the two storage
signals. gcd presence is a diagnostic, not proof that all four signals or payload
identifiers are correct. Keep versioned transport captures and use the four observed
states plus the approved payload/storage inventory. Do not rely on an undocumented
gcd prefix. See references/consent-mode.md for the executable helpers and recipes.
Whether the law permits a beacon under a given consent state is
compliance-testing. This skill asserts that when a beacon fires, its data and consent params are correct.
News and publisher sites have a tracking surface generic e-commerce plans miss. Cover all three:
article-view) on article load — assert the beacon fires once with article metadata (id, section, author).paywall_hit (or meter) event when the free-article meter is exhausted.Drive scroll with actual actions — mouse.wheel, element.scrollIntoView, or page.evaluate(() => window.scrollTo(...)) / scrollBy — and wait on the beacon (waitForRequest or your captured-events list), never waitForTimeout. Scrolling in fixed sleeps both flakes and masks threshold-timing bugs.
const buckets = [25, 50, 75, 100];
for (const pct of buckets) {
const [req] = await Promise.all([
page.waitForRequest(r => r.url().includes('/g/collect') && r.url().includes('en=scroll')),
page.evaluate(p => window.scrollTo(0, document.body.scrollHeight * (p / 100)), pct),
]);
expect(new URL(req.url()).searchParams.get('epn.percent_scrolled')).toBe(String(pct));
}
Full suite (article_view metadata, the four scroll buckets de-duplicated, and the paywall-meter event) in references/news-media.md.
Don't re-implement beacon capture in every test. Build one reusable Playwright fixture (test.extend) that listens with page.on('request', ...), matches all destination endpoints (GA4 /g/collect, facebook.com/tr, analytics.tiktok.com, px.ads.linkedin.com), parses each request's new URL(...).searchParams and postData(), and pushes a normalized { destination, eventName, params } onto a collected array tests assert against.
Critical trap: a capture helper must observe, so use page.on('request') (or page.route followed by route.continue()), never page.route(...).abort() — aborting blocks the very beacons you are trying to see. And it must capture pixels too, not GA4 only. Full fixture in references/capture-fixture.md.
The gate diffs captured events against the tracking-plan baseline and fails the build (non-zero exit) when a previously-firing event stops firing or a required param goes missing after a release. Structure it as a Playwright project that runs the journeys, captures every beacon via the fixture, and validates each against the plan; on any missing/dropped event the test expect fails, Playwright exits non-zero, and the GitHub Actions (or any CI) job goes red.
Do not make the gate "check GA4 DebugView manually," "only run against production traffic," or "warn but pass anyway" — none of those block a regression. The diff-against-baseline run belongs in PR CI, before merge. See references/ci-gating.md for the workflow YAML, the baseline-diff script, and how to surface missing-param failures in the job summary.
DIY Playwright interception is the right call for a bounded set of events on a few critical journeys gated in CI. It stops paying off at scale: hundreds of events across many domains, with a small team, and a need for continuous production / drift monitoring (catching a tag a marketer breaks in GTM at 2am, which a pre-merge CI gate never sees).
| Dimension | Build (Playwright) | Buy |
|---|---|---|
| Few events, key journeys, pre-merge gate | Best fit | Overkill |
| Hundreds of events, many domains | Maintenance crushes you | Buy |
| Continuous 24/7 production drift monitoring | Out of scope for CI | Buy |
| Small team, high coverage demand | Build cost too high | Buy |
Current live paid options worth naming:
Don't recommend Segment Protocols as the only validation (it governs data flowing through Segment, not arbitrary client beacons), and never call Google Tag Assistant a CI gate — it is an interactive debug tool, not an automated pass/fail. Tie the decision to scale, many domains, maintenance burden, and continuous monitoring — the axes where DIY stops paying off.
window.dataLayer (or the DOM) instead of the beaconpage.evaluate(() => window.dataLayer) proves a push happened, not that GA4 sent anything; toBeVisible() proves nothing about tracking. Intercept the /g/collect request and assert its en= and ep./epn. params. (The one exception: when the push itself is the contract — see dataLayer.push — but then never reach for the GA4 beacon instead.)
waitForTimeout to "wait for the event to fire"A fixed sleep flakes and hides timing bugs. Wait on the request: page.waitForRequest(r => r.url().includes('/g/collect')).
Name-only assertions pass while currency, value, and item_id are wrong. Validate every required param and its type against the tracking plan.
There is no source of truth, so nothing catches a renamed param across the suite. Keep the plan external and validate against it.
fbevents.js loaded, fbq ran)Neither proves the conversion is correct or de-duplicated. For Purchase, assert the client and server event_id match.
Seeding denied consent can hide an application that omits its own default. Observe all four real signals against the selected policy, and distinguish basic from advanced.
page.route(...).abort() inside a capture helperAborting blocks the beacons you meant to observe. Use page.on('request') (or route.continue()).
GA4 DebugView, production-only monitoring, or warn-but-pass do not stop a regression. Make CI exit non-zero on a dropped event or missing required param.
Smallest check first — prove the suite actually catches a regression, don't just trust a green run:
npx playwright test -g add_to_cart passes, and its trace (--trace on) shows the captured /g/collect request with en=add_to_cart. A green test with no matching request in the trace means you asserted on the wrong thing.value → amount in the app) and re-run the CI project — the build must exit non-zero with a missing/violation message. If it still passes, the gate is theater. When the application cannot be edited (a black-box target, a frozen fixture), prove it from the test side instead: feed the validator one good payload and several broken ones (missing param, wrong type, minor units where major units are required) and drive a deliberately broken static page (a data: URL that pushes the wrong object) through the same observer and assertions. Both must fail, and the broken-page run belongs in the execution log.eid from facebook.com/tr?ev=Purchase equals the server CAPI event_id for the same order. Different values mean Meta will double-count./g/collect, facebook.com/tr, etc.) and parses en=/ev= plus params from searchParams/postData — no DOM-only or dataLayer-only assertions. Exception: an application whose contract is the push itself, with no tag manager or collect endpoint, is done when every planned push is asserted against the plan and the backing record (see "When the push is the only observable"), and the beacon items below are recorded as not applicable.missing/type violations, not just the event name.event_id equals the server CAPI event_id.test.extend) collects GA4 + Meta + TikTok + LinkedIn beacons; no test re-implements capture and no capture helper calls route.abort().waitForRequest, no waitForTimeout.references/)/g/collect interception helper, batched-event POST-body parsing, region-endpoint handling.validateEvent helper, and separate assertions for schema and scenario values.addInitScript dataLayer.push wrapper and ecommerce items[] shape assertions.event_id dedup capture.gcs=/gcd= diagnostics.test.extend capture fixture.Automatically, when a task matches. Use when: "test analytics tracking," "GA4 event test," "verify the pix
Skills that do similar work, with their headline benchmark lift.
From the same source and nearby in the catalogue.