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://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

scripts/deploy_proxy.sh

#!/bin/bash
# Enable a service-extension environment, build the ext-proc proxy from the AFT
# template, deploy it, and confirm it came up EXTENSIBLE.
#
# This sequence is deterministic and order-dependent, and three of its steps
# fail in ways that look like something else:
#   - the environment PATCH replaces the whole properties map
#   - the PATCH is asynchronous, so an immediate readback looks like a failure
#   - the environment is locked while an instance attachment is running
# Encapsulated here so those are handled once rather than re-learned.
#
# Data goes to stdout as JSON. Diagnostics go to stderr.

set -euo pipefail

usage() {
  cat <<'EOF'
Deploy the Apigee Agent Gateway ext-proc proxy.

Usage:
  deploy_proxy.sh --org ORG --env ENV --proxy PROXY --template PATH [options]

Required:
  --org ORG              Apigee organization
  --env ENV              environment that will host the proxy
  --proxy PROXY          proxy name to create or revise
  --template PATH        AFT template; feature files must sit beside it

Options:
  --service-account SA   deployment SA; required if any policy calls a Google
                         API (Model Armor, embeddings, Vector Search), else the
                         deploy is rejected with MISSING_SERVICE_ACCOUNT
  --skip-property        environment property is already set; skip that step
  --yes                  apply. Without it, prints the plan and exits 0.
  --dry-run              explicit form of the default; prints and changes nothing
  --help                 this text

Semantic cache (only if the semanticCache feature is enabled):
  --index-endpoint-id ID Vector Search index endpoint. Its public domain is
                         read back and substituted into the findNeighbors URL;
                         the domain cannot be derived from any id you hold, and
                         the input file has no field for it.
  --index-id ID          Vector Search index, for the upsertDatapoints URL
  --embeddings-model M   with --index-id, refuses to deploy when the index
                         width does not match the model's native output width.
                         A mismatch is only fixable by rebuilding the index.
  --sc-project P         project holding the index endpoint
  --sc-region R          region holding the index endpoint
                         scripts/ensure_vector_search.sh creates all of these
                         and prints the ids as JSON.

Environment:
  CLOUDSDK_AUTH_ACCESS_TOKEN   set automatically from ADC if unset. Needed
                               because under context-aware access the default
                               gcloud credential is rejected with a message
                               that names the resource, not the credential.

Exit codes: 0 ok, 1 usage, 2 precondition failed, 3 deploy not EXTENSIBLE
EOF
}

log() { echo "[$(date +%H:%M:%S)] $*" >&2; }

# ---------------------------------------------------------------- display ----
# Same palette as assess_integration.sh so the whole skill looks like one tool.
# The tty test is on fd 2, NOT fd 1: every log line here goes to stderr, so
# testing stdout would colour a piped run and leave an interactive one plain --
# exactly backwards. Honours NO_COLOR (no-color.org) and a dumb terminal.
if [[ -t 2 && -z "${NO_COLOR:-}" && "${TERM:-dumb}" != "dumb" ]]; then
  C_RST=$'\033[0m'; C_DIM=$'\033[2m'; C_BLD=$'\033[1m'
  C_GRN=$'\033[32m'; C_RED=$'\033[31m'; C_YEL=$'\033[33m'; C_BLU=$'\033[36m'
else
  C_RST=; C_DIM=; C_BLD=; C_GRN=; C_RED=; C_YEL=; C_BLU=
fi

# hdr <title>           section banner
# row <verb> <text>     a planned change; CREATE/RESERVE cyan, MODIFY yellow
# cost <line>...        standing-spend callout, always yellow
hdr()  { printf '%s\n%s┌─ %s %s%s\n' "" "$C_DIM" "$1" "$(printf '─%.0s' $(seq 1 $(( 92 > ${#1} ? 92 - ${#1} : 3 ))))" "$C_RST" >&2; }
row()  { local c="$C_BLU"; case "$1" in MODIFY|PATCH|APPEND) c="$C_YEL";; REUSE) c="$C_GRN";; esac
         printf '  %s%-8s%s %s\n' "$c" "$1" "$C_RST" "$2" >&2; }
