Generated banner for Apigee Agent Gateway Governance
Generated logo for Apigee Agent Gateway Governance

Apigee Agent Gateway Governance

GoogleOfficialPublished by Google

  • Skill
  • Workflow
  • Automation and integrations
  • Apache-2.0
  • Low risk
  • Has scripts

From the author

Stands up an Apigee API proxy that a Google Cloud Agent Gateway calls as an ext-proc (Service Extension) callout on an agent's LLM traffic, to meter token usage per model, extract caller identity, and apply Model Armor prompt screening or a semantic cache. Use when wiring Apigee in as an AI gateway behind an Agent Gateway, when an agent's model egress needs governing, or when a callout proxy returns no verdict and you need to find out why. The distinguishing question is whether Apigee forwards the traffic or only inspects it: this skill is inspect-only, and builds the proxy itself. Don't use it when Apigee is meant to receive a request and route it onward to a backend — including hosting an MCP server behind Apigee — which needs a proxy with a target instead. Don't use for governing the agent's calls to tools or MCP servers. Don't use for choosing or switching which model a request goes to. Don't use for creating the Agent Gateway itself, nor for Apigee hybrid.
— the description by Google

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill automates the configuration of an Apigee API proxy to act as a service extension for a Google Cloud Agent Gateway. It enables inspection of LLM traffic to perform token metering, caller identity extraction, prompt screening via Model Armor, and semantic caching. The skill follows a structured deployment process using AFT templates and gcloud commands, ensuring the proxy is correctly wired to intercept and evaluate agent requests without acting as a backend router.

What it does

  • Stands up an inspect-only Apigee proxy for LLM traffic governance.
  • Configures token metering per model and enforces usage quotas.
  • Integrates Model Armor for prompt screening and semantic caching.
  • Manages gateway network connectivity and AuthzPolicy configuration.
  • Provides automated deployment scripts for consistent environment setup.
  • Supports both development and production modes with defined contract schemas.

When to use it

  • When you need to meter or govern LLM token usage for agents.
  • When you require prompt screening or semantic caching for agent egress.
  • When you need to extract caller identity from agent traffic.
  • When you are wiring Apigee as an AI gateway behind a Google Cloud Agent Gateway.

Advantages

  • Centralized governance for agent model egress.
  • Version-controlled proxy configuration via AFT templates.
  • Automated handling of complex network and service extension dependencies.
  • Non-intrusive inspection architecture that does not require backend routing.

Usage examples

“Set up Apigee governance for my existing Agent Gateway in dev mode.”
The agent will assess the current environment, apply default configurations for metering and identity, and generate a deployment plan for approval.
“Deploy the Apigee proxy using my production configuration file.”
The agent will validate the provided contract file, execute the deployment sequence, and verify the proxy is reporting as EXTENSIBLE.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/google/apigee-agent-gateway-governance/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip apigee-agent-gateway-governance.zip -d .agents/skills

Documentation by Google

Apigee as an Agent Gateway Service Extension

Wires an Apigee proxy in as an ext-proc callout on a GCP Agent Gateway. The proxy is a processor, not an API proxy: it has no backend, inspects the LLM request and response the gateway hands it, and returns a verdict.

Usage Attribution

MUST READ: Before executing any gcloud, REST API, or MCP command, you MUST read and follow the instructions in Usage Attribution.

You build the proxy; the user does not.

Two modes, one schema. Decide which before anything else, and say which. Input file present → prod mode: it is the contract, every value comes from it, and a missing or malformed field is a refusal, not a default. No input file and the user is describing an outcome → dev mode: apply the documented defaults, show a plan with durations and get approval, start with identity and metering only, write apigee-agent-gateway-governance.yaml at the end as the handoff. assets/apigee-agent-gateway-governance.example.yaml documents the schema both modes use.

Modes, defaults, durations, cost disclosure and the interlocks that bind both: references/modes.md. Read it before you start.

Colour the plan's ACTION column — green REUSE, cyan CREATE/RESERVE, yellow APPEND/MODIFY; full palette in references/modes.md. Yellow is the one that earns its place: it is the only signal that a resource the user already owns is being changed. Rendered plain, the one row that modifies something is indistinguishable from the dozen that do not — which is exactly the row the approval exists for.

Prerequisite: where the agent runs

Check this before building anything. It decides whether traffic can reach the gateway at all, and it is silent when wrong — every step below still succeeds while nothing arrives.

Use Agent Runtime, created with agent_gateway_config / agent_to_anywhere_config. It is the supported path and the orchestrator builds the client-side PSC, routing, DNS and cert trust for you. There is no gcloud surface for this and no console path — it is SDK-only, at agent creation time. See Route traffic through Agent Gateway for the binding, and references/networking-psc.md for a worked example.

Set GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES: "False" in the agent's env_vars, or every invocation 401s on the agent's own sessions.create, before user code runs. Deploy-time invisible. Full symptom and isolation: references/gotchas.md, Agent Gateway.

Where the agent runs is otherwise settled — the premise is a working gateway and a working agent. Do not explain how to build one or list unsupported compute unprompted; references/networking-psc.md has the constraints if it turns out to matter.

Tool versions

Do not assume these are installed or current.

ToolMinimumWhy
gcloud588.0.0apigee apis import --from-template is on the GA track at this version; older SDKs need gcloud beta
google-cloud-aiplatform[agent_engines,adk]>=2.1,<3Agent Runtime client used to bind the agent to the gateway
google-adk>=2.9,<3only if you build the test agent with ADK

On gcloud >= 588.0.0: every feature must keep its uid, and the template must never declare endpoints or targets. All six features set uid today and the template declares neither, so the shipped bundle is correct — but both are load-bearing now, and breaking either one still compiles, still deploys, and reports nothing. Mechanism and symptoms: references/gotchas.md, under Authoring (AFT).

gcloud version | head -1          # confirm >= 588.0.0
gcloud components update          # if older
pip install "google-cloud-aiplatform[agent_engines,adk]>=2.1,<3" "google-adk>=2.9,<3"

scripts/compile_check.py needs PyYAML and names the interpreter if it is missing. pip install pyyaml, or run it with a Python 3 that has it; gcloud's bundled interpreter does not, and the SDK's vendored yaml is Python 2.

python3 scripts/compile_check.py assets/apigee-proxy-template.yaml

Execution model

Three layers, and the choice between them is not stylistic:

LayerUsed forWhy
AFT YAML templatethe proxy itselfversion-controlled, reviewable, diffable
gcloudPSC, DNS, AuthzExtension, AuthzPolicynative commands exist
REST (curl)env properties, envgroups, deployments, debug sessionsno gcloud equivalent today

The customer owns one input file (assets/apigee-agent-gateway-governance.example.yaml). It is the version-controlled artifact. Never hand-edit the generated proxy: regenerate it.

Run the Apigee-side sequence with the script, not by hand. Steps 1, 3 and 4 below are order-dependent and each fails in a way that reads as a different problem. scripts/deploy_proxy.sh does all three, preserves the properties map, polls the async operation, and exits non-zero if the deployment does not come up EXTENSIBLE:

scripts/deploy_proxy.sh --org "$ORG" --env "$ENV" --proxy "$PROXY" \
  --template assets/apigee-proxy-template.yaml [--service-account "$SA"] --yes \
  [--index-endpoint-id ID --index-id ID --sc-project P --sc-region R --embeddings-model M]

Run it without --yes first: it prints the plan and changes nothing. Show that plan, then re-run with --yes once the user agrees.

The vector-search flags are the one exception to "edit the default in every feature that declares it". Those three ids are substituted at deploy time and checked for leftovers; hand-editing them into feature-semantic-cache.yaml deploys fine, skips the check, and makes the file single-use.

