The docuctl release service already describes itself: FastAPI publishes an OpenAPI spec at /openapi.json, with every path, parameter and the API key scheme. Google ADK’s OpenAPIToolset reads that spec and creates one tool per operation, so the docs assistant can answer “which staging releases failed?” from live data without a hand-written HTTP client. The key that guards the service travels as an ADK auth credential, added to each request by the tool, rather than a function argument. The replay checks that this API did not echo the key into model requests; a service that echoes headers or secrets in errors could expose it. In one recorded run on Gemini gemini-3.8-flash, the assistant listed the failed releases with one call:
Docuctl is the demonstration deployment CLI described by this lab’s bundled manual; the services here use local sample data.
When you need this. Most internal services a team runs already have an OpenAPI spec: a release API, a ticket system, a feature-flag service. When an agent should read from one of them, generating tools from the spec keeps the agent in step with the API, and the auth configuration stays in one place instead of in every tool.
Recorded example. The archived “Call a REST API from an OpenAPI spec, with authentication” demonstration retains the earlier shipctl scenario; the current lab uses docuctl and does not claim a new model result.
# main
> Which staging releases failed, and why?
call list_releases {"status": "failed", "env": "staging"}
http GET /envs/staging/releases key_sent=True request_id=e-57c158ea...
result {"result": [{"release": "r43", "status": "failed", "commit": "0be77c3", "reason": "health check failed"}, {"release": "r42", "status": "failed", "commit": "d41e
answer The following staging releases failed: - **r43**: health check failed (commit `0be77c3`) - **r42**: pre_deploy exited 1 (commit `d41e9a7`)1. Run the local release API
Download the complete lab ZIP. Extract it, then keep both terminals in adk-openapi-auth/. The folder includes the service, its data, the Python Runner and the native release_assistant package.
unzip call-a-rest-api-from-an-openapi-spec-with-authentication.zip
cd adk-openapi-auth
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt
cp release_assistant/.env.example release_assistant/.envEdit release_assistant/.env and fill GOOGLE_API_KEY locally. GOOGLE_GENAI_USE_ENTERPRISE=0 selects the Gemini API. The committed SHIP_API_KEY=local-release-demo is a public demonstration value for this local service, not a production credential. Use the same value in both terminals. Keep an active model key out of version control. If this terminal already exports a different backend or model, unset those values deliberately; native ADK does not override them from .env. An already installed Ollama model can be selected with DOCS_MODEL=ollama_chat/gemma4:e2b; these commands do not download one.
Keep terminal 1 running the local service:
export SHIP_API_KEY=local-release-demo
uvicorn ship_api:app --host 127.0.0.1 --port 8766ship_api.py stands in for the real service. It checks an X-Ship-Key header against SHIP_API_KEY with a constant-time comparison, logs each request’s X-Request-ID, and refuses rollbacks for this key.
"""A local stand-in for the docuctl release API: FastAPI, an API key header, and its own OpenAPI spec.
Run: SHIP_API_KEY=... uvicorn ship_api:app --port 8766
The spec is served at /openapi.json; every request is logged with its X-Request-ID.
"""
import os
import secrets
from typing import Literal
from fastapi import Depends, FastAPI, HTTPException, Request, Security
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
app = FastAPI(title="docuctl release API", version="2.4.0")
api_key = APIKeyHeader(name="X-Ship-Key", auto_error=False)
REQUESTS: list[dict] = [] # what the server saw, for the recording and the tests
RELEASES = {
"staging": [
{"release": "r43", "status": "failed", "commit": "0be77c3", "reason": "health check failed"},
{"release": "r42", "status": "failed", "commit": "d41e9a7", "reason": "pre_deploy exited 1"},
{"release": "r41", "status": "live", "commit": "8c1f2e0", "reason": None},
],
"production": [
{"release": "r40", "status": "live", "commit": "5e2a913", "reason": None},
{"release": "r39", "status": "previous", "commit": "77d0c41", "reason": None},
],
}
class Release(BaseModel):
release: str
status: Literal["live", "previous", "degraded", "failed", "pending"]
commit: str
reason: str | None
class Rollback(BaseModel):
to: str
def check_key(request: Request, key: str | None = Security(api_key)) -> None:
REQUESTS.append({"method": request.method, "path": request.url.path,
"request_id": request.headers.get("x-request-id"), "key_sent": key is not None})
if key is None or not secrets.compare_digest(key, os.environ["SHIP_API_KEY"]):
raise HTTPException(status_code=401, detail="missing or invalid X-Ship-Key")
@app.get("/envs/{env}/releases", operation_id="listReleases", response_model=list[Release],
dependencies=[Depends(check_key)])
def list_releases(env: Literal["staging", "production"], status: str | None = None):
"""List the releases of one environment, newest first. Filter by status, such as failed."""
return [r for r in RELEASES[env] if status is None or r["status"] == status]
@app.get("/releases/{release}", operation_id="getRelease", response_model=Release,
dependencies=[Depends(check_key)])
def get_release(release: str):
"""Show one release: its status, commit and failure reason."""
for env in RELEASES.values():
for r in env:
if r["release"] == release:
return r
raise HTTPException(status_code=404, detail=f"no release {release}")
@app.post("/envs/{env}/rollback", operation_id="rollbackEnvironment", response_model=Release,
dependencies=[Depends(check_key)])
def rollback(env: Literal["staging", "production"], body: Rollback):
"""Move an environment back to an earlier release."""
raise HTTPException(status_code=403, detail="this key may not roll back")Each route sets operation_id. That name becomes the tool name, so a stable operation id gives the agent stable tool names; without one, FastAPI generates an id from the function name and path.
2. Build the toolset from the published spec
"""The docs assistant answers release questions by calling the docuctl release API from its OpenAPI spec.
OpenAPIToolset turns each operation in the spec into a RestApiTool. The API key travels as an
auth credential that ADK adds to each HTTP request rather than a function argument.
"""
import asyncio
import json
import os
import sys
import httpx
from google.adk import Agent, Runner
from google.adk.agents.readonly_context import ReadonlyContext
from google.adk.models.lite_llm import LiteLlm
from google.adk.sessions import InMemorySessionService
from google.adk.tools.openapi_tool import OpenAPIToolset
from google.adk.tools.openapi_tool.auth.auth_helpers import token_to_scheme_credential
from google.genai import types
# 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")
API_URL = os.environ.get("SHIP_API_URL", "http://127.0.0.1:8766")
READ_ONLY = ["list_releases", "get_release"] # generated names: operationId in snake_case
def load_spec(url: str = API_URL) -> dict:
"""Fetch the spec the service publishes, and pin the server it should be called on."""
spec = httpx.get(f"{url}/openapi.json", timeout=5).raise_for_status().json()
spec["servers"] = [{"url": url}] # FastAPI omits servers; without one, ADK has no base URL
return spec
def request_id(context: ReadonlyContext) -> dict[str, str]:
"""Tag every API call with the ADK invocation, so server logs join up with agent traces."""
return {"X-Request-ID": context.invocation_id}
def release_tools(spec: dict, api_key: str, tool_filter: list[str] | None = READ_ONLY) -> OpenAPIToolset:
auth_scheme, auth_credential = token_to_scheme_credential("apikey", "header", "X-Ship-Key", api_key)
return OpenAPIToolset(spec_dict=spec, auth_scheme=auth_scheme, auth_credential=auth_credential,
tool_filter=tool_filter, header_provider=request_id)
INSTRUCTION = """You answer developers' questions about docuctl releases.
Use the release API tools to look up releases; never guess a release name or status.
You cannot change an environment. If asked to roll back, give the docuctl command instead:
docuctl rollback --env <env> --to <release>. Keep answers under 80 words."""
def make_agent(tools: OpenAPIToolset) -> Agent:
return Agent(
name="docs_assistant",
model=LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL,
instruction=INSTRUCTION,
tools=[tools],
generate_content_config=types.GenerateContentConfig(
temperature=0.2, thinking_config=types.ThinkingConfig(thinking_level="low")),
)
async def ask(runner: Runner, session_id: str, text: str) -> list:
message = types.Content(role="user", parts=[types.Part(text=text)])
return [e async for e in runner.run_async(user_id="dana", session_id=session_id, new_message=message)]
async def main(question: str) -> None:
tools = release_tools(load_spec(), os.environ["SHIP_API_KEY"])
print("tools:", [t.name for t in await tools.get_tools()])
runner = Runner(app_name="docs", agent=make_agent(tools), session_service=InMemorySessionService(),
auto_create_session=True)
for event in await ask(runner, "s1", question):
for call in event.get_function_calls():
print("call ", call.name, json.dumps(call.args))
for result in event.get_function_responses():
print("result", result.name, json.dumps(result.response)[:200])
if event.is_final_response() and event.content:
print("answer", "".join(p.text or "" for p in event.content.parts if not p.thought))
await runner.close()
if __name__ == "__main__":
asyncio.run(main(" ".join(sys.argv[1:])))Four choices in assistant.py matter:
- The spec comes from the service.
load_specfetches/openapi.jsonat startup, so a new endpoint or parameter reaches the agent without a code change. FastAPI leavesserversout of the spec, andload_specadds it; step 4 shows what happens otherwise. - The key is a credential.
token_to_scheme_credential("apikey", "header", "X-Ship-Key", key)returns anAuthSchemeand anAuthCredential. The toolset gives them to eachRestApiTool, which puts the key in the header when it sends the request. The function declarations the model receives describe only the parameters. - This agent receives read tools.
tool_filterkeepslist_releasesandget_release. The spec has3operations, and this toolset does not declare the rollback operation. Filtering controls this agent’s tool list; the server still has to authorize every request. The instruction tells it to give the docuctl command instead. - Each call is traceable.
header_provideraddsX-Request-IDwith the ADK invocation id, so a line in the API’s log joins up with the agent’s trace.
The alternative to OpenAPIToolset is a function tool per endpoint with httpx. That gives full control over error messages and retries, but every endpoint and auth change is code to write and review. Use the generated toolset when the spec is maintained and its generated names and schemas suit your agent.
A spec without servers. With no servers, the tool had no base URL. httpx raised UnsupportedProtocol and the whole run failed, before any request. Add servers to the spec, or set it after loading as load_spec does.
A filter written with operationIds. tool_filter=["listReleases", "getRelease"] matched no tool, and the agent started with an empty tool list and no error. Filter by the generated snake-case names, and assert the tool list at startup.
The recorded messages:
Tool list_releases execution failed. Analyze this execution error and your inputs. Retry with adjustments if applicable. But make sure don't retry more than 3 times. Execution Error: Status Code: 401, {"detail":"missing or invalid X-Ship-Key"}
UnsupportedProtocol: Request URL is missing an 'http://' or 'https://' protocol.
tools with tool_filter=["listReleases", "getRelease"]: []The wrong-key error text is ADK’s, not the API’s: it wraps the status and body and invites the model to retry, with a cap. Repeating a 401 or 403 with unchanged credentials will not fix authentication or permission. Stop that retry in application code and correct the credentials or access policy; inspect the API log to find the rejected call.
3. Ask questions that need the API
Open terminal 2 in the extracted project and activate its environment. adk run loads the importable package and then asks for your question; starting it and typing exit makes no model request. Sending a question does.
cd /path/to/unpacked/adk-openapi-auth
source .venv/bin/activate
adk run release_assistantAt [user]:, ask Which staging releases failed?. Look for list_releases, the API result and a final answer. Then ask Show release r41, or ask for a rollback and check whether a write tool was called. Model behavior can differ from the historical run below.
The native entry point builds an App, not a FastAPI wrapper. It calls the same load_spec, release_tools and make_agent functions as the Python example, preserving the live spec fetch, read-tool filter, API-key credential and invocation header. The API must already be running when ADK loads this package.
"""Native ADK app using the same spec, filter and auth as the Python Runner example.
Start ship_api first. ADK loads this package from the enclosing lab directory.
"""
import os
from google.adk.apps import App
from assistant import load_spec, make_agent, release_tools
key = os.environ.get("SHIP_API_KEY")
if not key:
raise ValueError("Set SHIP_API_KEY to the same local demo key as ship_api before loading release_assistant")
app = App(name="release_assistant", root_agent=make_agent(release_tools(load_spec(), key)))For the Dev UI, exit the CLI and run adk web --host 127.0.0.1 --port 8768 . in terminal 2. Open http://127.0.0.1:8768, select release_assistant, create a session and send the same question. Inspect the tool events. Web is a local developer interface, not service authentication.
The programmatic Runner remains useful when embedding the assistant in another Python application. It does not load the package’s .env automatically. Export the two keys locally and the service URL before using that path:
export GOOGLE_GENAI_USE_ENTERPRISE=0
# Set GOOGLE_API_KEY privately in this terminal.
export SHIP_API_KEY=local-release-demo
export SHIP_API_URL=http://127.0.0.1:8766
python assistant.py "Which staging releases failed?"Stop both services with Ctrl-C after the activity.
# main
> What is live in production right now?
call list_releases {"env": "production"}
http GET /envs/production/releases key_sent=True request_id=e-5700ed62...
result {"result": [{"release": "r40", "status": "live", "commit": "5e2a913", "reason": null}, {"release": "r39", "status": "previous", "commit": "77d0c41", "reason": n
answer Release **r40** (commit `5e2a913`) is currently live in production.
# main
> Roll production back to r39.
answer I cannot modify environments directly. To roll back production to r39, run: ```bash shipctl rollback --env production --to r39 ```The rollback request made no API call at all: the model had no rollback tool, so it answered with the command for a person to run.
A wrong API key. The API returned 401. ADK passed it to the model as a tool error that suggests a retry, and the model called the tool 1 time before it reported an authentication problem. Rotate keys outside the agent; alert on 401s in the API log rather than relying on the model to report them.
How generated tools send authenticated requests
OpenAPIToolset parses the spec once, resolves $refs and creates a RestApiTool for each operation, named from its operationId in snake case. Each tool builds a function declaration from the operation’s parameters and description. This is the declaration Gemini received for list_releases, with no trace of the key:
{
"description": "List the releases of one environment, newest first. Filter by status, such as failed.",
"name": "list_releases",
"parameters_json_schema": {
"properties": {
"env": {
"type": "string",
"enum": [
"staging",
"production"
],
"title": "Env",
"description": ""
},
"status": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Status",
"description": ""
}
},
"required": [
"env"
],
"title": "listReleases_Arguments",
"type": "object"
}
}When the model calls the tool, the RestApiTool maps the arguments to path and query parameters, adds the credential as the X-Ship-Key header and the header_provider headers, and sends the request with httpx. The JSON body comes back as the function response. The server’s log of the first question shows what arrived:
[
{
"method": "GET",
"path": "/envs/staging/releases",
"request_id": "e-57c158ea-bb2b-41c5-a8d6-ead39bba73fe",
"key_sent": true
}
]key_sent is the server’s own record that the header was present; the recording never stores the key.
Test the tools without a model
test_assistant.py starts the real API on another port with a fresh random key and replays the recorded Gemini turns.
python -m unittest -v test_assistant.py test_native_entrypoint.pytest_a_spec_without_servers_fails_the_run (test_assistant.ReleaseApi.test_a_spec_without_servers_fails_the_run) ... /Users/arjunprabhulal/aiml-lanes/envs/venv-agents/lib/python3.12/site-packages/google/adk/auth/auth_tool.py:96: DeprecationWarning: This method is deprecated. Use credential_key instead.
self.credential_key = self.get_credential_key()
/Users/arjunprabhulal/aiml-lanes/envs/venv-agents/lib/python3.12/site-packages/google/adk/models/llm_request.py:306: UserWarning: [EXPERIMENTAL] feature FeatureName.JSON_SCHEMA_FOR_FUNC_DECL is enabled.
declaration = tool._get_declaration()
/Users/arjunprabhulal/aiml-lanes/envs/venv-agents/lib/python3.12/site-packages/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py:816: DeprecationWarning: Setting per-request cookies=<...> is being deprecated, because the expected behaviour on cookie persistence is ambiguous. Set cookies directly on the client instance instead.
return await client.request(**request_params)
ok
test_a_wrong_key_comes_back_to_the_model_as_a_tool_error (test_assistant.ReleaseApi.test_a_wrong_key_comes_back_to_the_model_as_a_tool_error) ... /Users/arjunprabhulal/aiml-lanes/envs/venv-agents/lib/python3.12/site-packages/google/adk/auth/auth_tool.py:96: DeprecationWarning: This method is deprecated. Use credential_key instead.
self.credential_key = self.get_credential_key()
ok
test_each_call_carries_the_invocation_id (test_assistant.ReleaseApi.test_each_call_carries_the_invocation_id) ... ok
test_operation_ids_in_the_filter_match_nothing (test_assistant.ReleaseApi.test_operation_ids_in_the_filter_match_nothing) ... ok
test_operations_become_snake_case_tools (test_assistant.ReleaseApi.test_operations_become_snake_case_tools) ... ok
test_the_key_is_a_header_and_absent_from_recorded_model_requests (test_assistant.ReleaseApi.test_the_key_is_a_header_and_absent_from_recorded_model_requests) ... ok
test_the_model_was_never_offered_the_rollback_operation (test_assistant.ReleaseApi.test_the_model_was_never_offered_the_rollback_operation) ... ok
----------------------------------------------------------------------
Ran 7 tests in 0.273s
OKThe saved output reports 7 replay tests, and test_native_entrypoint.py checks native App auth/filter construction and recorder opt-in. test_the_key_is_a_header_and_absent_from_recorded_model_requests checks both sides: the server received the header, and no model request contained the key.
Read the test and recording scripts
"""Replay the recorded Gemini turns against the real local release API, offline.
Run: python -m unittest -v test_assistant.py
"""
import asyncio
import json
import logging
import os
import secrets
import threading
import time
import unittest
import warnings
from pathlib import Path
warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)
os.environ["SHIP_API_KEY"] = secrets.token_urlsafe(24)
import httpx # noqa: E402
import uvicorn # noqa: E402
from google.adk import Runner # noqa: E402
from google.adk.models.base_llm import BaseLlm # noqa: E402
from google.adk.models.llm_response import LlmResponse # noqa: E402
from google.adk.sessions import InMemorySessionService # noqa: E402
from google.genai import types # noqa: E402
import assistant # noqa: E402
import ship_api # noqa: E402
RECORD = json.loads(Path(__file__).with_name("recorded-runs.json").read_text(encoding="utf-8"))
URL = "http://127.0.0.1:8767"
KEY = os.environ["SHIP_API_KEY"]
class ReplayLlm(BaseLlm):
turns: list
requests: list = []
async def generate_content_async(self, llm_request, stream=False):
self.requests.append(llm_request.model_dump_json(exclude_none=True))
yield LlmResponse(content=types.Content.model_validate(self.turns.pop(0)))
def replay(tools, steps: list):
model = ReplayLlm(model="replay", turns=[t for s in steps for t in s["model_turns"]], requests=[])
runner = Runner(app_name="docs", agent=assistant.make_agent(tools).clone(update={"model": model}),
session_service=InMemorySessionService(), auto_create_session=True)
async def go():
events = []
for step in steps:
events.append(await assistant.ask(runner, "s1", step["question"]))
await runner.close()
return events
return asyncio.run(go()), model
class ReleaseApi(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = uvicorn.Server(uvicorn.Config(ship_api.app, host="127.0.0.1", port=8767, log_level="warning"))
threading.Thread(target=cls.server.run, daemon=True).start()
while not cls.server.started:
time.sleep(0.05)
cls.spec = assistant.load_spec(URL)
@classmethod
def tearDownClass(cls):
cls.server.should_exit = True
def setUp(self):
ship_api.REQUESTS.clear()
def tools(self, **kwargs):
return asyncio.run(assistant.release_tools(self.spec, KEY, **kwargs).get_tools())
def test_operations_become_snake_case_tools(self):
self.assertEqual([t.name for t in self.tools()], ["list_releases", "get_release"])
self.assertEqual([t.name for t in self.tools(tool_filter=None)],
["list_releases", "get_release", "rollback_environment"])
def test_operation_ids_in_the_filter_match_nothing(self):
self.assertEqual(self.tools(tool_filter=["listReleases", "getRelease"]), [])
self.assertEqual(RECORD["failures"]["camel_case_filter"], [])
def test_the_key_is_a_header_and_absent_from_recorded_model_requests(self):
events, model = replay(assistant.release_tools(self.spec, KEY), RECORD["run"]["steps"])
self.assertEqual([h["key_sent"] for h in ship_api.REQUESTS], [True, True])
self.assertTrue(all(KEY not in r for r in model.requests))
result = events[0][-2].get_function_responses()[0].response
self.assertEqual(result, RECORD["run"]["steps"][0]["results"][0])
def test_each_call_carries_the_invocation_id(self):
events, _ = replay(assistant.release_tools(self.spec, KEY), RECORD["run"]["steps"][:1])
self.assertEqual(ship_api.REQUESTS[0]["request_id"], events[0][0].invocation_id)
def test_a_wrong_key_comes_back_to_the_model_as_a_tool_error(self):
steps = RECORD["failures"]["wrong_key"]["steps"]
events, _ = replay(assistant.release_tools(self.spec, "not-the-key"), steps)
error = [r.response for e in events[0] for r in e.get_function_responses()][0]["error"]
self.assertIn("Status Code: 401", error)
def test_a_spec_without_servers_fails_the_run(self):
spec = {k: v for k, v in self.spec.items() if k != "servers"}
with self.assertRaisesRegex(httpx.UnsupportedProtocol, "missing an 'http://' or 'https://'"):
replay(assistant.release_tools(spec, KEY), RECORD["failures"]["no_servers"]["steps"])
self.assertEqual(ship_api.REQUESTS, [])
def test_the_model_was_never_offered_the_rollback_operation(self):
names = [d["name"] for d in RECORD["run"]["declarations"]]
self.assertEqual(names, ["list_releases", "get_release"])
self.assertEqual(RECORD["run"]["steps"][2]["calls"], [])
if __name__ == "__main__":
unittest.main()python record.py --record is an optional recording operation: it starts its own API, makes several model calls and overwrites saved run files. It is not the normal one-question activity. Stop the manually started API first if you deliberately regenerate a separate copy of the lab. Running python record.py alone refuses before starting services or making model calls.
"""Record the docs assistant calling the local release API through OpenAPIToolset, on Gemini.
Run from this folder with GOOGLE_API_KEY set: python record.py --record
Starts ship_api on 127.0.0.1:8766 with a random API key, which is never written to disk.
Writes recorded-runs.json and run-output.txt. The tests replay the model turns offline.
"""
import asyncio
import json
import os
import secrets
import threading
import time
import warnings
from pathlib import Path
warnings.filterwarnings("ignore")
os.environ["SHIP_API_KEY"] = secrets.token_urlsafe(24)
import uvicorn # noqa: E402
from google.adk import Runner # noqa: E402
from google.adk.sessions import InMemorySessionService # noqa: E402
import assistant # noqa: E402
import ship_api # noqa: E402
HERE = Path(__file__).resolve().parent
QUESTIONS = [
"Which staging releases failed, and why?",
"What is live in production right now?",
"Roll production back to r39.",
]
def serve() -> uvicorn.Server:
server = uvicorn.Server(uvicorn.Config(ship_api.app, host="127.0.0.1", port=8766, log_level="warning"))
threading.Thread(target=server.run, daemon=True).start()
while not server.started:
time.sleep(0.05)
return server
def recording(agent, turns: list, declarations: list):
def before(callback_context, llm_request):
if not declarations:
declarations.extend(f.model_dump(mode="json", exclude_none=True)
for t in llm_request.config.tools for f in t.function_declarations)
def after(callback_context, llm_response):
if llm_response.content:
content = llm_response.content.model_dump(mode="json", exclude_none=True)
for part in content.get("parts", []):
part.pop("thought_signature", None)
turns.append(content)
return agent.clone(update={"before_model_callback": before, "after_model_callback": after})
async def conversation(tools, questions: list) -> dict:
turns, declarations = [], []
runner = Runner(app_name="docs", agent=recording(assistant.make_agent(tools), turns, declarations),
session_service=InMemorySessionService(), auto_create_session=True)
steps = []
for question in questions:
start_turns, start_requests = len(turns), len(ship_api.REQUESTS)
error = None
try:
events = await assistant.ask(runner, "s1", question)
except Exception as exc: # recorded as a failure, not hidden
events, error = [], f"{type(exc).__name__}: {exc}"
answer = next(("".join(p.text or "" for p in e.content.parts if not p.thought).strip()
for e in reversed(events) if e.is_final_response() and e.content), "")
steps.append({
"question": question,
"calls": [{"name": c.name, "args": c.args} for e in events for c in e.get_function_calls()],
"results": [r.response for e in events for r in e.get_function_responses()],
"http": ship_api.REQUESTS[start_requests:], "model_turns": turns[start_turns:],
"answer": answer, "error": error,
})
await runner.close()
return {"tools": [t.name for t in await tools.get_tools()], "declarations": declarations, "steps": steps}
async def main() -> None:
server = serve()
spec = assistant.load_spec()
key = os.environ["SHIP_API_KEY"]
main_run = await conversation(assistant.release_tools(spec, key), QUESTIONS)
wrong_key = await conversation(assistant.release_tools(spec, "not-the-key"), QUESTIONS[:1])
camel = assistant.release_tools(spec, key, tool_filter=["listReleases", "getRelease"])
no_servers = await conversation(assistant.release_tools({k: v for k, v in spec.items() if k != "servers"}, key),
QUESTIONS[:1])
server.should_exit = True
record = {
"model": assistant.MODEL,
"operations": {path: {m: op["operationId"] for m, op in ops.items()} for path, ops in spec["paths"].items()},
"security_schemes": spec["components"]["securitySchemes"],
"run": main_run,
"failures": {"wrong_key": wrong_key, "camel_case_filter": [t.name for t in await camel.get_tools()],
"no_servers": no_servers},
}
text = json.dumps(record, indent=2)
assert key not in text, "the API key must never be recorded"
(HERE / "recorded-runs.json").write_text(text + "\n", encoding="utf-8")
lines = [f"tools: {main_run['tools']}", ""]
for label, run in [("main", main_run), ("wrong key", wrong_key), ("spec without servers", no_servers)]:
for s in run["steps"]:
lines.append(f"# {label}\n> {s['question']}")
lines += [f"call {c['name']} {json.dumps(c['args'])}" for c in s["calls"]]
lines += [f"http {h['method']} {h['path']} key_sent={h['key_sent']} request_id={h['request_id'][:10]}..."
for h in s["http"]]
lines += [f"result {json.dumps(r)[:160]}" for r in s["results"]]
lines.append(f"error {s['error']}" if s["error"] else "answer " + s["answer"].replace("\n", " ")[:220])
lines.append("")
lines.append(f"# tool_filter with operationIds: {record['failures']['camel_case_filter']}")
(HERE / "run-output.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
print("\n".join(lines))
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Optional live-model recording; overwrites saved run files.")
parser.add_argument("--record", action="store_true", help="explicitly allow live model calls and record replacement")
args = parser.parse_args()
if not args.record:
parser.error("recording is opt-in: use --record; normal lesson commands do not need this script")
asyncio.run(main())Choose the credential type
An API key suits a service-to-service call where the agent acts as itself. When the agent acts for a signed-in user, the credential should be that user’s token: token_to_scheme_credential("oauth2Token", ...) attaches a bearer token, and OAuth2Auth with AuthCredentialTypes.OAUTH2 starts an interactive OAuth flow that the ADK authentication page describes. Either way, the server, not the tool filter, is the real permission check: ship_api refuses rollbacks for this key even if a future toolset declares that operation.
- Make tools parallel-ready: let several tool calls from one model response run at the same time.
Check your understanding
Read the explanation
The filter controls this agent’s declared tools. The service must authorize each request regardless of how it was generated.
References
Checked October 22, 2026, against adk.dev and google-adk 2.12.0.
Previously on the blog: Google ADK OpenAPI Tools: OpenAPIToolset & Authentication covers the ADK 1.x version of this lesson.