How to Make Google Antigravity Agent Skills Configurable With YAML Overrides
A step-by-step tutorial for building a YAML-based configuration layer that lets a Google Antigravity Agent Skill behave differently per project, without forking or editing the skill itself.
Google Antigravity’s Agent Skills let you teach a coding agent a workflow once, as a folder with a SKILL.md file inside it, and have the agent reuse that workflow automatically whenever it fits the task. The catch is that a skill’s instructions are plain static text. If your team wants the same skill to behave slightly differently in two different projects, the only built-in option is to copy the whole skill and hand-edit it, and the moment you do that, you own a fork that never gets the original author’s fixes again.
Table Of Content
- What you need before you start
- Step 1: Set up your workspace
- Step 2: Write the skill’s default configuration
- Step 3: Write the SKILL.md that documents it
- Step 4: Build the config loader
- Step 5: Add a project-level override
- Step 6: Wire the config into real behavior
- Common mistakes and how to verify each step worked
- Verifying everything works end to end
A recent freeCodeCamp tutorial by Obum first named this the configurable skills pattern and worked through it using a git commit message formatter as the example. This tutorial teaches the same underlying idea, a skill ships its own defaults, a project overrides only what it needs, and a script merges the two, but builds and tests a different, original example from scratch: a pull request description generator. Every claim below about how Antigravity itself handles skills was independently verified against Google’s own Antigravity documentation rather than taken on faith from a single source. You will build and test every piece of the configuration layer yourself, entirely in Python, so you can follow along and verify every result even if you do not have Antigravity installed. If you do have Antigravity, the last step shows exactly where these files belong so an agent can pick them up.
Three terms are worth defining before the first command:
Agent Skill is, in Antigravity’s own words, “an open standard for extending agent capabilities. A skill is a folder containing a SKILL.md file with instructions that the agent can follow when working on specific tasks,” per Google’s Antigravity documentation.
Frontmatter is the block of YAML at the top of a SKILL.md file, between two --- lines, that describes the skill before its instructions begin. Antigravity’s docs define two fields: description is required and is “what the agent sees when deciding whether to apply the skill,” while name is optional and “defaults to the folder name if not provided.”
Progressive disclosure is how Antigravity decides when to use a skill, in three stages the docs name explicitly: “Discovery: When a conversation starts, the agent sees a list of available skills with their names and descriptions,” “Activation: If a skill looks relevant to your task, the agent reads the full SKILL.md content,” and “Execution: The agent follows the skill’s instructions while working on your task.” The agent only pays the cost of reading a skill’s full instructions once it looks relevant, which is why the description field matters so much: it is the only thing standing between your skill and the agent never using it.
What you need before you start
- Python 3.10 or newer. This tutorial was tested against Python 3.11.15. You need 3.10+ specifically because the code uses the
str | Noneunion type syntax; check your version withpython3 --version. pipand the PyYAML package (installed in Step 1).- Basic terminal comfort and basic YAML comfort. No prior Agent Skills experience is assumed, and you do not need Google Antigravity installed to follow along; every command below runs against plain files on your own disk.
- If you do want to use the finished skill with a real agent, a Google Antigravity install (the IDE, CLI, or SDK all work, since a skill is just files) lets you try that last step for real.
Step 1: Set up your workspace
Create a project folder and the skill’s directory structure. Antigravity’s docs are specific about this layout: “Antigravity now defaults to .agents/skills, but still maintains backward support for .agent/skills,” so this tutorial uses the current default, plural, form throughout.
# Context: any empty directory, used as a stand-in for a real project.
# Purpose: create the folder Antigravity looks in for workspace-specific skills.
mkdir -p pr-skill-demo/.agents/skills/pr-description/scripts
cd pr-skill-demo
python3 --version
Expected output: the mkdir produces no output on success, and python3 --version should print Python 3.10 or higher. Now install PyYAML, the library both scripts below use to read and write YAML:
pip install pyyaml
Expected output: a short install log ending in Successfully installed pyyaml-<version> (this tutorial saw pyyaml-6.0.3). If your system Python is externally managed and pip install refuses to run, create a virtual environment first with python3 -m venv venv && source venv/bin/activate, then repeat the install.
Step 2: Write the skill’s default configuration
The whole point of this pattern is that a skill’s tunable behavior lives in data, not in the instructions themselves. Create .agents/skills/pr-description/config.default.yaml:
# Default settings for the pr-description skill.
# Project-level overrides live in <workspace-root>/.agents/skills.config.yaml
sections:
- Summary
- Changes
- Testing
require_issue_link: false
tone: concise
footer:
include_reviewer_checklist: false
signoff: "Generated with the pr-description skill"
This defines a skill that drafts pull request descriptions with three sections, a concise tone, no requirement to link an issue, and no reviewer checklist. Every one of these is a plain value a project might reasonably want to change, which is exactly what makes it worth externalizing instead of writing directly into the skill’s instructions.
Step 3: Write the SKILL.md that documents it
Create .agents/skills/pr-description/SKILL.md:
---
name: pr-description
description: Drafts a pull request description in this project's required format. Use when the user asks to write, generate, or draft a PR description or pull request summary.
---
# PR Description
This skill drafts a pull request description. Its exact required sections,
tone, and issue-link rule are **not** hard-coded here. They live in
`config.default.yaml` next to this file, and a project can override them in
`.agents/skills.config.yaml` at the workspace root.
## How to use it
1. Run `python3 scripts/resolve_config.py pr-description` to see the
merged configuration (defaults plus any project override) as JSON.
2. Run `python3 scripts/render_pr_description.py` with the PR's summary,
list of changes, testing notes, and (if required) an issue number.
3. Use the rendered Markdown as the PR description. Do not add or remove
sections beyond what the resolved config lists.
Treat both scripts as black boxes: run them with `--help` if you need to
check their arguments, rather than reading the full source first.
Notice what is not in this file: there is no list of required sections, no tone setting, none of the actual configurable behavior. The frontmatter’s description is what an agent uses to decide this skill is relevant, and the instructions tell the agent to run a script rather than hard-coding what that script will return. The “Treat both scripts as black boxes” line is a direct application of Antigravity’s own documented best practice: “If your skill includes scripts, encourage the agent to run them with --help first rather than reading the entire source code. This keeps the agent’s context focused on the task.”
Step 4: Build the config loader
This is the piece that actually does the merging. Create .agents/skills/pr-description/scripts/resolve_config.py:
#!/usr/bin/env python3
"""Merge a skill's config.default.yaml with a project's override file."""
import argparse
import json
import sys
from pathlib import Path
import yaml
OVERRIDE_FILENAME = "skills.config.yaml"
def find_workspace_root(start: Path) -> Path:
"""Walk upward from `start` until a `.agents` folder is found."""
current = start.resolve()
for candidate in [current, *current.parents]:
if (candidate / ".agents").is_dir():
return candidate
raise FileNotFoundError(f"No .agents directory found above {start}")
def deep_merge(base, override):
"""Recursively merge override onto base.
Dicts merge key by key. Anything else (scalars, lists) is replaced
wholesale by the override's value when the override provides that key.
"""
if not isinstance(base, dict) or not isinstance(override, dict):
return override
merged = dict(base)
for key, value in override.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = deep_merge(merged[key], value)
else:
merged[key] = value
return merged
def unknown_keys(base: dict, override: dict, prefix: str = "") -> list:
"""Return dotted-path keys present in override but absent from base."""
found = []
for key, value in override.items():
path = f"{prefix}{key}"
if key not in base:
found.append(path)
elif isinstance(value, dict) and isinstance(base.get(key), dict):
found.extend(unknown_keys(base[key], value, prefix=f"{path}."))
return found
def resolve(skill_name: str, workspace_root: Path) -> dict:
skill_dir = workspace_root / ".agents" / "skills" / skill_name
default_path = skill_dir / "config.default.yaml"
if not default_path.is_file():
raise FileNotFoundError(f"No config.default.yaml for skill '{skill_name}' at {default_path}")
defaults = yaml.safe_load(default_path.read_text()) or {}
override_path = workspace_root / ".agents" / OVERRIDE_FILENAME
all_overrides = {}
if override_path.is_file():
all_overrides = yaml.safe_load(override_path.read_text()) or {}
else:
print(f"# note: no override file at {override_path}, using defaults only", file=sys.stderr)
skill_override = all_overrides.get(skill_name, {})
unknown = unknown_keys(defaults, skill_override)
for path in unknown:
print(
f"# warning: override key '{skill_name}.{path}' has no matching default; "
"check for a typo, it will still be applied",
file=sys.stderr,
)
return deep_merge(defaults, skill_override)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("skill_name", help="Folder name under .agents/skills/")
args = parser.parse_args()
workspace_root = find_workspace_root(Path.cwd())
merged = resolve(args.skill_name, workspace_root)
print(json.dumps(merged, indent=2))
if __name__ == "__main__":
main()
Two functions carry the real logic. deep_merge() walks both dictionaries together: when a key holds a nested dictionary on both sides, it recurses into that nested dictionary instead of replacing it outright, so a project can override one nested setting without wiping out its siblings. Anything that is not a dictionary on both sides, a string, a boolean, or a list, gets replaced wholesale by the override’s value. unknown_keys() is a small safety net: it walks the override looking for keys that do not exist anywhere in the defaults, which almost always means a typo, and reports them as warnings without blocking the merge.
find_workspace_root() walks upward from your current directory looking for a .agents folder, the same way tools like git walk upward looking for a .git folder. That is what lets you run these scripts from inside a project without hard-coding a path.
Run it now, before any override file exists:
python3 .agents/skills/pr-description/scripts/resolve_config.py pr-description
# note: no override file at /path/to/pr-skill-demo/.agents/skills.config.yaml, using defaults only
{
"sections": [
"Summary",
"Changes",
"Testing"
],
"require_issue_link": false,
"tone": "concise",
"footer": {
"include_reviewer_checklist": false,
"signoff": "Generated with the pr-description skill"
}
}
What happened: the note on the first line went to stderr, not stdout, which is deliberate: it lets you pipe the JSON on stdout into another program (as Step 6 does) without that note getting mixed into the data. With no override file present, the resolved config is just the defaults, unchanged.
Step 5: Add a project-level override
Now simulate a stricter project, say an infrastructure team that wants a root-cause section, a mandatory issue link, and a reviewer checklist. Create .agents/skills.config.yaml at the workspace root, a sibling of the .agents/skills/ folder, not inside it:
pr-description:
sections:
- Summary
- Root Cause
- Changes
- Testing
- Rollback Plan
require_issue_link: true
tone: detailed
footer:
include_reviewer_checklist: true
This file is a convention this tutorial defines, not a built-in Antigravity feature. Antigravity itself never reads skills.config.yaml; only resolve_config.py does, because SKILL.md instructs the agent to run that script. Keeping that distinction straight matters: renaming or moving this file will not break Antigravity, it will just mean your own scripts stop finding your overrides.
Run the resolver again:
python3 .agents/skills/pr-description/scripts/resolve_config.py pr-description
{
"sections": [
"Summary",
"Root Cause",
"Changes",
"Testing",
"Rollback Plan"
],
"require_issue_link": true,
"tone": "detailed",
"footer": {
"include_reviewer_checklist": true,
"signoff": "Generated with the pr-description skill"
}
}
Look closely at footer. The override only set include_reviewer_checklist: true and never mentioned signoff at all, yet the resolved output still has both keys: include_reviewer_checklist changed, and signoff survived untouched from the defaults. That is deep_merge() recursing into the nested dictionary instead of replacing it wholesale. Compare that to sections: the override’s five-item list completely replaced the defaults’ three-item list, it did not append to it or merge item by item. This asymmetry, dictionaries merge key by key, everything else gets replaced outright, is the single most important behavior to understand about this pattern, and it is exactly the kind of detail that is easy to get wrong if you write a merge function without testing it against a real nested override.
Step 6: Wire the config into real behavior
A resolved config that only ever gets printed as JSON is not useful by itself. Create .agents/skills/pr-description/scripts/render_pr_description.py, which imports the resolver and uses its output to actually build a PR description:
#!/usr/bin/env python3
"""Render a PR description Markdown template using the resolved skill config."""
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from resolve_config import find_workspace_root, resolve # noqa: E402
SECTION_PLACEHOLDERS = {
"Summary": "{summary}",
"Changes": "{changes}",
"Testing": "{testing}",
"Root Cause": "{root_cause}",
"Rollback Plan": "{rollback_plan}",
}
def render(config: dict, summary: str, changes: str, testing: str, issue: str | None,
root_cause: str | None = None, rollback_plan: str | None = None) -> str:
if config.get("require_issue_link") and not issue:
raise ValueError("this project's config requires an issue link (pass --issue) but none was given")
values = {
"summary": summary,
"changes": changes,
"testing": testing,
"root_cause": root_cause or "_not provided_",
"rollback_plan": rollback_plan or "_not provided_",
}
lines = []
if issue:
lines.append(f"Closes #{issue}\n")
for section in config["sections"]:
placeholder = SECTION_PLACEHOLDERS.get(section)
body = values.get(placeholder.strip("{}")) if placeholder else "_no content mapped for this section_"
lines.append(f"## {section}\n{body}\n")
if config.get("tone") == "detailed":
lines.append("_(detailed tone: include reasoning, not just what changed)_\n")
if config.get("footer", {}).get("include_reviewer_checklist"):
lines.append(
"## Reviewer checklist\n- [ ] Tests pass\n- [ ] No secrets in diff\n- [ ] Docs updated\n"
)
signoff = config.get("footer", {}).get("signoff")
if signoff:
lines.append(f"---\n_{signoff}_")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--summary", required=True)
parser.add_argument("--changes", required=True)
parser.add_argument("--testing", required=True)
parser.add_argument("--issue", default=None)
parser.add_argument("--root-cause", default=None)
parser.add_argument("--rollback-plan", default=None)
args = parser.parse_args()
workspace_root = find_workspace_root(Path.cwd())
config = resolve("pr-description", workspace_root)
print(render(
config,
summary=args.summary,
changes=args.changes,
testing=args.testing,
issue=args.issue,
root_cause=args.root_cause,
rollback_plan=args.rollback_plan,
))
if __name__ == "__main__":
main()
The important line is if config.get("require_issue_link") and not issue: raise ValueError(...). This is the resolved config actually changing behavior, not just changing what gets printed: the exact same script enforces a mandatory issue link for the strict project and does not for the permissive one, based entirely on which config it resolved.
Try it with no override in a second, separate project, to confirm the defaults still work on their own:
# Context: a second, independent project directory with its own copy of the skill
# and no skills.config.yaml override file.
mkdir -p ../pr-skill-demo-projectb/.agents/skills
cp -r .agents/skills/pr-description ../pr-skill-demo-projectb/.agents/skills/
cd ../pr-skill-demo-projectb
python3 .agents/skills/pr-description/scripts/render_pr_description.py \
--summary "Fix off-by-one error in pagination" \
--changes "Adjusted page_size calculation in paginate()" \
--testing "Added a regression test for the boundary case; full suite passes"
## Summary
Fix off-by-one error in pagination
## Changes
Adjusted page_size calculation in paginate()
## Testing
Added a regression test for the boundary case; full suite passes
---
_Generated with the pr-description skill_
Three sections, concise tone, no issue link required, exactly the defaults from Step 2, and no cross-contamination from the first project’s override file, because that file simply does not exist in this second project’s .agents folder.
Now go back to the first project and try the same command with no --issue flag, where the config requires one:
cd ../pr-skill-demo
python3 .agents/skills/pr-description/scripts/render_pr_description.py \
--summary "Fix off-by-one error in pagination" \
--changes "Adjusted page_size calculation in paginate()" \
--testing "Added a regression test for the boundary case; full suite passes"
Traceback (most recent call last):
...
raise ValueError("this project's config requires an issue link (pass --issue) but none was given")
ValueError: this project's config requires an issue link (pass --issue) but none was given
Now supply everything the stricter config asks for:
python3 .agents/skills/pr-description/scripts/render_pr_description.py \
--summary "Fix off-by-one error in pagination" \
--changes "Adjusted page_size calculation in paginate()" \
--testing "Added a regression test for the boundary case; full suite passes" \
--issue 482 \
--root-cause "paginate() used <= instead of < when comparing against page_size" \
--rollback-plan "revert this commit; no schema or data migration involved"
Closes #482
## Summary
Fix off-by-one error in pagination
## Root Cause
paginate() used <= instead of < when comparing against page_size
## Changes
Adjusted page_size calculation in paginate()
## Testing
Added a regression test for the boundary case; full suite passes
## Rollback Plan
revert this commit; no schema or data migration involved
_(detailed tone: include reasoning, not just what changed)_
## Reviewer checklist
- [ ] Tests pass
- [ ] No secrets in diff
- [ ] Docs updated
---
_Generated with the pr-description skill_
Same script, same code, two genuinely different behaviors, entirely because of which project’s .agents folder it ran from. That is the whole pattern working end to end.
Common mistakes and how to verify each step worked
A typo in an override key silently does nothing useful. Change sections to a misspelled sectoins in skills.config.yaml and rerun the resolver:
# warning: override key 'pr-description.sectoins' has no matching default; check for a typo, it will still be applied
{
"sections": [
"Summary",
"Changes",
"Testing"
],
...
"sectoins": [
"Summary",
"Testing"
]
}
The real sections key silently stayed at its default value, and a brand new, useless sectoins key got added alongside it. Nothing crashed, which is exactly the danger: without the warning on stderr, this would look like a successful override that quietly did not apply. Always check stderr, not just the JSON on stdout, after changing an override file.
Bad YAML indentation fails loudly, which is the good outcome. Misalign one key in skills.config.yaml (for example, indenting require_issue_link one space short of its siblings) and the resolver raises a yaml.parser.ParserError naming the exact line and column, instead of silently misparsing the file. If you ever see this error, fix the indentation to match the surrounding keys exactly; YAML has no tabs and is whitespace-sensitive, so mixing indentation widths within one file is the most common cause.
The override file exists but nothing changes. Check that skills.config.yaml lives directly under .agents/ at the workspace root, not inside .agents/skills/ or inside the skill’s own folder. find_workspace_root() only looks for a .agents directory; it does not validate where inside it the override file sits, so a misplaced file is simply never read, and you will see the “using defaults only” note even though the file exists somewhere on disk.
Expecting Antigravity to read your override file automatically. It will not. Antigravity’s own configuration surface for skills is limited to the two folder locations and the two frontmatter fields covered in Step 3; there is no documented native override mechanism. This entire config-merging behavior only happens because the instructions inside SKILL.md tell the agent to run resolve_config.py. If you rewrite those instructions to skip that step, the override file becomes inert.
Lists and dictionaries do not behave the same way under this merge. As Step 5 showed, overriding one key inside a nested dictionary (footer.include_reviewer_checklist) leaves its sibling keys untouched, but overriding a list (sections) replaces the entire list. If you want to add one item to a default list without retyping the rest, you have to spell out the full list in your override; this loader does not attempt to merge lists item by item, and a merge function that tried to guess how to combine two lists would be guessing wrong as often as right.
Verifying everything works end to end
Before relying on this pattern for a real skill, confirm all of the following, each of which was checked while building this tutorial: running the resolver with no override file present prints only the defaults and a note on stderr; adding an override changes only the keys it specifies while nested sibling keys survive; a second, independent project with no override of its own gets clean defaults with no leakage from the first project; a misspelled override key produces a warning instead of a silent no-op; invalid YAML fails with a specific parse error instead of a wrong config; and a script that validates its resolved config (like the issue-link check here) actually blocks bad output instead of just printing whatever it was given.
From here, if you have Antigravity installed, copy the finished .agents/skills/pr-description/ folder into a real project’s workspace root and ask the agent to draft a PR description; the agent will discover the skill through the same progressive disclosure process described at the top of this tutorial, read its instructions, and run the scripts you just built and tested by hand. To share this skill with a team, per Antigravity’s own guidance, the skill folder is just files, so committing .agents/skills/pr-description/ to your project’s repository is enough for every team member who pulls the repo to have it. If you also want to see this exact SKILL.md-plus-frontmatter convention applied to a different agent tool, this site’s guide to writing and organizing CLAUDE.md files for Claude Code covers the equivalent configuration surface for that tool.








No Comment! Be the first one.