scripts/ensure_gateway_connectivity.sh is the same idea for the gateway leg (step 0). It is the only part of this skill that modifies a resource the user already owns, so it applies nothing without --yes.

The steps are written out below so you can diagnose a failure, not so you can retype them.

Inputs you must resolve first

Environment prerequisites: references/setup.md — gcloud SDK (588+), project, billing, gcloud services enable, region, credentials, IAM. Nothing below creates any of it.

Prod: from the input file, then environment, then ask — never invent. Dev: apply the references/modes.md defaults, and ask only for what has none.

  • project, region

  • apigee.org, apigee.environment, apigee.envgroup, apigee.hostname, apigee.proxy

  • network.vpc, network.subnet, network.networkAttachment

  • gateway.name, gateway.matchPaths (a list, one entry per wire shape), gateway.manageNetworkConfig

matchPaths drives TWO artifacts; write both from the same list. The AuthzPolicy paths[].suffix gets one suffix per entry, verbatim with leading punctuation kept — it decides what the gateway routes to Apigee. The template's LLM_URI_MATCH gets one (request.uri Like "*<entry>*") clause per entry, OR-ed, leading : or / stripped — it decides what the proxy acts on once it arrives. The shipped two-entry default gives:

((request.uri Like "*generateContent*") or (request.uri Like "*chat/completions*"))

The AuthzPolicy is the outer gate and wins: a suffix list narrower than matchPaths means the missing shape never reaches Apigee at all. Derive both from the input file and never from the default when the file disagrees — the file is the reviewable contract and a mismatch is invisible once deployed.

A one-entry list is legitimate and governs one shape. Dropping an entry governs one fewer silently — deploys clean, zero transactions for the shape dropped — and narrows feature-normalize, Model Armor and the cache together. references/gotchas.md, Runtime.

semanticCache is a KNOWN ISSUE whenever it is on — warn, then build it. The key is the prompt embedding alone: no caller, no model, no shape. A hit therefore crosses callers — similar prompts share an answer — and one model behind one shape does not make it safe. With more shapes the first model to answer answers for the other too: empty HTTP 200 across shapes, wrong-model body within one. Warn whenever the cache is on, not only on multi-shape, then build. Wording: references/modes.md; mechanism: references/gotchas.md.

Each entry is a SUFFIX match. You write these, not the user — derive them from which endpoint their agent actually calls, and do not ask them to reason about suffixes. Vertex's OpenAI-compatible endpoint and api.openai.com need different entries, and getting it wrong governs nothing silently; the forms and the evidence are in references/gotchas.md, Runtime.

There is no apiShape field. Metering does not depend on the wire shape — the count is normalised in JS across both — so the field selected nothing and was removed. Adding a shape is an || branch in extract-model.js plus an entry in matchPaths.

Three places carry the callout hostname and all three must match — the Apigee envgroup, the AuthzExtension authority/service, and the private DNS A record. You set all three from one value: a build invariant to hold, not a question for the user. It becomes a topic only when a callout never lands, and then it is the first check. Build order matters alongside it — the AuthzExtension is inert until the host resolves and the proxy is deployed, so "the extension exists" is not evidence that leg works. references/gotchas.md, Runtime.

Workflow

0. Gateway: give it a route into the VPC

A gateway with no networkConfig has no route into the customer's VPC and cannot resolve the callout hostname. This skill builds it — network attachment, egress and DNS peering — so its absence is a build step, not a blocker, and should be reported that way. Do not open with an alarm about a resource you are about to create.

One thing here does need the user, and it is the reason this is step 0 rather than a silent prerequisite: attaching egress is one-way and affects traffic they already run. Settle it in the plan, before anything executes — see "Confirm everything up front" below. Capturing a known-good agent turn first is worth doing when they are nervous about a production gateway, because it separates "never worked" from "we broke it"; it is a precaution to offer, not a step to impose.

gcloud network-services agent-gateways describe "$GATEWAY" \
  --location="$REGION" --project="$PROJECT" --format='value(networkConfig)'
gcloud network-security authz-policies list \
  --location="$REGION" --project="$PROJECT"

A console-created gateway already has a CUSTOM AuthzPolicy — an IAP REQUEST_AUTHZ one, which coexists with ours and is not needed for the egress JWT. You never create the gateway, so assess it rather than assuming. references/gotchas.md, Agent Gateway.

Empty networkConfig output means missing, and it is easy to miss: the gateway still serves ordinary agent traffic perfectly, because that path goes to public Google endpoints and never enters the VPC. Only the Apigee callout fails.

If it is missing, behaviour depends on gateway.manageNetworkConfig:

  • true — run the script below. It creates the network attachment, then read-modify-writes the gateway to add the egress attachment and DNS peering. Agreement for this is taken in the plan, not here; --yes carries it.

  • false — the user has said they want to make this change themselves. Print the script invocation and let them run it. Do not modify the gateway.

scripts/ensure_gateway_connectivity.sh \
  --gateway "$GATEWAY" --project "$PROJECT" --region "$REGION" \
  --vpc "$VPC" --subnet "$SUBNET" --attachment "$ATTACHMENT" \
  --domain "$DNS_DOMAIN"            # add --yes only after the user agrees

Either way networkConfig has to exist before the Apigee work starts — not because its absence is an error, but because every later step then succeeds and governs nothing.

Use the script, not the raw commands: import is a whole-resource upsert and a hand-built YAML silently drops the gateway's registries and protocols. Why, and the etag and PROTOCOL_UNSPECIFIED handling: references/gotchas.md.

1. Apigee: enable the environment

The env must carry apigee-service-extension-enabled=true. No gcloud flag exists; PATCH the properties map.

curl -s -H "Authorization: Bearer $TOKEN" \
  "https://apigee.googleapis.com/v1/organizations/$ORG/environments/$ENV"

The PATCH replaces the whole map. Read it first and re-send every property you intend to keep, or you will silently drop them.

curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  "https://apigee.googleapis.com/v1/organizations/$ORG/environments/$ENV?updateMask=properties" \
  -d '{"properties":{"property":[{"name":"apigee-service-extension-enabled","value":"true"}]}}'

This does not disable Apigee Analytics — the two coexist; if analytics looks missing it is the separate per-environment add-on. references/gotchas.md.

Use a dedicated environment, attached to exactly one envgroup. An environment in two makes the gateway 404 every request with the extension never invoked. It also leaves the / base path free, which is what the proxy ships with.

scripts/ensure_apigee_environment.sh creates the environment, attaches it to the regional runtime instance, and creates and attaches the envgroup — so step 2 is already done when it succeeds. It creates billable resources and applies nothing without --yes; that agreement is taken in the plan, not here.

scripts/ensure_apigee_environment.sh \
  --org "$ORG" --env "$ENV" --region "$REGION" \
  --envgroup "$ENVGROUP" --hostname "$HOSTNAME"      # add --yes to apply

It is idempotent and resumable, and refuses with exit 2 when the environment is already in another envgroup or the envgroup exists on a different hostname. It also handles three things a runbook tends to get wrong — the omitted type on create, the four-minute instance attachment that locks the environment, and polling the operation rather than a GET that succeeds too early.

2. Apigee: envgroup on the callout hostname

Done already if step 1's script ran. Verify, or do it by hand for an envgroup that already exists. The hostname must be exactly the callout host, and the group must be attached to the env.

curl -s -H "Authorization: Bearer $TOKEN" \
  "https://apigee.googleapis.com/v1/organizations/$ORG/envgroups/$ENVGROUP/attachments"

How many envgroups is the environment in? More than one and the callout never lands. deploy_proxy.sh enforces this before touching anything and exits 2 naming them; assess_integration.sh reports it. Read the failure literally if it fires — the environment is wrong, not the script.