cost() { printf '  %s%s%s\n' "$C_YEL" "$*" "$C_RST" >&2; }

die() { echo "error: $1" >&2; exit "${2:-1}"; }

# Applies nothing without --yes, matching ensure_*/teardown_*. This script
# patches the environment, imports the proxy and deploys a revision, any of
# which can affect live traffic, so the default is plan-only.
ORG=""; ENV=""; PROXY=""; TEMPLATE=""; SA=""; SKIP_PROPERTY=0; DRY_RUN=1; BASE_PATH="/"
INDEX_ENDPOINT_ID=""; INDEX_ID=""; SC_PROJECT=""; SC_REGION=""; EMBEDDINGS_MODEL=""
while [[ $# -gt 0 ]]; do
  case "$1" in
    --org) ORG="$2"; shift 2 ;;
    --env) ENV="$2"; shift 2 ;;
    --proxy) PROXY="$2"; shift 2 ;;
    --template) TEMPLATE="$2"; shift 2 ;;
    --service-account) SA="$2"; shift 2 ;;
    --index-endpoint-id) INDEX_ENDPOINT_ID="$2"; shift 2 ;;
    --index-id) INDEX_ID="$2"; shift 2 ;;
    --embeddings-model) EMBEDDINGS_MODEL="$2"; shift 2 ;;
    --sc-project) SC_PROJECT="$2"; shift 2 ;;
    --sc-region) SC_REGION="$2"; shift 2 ;;
    --skip-property) SKIP_PROPERTY=1; shift ;;
    --yes) DRY_RUN=0; shift ;;
    --dry-run) DRY_RUN=1; shift ;;
    --help|-h) usage; exit 0 ;;
    *) usage >&2; die "unknown argument: $1" ;;
  esac
done

[[ -n "$ORG" && -n "$ENV" && -n "$PROXY" && -n "$TEMPLATE" ]] \
  || { usage >&2; die "missing required argument"; }
[[ -f "$TEMPLATE" ]] || die "template not found: $TEMPLATE"
if [[ -n "$INDEX_ENDPOINT_ID" ]]; then
  [[ -n "$SC_PROJECT" && -n "$SC_REGION" ]] \
    || { usage >&2; die "--index-endpoint-id also needs --sc-project and --sc-region (the Vector Search endpoint's own project/region)" 1; }
fi

# Echoes the command in dry-run. Redacts the bearer token: printing it would
# put a live credential into whatever captures stderr.
run() {
  if [[ $DRY_RUN -eq 1 ]]; then
    local shown=()
    for a in "$@"; do
      case "$a" in
        "Authorization: Bearer "*) shown+=("Authorization: Bearer [REDACTED]") ;;
        *) shown+=("$a") ;;
      esac
    done
    printf '  %sDRY-RUN%s %s\n' "$C_BLU" "$C_RST" "${shown[*]}" >&2
    return 0
  fi
  "$@"
}

: "${CLOUDSDK_AUTH_ACCESS_TOKEN:=$(gcloud auth application-default print-access-token 2>/dev/null)}"
export CLOUDSDK_AUTH_ACCESS_TOKEN
[[ -n "$CLOUDSDK_AUTH_ACCESS_TOKEN" ]] || die "could not obtain an ADC token; run 'gcloud auth application-default login'" 2

API="https://apigee.googleapis.com/v1/organizations/$ORG"
auth=(-H "Authorization: Bearer $CLOUDSDK_AUTH_ACCESS_TOKEN")

