Give the docs assistant a release-notes procedure: fetch merged pull requests, group them by change type and follow the team’s writing rules. An Agent Skill packages that procedure as a folder containing SKILL.md, references and scripts. ADK’s SkillToolset lets the agent discover the procedure and load its details when the question needs them. This lab uses bundled docuctl pull requests for version 2.4.0 and a Python grouping script.
When you need this. When one agent does several jobs that each have their own procedure, such as a docs assistant that writes release notes, reviews pages and drafts deprecation notices, we keep each procedure as a skill folder that the team can review like code. The toolset loads the body on demand; the model must still choose and follow the skill correctly.
The skills folder is one more toolset on the same docs assistant.
Write a skill folder
Download the complete lab ZIP, extract it, and open a terminal in adk-skills/. The commands below use that directory and its pinned dependencies. Set GOOGLE_API_KEY in this terminal before running the agent.
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txtA skill is a directory whose name matches the name in its SKILL.md frontmatter. The frontmatter is what the model sees first; the body is the procedure; references/, assets/ and scripts/ hold files the procedure points to. This follows the Agent Skills specification. Other runtimes can read this format; ADK-specific metadata and executor behavior still need adaptation.
---
name: release-notes
description: Write the release notes for a docuctl version from its merged pull requests. Use when someone asks for release notes, a changelog entry or "what changed" in a version.
metadata:
adk_additional_tools:
- list_merged_prs
---
Follow these steps exactly.
1. Call `list_merged_prs` with the version the user named.
2. Run `scripts/group_changes.py` with the pull request titles as positional arguments. It groups them by Conventional Commits type and prints JSON.
3. Read `references/style.md` and write the notes in that format, using only the groups the script printed.
4. Never invent a change. If `list_merged_prs` returns an error, report it and stop.The description helps the model choose a skill, so write it as “what it does, and when to use it”. The metadata.adk_additional_tools key names tools that the skill needs: ADK offers list_merged_prs to the model only after this skill is loaded. That key is documented in the installed package’s Frontmatter model, not on the adk.dev Skills page.
The script is ordinary Python that prints JSON. Grouping titles by Conventional Commits type is a deterministic job, so the skill runs code for it instead of asking the model to sort:
"""Group pull request titles by Conventional Commits type and print JSON."""
import json
import re
import sys
SECTIONS = {"feat": "features", "fix": "fixes", "docs": "docs"}
groups = {"breaking": [], "features": [], "fixes": [], "docs": [], "skipped": []}
for title in sys.argv[1:]:
match = re.match(r"^(\w+)(\([^)]*\))?(!)?:\s*(.+)$", title)
if not match:
groups["skipped"].append(title)
elif match[3]:
groups["breaking"].append(match[4])
else:
groups[SECTIONS.get(match[1], "skipped")].append(match[4])
print(json.dumps(groups))A folder named differently from its skill. load_skills_from_dir raises ValueError at startup. Match the folder name to the frontmatter name, in lowercase kebab-case.
Load the folder with SkillToolset
Skills are part of core google-adk; no extra is needed. adk.dev marks Skills as Experimental, so pin google-adk and rerun the tests when you upgrade. To run Gemma locally instead of Gemini, set DOCS_MODEL=ollama_chat/gemma4:e2b; the agent then uses LiteLlm.
"""The docs assistant with skills loaded from a folder through SkillToolset."""
import asyncio
import os
import sys
from pathlib import Path
from google.adk import Agent, Runner
from google.adk.code_executors import UnsafeLocalCodeExecutor
from google.adk.models.lite_llm import LiteLlm
from google.adk.sessions import InMemorySessionService
from google.adk.skills import load_skills_from_dir
from google.adk.tools.skill_toolset import SkillToolset
from google.genai import types
HERE = Path(__file__).resolve().parent
# Gemini through ADK's native client; set DOCS_MODEL=ollama_chat/gemma4:e2b to run Gemma locally.
MODEL = os.environ.get("DOCS_MODEL", "gemini-3.8-flash")
PAGES = sorted(p.stem for p in (HERE / "docs").glob("*.md"))
MERGED = { # the merged pull requests per version, as a release tool would return them
"2.4.0": [
{"number": 212, "title": "feat: add --dry-run to rollback"},
{"number": 215, "title": "fix: keep the token cache after a failed login"},
{"number": 218, "title": "feat!: remove the deprecated release revert command"},
{"number": 220, "title": "docs: document rollback --to"},
{"number": 221, "title": "ci: cache the Go toolchain"},
],
}
def read_page(page: str) -> dict:
"""Read one page of the docuctl documentation: config, deploy, rollback or tokens."""
if page not in PAGES:
return {"error": f"No page {page!r}. Pages: {', '.join(PAGES)}."}
root = (HERE / "docs").resolve()
path = (root / f"{page}.md").resolve()
if not path.is_relative_to(root) or not path.is_file():
return {"error": f"No readable docs page {page!r}."}
return {"page": page, "text": path.read_text(encoding="utf-8")}
def list_merged_prs(version: str) -> dict:
"""List the pull requests merged into a docuctl version, such as 2.4.0."""
if version not in MERGED:
return {"error": f"No release {version!r}. Known: {', '.join(MERGED)}."}
return {"version": version, "pull_requests": MERGED[version]}
def skills_toolset(folder: Path = HERE / "skills", run_scripts: bool = True) -> SkillToolset:
"""Every skill folder under skills/; list_merged_prs is offered only after release-notes is loaded."""
return SkillToolset(
skills=load_skills_from_dir(folder),
# Runs skill scripts in a child Python process with your permissions: local development only.
code_executor=UnsafeLocalCodeExecutor() if run_scripts else None,
script_timeout=30,
additional_tools=[list_merged_prs],
)
def build_agent(model=None, toolset: SkillToolset | None = None) -> Agent:
return Agent(
name="docs_assistant",
model=model or (LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL),
instruction="You help developers keep the docuctl documentation and releases correct. "
"Read pages with read_page. Say plainly when you cannot do something with your tools.",
tools=[read_page, toolset or skills_toolset()],
generate_content_config=types.GenerateContentConfig(
temperature=0.2, thinking_config=types.ThinkingConfig(thinking_level="low")),
)
async def ask(text: str, agent: Agent | None = None) -> list:
sessions = InMemorySessionService()
runner = Runner(app_name="docs", agent=agent or build_agent(), session_service=sessions)
try:
session = await sessions.create_session(app_name="docs", user_id="dev")
message = types.Content(role="user", parts=[types.Part(text=text)])
return [e async for e in runner.run_async(user_id="dev", session_id=session.id, new_message=message)]
finally:
await runner.close()
def show(events: list, width: int = 160) -> str:
lines = []
for event in events:
for call in event.get_function_calls():
lines.append(f"call {call.name} {call.args}")
for result in event.get_function_responses():
lines.append(f"result {result.name} {str(result.response)[:width]}")
if event.is_final_response() and event.author != "user" and event.content:
lines.append("answer " + "".join(p.text or "" for p in event.content.parts if not p.thought).strip())
return "\n".join(lines)
if __name__ == "__main__":
print(show(asyncio.run(ask(" ".join(sys.argv[1:])))))load_skills_from_dir reads every subfolder of skills/ once, validates each frontmatter and returns Skill objects with their files already in memory. SkillToolset turns them into four tools: list_skills, load_skill, load_skill_resource and run_skill_script. It also adds a system instruction that tells the model to load a skill before using it and to follow its steps.
additional_tools=[list_merged_prs] registers the function with the toolset instead of the agent, and the skill’s metadata decides when it is offered. read_page stays on the agent because every conversation may need it.
code_executor is what makes run_skill_script work. UnsafeLocalCodeExecutor runs each script in a child Python process on your machine, with your user’s permissions and a copy of your environment variables. This executor provides no filesystem isolation. Review a skill’s scripts/ and the environment it inherits before running it; use an executor with the isolation your application requires. The model also chooses each script’s arguments, so scripts should validate their input and pass arguments directly to reviewed functions or executables rather than interpolating them into shell commands.
Here, group_changes.py reads title strings from sys.argv, classifies them with a regular expression and prints JSON. It treats each argument as data and never executes it as a shell command.
No code executor. run_skill_script is not offered. The recorded model read the script and grouped the five titles itself, producing plausible notes through a different method. Supply an executor and assert that the trajectory includes the script call.
A resource path outside the skill. load_skill_resource returns RESOURCE_NOT_FOUND or INVALID_RESOURCE_PATH; it looks up bundled files in memory rather than reading an arbitrary disk path. Keep secrets out of the bundled files too.
Run the skill
python agent.py "Write the release notes for docuctl 2.4.0."
python agent.py "Write the release notes for docuctl 9.9.0."Inspect the tool calls as well as the final notes. For version 2.4.0, look for list_skills, load_skill, list_merged_prs, the style-guide read and run_skill_script. Check that the notes omit the ci: pull request, as the style guide requires. For 9.9.0, the fixture tool returns an error; the assistant should report it and stop.
A step’s tool returns an error. For the unknown release, the recorded model reports the error and stops, following the skill’s stop instruction. Write that stop condition into the procedure and test the recorded tool trajectory; the instruction does not enforce it in code.
Inspect skill discovery and active session state
A skill reaches the model in three levels. L1 is the frontmatter: list_skills returns each name and description as XML. L2 is the SKILL.md body, returned by load_skill. L3 is the files: load_skill_resource returns one reference, asset or script source, and run_skill_script runs a script. Loading a skill also records it as active in session state, which is how the toolset knows to add the skill’s adk_additional_tools:
Inspect the saved test fixture
Recorded example. The archived “Give an agent skills from a folder with SkillToolset” demonstration retains the earlier shipctl scenario; the current lab uses docuctl and does not claim a new model result.
request 1: list_skills, load_skill, load_skill_resource, read_page, run_skill_script
request 2: list_skills, load_skill, load_skill_resource, read_page, run_skill_script
request 3: list_merged_prs, list_skills, load_skill, load_skill_resource, read_page, run_skill_script
request 4: list_merged_prs, list_skills, load_skill, load_skill_resource, read_page, run_skill_script
request 5: list_merged_prs, list_skills, load_skill, load_skill_resource, read_page, run_skill_scriptLazy discovery, the default, offers list_skills and keeps the catalog out of the system instruction. discovery_mode=SkillDiscoveryMode.EAGER puts the catalog in that instruction and removes list_skills. Try it with this small catalog. Compare the calls: finding the skill sooner may save a turn, but the model still chooses whether to combine its other reads. Measure that behavior with your own questions.
run_skill_script does not run the file from skills/ on disk. It builds a wrapper program that writes the skill’s files into a temporary directory and runs the script there with the arguments the model chose, under script_timeout seconds, and returns stdout, stderr and a status. The temporary directory receives that skill’s bundled files. This is not filesystem isolation: UnsafeLocalCodeExecutor can still access other paths with the process’s permissions.
Note: ADK can also fetch skills on demand from the Google Cloud Skill Registry, a Preview service that needs a Google Cloud project with the Skill Registry API enabled. You pass a GCPSkillRegistry as SkillToolset(registry=...), and the toolset adds a search_skills tool. This lesson stays with local folders. See ADK: Google Cloud Skill Registry.
Test the skills offline
test_agent.py replays the recorded model turns. Skill loading, the four skill tools, the gated list_merged_prs, the script run through UnsafeLocalCodeExecutor and the agent loop all run for real.
python -m unittest -v test_agent.pyRead the offline test
"""Replay the recorded Gemini turns against the real SkillToolset, offline.
Run: python -m unittest -v test_agent.py
The model is replaced by its recorded turns; skill loading, the skill tools, the script run and the agent loop are real.
"""
import asyncio
import json
import logging
import shutil
import tempfile
from unittest.mock import patch
import unittest
import warnings
from pathlib import Path
warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)
from google.adk.models.base_llm import BaseLlm # noqa: E402
from google.adk.models.llm_response import LlmResponse # noqa: E402
from google.adk.skills import load_skill_from_dir # noqa: E402
from google.adk.tools.skill_toolset import SkillDiscoveryMode, SkillToolset # noqa: E402
from google.genai import types # noqa: E402
import agent # noqa: E402
HERE = Path(__file__).resolve().parent
RECORD = json.loads((HERE / "recorded-runs.json").read_text(encoding="utf-8"))
class ReplayLlm(BaseLlm):
turns: list
requests: list = []
async def generate_content_async(self, llm_request, stream=False):
self.requests.append(llm_request)
yield LlmResponse(content=types.Content.model_validate(self.turns.pop(0)))
def replay(turns: list, toolset=None) -> tuple[str, ReplayLlm]:
model = ReplayLlm(model="replay", turns=list(turns), requests=[])
toolset = toolset or agent.skills_toolset(folder=HERE / "historical-shipctl-source" / "skills")
events = asyncio.run(agent.ask("replayed question", agent.build_agent(model, toolset)))
return agent.show(events), model
def setUpModule():
warnings.simplefilter("ignore") # unittest resets warning filters before the tests run
def offered(request) -> list[str]:
return sorted(d.name for t in request.config.tools for d in t.function_declarations)
def call(name: str, **args) -> dict:
return {"role": "model", "parts": [{"function_call": {"name": name, "args": args}}]}
class SkillToolsetTests(unittest.TestCase):
def test_the_folder_loads_both_skills(self):
self.assertEqual(sorted(s.name for s in agent.load_skills_from_dir(HERE / "skills")), sorted(RECORD["skills"]))
def test_release_notes_follow_the_skill_and_run_its_script(self):
run = RECORD["runs"]["release"]
transcript, _ = replay(run["model_turns"])
self.assertEqual(transcript, run["transcript"])
self.assertIn('result run_skill_script {\'skill_name\': \'release-notes\', \'file_path\': \'scripts/group_changes.py\', \'stdout\': \'{"breaking": ["remove the deprecated', transcript)
self.assertNotIn("cache the Go toolchain (#221)", transcript)
def test_list_merged_prs_is_offered_only_after_the_skill_loads(self):
_, model = replay(RECORD["runs"]["release"]["model_turns"])
self.assertNotIn("list_merged_prs", offered(model.requests[0]))
self.assertIn("list_merged_prs", offered(model.requests[2]))
def test_eager_discovery_puts_the_catalog_in_the_instruction(self):
eager = SkillToolset(skills=agent.load_skills_from_dir(HERE / "skills"), discovery_mode=SkillDiscoveryMode.EAGER)
_, model = replay([{"role": "model", "parts": [{"text": "ok"}]}], eager)
self.assertNotIn("list_skills", offered(model.requests[0]))
self.assertIn("release-notes", model.requests[0].config.system_instruction)
def test_a_folder_named_differently_from_its_skill_is_refused(self):
with tempfile.TemporaryDirectory() as tmp:
shutil.copytree(HERE / "skills" / "release-notes", Path(tmp) / "release_notes")
with self.assertRaises(ValueError) as caught:
load_skill_from_dir(Path(tmp) / "release_notes")
self.assertEqual(f"ValueError: {caught.exception}", RECORD["failures"]["name_mismatch"])
def test_without_a_code_executor_the_script_never_runs(self):
run = RECORD["runs"]["no-executor"]
transcript, model = replay(run["model_turns"], agent.skills_toolset(folder=HERE / "historical-shipctl-source" / "skills", run_scripts=False))
self.assertEqual(transcript, run["transcript"])
self.assertTrue(all("run_skill_script" not in offered(r) for r in model.requests))
self.assertNotIn("call run_skill_script", transcript)
self.assertIn("call load_skill_resource {'skill_name': 'release-notes', 'file_path': 'scripts/group_changes.py'}", transcript)
def test_resource_paths_cannot_leave_the_skill(self):
turns = [call("load_skill_resource", skill_name="release-notes", file_path="references/../../../agent.py"),
call("load_skill_resource", skill_name="release-notes", file_path="../agent.py"),
{"role": "model", "parts": [{"text": "stopped"}]}]
transcript, _ = replay(turns)
self.assertIn("'error_code': 'RESOURCE_NOT_FOUND'", transcript)
self.assertIn("'error_code': 'INVALID_RESOURCE_PATH'", transcript)
self.assertNotIn("MERGED", transcript)
def test_an_unknown_version_stops_the_skill(self):
run = RECORD["runs"]["unknown-version"]
transcript, _ = replay(run["model_turns"])
self.assertEqual(transcript, run["transcript"])
self.assertNotIn("run_skill_script", transcript)
class DocsPathSafety(unittest.TestCase):
def test_read_page_refuses_an_out_of_tree_symlink(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "docs").mkdir()
(root / "outside.txt").write_text("private content")
(root / "docs/config.md").symlink_to(root / "outside.txt")
with patch.object(agent, "HERE", root), patch.object(agent, "PAGES", ["config"]):
result = agent.read_page("config")
self.assertIn("error", result)
self.assertNotIn("private content", str(result))
if __name__ == "__main__":
unittest.main()Check your understanding
Read the explanation
Check both the release-note content and the script call when the procedure requires deterministic grouping.
- Use Gemini built-in tools: Google Search grounding and code execution: run search and code on Gemini’s servers instead of your machine.
References
Official sources checked October 21, 2026, against google-adk 2.12.0.