3. Build the proxy from the template
gcloud apigee apis import organizations/$ORG/apis/$PROXY \
  --from-template=assets/apigee-proxy-template.yaml

Each features: entry resolves relative to the template file's own directory, never the working directory, so a bare filename must sit beside the template (a subpath like features/feature-identity.yaml works too). assets/ is flat by convention, not compiler demand — but moving a file without updating features: fails the import with a bare FileNotFoundError.

If you get Permission denied on resource ... (or it may not exist) on an org you demonstrably have access to, it is the credential, not the access. With context-aware access enabled the default gcloud credential is rejected. Pass the ADC token instead:

CLOUDSDK_AUTH_ACCESS_TOKEN=$(gcloud auth application-default print-access-token) \
  gcloud apigee apis import ...

To change a parameter, edit the default in every FEATURE that declares it — not in the template. gcloud apigee apis import passes an empty caller-parameter map, so each feature resolves its own default and the template's declaration of the same name is never read. Editing the template changes nothing, silently, and the compile still passes. Measured: setting MA_CONTINUE_ON_ERROR to DRY_RUN in the template shipped ENFORCE on a fail-closed policy. LLM_URI_MATCH is declared in four features and all four must agree. scripts/compile_check.py fails on any disagreement. There is no --parameters flag.

Enable an optional recipe by adding its feature to the template's features list. See references/recipes.md.

tokenMetering.models in the input file is NOT generative. It documents which buckets exist; it does not create them. A bucket is real only when it has an LLMTokenQuota step in feature-token-metering.yaml and a matching *_TOKEN_LIMIT parameter. Three exist: gemini-pro, gemini-flash and other — the catch-all for any model whose name contains neither pro nor flash. Adding a fourth entry to the list meters nothing, and omitting other silently unmeters every non-Gemini model: the count is extracted and then discarded, with no ratelimit reference anywhere in the transaction. Adding a model is a feature-file edit, not a config change.

4. Deploy

Deploy to the service-extension env, then confirm the deployment reports proxyDeploymentType: EXTENSIBLE.

curl -s -H "Authorization: Bearer $TOKEN" \
  ".../environments/$ENV/deployments"

STANDARD means it will not govern the agent's off-Apigee tool egress.

If any policy calls a Google API — Model Armor, embeddings, Vector Search — deploy with a service account or the deploy is rejected with MISSING_SERVICE_ACCOUNT:

curl -X POST -H "Authorization: Bearer $TOKEN" \
  ".../apis/$PROXY/revisions/$REV/deployments?override=true&serviceAccount=$DEPLOY_SA"
5. Networking

This is the gateway → Apigee leg only. PSC endpoint to the Apigee instance service attachment, plus a private DNS A record. See references/networking-psc.md. The Apigee instance consumerAcceptList must include your project or the connection never reaches ACCEPTED.

The agent → gateway leg is the prerequisite above, and is not built here.

6. Bind the gateway

AuthzExtension then AuthzPolicy — in that order, and only once the host resolves and the proxy is deployed. The extension is inert until then.

Both document bodies ship as assets. Copy and substitute; do not invent them — assets/authz-extension.example.yaml and assets/authz-policy.example.yaml. They carry the fields that are load-bearing and have no obvious default: wireFormat: EXT_PROC_GRPC, failOpen, the authority/service pair, the metadata body flags, and the policy's customProvider / httpRules nesting.

gcloud service-extensions authz-extensions import $AUTHZ_EXT \
  --source=authz-extension.yaml --location=$REGION --project=$PROJECT
gcloud beta network-security authz-policies import $AUTHZ_POLICY \
  --source=authz-policy.yaml --location=$REGION --project=$PROJECT

The policy's resource paths take the project NUMBER, not the ID — both target.resources and customProvider.authzExtension.resources. Resolve it yourself; narrating two numbers at the user does not help. The trap, for when a callout never lands: the number in the gateway's serviceExtensionsServiceAccount is the Google-managed tenant project, not theirs, and it is the only one on screen, so it is the natural thing to copy. A wrong number imports cleanly and targets nothing. Copy the slots from assets/authz-policy.example.yaml.

7. Verify

You cannot test with curl — the host is internal and the proxy expects the gateway's IAP-egress JWT. Run scripts/read_debug_session.sh --arm, drive a turn, read it back with --session. Expect one transaction per turn per governed shape under a suffix list this narrow; zero alone does not mean the callout is dead — the script prints the probe that settles it.

Hard rules

Gate every step — how urgently depends on your AuthzPolicy. An ungated continueOnError="false" policy faults on any call that carries no prompt, and that breaks the agent's turn. Historically the most common failure here.

A narrow suffix list routes only the model call to Apigee, so the trap mostly does not fire. Broaden the policy and it fires at once: the ext-proc then sees every agent egress — session create/read, appendEvent, OpenTelemetry exports — measured at 12 calls for one single-question turn, of which one was the model call. Gate the steps regardless; a condition is cheap and it is what makes the policy safe to widen later. Full measurement: references/gotchas.md.

  • model call: (request.uri Like "*generateContent*")

Never fail-close on your own metering. Keep continueOnError="true" on the JavaScript and quota policies so a metering bug cannot break LLM traffic.

This skill meters by default and can enforce on request. Enforcement is assets/feature-token-enforce.yaml: request-flow EnforceOnly policies sharing each SharedName with the response-flow CountOnly half. Add it to the template's feature list. Verified live on ext-proc traffic — the over-budget caller gets a 429, model never called.

Never offer CountOnly=false as a substitute: in the response flow it cannot gate (the answer is already on the wire) and it freezes the counter, losing the metering too.

State two things whenever you offer it. It is one request late — the turn that crosses the limit completes, because a token cost is not known until the model answers. And it buckets on the requested model, which under an OpenAI alias can differ from the resolved model the counting side bills. The two traps that both present as "it never refuses": references/gotchas.md.

Normalize the model to a bucket. With an OpenAI-compatible endpoint the model is an alias in the request body and the resolved name is in the response body (google/gemini-2.5-pro). Exact-matching a raw name fails on the response path.

jsonPath needs its third argument true so it returns a scalar. Without it the quota policy receives a node it cannot parse.

References

  • references/modes.md — read first. How to answer "what can you do", dev vs prod, the defaults, durations, cost disclosure, the interlocks, and reading an assess report

  • scripts/assess_integration.sh — read-only. Setup Status Summary then a per-check table; --json carries both. Lead with the summary and read out the Enforcement row. Run it before planning: it decides CREATE vs REUSE. TODO is a build step, not a failure; only FAIL exits non-zero

  • references/networking-psc.md — both network legs, PSC, DNS, acceptance

  • references/recipes.md — Model Armor and semantic cache

  • references/verify.md — debug sessions and the checklist script

  • references/setup.md — SDK, project, billing, APIs, region, auth, IAM

  • references/gotchas.md — 63 failure modes and their symptoms

  • assets/apigee-agent-gateway-governance.example.yaml — the input contract

  • assets/apigee-proxy-template.yaml — the AFT template. Features merge by priority and all sit beside it: feature-identity.yaml (10), feature-normalize.yaml (15), feature-model-armor.yaml (20), feature-semantic-cache.yaml (30), feature-token-metering.yaml (50).

  • assets/authz-extension.example.yaml, assets/authz-policy.example.yaml — the step-6 bodies. Copy and substitute; paths take the project number

    Anything read out of the request body is normalised once, at priority 15, not inside whichever feature needs it first. LLM_URI_MATCH is shared, so widening it arms every feature gated on it simultaneously — including Model Armor, which fails closed. Model Armor and the cache both read agw.user_prompt; the quotas read agw.total_tokens, set on the response path by the metering feature's own JS for the same reason.

  • scripts/ensure_apigee_environment.sh — create the environment, attach it to the instance, create and attach the envgroup. Idempotent, --yes gated

  • scripts/ensure_gateway_connectivity.sh — route the gateway into the VPC. Read-modify-write; --yes gated

  • scripts/ensure_vector_search.sh — only when semanticCache is enabled: build index and endpoint, deploy one on the other, read back the domain. --yes gated, ~34 min, bills continuously

  • scripts/teardown_vector_search.sh — the only thing that STOPS that charge. Refuses until the revision drops SCL-lookup. --yes gated

  • scripts/deploy_proxy.sh — the Apigee-side sequence, idempotent, --dry-run

  • scripts/read_debug_session.sh — --arm a session (printing when it actually closes) and read it back as the verify.md checklist, instead of parsing the transaction JSON by hand

  • scripts/compile_check.py — compile the template offline, no org needed