# 0. Envgroup-count precondition. A service-extension environment must be
# attached to exactly ONE envgroup. In two or more, the gateway 404s every agent
# request with the extension never invoked -- no Apigee error, no debug session,
# no Cloud Monitoring entry. Every other check in this script passes and the
# proxy deploys clean as EXTENSIBLE, so nothing downstream catches it.
#
# This is independent of the base-path check below: an environment can be in two
# envgroups and still have "/" free. Checked first because it is cheap,
# deterministic, and the alternative is bisecting against a known-good setup.
log "counting envgroups attached to $ENV"
envgroup_report=$(
  curl -sf "${auth[@]}" "$API/envgroups" 2>/dev/null \
    | python3 -c '
import json, sys
try:
    groups = [g["name"] for g in json.load(sys.stdin).get("environmentGroups", [])]
except Exception:
    groups = []
print("\n".join(groups))
' 2>/dev/null \
    | while read -r g; do
        [[ -z "$g" ]] && continue
        if curl -sf "${auth[@]}" "$API/envgroups/${g}/attachments" 2>/dev/null \
             | python3 -c "
import json, sys
try:
    atts = json.load(sys.stdin).get('environmentGroupAttachments', [])
except Exception:
    sys.exit(1)
sys.exit(0 if any(a.get('environment') == '$ENV' for a in atts) else 1)
"; then printf '%s ' "$g"; fi
      done
) || true
envgroup_report="${envgroup_report% }"
envgroup_count=$(wc -w <<<"$envgroup_report")

if [[ "$envgroup_count" -eq 1 ]]; then
  log "    $ENV is in exactly one envgroup ($envgroup_report)"
elif [[ "$envgroup_count" -eq 0 ]]; then
  log "    $ENV is in no envgroup yet -- it must end up in exactly one"
else
  die "environment '$ENV' is attached to $envgroup_count envgroups ($envgroup_report). A service-extension environment must be in exactly one, or the gateway returns 404 for every agent request with the extension never invoked and no error reported anywhere. Use a dedicated environment. See references/gotchas.md." 2
fi

# 1. Environment property, preserving every property already set.
if [[ $SKIP_PROPERTY -eq 0 ]]; then
  log "reading existing properties on $ENV"
  existing=$(curl -sf "${auth[@]}" "$API/environments/$ENV") \
    || die "cannot read environment $ENV; check the org and your access" 2

  merged=$(echo "$existing" | python3 -c '
import json, sys
env = json.load(sys.stdin)
props = env.get("properties", {}).get("property", [])
props = [p for p in props if p.get("name") != "apigee-service-extension-enabled"]
props.append({"name": "apigee-service-extension-enabled", "value": "true"})
print(json.dumps({"properties": {"property": props}}))')

  log "setting apigee-service-extension-enabled=true (async; will poll)"
  if [[ $DRY_RUN -eq 0 ]]; then
    resp=$(curl -sf -X PATCH "${auth[@]}" -H 'Content-Type: application/json' \
      "$API/environments/$ENV?updateMask=properties" -d "$merged") \
      || die "property PATCH failed. If it reported FAILED_PRECONDITION with a lock, an instance attachment is still running -- wait for it and retry" 2

    op=$(echo "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("name",""))')
    if [[ -n "$op" ]]; then
      # Both failure modes here surface LATER as "proxy is not EXTENSIBLE"
      # (exit 3), which sends you to the proxy and the template -- neither of
      # which is the cause. Report them where they happen instead:
      #   1. the operation never finished inside the window
      #   2. the operation finished and FAILED
      op_done=0; op_err=""
      for _ in $(seq 1 60); do
        opjson=$(curl -sf "${auth[@]}" "https://apigee.googleapis.com/v1/$op" || true)
        read -r done_ op_err <<<"$(printf '%s' "$opjson" | python3 -c '
import json, sys
try:
  d = json.load(sys.stdin)
except Exception:
  print("False", ""); raise SystemExit
err = d.get("error") or {}
print(d.get("done", False), (err.get("message") or "").replace("\n", " "))')"
        if [[ "$done_" == "True" ]]; then op_done=1; break; fi
        sleep 5
      done
      [[ -n "$op_err" ]] && die "setting apigee-service-extension-enabled failed: $op_err" 2
      (( op_done )) || die "the property operation did not finish within 5 minutes ($op). The environment property is what makes an extensible proxy deployable, so do not continue -- re-run once it completes." 2
      log "property operation complete"
    fi
  else
    printf '  %sDRY-RUN%s PATCH %s/environments/%s?updateMask=properties\n' \
      "$C_BLU" "$C_RST" "$API" "$ENV" >&2
  fi
fi

# 1a. Semantic cache: index width must match the embeddings model.
#
# The SemanticCacheLookup policy takes only a URL, so the model's native output
# width is what lands in the index, and index width is fixed at creation. A
# mismatch is not a config error you can correct -- the remedy is a full index
# rebuild, and the deploy step alone runs about 34 minutes. Checked here so it
# fails in seconds rather than at runtime against a live agent.
if [[ -n "$INDEX_ID" && -n "$EMBEDDINGS_MODEL" ]]; then
  case "$EMBEDDINGS_MODEL" in
    gemini-embedding-001)            want_dims=3072 ;;
    text-embedding-005)              want_dims=768 ;;
    text-multilingual-embedding-002) want_dims=768 ;;
    text-embedding-004)              want_dims=768 ;;
    *) die "unknown --embeddings-model '$EMBEDDINGS_MODEL'; extend the width table in this script and in scripts/ensure_vector_search.sh after measuring the model's output width" 2 ;;
  esac
  log "checking index $INDEX_ID is ${want_dims}-dimensional for $EMBEDDINGS_MODEL"
  have_dims="$(gcloud ai indexes describe "$INDEX_ID" \
    --region="$SC_REGION" --project="$SC_PROJECT" \
    --format='value(metadata.config.dimensions)' 2>/dev/null || true)"
  [[ -n "$have_dims" ]] \
    || die "cannot read dimensions for index $INDEX_ID in $SC_PROJECT/$SC_REGION; check --index-id, --sc-project and --sc-region" 2
  [[ "$have_dims" == "$want_dims" ]] \
    || die "index $INDEX_ID is ${have_dims}-dimensional but --embeddings-model $EMBEDDINGS_MODEL emits $want_dims. Dimensions are fixed at index creation and the policy cannot truncate, so this cannot be reconciled in config -- pick a ${have_dims}-dimensional model, or build a new index (scripts/ensure_vector_search.sh)." 2
  log "    dimensions match ($have_dims)"
fi

# 1a-bis. The REVERSE of the leftover-placeholder check further down.
#
# That one catches a placeholder that survived substitution. This one catches
# a placeholder that was never there -- i.e. someone pasted concrete ids into
# the shipped feature file instead of passing the flags. That deploys
# correctly, so nothing else notices, but it bakes one deployment's ids into a
# file meant to be reusable and skips the substitution check rather than
# exercising it. A warning, not a refusal: a deliberate fork is legitimate,
# and failing a working deploy over file hygiene would be worse than the
# problem.
SRC_CACHE="$(dirname "$TEMPLATE")/feature-semantic-cache.yaml"
if [[ -e "$SRC_CACHE" ]]; then
  SUBSTITUTED=""
  for tok in INDEX_ENDPOINT_DOMAIN INDEX_ENDPOINT_ID INDEX_ID; do
    grep -q "\b$tok\b" "$SRC_CACHE" || SUBSTITUTED="$SUBSTITUTED $tok"
  done
  if [[ -n "$SUBSTITUTED" ]]; then
    log "!! WARNING: feature-semantic-cache.yaml has no$SUBSTITUTED placeholder."
    log "!!          It looks hand-substituted. Concrete ids in a shipped asset"
    log "!!          are not reusable across deployments, and the"
    log "!!          leftover-placeholder check below cannot verify what it"
    log "!!          cannot see. Prefer: git checkout the feature file and pass"
    log "!!          --index-endpoint-id / --index-id / --sc-project / --sc-region."
  fi
fi