Files

references/gotchas.md

Failure modes, by symptom

Four groups. Runtime gotchas come from a working ext-proc setup and were observed live. Authoring gotchas were found compiling this template through the gcloud AFT compiler and apply to anyone writing AFT features. Tooling and Agent Gateway gotchas were hit standing this up against a live org.

Contents

  • Runtime: lines 15-563

  • Authoring (AFT): lines 564-632

  • Tooling and environment: lines 633-717

  • Agent Gateway: lines 718-833

Runtime

Every agent request returns 404 the moment you attach the AuthzPolicy, and the extension is never invoked. The environment hosting the ext-proc proxy is attached to more than one envgroup. It must be in exactly one.

A debug session captures nothing and Cloud Monitoring records no requests, so it looks like a networking or callout problem — but the callout target is fine.

The one curl below is a reachability probe and nothing more. From inside the VPC, curl -k -H "Host: <hostname>" https://<psc-ip>/ returns HTTP 200, which proves only that the PSC endpoint and DNS resolve. It carries no IAP-egress JWT and no agent body, so it exercises neither the callout nor a single policy. Never offer it as a way to test whether the integration works — that is a debug session against live agent traffic, and there is no substitute. Count the environment's envgroups before suspecting anything else:

for g in $(curl -s -H "Authorization: Bearer $TOKEN" \
    ".../organizations/$ORG/envgroups" | python3 -c \
    "import json,sys; print(' '.join(g['name'] for g in json.load(sys.stdin)['environmentGroups']))"); do
  echo -n "$g -> "; curl -s -H "Authorization: Bearer $TOKEN" \
    ".../organizations/$ORG/envgroups/${g}/attachments" | python3 -c \
    "import json,sys; print([a.get('environment') for a in json.load(sys.stdin).get('environmentGroupAttachments',[])])"
done

Isolated by changing one variable at a time against a working setup. These are not causes, each individually disproved: a second CUSTOM AuthzPolicy on the gateway, gateway registries, the extension's wireFormat / failOpen, a .local callout domain, and a leading colon in the AuthzPolicy path suffix.

Two of those are worth stating positively, because both look plausible enough to cost someone an afternoon. A .local callout domain resolves fine — the gateway reaches it over DNS peering, where multicast DNS never enters the picture. And the shipped :generateContent suffix, colon included, was re-confirmed against live traffic on a dedicated environment: the transaction was captured, the client ID and model were extracted, and the quota counted.

Attaching a just-created environment fails with FAILED_PRECONDITION: the resource is locked by another operation that is 1 percent completed. The environment is readable long before it is ready. GET /environments/{env} returns 200 while the create operation is still at 1%, so a "wait until I can fetch it" loop exits immediately and the instance attach is then rejected for holding a lock.

Poll the create operation instead — the POST returns it, and the error message names it too:

curl -s -H "Authorization: Bearer $TOKEN" \
  "https://apigee.googleapis.com/v1/organizations/$ORG/operations/$OP_ID"
# done:true, metadata.state:FINISHED, and `error` set if it failed

The same lock covers the instance attachment, which takes about four minutes — a property PATCH issued in that window fails in a way that reads like a permissions problem. scripts/ensure_apigee_environment.sh waits on both operations.

The gateway import is rejected: domain "x." does not exist as a private zone. dnsPeeringConfig.domains is validated against private zones that already exist in the target VPC, so the zone has to be created before the gateway is patched — earlier than the step order suggests. The zone may be empty; only its existence is checked, and the A record cannot be written until the PSC endpoint has an IP anyway. scripts/ensure_gateway_connectivity.sh creates it.

Deploy fails with CONFLICTING_DEPLOYMENT. Apigee allows one proxy per base path per environment, and this proxy sits at /. On the dedicated environment the skill now requires, / is free and this cannot happen — so hitting it means the environment is shared, and the 404 above is coming for you next. Fix the environment, not the base path. The failure surfaces only at deploy time, after the import has already left an undeployed revision behind, so deploy_proxy.sh enumerates base paths first and names the occupant.

A non-root base path does work, if you need one. Verified live: the same proxy rebuilt with basePath: "/v1beta1" was invoked normally and the whole policy chain ran — the Vertex path the gateway forwards is /v1beta1/projects/…/models/gemini-2.5-flash:generateContent, so /v1beta1 matches it. Proof it really metered rather than merely returning 200: the flash counter moved 235 → 302, exactly the turn's own 67 tokens.

Two limits before reaching for it. It does not let you share an environment — the 404 is caused by the envgroup count, which base paths have nothing to do with. And it narrows the proxy permanently: the template also matches */v1/chat/completions*, and a /v1beta1 base path can never see those. Use it only to run a second processor alongside this one in the same dedicated environment.

The callout never lands — check the gateway first. Before suspecting anything you built, confirm the gateway can reach the VPC at all:

gcloud network-services agent-gateways describe "$GATEWAY" \
  --location="$REGION" --project="$PROJECT" --format='value(networkConfig)'

Empty output means the gateway has no egress networkAttachment and no dnsPeeringConfig, so it has no route to the Apigee PSC endpoint and cannot resolve the callout host. Everything downstream still builds and deploys cleanly. The trap is that the gateway keeps serving ordinary agent traffic perfectly — that path goes to public Google endpoints and never enters the VPC — so the agent looks healthy while the callout is dead. scripts/ensure_gateway_connectivity.sh fixes it.

The callout never lands, and the gateway is fine. Then it is the hostnames. The Apigee envgroup hostname, the AuthzExtension authority/service, and the private DNS A record must be the same string. Any divergence and nothing arrives — with no error anywhere.

Check the build order at the same time: the AuthzExtension is inert until the callout host resolves and the proxy is deployed. Creating the extension and policy before those exist succeeds and reports nothing wrong, and the callout stays dead until the two catch up. An extension created too early is not self-healing in any visible way — re-check it once the host and deployment are in place.

Policies run but extract nothing. The AuthzExtension metadata needs apigee-request-body: 'true' and apigee-response-body: 'true'. Without them request.content and response.content are empty and body extraction yields nothing.

No error is raised anywhere — not in the trace, not in the policy, not on the gateway. The extraction simply resolves empty and everything downstream carries on, so there is nothing to search for and no failure to diagnose. Do not go looking for an error message; check the two metadata flags first. This is the single most common cause of "the policy runs but extracts nothing".

The agent's turn breaks the moment you add a policy. Most AI policies default to continueOnError="false", so on a call carrying no prompt the extraction resolves empty, the policy raises, and the turn fails.

Whether this bites depends on how broadly the AuthzPolicy matches. With a suffix list holding only model-call paths, only model calls reach Apigee — with :generateContent alone, unfiltered debug sessions capture exactly one transaction per turn — so every call the proxy sees carries a prompt. Broaden the match and the ext-proc sees every agent egress instead: session create and read, appendEvent, and the OpenTelemetry /v1/metrics, /v1/logs and /v1/traces posts. Measured by widening the policy to prefix: / for one single-question turn: 12 transactions, exactly one of them the model call. The other 11 are promptless, and each one is a chance for an ungated policy to fault. Gate every step anyway; it costs a condition and it is what lets the policy be widened safely.

Token counts never match the per-model counter. Normalize the model to a bucket. On an OpenAI-compatible endpoint the request body carries an alias (gemini-flash) and the response body carries the resolved name (google/gemini-2.5-pro); an exact-match condition on the raw string fails on the response path.

The quota policy errors on the token source. jsonPath needs its third argument true to return a scalar. Without it the policy receives a node or array it cannot parse.

total_tokens exceeds prompt + completion. Observed: 1135 against 223 for a thinking model. Metering usage.total_tokens bills the full amount, which is usually intended. The payload does not label the excess, so don't report a breakdown it doesn't contain.

A response-path deny does not reach the caller at all. This is the one to read twice, because the intuitive failure — an ugly error — is not what happens. The caller gets a complete, successful answer while Apigee believes it denied the request.

The token quota runs in PostFlow/Response, because the token count does not exist until the model has answered. By then the response is already on the wire. Verified live, paired by token count against client output:

quota continueOnErrorextension failOpenApigeewhat the client got
true (as shipped)trueCONTINUE, 200full answer
falsetrueABORT, 429full answer
falsefalseABORT, 429→500full answer

The 500 in the trace is a decoy. It appears only after RESP_SENT, in Mint/Analytics bookkeeping, carrying the 429's reason phrase (status=500, reason="Too Many Requests"). It is never transmitted. It is also the first thing an operator sees, which is how this was mis-documented for so long.

Switching the quota to blocking also destroys the metering. A blocking quota refuses to count the request that violated it, so used.count freezes at the limit for the remainder of the window — observed stalling at 149/200. Count-only gives accurate metering and no gating; blocking gives neither.

Request-path faults do propagate, so enforcement belongs there — and the policy has a first-class way to do it. Use the documented SharedName split rather than hand-rolling an observer:

<!-- request flow: checks the counter, never increments it.
     LLMModelSource is REQUIRED here even though this half never counts --
     omitting it is a live outage, see "LLMModelSource is REQUIRED" below. -->
<LLMTokenQuota name="LTQ-enforce-pro" type="rollingwindow">
  <SharedName>llm-tokens-pro</SharedName>
  <EnforceOnly>true</EnforceOnly>
  <Allow count="40000"/>
  <Interval>1</Interval><TimeUnit>hour</TimeUnit>
  <Distributed>true</Distributed>
  <LLMModelSource>{agw.req_model_bucket}</LLMModelSource>
</LLMTokenQuota>

<!-- response flow: increments it, never refuses -->
<LLMTokenQuota name="LTQ-count-pro" type="rollingwindow">
  <SharedName>llm-tokens-pro</SharedName>
  <CountOnly>true</CountOnly>
  <Allow count="40000"/>
  <Interval>1</Interval><TimeUnit>hour</TimeUnit>
  <Distributed>true</Distributed>
  <LLMTokenUsageSource>{agw.total_tokens}</LLMTokenUsageSource>
  <LLMModelSource>{agw.model_name}</LLMModelSource>
</LLMTokenQuota>

Exactly one of CountOnly and EnforceOnly may be set on a policy. The enforce half carries no LLMTokenUsageSource — it only reads the counter the other half writes — but it does still require LLMModelSource, which is not optional and not implied by the absence of counting. Apigee's own sample for this pattern is a <RouteRule name="noroute"/> proxy, which is our shape, so a processor with no target is not a blocker.

An earlier version of this file said enforcement "cannot work" here. That was wrong and the inference was bad: it generalised a real observation about one CountOnly=false policy in the response flow into a claim about the policy type. Do not repeat it to a user.

Shipped as assets/feature-token-enforce.yaml — three enforce policies, one per SharedName, gated on the requested-model bucket, off by default.

Verified live in the shape that matters: a no-route proxy invoked as EXT_PROC_GRPC by an Agent Gateway, on real Agent Runtime traffic, where request and response arrive as separate gRPC messages. The over-budget turn was refused with 429 Too Many Requests and "Rate limit LLM Token quota violation", before the model was called. From that trace, flash budget 500:

ratelimit.enforce-LTQ-flash.used.count      = 1086   <- written by CountOnly
ratelimit.enforce-LTQ-flash.allowed.count   = 500
ratelimit.enforce-LTQ-flash.exceed.count    = 586
error.state                                 = PROXY_REQ_FLOW

The request-path policy read exactly what the response-path policy had accumulated under the same SharedName, so the linkage does join the two halves in a no-route ext-proc proxy. <Identifier> also scopes per caller on the enforce half — ratelimit.*.identifier resolved to the calling agent's principal, not a shared bucket.

An earlier report of "0 refusals at any budget" was a test harness pointed at a different project, so that traffic never reached the proxy under test. Not a platform defect — but worth keeping as a reminder to confirm traffic arrives before concluding a policy does not fire.

Two traps that both present identically as "it never refuses".

The nasty one: bucketing on a raw URI substring. The enforce half buckets on the requested model, and testing the whole URI for a model name is unsafe:

/v1beta1/projects/.../models/gemini-2.5-flash:generateContent
                ^^^ contains "pro" inside *pro*jects

A request.uri Like "*pro*" condition therefore matches a flash request, sends it to the pro bucket, finds headroom and allows — while the count half correctly bills flash. Indistinguishable from a broken platform, and entirely a config error. The shipped JS anchors the match after models/ and buckets on the extracted name for exactly this reason; do not simplify it into a substring test on request.uri.

The loud one: omitting LLMModelSource, below.

LLMModelSource is REQUIRED on the enforce half. It is tempting to infer that a policy which never counts needs no source elements at all; that inference caused an outage. The restriction is on LLMTokenUsageSource only — the enforce half must not count. It must still know which bucket to check. Omit LLMModelSource and the policy substitutes a default of jsonPath('$.model', request.content, true), which does not resolve on a Gemini-native :generateContent call because the model is in the URI, not the body. It then raises FailedToResolveModelName, and at continueOnError="false" that refuses every governed turn, not just over-budget ones. Measured: 100% of traffic down for 8 minutes.

One limitation by construction: the enforce half buckets on the requested model, because the resolved name only exists in the response. On an OpenAI-compatible endpoint an alias may resolve to a different model, so a call can be enforced against one bucket and counted against another. Everything is still counted and still checked; it is only unsafe if someone assumes a per-model limit is exact under aliasing.

Two further properties to accept — one a design constraint, one a RaiseFault defect.

Enforcement is one turn late. The check reads the counter as it stood before the current turn, because that turn's cost is unknown until the model answers. Callers can overshoot by one turn. Unavoidable when enforcing in this position.

RaiseFault discards FaultResponse.Set entirely — and this is NOT a general property of request-path faults. Easy to get backwards, and Model Armor is a live counter-example in this same proxy:

fault sourceclient statusclient payload
a policy's own error carrying an HTTP mapping — SanitizeUserPrompt raising steps.sanitize.user.prompt.FilterMatched400, preservedpreserved in full, naming the matched filter
RaiseFault, whatever StatusCode is configuredalways 500always Raising fault. Fault name : <policy>

Confirmed by single-variable test: a RaiseFault's StatusCode changed 429 → 400 with ReasonPhrase to match, verified in the deployed bundle, changed nothing at the client. Status, reason phrase and body are all dropped, and no configuration recovers them.