# 1b. Semantic cache: resolve the index endpoint domain and substitute it.
#
# The findNeighbors host is the index endpoint's OWN domain, and its leading
# label is assigned at endpoint-creation time -- it matches neither the index
# id, the endpoint id, nor the project number, so it cannot be derived and the
# input file has no field for it. It has to be read back. Without this, a user
# who filled in every documented field still cannot compile a working cache.
if [[ -n "$INDEX_ENDPOINT_ID" ]]; then
  log "resolving index endpoint domain for $INDEX_ENDPOINT_ID"
  ENDPOINT_DOMAIN="$(gcloud ai index-endpoints describe "$INDEX_ENDPOINT_ID" \
    --region="$SC_REGION" --project="$SC_PROJECT" \
    --format='value(publicEndpointDomainName)' 2>/dev/null || true)"
  [[ -n "$ENDPOINT_DOMAIN" ]] \
    || die "index endpoint $INDEX_ENDPOINT_ID reports no publicEndpointDomainName. It must be created with --public-endpoint-enabled and have an index deployed; scripts/ensure_vector_search.sh does both." 2
  log "    $ENDPOINT_DOMAIN"

  # Work on a copy: substituting in place would edit the shipped assets.
  SUBST_DIR="$(mktemp -d)"; trap 'rm -rf "$SUBST_DIR"' EXIT
  cp "$(dirname "$TEMPLATE")"/*.yaml "$SUBST_DIR"/ 2>/dev/null || true
  TEMPLATE="$SUBST_DIR/$(basename "$TEMPLATE")"

  # WORD BOUNDARIES ARE LOAD-BEARING. DEPLOYED_INDEX_ID *contains* INDEX_ID, so
  # a plain s/INDEX_ID/.../g rewrites the parameter's own name:
  #     - name: DEPLOYED_INDEX_ID   ->   - name: DEPLOYED_3873425533026959360
  # and rewrites its reference to match, so the bundle still compiles and still
  # works -- the breakage only surfaces later, when the template tries to
  # supply a DEPLOYED_INDEX_ID that no longer exists. Same shape for
  # INDEX_ENDPOINT_ID against INDEX_ENDPOINT_DOMAIN.
  for f in "$SUBST_DIR"/*.yaml; do
    [[ -e "$f" ]] || continue
    # Longest first as a second line of defence; \b is what actually does it.
    sed -i \
      -e "s|\bINDEX_ENDPOINT_DOMAIN\b|$ENDPOINT_DOMAIN|g" \
      -e "s|\bINDEX_ENDPOINT_ID\b|$INDEX_ENDPOINT_ID|g" \
      ${INDEX_ID:+-e "s|\bINDEX_ID\b|$INDEX_ID|g"} \
      -e "s|\bPROJECT\b|$SC_PROJECT|g" \
      -e "s|\bREGION\b|$SC_REGION|g" \
      "$f"
  done

  # Assert nothing survived. A leftover placeholder compiles fine and fails at
  # runtime against a hostname that does not resolve.
  if leftovers=$(grep -l -E '\b(INDEX_ENDPOINT_DOMAIN|INDEX_ENDPOINT_ID|INDEX_ID)\b' "$SUBST_DIR"/*.yaml 2>/dev/null); then
    log "placeholders survived substitution in:"; printf '%s\n' "$leftovers" >&2
    die "substitution incomplete -- refusing to import a half-substituted bundle" 2
  fi
  # And that the trap did not fire: the parameter must still be named.
  if grep -q 'DEPLOYED_INDEX_ID' "$SUBST_DIR"/feature-semantic-cache.yaml 2>/dev/null; then
    log "    DEPLOYED_INDEX_ID intact; substitution did not corrupt it"
  elif [[ -e "$SUBST_DIR/feature-semantic-cache.yaml" ]]; then
    die "substitution corrupted the DEPLOYED_INDEX_ID parameter name -- this is the INDEX_ID substring trap. Do not import this bundle." 2
  fi
fi

# 1c. Model Armor's template name has its own placeholders, and they are NOT
# covered by the semantic-cache substitution above -- that whole block only
# runs when --index-endpoint-id is passed. Model Armor needs no flag on this
# script, so an unsubstituted MA_TEMPLATE_NAME would otherwise import and
# deploy clean and then fail every matching request at runtime, which is the
# worst failure shape this skill has: fail-closed, on live traffic, with
# nothing wrong at deploy time.
#
# Checked against the bundle actually being imported, whatever produced it.
# The anchored pattern matters: the shipped template lists the optional
# features as COMMENTED examples, so a plain substring match fires on the
# default bundle and blocks a deploy that has no Model Armor in it at all.
# Match an actual list entry only.
MA_FILE="$(dirname "$TEMPLATE")/feature-model-armor.yaml"
if [[ -e "$MA_FILE" ]] && grep -qE '^[[:space:]]*-[[:space:]]*feature-model-armor\.yaml' "$TEMPLATE"; then
  if ma_left=$(grep -nE '\b(MA_TEMPLATE|PROJECT|REGION)\b' "$MA_FILE"); then
    log "Model Armor is enabled but its template name still carries placeholders:"
    printf '%s\n' "$ma_left" >&2
    log "    substitute projects/PROJECT/locations/REGION/templates/MA_TEMPLATE"
    log "    with your real Model Armor template, or drop feature-model-armor.yaml"
    log "    from the template's features list."
    die "refusing to deploy Model Armor with an unresolved template name" 2
  fi
  log "    Model Armor template name substituted"
fi

# 2. Build from the template. GA carries --from-template as of gcloud 588.0.0.
log "importing $PROXY from $TEMPLATE"
if [[ $DRY_RUN -eq 1 ]]; then
  printf '  %sDRY-RUN%s gcloud apigee apis import organizations/%s/apis/%s --from-template=%s\n' \
    "$C_BLU" "$C_RST" "$ORG" "$PROXY" "$TEMPLATE" >&2
  REV="DRY-RUN"
else
  out=$(gcloud apigee apis import "organizations/$ORG/apis/$PROXY" \
        --from-template="$TEMPLATE" 2>&1) \
    || die "import failed. Needs gcloud >= 588.0.0 for --from-template on GA. Output: $out" 2
  REV=$(echo "$out" | sed -n "s/^revision: '\{0,1\}\([0-9]*\)'\{0,1\}/\1/p" | tail -1)
  [[ -n "$REV" ]] || die "import succeeded but no revision was returned: $out" 2
  log "imported revision $REV"
fi

# 2b. Base-path conflict pre-check. Apigee allows one proxy per base path per
# environment, and the failure arrives only at deploy time -- after the import
# has already left an undeployed revision behind. Enumerate first and name the
# offender.
if [[ $DRY_RUN -eq 0 ]]; then
  log "checking base paths already taken in $ENV"
  conflict="$(curl -s "${auth[@]}" "$API/environments/$ENV/deployments" 2>/dev/null | python3 -c "
import json, sys, urllib.request, os
want = '$BASE_PATH'
proxy = '$PROXY'
try:
    deps = json.load(sys.stdin).get('deployments', [])
except Exception:
    sys.exit(0)
tok = os.environ['CLOUDSDK_AUTH_ACCESS_TOKEN']
api = '$API'
for d in deps:
    name, rev = d.get('apiProxy'), d.get('revision')
    if name == proxy:
        continue
    req = urllib.request.Request(f'{api}/apis/{name}/revisions/{rev}',
                                 headers={'Authorization': f'Bearer {tok}'})
    try:
        body = json.load(urllib.request.urlopen(req, timeout=15))
    except Exception:
        continue
    for bp in body.get('basepaths', []):
        if bp == want:
            print(f'{name} revision {rev}')
            sys.exit(0)
" 2>/dev/null || true)"
  if [[ -n "$conflict" ]]; then
    die "base path '$BASE_PATH' in environment '$ENV' is already taken by $conflict. '$ENV' is carrying other traffic, so it is not the dedicated environment this proxy needs -- a shared environment also causes the 404-with-no-error failure described in references/gotchas.md. Move this proxy to a dedicated environment rather than freeing the path." 2
  fi
  log "    base path $BASE_PATH is free"
fi

# 3. Deploy.
deploy_url="$API/environments/$ENV/apis/$PROXY/revisions/$REV/deployments?override=true"
[[ -n "$SA" ]] && deploy_url="$deploy_url&serviceAccount=$SA"
log "deploying revision $REV"
if [[ $DRY_RUN -eq 0 ]]; then
  # No -f: -f discards the body, and the body is where the real cause lives.
  # A CONFLICTING_DEPLOYMENT was once reported as a service-account problem
  # purely because the response was thrown away.
  deploy_body="$(curl -s -w '\n%{http_code}' -X POST "${auth[@]}" "$deploy_url")"
  deploy_code="${deploy_body##*$'\n'}"; deploy_body="${deploy_body%$'\n'*}"
  if [[ "$deploy_code" != 2* ]]; then
    log "deploy failed (HTTP $deploy_code). Apigee said:"
    printf '%s\n' "$deploy_body" >&2
    case "$deploy_body" in
      *CONFLICTING_DEPLOYMENT*)
        die "another proxy already occupies this base path in $ENV, so $ENV is not a dedicated environment; move this proxy to one of its own" 2 ;;
      *MISSING_SERVICE_ACCOUNT*)
        die "pass --service-account: a policy in this proxy calls a Google API" 2 ;;
      *) die "deploy failed" 2 ;;
    esac
  fi
else
  run curl -s -X POST "${auth[@]}" "$deploy_url" >/dev/null
fi

# 4. Confirm EXTENSIBLE. STANDARD means it will not see off-Apigee egress.
if [[ $DRY_RUN -eq 1 ]]; then
  printf '  %sNot applying.%s Re-run with %s--yes%s to make these changes.\n' \
    "$C_BLD" "$C_RST" "$C_BLD" "$C_RST" >&2
  echo '{"dryRun": true, "changed": false, "reason": "dry run, --yes not given"}'
  exit 0
fi

log "waiting for the deployment to report its type"
for _ in $(seq 1 20); do
  state=$(curl -sf "${auth[@]}" "$API/environments/$ENV/deployments" \
    | python3 -c "
import json, sys
for d in json.load(sys.stdin).get('deployments', []):
    if d.get('apiProxy') == '$PROXY' and d.get('revision') == '$REV':
        print(d.get('proxyDeploymentType', '')); break
" 2>/dev/null || true)
  [[ -n "$state" ]] && break
  sleep 5
done

# The deployment type is the whole verdict: STANDARD deploys clean and governs
# nothing, so it must not be just another field in a JSON blob.
hdr "Deployment result"
if [[ "$state" == "EXTENSIBLE" ]]; then
  printf '  %s%-12s%s %s rev %s on %s\n' "$C_GRN" "EXTENSIBLE" "$C_RST" "$PROXY" "$REV" "$ENV" >&2
  printf '  %sThe gateway can call this proxy.%s\n' "$C_DIM" "$C_RST" >&2
else
  printf '  %s%-12s%s %s rev %s on %s\n' "$C_RED" "${state:-UNKNOWN}" "$C_RST" "$PROXY" "$REV" "$ENV" >&2
  printf '  %sNOT EXTENSIBLE -- the callout will never be invoked. The proxy is\n  deployed and healthy and governs nothing. Check that the environment\n  carries apigee-service-extension-enabled=true and is COMPREHENSIVE.%s\n' \
    "$C_RED" "$C_RST" >&2
fi

python3 -c "
import json
print(json.dumps({'org': '$ORG', 'environment': '$ENV', 'proxy': '$PROXY',
                  'revision': '$REV', 'proxyDeploymentType': '$state'}, indent=2))"

if [[ "$state" != "EXTENSIBLE" ]]; then
  echo "error: deployed as '${state:-unknown}', not EXTENSIBLE. The proxy will not" >&2
  echo "       see the agent's off-Apigee tool egress. Check that the environment" >&2
  echo "       property is set and that the proxy is genuinely no-route." >&2
  exit 3
fi
log "EXTENSIBLE confirmed"

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 or Google.

Download ZIP

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