Two consequences. Do not promise an opaque 500 from a policy that raises its own mapped error — Model Armor returns a clean 400 with detail. And because the policy name is provably the only field a RaiseFault transmits, naming one is load-bearing, not cosmetic: it is the entire diagnostic the caller gets.

A tool policy "doesn't fire". Check proxyDeploymentType. A proxy carrying only generic policies deploys STANDARD and does not see the agent's calls to off-Apigee tools or MCP servers. It must be EXTENSIBLE.

Analytics went dark. Not caused by the Service Extension. Setting apigee-service-extension-enabled=true does not disable Apigee Analytics — they coexist. On a Pay-as-you-go org, Apigee API Analytics is a paid per-environment add-on that is simply off at environment creation; enable it with addonsConfig:setAddonEnablement {"analyticsEnabled":true}. On a Subscription org it is on already. Check billingType on the org before concluding anything. Cloud Monitoring (apigee.googleapis.com/proxyv2/*) works either way.

A property you did not touch disappeared. The environment PATCH replaces the entire properties map. Read it first, re-send everything you want to keep.

Deploy is rejected with MISSING_SERVICE_ACCOUNT. Any policy calling a Google API needs ?serviceAccount= on deploy, plus the matching role — roles/modelarmor.user, roles/aiplatform.user.

Token metering bills for cache hits. The two features are individually correct and wrong together, and nothing surfaces it. Measured: the flash counter moved 683 → 780 across a cache hit — exactly the 97 tokens of the replayed answer — with Gemini never called.

Nothing in the metering steps is conditional on anything the cache sets. JS-extract-model reads request.uri, which on a hit still ends :generateContent, so agw.model_name is set as usual; the quota then reads usageMetadata out of the replayed response and counts it. Populate is the only step in the proxy that knows about cache_hit.

It inverts the point of the feature. The cache avoids model spend, but the caller is metered as though every hit were an inference, so a high-hit-rate workload exhausts its quota while costing almost nothing to serve.

Fixed in feature-token-metering.yaml by adding populate's cache-hit clause to both quota steps, keeping the model-name gate. AM-quota-headers stays ungated so a hit still returns headers, and JS-extract-model stays ungated because it sets the model name those headers report. This is the one cross-feature reference in the template, and it degrades safely: with the cache disabled the variable never resolves and != true is true.

Placeholder substitution corrupts DEPLOYED_INDEX_ID silently. DEPLOYED_INDEX_ID contains INDEX_ID, so an ordered sed over the feature file rewrites the parameter's own name:

sed 's|INDEX_ID|3873425533026959360|g'
  - name: DEPLOYED_INDEX_ID               ->  - name: DEPLOYED_3873425533026959360
    DeployedIndexID: "{DEPLOYED_INDEX_ID}" ->  "{DEPLOYED_3873425533026959360}"

What makes it dangerous is that it survives: declaration and reference are renamed consistently, so the bundle compiles and runs. It only surfaces later, when the template supplies a DEPLOYED_INDEX_ID that no longer exists. Use word boundaries (\bINDEX_ID\b) or replace longest-first, and assert that no placeholder survives. INDEX_ENDPOINT_ID and INDEX_ENDPOINT_DOMAIN share a prefix the same way. deploy_proxy.sh does both and refuses to import a half-substituted bundle.

The findNeighbors host cannot be derived, so it is not an input. The index endpoint's domain has a leading label assigned at creation time that matches neither the index id, the endpoint id, nor the project number. It can only be read back, which is why apigee-agent-gateway-governance.example.yaml has no field for it and deploy_proxy.sh --index-endpoint-id substitutes it at deploy time.

Semantic cache re-populates on a hit. On a no-route proxy the response flow runs on a cache hit too, because there is no target flow for populate to live in. Ungated, it re-embeds and re-upserts the answer it just served. Gate populate on the lookup's cache-hit flag.

The cache-hit condition never matches. Runtime emits SemanticCacheLookup.<policy>.cache_hit. The policy reference documents semanticcache.lookup.<policy>.cache_hit, which does not resolve — and because an unresolved variable makes != true true, it fails open. Read real variable names out of a trace before conditioning on them.

<DeployedIndexId> fails bundle validation. It is <DeployedIndexID>, all-caps ID.

The transaction you wanted isn't in the debug session. First check how many there are, because the answer differs completely.

One per governed shape, and each is a model call — that is correct, not truncation. A suffix list of model-call paths routes only those, so one transaction per turn per shape is the healthy result. With :generateContent alone that is exactly one.

Zero — the callout is not landing at all. Work the entries above: gateway networkConfig, then the three hostnames, then the envgroup count.

Many, and yours got pushed out — only happens with a broadly-matching AuthzPolicy. Measured at prefix: /: 12 transactions for one single-question turn against a count cap of 15. Arm the session with a filter rather than firing more traffic, which only makes it worse.

Something that worked an hour ago returns nothing. In sandbox orgs an IAM reaper may strip non-baseline bindings on the deployment SA roughly every 30 minutes. The symptom is not a clean 403 — it is an empty agent turn. Re-grant immediately before a test run.

ServiceCallout runs despite being unsupported. It is on the Extension Processor unsupported-policy list, yet imports, deploys EXTENSIBLE and executes from both request and response flows. Docs and platform disagree; don't build on it until that is resolved.

An OpenAI-shape agent is governed by nothing and reports nothing, because its matchPaths entry has a /v1 in it — or because there is no entry for it at all. The AuthzPolicy binds on path suffix, and Vertex's OpenAI-compatible endpoint ends openapi/chat/completions — so /v1/chat/completions is not a suffix of it and never matches. Measured: a debug session open across two successful agent turns captured 0 transactions; adding /chat/completions captured 1. Same signature as the envgroup 404 — everything deploys clean and governs nothing. /v1/... is correct only for literal api.openai.com.

The "no entry at all" case is the one to watch once a second shape has been added. matchPaths compiles to LLM_URI_MATCH, a one-entry list is a legal way to say "govern one shape", and nothing distinguishes a deliberate single entry from a forgotten second one. If a shape is reporting zero transactions, check the list length before anything else.

With the cache on and two models behind one proxy, an agent returns an EMPTY answer and no error. Recognise this symptom, because it sends you into the agent code and the cause is in the proxy. SemanticCacheLookup keys on the embedding of the prompt and nothing else — no model, no wire shape, no tenant — so whichever model answers a given prompt first answers it for every model. Reproduced across three consecutive runs, a Gemini agent and an OpenAI-shape agent against one proxy:

cache_hit           : true
agw.requested_model : openai/gpt-oss-120b-maas      <- on a GEMINI request
response            : {"choices":[{"message":{"content":"Mars."}}], ...}

The Gemini client looks for candidates[].content.parts[], finds choices[], matches nothing, and returns {'parts': [], 'role': 'model'}. No exception, no fault, a 200 — an empty success.

Three consequences, worst first: cross-shape corruption, which degrades to empty rather than erroring; wrong-model answers, since any model's answer satisfies any model's question; and metering misattribution, because extract-model.js reads the model out of response.content, which on a hit is the cached response, so the request is billed to the wrong bucket.

The priority-15 normalisation is what makes this reachable — giving Model Armor and the cache a shape-agnostic agw.user_prompt is exactly what lets a Gemini prompt and an OpenAI prompt produce the same key. Both changes are still right; this is the seam between them.

The policy exposes no key composition, so there is no clean fix. Pick a trade-off:

mitigationcost
disable the cache when >1 model is behind the proxyloses the feature; the right default
one index per model bucket, keyed on agw.model_namemultiplies the most expensive resource in the design
prefix agw.user_prompt with agw.model_name in extract-prompt.jsone line and it works, but perturbs the semantic space so near-miss prompts stop matching

Vector Search restricts cannot be used for this, and it is the first idea everyone has. The API does support namespace/allow-list filtering on upsertDatapoints and findNeighbors, so writing the resolved bucket as a restrict on populate and filtering on it at lookup would scope hits to one model without touching the embedding or the index geometry. The obstacle is not Vector Search, it is the policy surface: SemanticCacheLookup exposes only UserPromptSource, Embeddings.VertexAI.URL and SimilaritySearch.VertexAI.{URL, DeployedIndexID, Threshold}, and SemanticCachePopulate only SimilaritySearch.VertexAI.URL and TTLInSeconds. Both build their request bodies internally and neither takes a restricts element, so there is nowhere to put one. This is not a prototype-and-see — there is no seam.

A fix is open with the Apigee team: make the cache key a configurable composite identifier, so it can carry something beyond the prompt embedding. Model and wire shape are the same missing dimension as tenant, so the evidence above was attached to that request rather than raised separately. That request is the restricts idea asked at the layer that can grant it.

A non-Gemini model consumes no quota at all, silently. agw.model_name buckets on the substrings pro and flash, and only those two buckets had a quota bound to them. Measured with gpt-oss-120b-maas: the token count was extracted correctly (agw.total_tokens = 223) and then discarded — zero ratelimit references in the whole transaction. This is worse than a resolution failure, which at least raises FailedToResolveTokenUsageCount into the trace. The shipped fix is a catch-all LTQ-count-other on OTHER_TOKEN_LIMIT. The real fix is generating one quota per entry in the models: list, which today reads as authoritative and generates nothing.

Widening LLM_URI_MATCH to a second wire shape makes Model Armor return 500 on every request of that shape. The condition is shared by every feature gated on it, so widening it arms them all at once — and Model Armor is continueOnError: false by design. Its UserPromptSource JSONPath is written for one body layout, does not resolve against the other, and the policy fails closed. The result is not a skipped scan, it is a hard failure per request, with the trace showing modelarmor-SUP-sanitize faulting and the cache policies never reached.

The fix is structural, not a typo fix: anything derived from the request body is normalised once, in feature-normalize.yaml at priority 15, which runs after identity (10) and before Model Armor (20) and the cache (30). Putting the normalisation inside the cache feature at priority 30 compiles, deploys, and does nothing — it runs after its consumer. Check the compiled order, which makes it obvious:

identity-VJ, identity-AM, normalize-JS-extract-prompt, modelarmor-SUP-sanitize, semanticcache-SCL-lookup

Note the failure direction was lucky. A fail-open policy would have degraded quietly; Model Armor failing closed is what made this findable in one request.

The semantic cache stores upstream errors and replays them as HTTP 200. SemanticCachePopulate is status-blind — gated only on cache_hit, it will cache any response the flow produces. Observed: a transient 429 from a throttled model was written to the cache, then served to every identical prompt for the whole TTL, with a 200 status. A momentary blip became a persistent wrong answer that defeats status checking, and metering saw nothing (token_source = none, used.count = 0 throughout). Client-side the symptom was a crash — Vertex wraps error envelopes in a JSON array while success bodies are bare objects.

Gate populate on response.status.code = 200. Verified across ten transactions: populate executed on the one successful response and skipped all nine 429s. Raised with the Apigee team — the policy should not cache non-2xx by default, or should expose an eligibility control. Two caveats went with it: this was seen on a no-route proxy, where the response flow runs for every outcome, and the 200 on replay may come from the ext-proc short-circuit rather than the cache policy — the composite behaviour is what was measured.

Authoring (AFT)

Found while compiling this template. Reproduce with scripts/compile_check.py.

gcloud 588.0.0 changed two AFT compiler behaviours, and both fail silently. Tracked upstream as a compiler regression. Neither breaks the shipped bundle — all six features set uid and the template declares no endpoints/targets — but both become load-bearing from 588.0.0 on, and the guard is invisible.

  1. Policy names are namespaced only for features that declare a uid. Before 588.0.0 the feature name was always prefixed. Now, with uid: semanticcache set, SCL-lookup still compiles to semanticcache-SCL-lookup. Remove the uid and the prefix disappears, while every condition naming the policy in full keeps the old name — including SemanticCacheLookup.semanticcache-SCL-lookup.cache_hit, which gates all three quota steps. The bundle compiles, deploys, and the cache-hit guard silently stops matching.

  2. Template-level endpoints/targets are dropped whenever the template supplies features. A feature's defaultEndpoint is only a fragment merged into endpoints that already exist, so a template that declares its endpoint and features ends up with zero endpoints and every flow discarded. This template keeps its endpoint inside feature-identity.yaml (priority 10) for that reason. Do not move it back. The import fails with The proxy must contain a ProxyEndpoint, which names the wrong cause.

Evidence note: read off the compiler's own behaviour change plus the feature files, not a live 588.0.0 import. Worth one real import to confirm.

A macro in the template's own body is never substituted. The compiler runs parameter replacement over feature content only; the template's endpoints are copied verbatim. A {BASE_PATH} written in the template's basePath reaches Apigee as the literal string {BASE_PATH}. Template-level parameters exist to supply override values to features that declare a parameter of the same name — they are not usable inside the template itself. Write literals there.

Two Flow entries named PreFlow emit two <PreFlow> elements. models.Flow carries a single mode, so a PreFlow with both a Request and a Response phase cannot be expressed as one entry; the converter emits one element per entry, producing invalid ProxyEndpoint XML. Put response-phase steps in PostFlow/Response, which is what AFT's own fixtures do. Note this is a deviation from a playbook that specifies PreFlow Response — verify on a live org.

Cross-policy variable references are not namespaced. Every policy is renamed <feature>-<policy>, and <Step><Name> references and jsc:// resource URLs are rewritten to match. Policy-derived flow variables are not: jwt.<policy>.decoded.claim.*, SemanticCacheLookup.<policy>.cache_hit, parsepayload.<policy>.operation, and the stepName attribute on UseQuotaConfigInAPIProduct all keep whatever you wrote. Write them pre-namespaced (jwt.identity-VJ-iap-egress-jwt.decoded.claim.email) and they will break if the feature is renamed or given a uid.

A feature file is not found. Each features: entry is joined onto the template file's own directory, never the working directory. Moving a feature into assets/features/ without changing its features: entry to features/<name>.yaml breaks the import with a bare FileNotFoundError. The subdirectory itself is fine — verified by compiling a template whose entries carry the subpath — so it is the mismatch that breaks it, not the nesting.

An empty targets: list still emits <TargetEndpoints></TargetEndpoints>. Harmless in the bundles compiled here, but confirm against bundle validation on first deploy.

Apigee message templates and AFT macros share {}. {agw.model_name} is left alone only because no declared parameter is named that. Never name an AFT parameter after a runtime variable.

Tooling and environment

"Extensible proxy can not be deployed to a base environment" — on an environment the scripts created minutes earlier, where every prior step passed. The environment type is BASE, and BASE cannot host an extensible proxy. The remedy is a PATCH, not a rebuild:

curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  ".../organizations/$ORG/environments/$ENV?updateMask=type" \
  -d '{"type":"COMPREHENSIVE"}'

It is BASE on purpose. The create deliberately omits type, because sending it is rejected outright on a SUBSCRIPTION_2021 org with a message about pricing plans — so the org default applies, and that default is BASE.

What makes this one disorienting is where it lands. The create, the instance attach and the apigee-service-extension-enabled property PATCH all succeed against a BASE environment. Nothing warns. The failure surfaces only at proxy deploy, several steps and roughly four minutes of attachment later, which points suspicion at the proxy or the template rather than at an environment that was reported healthy. Patching to COMPREHENSIVE before the instance attach turns a four-minute round trip into a few seconds, and is idempotent — patching an already-COMPREHENSIVE environment is a no-op, and the service-extension property survives it.

Reading the type back has its own trap: a healthy environment commonly returns no type key at all. Absent is not BASE. Only an explicit BASE blocks the deploy — see scripts/ensure_apigee_environment.sh and the matching check in scripts/assess_integration.sh.

apigee-service-extension-enabled=true does not disable Apigee Analytics. The two coexist; a claim to the contrary circulated in an earlier playbook and is wrong. Apigee API Analytics is a per-environment add-on orthogonal to the property: on by default on a Subscription org, and on Pay-as-you-go a paid add-on that is off at environment creation and must be enabled explicitly. So analytics missing on a service-extension environment is almost always the add-on, not the property. Enable it with:

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  ".../organizations/$ORG/environments/$ENV/addonsConfig:setAddonEnablement" \
  -d '{"analyticsEnabled":true}'

It bills from the moment enablement begins, so check billingType on the org before telling a customer either way.

Hit while running this against a live org.

--from-template used to be beta-only. It is on GA as of gcloud 588.0.0. Verified by a real import: plain gcloud apigee apis import --from-template succeeds. On an older SDK the GA command rejects the flag, which is easy to misdiagnose as "the command does not exist in this SDK version" -- the fix there is gcloud beta apigee apis import, or upgrading.

"Permission denied on resource ... (or it may not exist)" can be a credential path, not access. With context-aware access enabled, gcloud's default account credential was rejected on an org the same user could reach fine over REST with an ADC token. The error names the resource, which points you at IAM rather than at the credential. Workaround:

CLOUDSDK_AUTH_ACCESS_TOKEN=$(gcloud auth application-default print-access-token) \
  gcloud apigee apis import ...

compile_check.py dies on ModuleNotFoundError: No module named 'yaml'. The interpreter you ran it with does not carry PyYAML. The script now says so by name rather than raising a bare traceback. pip install pyyaml, or re-run with a Python 3 that already has it. Note the gcloud SDK's bundled interpreter does not, and the SDK's vendored yaml is Python 2 -- neither is the answer. The script needs PyYAML and the in-repo gcloud AFT modules, nothing else.

An environment is locked while an instance attachment runs. Setting the apigee-service-extension-enabled property returns 400 FAILED_PRECONDITION ... resource is locked by another operation until the attachment LRO finishes. Attach first, then set properties.

The environment property PATCH is asynchronous. It returns a long-running operation, not the updated Environment, and a readback immediately afterwards shows empty properties. Poll the operation before concluding the property did not stick.

Agent Gateway

Two CONTENT_AUTHZ policies on one gateway: the execution order is undefined. Do not invent one.

This is the single most tempting place in the whole integration to fabricate an answer, because every instinct says a deterministic system must have a documented tie-break. There is none. Specifically, none of these is the rule, and stating any of them to a customer is worse than saying "unknown":

  • not alphabetical by resource name

  • not creation order, or last-writer-wins

  • not any priority field — AuthzPolicy has none for this

  • not the order they appear in a list response

The honest answer is that precedence between two CONTENT_AUTHZ policies on the same gateway is not defined or documented, so the safe assumption is that either may run first and neither may depend on the other's effect. If ordering actually matters for a design, that design needs changing.

Coexistence itself is normal and expected: a gateway acquires a policy automatically when agents are registered, and other governance products add their own. A REQUEST_AUTHZ policy alongside a CONTENT_AUTHZ one is a different phase entirely and chains cleanly — that case is not ambiguous and should not be described as if it were.

They also cannot be merged. An AuthzPolicy carries a single custom provider, so each product needs its own and the pair has to coexist. A gateway allows 4 CUSTOM policies, so there is room. Expect "then can we combine them" as the immediate follow-up to the precedence answer, and answer it unprompted.

Every invocation of a gateway-attached agent returns 401, and the agent's own code never runs. The container fails on its own outbound sessions.create with 401 UNAUTHENTICATED, which reaches the client wrapped as a 400 INVALID_ARGUMENT Reasoning Engine Execution failed.

Fix, in the agent's deploy config:

"env_vars": {
    "GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES": "False",
},

Two conditions are both required: the agent is attached to an Agent Gateway, and it was built after 2026-09-30. Neither alone reproduces -- the same agent without the gateway serves fine, and an engine built before that date with byte-identical config still serves.

Three things make it expensive to diagnose. It is invisible at deploy time: the deploy succeeds and the engine reports healthy. The failing spec and a working one differ in nothing readable -- the operative setting is absent from both and resolves to a runtime default that GetReasoningEngine does not report, so a field-by-field diff shows only name and timestamps. And the traceback points at ADK session code, which reads like an agent bug rather than a platform one.

Tracked upstream with the full isolation table; it is an instance of the cert-bound-token family where an Agent Identity token reaches a non-mTLS endpoint. The gateway is what enforces the cert binding, which is why removing it appears to "fix" the agent.

The gateway itself is out of scope for this skill, but these cost hours if you are standing one up to test against. Background: Agent Gateway overview and Set up an Agent Gateway.

Everything is built, and no traffic arrives. Check where the agent runs before anything else. A GCE VM can never reach the gateway — the supported compute is GKE or Cloud Run, and Cloud Run must be in ambient networking mode. Use Agent Runtime with agent_gateway_config. See references/networking-psc.md.

A consumer PSC endpoint to the gateway stays PENDING. Expected, and not fixable with gcloud: acceptance needs an internal References.CreateReference call. It presents as TCP 443 closed, which looks like a firewall rule you are missing.

Agent Runtime creation fails with "Permission denied to get Agent Gateway". The Vertex AI service agent (service-<project-number>@gcp-sa-aiplatform.iam.gserviceaccount.com) resolves the gateway on your behalf and needs networkservices.agentGateways.get on the gateway project. Grant roles/networkservices.viewer — it is the narrowest predefined role that carries the permission, and neither roles/networkservices.agentGatewayUser nor …agentGatewayViewer does, despite the names. The error suggests waiting for API-enablement propagation, which will send you off waiting for something that never arrives if the binding is simply absent.

There is no gcloud surface for Agent Runtime. No agent-engines or reasoning-engines group on GA, beta or alpha. Attaching an agent to a gateway is SDK-only, which is easy to misread as the feature being unavailable.

AgentGateway has no create verb. Only import, export, describe, list and delete. It is declarative YAML or nothing.

dnsPeeringConfig.targetNetwork rejects a bare network name. It wants projects/{project}/global/networks/{network}, despite the field name.

The two dnsPeeringConfig fields are different schemas. AgentGatewayNetworkConfig takes {targetProject, targetNetwork, domains[]}; EgressNetworkConfig takes {targetNetwork, domain}, singular. Copying one into the other fails validation.

agentConnectivityTemplate wants the project number. Not the ID. It is also mutually exclusive with networkConfig — set both and the import is rejected. Attaching one to an existing gateway returned code 13, an internal error has occurred after ten minutes; treat the template path as unproven.

Budget ten minutes per import. AgentGateway and AuthzPolicy imports both take roughly that. They are not hung.

A fresh VPC may have no firewall rules at all, so SSH to a test instance times out before you get far enough to debug anything else.

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs a command-line tool, Needs Python, Needs Node.js

Security

What our scanner noticed· Low risk

  • Instructions that use the network
  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Google
Publisher
Google (Official)
Source
google/skills · skills/cloud/apigee-agent-gateway-governance · commit 2194d3b
Licence
Apache License 2.0 (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Google.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download apigee-agent-gateway-governance.zip