<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dan Henderson</title>
    <description>The latest articles on DEV Community by Dan Henderson (@danhenderson_cloud).</description>
    <link>https://dev.to/danhenderson_cloud</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4032607%2F16a50557-f067-4ca0-912e-262e162f0adb.png</url>
      <title>DEV Community: Dan Henderson</title>
      <link>https://dev.to/danhenderson_cloud</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danhenderson_cloud"/>
    <language>en</language>
    <item>
      <title>Cloud Run vs. GKE Autopilot: A Comparison for ADK Agents</title>
      <dc:creator>Dan Henderson</dc:creator>
      <pubDate>Fri, 31 Jul 2026 13:00:30 +0000</pubDate>
      <link>https://dev.to/danhenderson_cloud/cloud-run-vs-gke-autopilot-a-comparison-for-adk-agents-1ma1</link>
      <guid>https://dev.to/danhenderson_cloud/cloud-run-vs-gke-autopilot-a-comparison-for-adk-agents-1ma1</guid>
      <description>&lt;p&gt;Cloud Run and GKE Autopilot are both reasonable places to run an ADK agent, and that's exactly why I wanted to put them side by side rather than take either one on faith. Cloud Run is the serverless option: point it at a container and it scales with request volume without you touching a node, a cluster, or a networking layer. In my experience, that's the shape most ADK agents actually take: mostly idle, with an occasional burst of requests, which is exactly what Cloud Run is built for. GKE Autopilot is the other end of the same idea: still hands-off on node management, Google runs the control plane and provisions capacity for you, but it's still Kubernetes underneath, which means the platform-level controls (RuntimeClasses, GPU node pools) that Cloud Run doesn't expose are still there if an agent workload actually needs them.&lt;/p&gt;

&lt;p&gt;Both are plausible defaults, and I hadn't actually run one against the other before, so I built the same ADK agent container and deployed it unchanged to both. Same image, cold starts measured on both across repeated runs, one real load test pattern rather than the three I'd originally planned (I'll explain that scope cut honestly further down). Nothing here is invented, rounded, or smoothed over. This is what actually happened when I ran it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2hlajbyptdts4raqqkg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2hlajbyptdts4raqqkg.png" alt=" " width="800" height="464"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 1: Same image, same destination API, different identity path. The Workload Identity binding on the GKE side is not optional, and nothing on the Cloud Run side needed it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One container image, built once, pushed to Artifact Registry, deployed unchanged to both platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud Run&lt;/strong&gt;: &lt;code&gt;min-instances=0&lt;/code&gt;, autoscaling, default Compute service account, already holding &lt;code&gt;roles/aiplatform.user&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GKE Autopilot&lt;/strong&gt;: same image, a &lt;code&gt;Deployment&lt;/code&gt; I manually scaled between 0 and 1 replicas to simulate idle/cold-start behavior (Autopilot has no native scale-to-zero; I'll say that plainly here and again later, because it matters), plus a &lt;code&gt;RuntimeClass&lt;/code&gt; attaching gVisor sandboxing, which is really the whole reason this build exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both were deliberately configured to sit at zero when idle, not because that's how you'd necessarily run either in production, but because a cold-start comparison only means anything if both platforms are actually starting from cold. &lt;code&gt;min-instances=0&lt;/code&gt; on Cloud Run and scaling the GKE &lt;code&gt;Deployment&lt;/code&gt; down to zero replicas are the two mechanisms that make that possible, and they're the reason the numbers in the Cold-Start Comparison section exist at all.&lt;/p&gt;


&lt;h2&gt;
  
  
  Walkthrough
&lt;/h2&gt;

&lt;p&gt;Deployed side by side, so the equivalent step on each platform sits next to its counterpart rather than buried in two separate sections.&lt;/p&gt;
&lt;h3&gt;
  
  
  Shared: one image, one Artifact Registry repo
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud builds submit &lt;span class="nt"&gt;--tag&lt;/span&gt; europe-west2-docker.pkg.dev/PROJECT_ID/adk-agent-repo/adk-agent:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff5gdatlq8k8ebawqgjw2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff5gdatlq8k8ebawqgjw2.png" alt=" " width="798" height="254"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 2: One image, one repo, deployed unchanged to both platforms below.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Cloud Run
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run deploy adk-agent-cloudrun &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west2-docker.pkg.dev/PROJECT_ID/adk-agent-repo/adk-agent:latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--min-instances&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-instances&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhcb1igesnz77vn14f1fw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhcb1igesnz77vn14f1fw.png" alt=" " width="800" height="114"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 3: Deployed, scale-to-zero by default, no identity configuration step required.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;No identity step here, none. The service ran as the default Compute service account, which already held &lt;code&gt;roles/aiplatform.user&lt;/code&gt; from earlier testing, and calls to Vertex AI just worked.&lt;/p&gt;
&lt;h3&gt;
  
  
  GKE Autopilot
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud container clusters create-auto adk-agent-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffxrtuv67fj01tubqlwxx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffxrtuv67fj01tubqlwxx.png" alt=" " width="793" height="39"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 4a: Cluster provisioned.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkebeid98ffuszby0i9ey.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkebeid98ffuszby0i9ey.png" alt=" " width="800" height="141"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 4b: Confirmed running in Autopilot mode.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deployment.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkebeid98ffuszby0i9ey.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkebeid98ffuszby0i9ey.png" alt=" " width="800" height="141"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 5: Service endpoint live, deployment deliberately scaled to zero: the manual stand-in for scale-to-zero used throughout this build.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Workload Identity gap
&lt;/h3&gt;

&lt;p&gt;This is the bit I want to slow down on, because it's the clearest evidence for the whole point of this article. Every single request to Vertex AI from the GKE pod failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;403 PermissionDenied: aiplatform.endpoints.predict
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth checking rather than assuming: confirm &lt;code&gt;roles/aiplatform.user&lt;/code&gt; is actually bound to the Compute service account before going further.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud projects get-iam-policy PROJECT_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--flatten&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"bindings[].members"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"bindings.role:roles/aiplatform.user"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"table(bindings.members)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was. The Compute service account already had &lt;code&gt;roles/aiplatform.user&lt;/code&gt;, the exact identity Cloud Run had just used successfully. GKE Autopilot enforces Workload Identity by default, and a pod does &lt;strong&gt;not&lt;/strong&gt; automatically inherit the node's service account permissions the way Cloud Run's runtime does. Fixing it took three explicit steps, none of which have a Cloud Run equivalent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create serviceaccount adk-agent-ksa

gcloud iam service-accounts add-iam-policy-binding &lt;span class="se"&gt;\&lt;/span&gt;
  PROJECT_ID-compute@developer.gserviceaccount.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/iam.workloadIdentityUser"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"serviceAccount:PROJECT_ID.svc.id.goog[default/adk-agent-ksa]"&lt;/span&gt;

kubectl annotate serviceaccount adk-agent-ksa &lt;span class="se"&gt;\&lt;/span&gt;
  iam.gke.io/gcp-service-account&lt;span class="o"&gt;=&lt;/span&gt;PROJECT_ID-compute@developer.gserviceaccount.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;serviceAccountName: adk-agent-ksa&lt;/code&gt; on the pod spec, and the same identity Cloud Run got for free finally worked on GKE too. This is a real, non-trivial cost of choosing GKE Autopilot.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Cold-Start Comparison
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cloud Run, 4 runs, &lt;code&gt;min-instances=0&lt;/code&gt;, 5 to 10 minutes idle between each:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2.83s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;13.62s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;20.32s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3.23s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F01bg2aolfiolnjzbzcz6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F01bg2aolfiolnjzbzcz6.png" alt=" " width="799" height="181"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 6: Real cold-start timings, unedited. The variance is part of the finding.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That spread is a real finding, and I'm not going to smooth it into an average and move on. 2.83s to 20.32s across cold starts that should be identical is wide. The idle gaps between runs weren't perfectly consistent, and I didn't isolate Vertex AI's own API-side latency from container cold-start time for these specific runs, so I can't attribute the spread precisely. Some of it might be Gemini Flash's own latency at Google's end, not Cloud Run's container startup at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GKE Autopilot, pod scale 0 to 1, timed via &lt;code&gt;kubectl wait&lt;/code&gt;, then a timed request once ready:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Pod scale-up&lt;/th&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3.020s&lt;/td&gt;
&lt;td&gt;0.024s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2.882s&lt;/td&gt;
&lt;td&gt;0.060s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3.703s&lt;/td&gt;
&lt;td&gt;0.041s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3.450s&lt;/td&gt;
&lt;td&gt;0.025s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv77uv88fjrech69z6qmd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv77uv88fjrech69z6qmd.png" alt=" " width="800" height="557"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 7: Scale-from-zero timed across four runs, verified against a real Vertex AI response rather than trusted on timing alone.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Worth explaining why there's no dramatic first run outlier here. Early in this build, a Workload Identity permissions gap meant every request to the GKE deployment was failing with a 403, including my first cold-start pass, which returned fast because it was failing fast, not completing a real call. Once the identity issue was fixed and I reran the test against a confirmed working endpoint, the numbers below are what I got: no fresh-node spike, likely because the node was already warm from earlier testing by that point, not because the effect doesn't exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GKE doesn't scale to zero natively.&lt;/strong&gt; Every number in the GKE table above comes from me manually scaling a &lt;code&gt;Deployment&lt;/code&gt; between 0 and 1 replicas with &lt;code&gt;kubectl scale&lt;/code&gt;, standing in for the zero-infrastructure-cost idle state that Cloud Run's &lt;code&gt;min-instances=0&lt;/code&gt; gives you as a first-class primitive. Autopilot still bills for cluster management regardless, and a real production deployment would more likely run a small non-zero minimum than actually scale pods to zero.&lt;sup id="fnref2"&gt;2&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;A real Gemini Flash call through the GKE deployment, checked separately, came back with &lt;code&gt;model_call_seconds: 2.278&lt;/code&gt; and the response &lt;em&gt;"Hello, nice to meet you."&lt;/em&gt; Confirmation that this dataset reflects genuine successful calls through a working identity chain, not fast failures dressed up as timings.&lt;/p&gt;




&lt;h2&gt;
  
  
  Load Test
&lt;/h2&gt;

&lt;p&gt;One traffic pattern, not the three I'd originally planned, and I want to name that as a deliberate call rather than let it slide by as an unmentioned scope cut. Steady-moderate load, roughly 240 requests over about two minutes, run separately against each platform rather than at the same time. This isn't a head-to-head resource-contention test, just each platform's own behavior under the same shape of traffic.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgcqmfu7irboae7jn4glu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgcqmfu7irboae7jn4glu.png" alt=" " width="799" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 8: Same load shape, two different metrics: Cloud Run's own request-rate curve against GKE's container CPU-time curve.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Cloud Run's request-count metric ramps cleanly to roughly 2 requests/second, peaks sharply, then drops off as the burst ends. GKE's CPU usage time metric tells the same story structurally: ramp, a sustained plateau through the load window, then the same drop-off, just measured as CPU-time rather than a request rate, since that's what the console actually gave me for this metric. I'm reading the shape off the chart here rather than quoting an exact peak figure, since I don't have a precise value pulled from the underlying data, only the rendered graph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost&lt;/strong&gt;: I'm deliberately not quoting a session total here. A short, low-volume test session doesn't represent what either platform actually costs to run at real traffic. If cost is the deciding factor for your workload, model it against your own traffic pattern and Vertex AI call volume in a production-like environment rather than against anything in this article.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gVisor Example
&lt;/h2&gt;

&lt;p&gt;This is where GKE Autopilot's extra complexity stops being overhead and turns into something you'd actually reach for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# deployment.yaml (relevant excerpt)&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;runtimeClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gvisor&lt;/span&gt;
      &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;adk-agent-ksa&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deployment.yaml

kubectl get pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;adk-agent &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[0].spec.runtimeClassName}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzpw736p6ystckprm6yo7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzpw736p6ystckprm6yo7.png" alt=" " width="800" height="210"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 9: Confirmed from the live pod spec, not the manifest. The pod is actually running under gVisor.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's the pod's live spec talking, not the YAML I wrote, proof it's actually running under gVisor's sandboxed runtime, not just asking nicely for one. GKE Autopilot lets you attach a &lt;code&gt;RuntimeClass&lt;/code&gt; and get syscall-level sandbox isolation on a per-workload basis, chosen deliberately rather than inherited by default. Cloud Run isn't unsandboxed by any stretch (its own execution environment already runs on gVisor), but it doesn't give you this specific lever: the ability to pick and attach it, per workload, on purpose.&lt;sup id="fnref3"&gt;3&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;For an ADK agent, that lever matters most in one specific case: a workload executing generated code, handling genuinely untrusted input, or otherwise needing a harder isolation boundary than the platform default gives you automatically. That's the real, specific reason to reach for GKE Autopilot here, not some vague sense that more control is always better.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Actual Recommendation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Default to Cloud Run.&lt;/strong&gt; The evidence from this build backs it up plainly: cold starts were broadly comparable between the two platforms once the corrected GKE data reflected real calls rather than fast failures, the identity story needed zero extra configuration on Cloud Run, and scale-to-zero is a real primitive rather than a &lt;code&gt;kubectl scale&lt;/code&gt; script standing in for one. For the request-response shape most ADK agents actually have, GKE Autopilot's extra machinery just sits there unused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Move to GKE Autopilot specifically when you need gVisor-level sandboxing or dedicated GPU node pools&lt;/strong&gt;: node-level controls Cloud Run genuinely doesn't expose. Go in knowing about the Workload Identity step ahead of time: it's not optional, it produces a 403 with zero obvious connection to its actual cause if you haven't been warned. That's the honest price of the extra control, weighed against what that control actually buys you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cloud Run's cold-start variance isn't fully explained, and I'd rather say so than round it away.&lt;/strong&gt; 2.83s to 20.32s across runs that should be identical is a real spread, but I didn't isolate container startup time from Vertex AI's own API-side latency for these specific runs. A cleaner follow-up would instrument &lt;code&gt;model_call_seconds&lt;/code&gt; on the Cloud Run side the way I did for one GKE call, to actually know how much of that spread is Cloud Run and how much is Gemini Flash itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GKE's scale-to-zero is simulated, not native.&lt;/strong&gt; Every GKE cold-start number came from manually scaling a Deployment with &lt;code&gt;kubectl scale&lt;/code&gt;, not a platform-level scale-to-zero primitive. Autopilot doesn't have one the way Cloud Run does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only one traffic pattern got load-tested&lt;/strong&gt;: steady-moderate, roughly 240 requests over two minutes, run separately against each platform rather than concurrently. The original plan covered three patterns (bursty/idle, steady-low, steady-moderate); this is a deliberate scope cut, not something I'm pretending didn't happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost wasn't rigorously measured, and I'm not pretending otherwise.&lt;/strong&gt; Validate cost against your own production traffic pattern and Vertex AI call volume before treating either platform as the cheaper option. A short test session isn't a substitute for that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A short test session isn't sustained production load.&lt;/strong&gt; A handful of cold-start runs and one two-minute load test give an honest snapshot of this specific workload's behavior on the day I ran it, not evidence of how either platform holds up under real, variable production traffic over days or weeks.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud Run is the right default for most ADK agents.&lt;/strong&gt; Faster cold starts in the common case, zero identity configuration, and genuine scale-to-zero rather than something you have to fake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GKE Autopilot enforces Workload Identity by default, and it caught me out.&lt;/strong&gt; A pod doesn't inherit the node's service account permissions the way Cloud Run's runtime does. Budget time for this if you choose GKE.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The actual reason to choose GKE Autopilot here is gVisor-level sandboxing or dedicated GPU node pools&lt;/strong&gt;, not a general sense that more control is better. Pick it for a specific, named requirement Cloud Run doesn't expose.&lt;/li&gt;
&lt;/ul&gt;







&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;Google Cloud. &lt;em&gt;Use Workload Identity Federation for GKE&lt;/em&gt;. &lt;a href="https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity" rel="noopener noreferrer"&gt;https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;Google Cloud. &lt;em&gt;GKE pricing&lt;/em&gt;. &lt;a href="https://cloud.google.com/kubernetes-engine/pricing" rel="noopener noreferrer"&gt;https://cloud.google.com/kubernetes-engine/pricing&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;Google Cloud. &lt;em&gt;About GKE Sandbox (gVisor)&lt;/em&gt;. &lt;a href="https://cloud.google.com/kubernetes-engine/docs/concepts/sandbox-pods" rel="noopener noreferrer"&gt;https://cloud.google.com/kubernetes-engine/docs/concepts/sandbox-pods&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>googlecloud</category>
      <category>infrastructure</category>
      <category>cloudrun</category>
      <category>agentplatform</category>
    </item>
    <item>
      <title>ADK Agent Identity on Google Cloud: Separate Identities, Separate Blast Radius</title>
      <dc:creator>Dan Henderson</dc:creator>
      <pubDate>Wed, 15 Jul 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/danhenderson_cloud/adk-agent-identity-on-google-cloud-separate-identities-separate-blast-radius-4nab</link>
      <guid>https://dev.to/danhenderson_cloud/adk-agent-identity-on-google-cloud-separate-identities-separate-blast-radius-4nab</guid>
      <description>

&lt;p&gt;When a model-driven orchestrator delegates a task to a specialist agent, something specific happens at the infrastructure layer: one Cloud Run service makes an authenticated HTTP call to another. The caller presents its own service account token. The callee checks IAM before accepting anything.&lt;/p&gt;

&lt;p&gt;What doesn't happen matters just as much. The orchestrator's identity isn't inherited by the sub-agent. The sub-agent runs as itself (its own service account, its own IAM scope), and whatever the orchestrator is permitted to do has no bearing on what the sub-agent is permitted to do. That's the architecture. Most multi-agent implementations don't make it explicit, and a fair number quietly assume the opposite.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://danhenderson.cloud/blog/antigravity-enterprise-agentic-development/" rel="noopener noreferrer"&gt;the Antigravity article&lt;/a&gt; that separation was a conceptual argument about governance. Here it's two Cloud Run services, two service accounts, and a specific IAM binding you can point at. And, as it turned out, a wrong default I had to catch and fix along the way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdnq7zaw0zv8dmjkvlr5n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdnq7zaw0zv8dmjkvlr5n.png" alt=" " width="800" height="529"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 1: Two Cloud Run services, two service accounts. The &lt;code&gt;run.invoker&lt;/code&gt; binding is scoped to one named service, not the project, and that's what defines the blast radius in either direction.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the System in Antigravity
&lt;/h2&gt;

&lt;p&gt;Two agents, both deliberately simple. The orchestrator receives a query and decides whether to answer it directly or delegate to a specialist. The specialist retrieves a document summary. Neither does anything more sophisticated than that, because the subject of this article is the identity layer sitting underneath them, not the reasoning inside them.&lt;/p&gt;

&lt;p&gt;Antigravity scaffolds and deploys the sub-agent first, through &lt;code&gt;agents-cli&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use agents-cli to build a simple document retrieval specialist agent
using ADK. The agent accepts a query and returns a document summary.
Deploy to Cloud Run in europe-west2 with authentication required.
Name the service: doc-retrieval-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agents-cli create doc-retrieval-agent &lt;span class="nt"&gt;--prototype&lt;/span&gt; &lt;span class="nt"&gt;--yes&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;doc-retrieval-agent &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; agents-cli &lt;span class="nb"&gt;install
&lt;/span&gt;agents-cli scaffold enhance &lt;span class="nt"&gt;--deployment-target&lt;/span&gt; cloud_run &lt;span class="nt"&gt;--yes&lt;/span&gt;
agents-cli deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F83kt47br039ap9dmkhpo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F83kt47br039ap9dmkhpo.png" alt=" " width="799" height="259"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 2: &lt;code&gt;gemini-2.0-flash&lt;/code&gt; wasn't available in europe-west2, so &lt;code&gt;agents-cli&lt;/code&gt; fell back to &lt;code&gt;gemini-2.5-flash&lt;/code&gt; on its own, a regional constraint I'd otherwise have had to catch by hand. Both agents run &lt;code&gt;2.5-flash&lt;/code&gt; from here on.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What agents-cli Gives You By Default
&lt;/h2&gt;

&lt;p&gt;Both services deploy cleanly, both requiring authentication:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2i1nd0nh4hzfa2t3roo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2i1nd0nh4hzfa2t3roo.png" alt=" " width="800" height="173"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 3: Two services, both authenticated at the ingress level. Nothing wrong with this picture yet.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;But look at what identity they're actually running as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run services describe doc-retrieval-agent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west2 &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"value(spec.template.spec.serviceAccountName)"&lt;/span&gt;

gcloud run services describe orchestrator-agent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west2 &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"value(spec.template.spec.serviceAccountName)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp0dn49s9zblvojbt691a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp0dn49s9zblvojbt691a.png" alt=" " width="799" height="233"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 4: Same answer, twice. Both services are running as the default Compute Engine service account.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's the bit that made me stop and go back through the deploy. The default Compute Engine service account carries &lt;code&gt;roles/editor&lt;/code&gt; at the project level, so two agents sharing it don't just share access: a compromised sub-agent has Editor rights across the entire project, identical to the orchestrator's. There's no blast radius here at all, because nothing separates the two agents to begin with.&lt;code&gt;agents-cli&lt;/code&gt; gets you to a working deployment fast. It doesn't get you to a secure one automatically. The IAM design is left to you, and if you don't go looking for it, this is what ships.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix: Separate Identities
&lt;/h2&gt;

&lt;p&gt;Dedicated service accounts, one per agent, each holding only what that agent needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud iam service-accounts create doc-retrieval-sa &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class="nt"&gt;--display-name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Doc Retrieval Sub-Agent"&lt;/span&gt;

gcloud iam service-accounts create orchestrator-sa &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class="nt"&gt;--display-name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Orchestrator Agent"&lt;/span&gt;

&lt;span class="c"&gt;# Sub-agent: Vertex AI only&lt;/span&gt;
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"serviceAccount:doc-retrieval-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/aiplatform.user"&lt;/span&gt;

&lt;span class="c"&gt;# Orchestrator: Vertex AI, plus invoker on the sub-agent service only&lt;/span&gt;
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"serviceAccount:orchestrator-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/aiplatform.user"&lt;/span&gt;

gcloud run services add-iam-policy-binding doc-retrieval-agent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west2 &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"serviceAccount:orchestrator-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/run.invoker"&lt;/span&gt;

&lt;span class="c"&gt;# Point each Cloud Run service at its dedicated SA&lt;/span&gt;
gcloud run services update doc-retrieval-agent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west2 &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;doc-retrieval-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com

gcloud run services update orchestrator-agent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west2 &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;orchestrator-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"doc-retrieval-agent SA:"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; gcloud run services describe doc-retrieval-agent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west2 &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"value(spec.template.spec.serviceAccountName)"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"orchestrator-agent SA:"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; gcloud run services describe orchestrator-agent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west2 &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"value(spec.template.spec.serviceAccountName)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzr8y11a8dye5dtbfbeo8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzr8y11a8dye5dtbfbeo8.png" alt=" " width="800" height="77"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 5: Different answers now. That's the whole fix, and it's the difference between Figure 4 and this one that the rest of the article's argument rests on.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The scoping decision inside that fix is the one that actually matters, at least to me: &lt;code&gt;roles/run.invoker&lt;/code&gt; is granted on the sub-agent's &lt;em&gt;specific service&lt;/em&gt;, not at the project level. Project-level would let the orchestrator invoke any Cloud Run service in &lt;code&gt;YOUR_PROJECT_ID&lt;/code&gt;; service-level lets it invoke exactly one. The intent was always the second (which, to be clear, was the only intent I had), and getting there costs one extra flag.&lt;/p&gt;

&lt;p&gt;Antigravity's own deploy flow confirms the same binding, from its own summary:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4u01yotwcvxt6c5yeavz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4u01yotwcvxt6c5yeavz.png" alt=" " width="721" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 6: Antigravity gets the &lt;code&gt;run.invoker&lt;/code&gt; binding right for the delegation, while still defaulting the service account underneath it to the project-wide Compute Engine one. It gets the binding right and still leaves the identity choice to you.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Testing the Delegation Chain
&lt;/h2&gt;

&lt;p&gt;The ADK API on Cloud Run doesn't auto-create sessions. Each caller creates one explicitly before it can send a message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud auth print-identity-token&lt;span class="si"&gt;)&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORCH_URL&lt;/span&gt;&lt;span class="s2"&gt;/apps/app/users/user_001/sessions/session_010"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORCH_URL&lt;/span&gt;&lt;span class="s2"&gt;/run_sse"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"app_name":"app","user_id":"user_001","session_id":"session_010","new_message":{"role":"user","parts":[{"text":"Retrieve a document summary on IAM best practices for Cloud Run"}]},"streaming":false}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzb90cia3cswio7do0cxg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzb90cia3cswio7do0cxg.png" alt=" " width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 7: The delegation shows up in the response itself: &lt;code&gt;transfer_to_agent&lt;/code&gt; fires with &lt;code&gt;agent_name: doc_retrieval_agent&lt;/code&gt;, and the synthesised summary comes back through the orchestrator.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Worth flagging: &lt;code&gt;app_name&lt;/code&gt; in these calls is &lt;code&gt;"app"&lt;/code&gt;, not &lt;code&gt;orchestrator_agent&lt;/code&gt;, which is what &lt;code&gt;agents-cli&lt;/code&gt; names it by default on Cloud Run, regardless of what the agent itself is called.&lt;/p&gt;

&lt;p&gt;A general question, run the same way, shows the orchestrator isn't delegating everything by reflex:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORCH_URL&lt;/span&gt;&lt;span class="s2"&gt;/run_sse"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"app_name":"app","user_id":"user_001","session_id":"session_008","new_message":{"role":"user","parts":[{"text":"What is the capital of France?"}]},"streaming":false}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fft62e6b7ewku9qpmxqnn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fft62e6b7ewku9qpmxqnn.png" alt=" " width="798" height="156"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 8: No delegation, no A2A call, nothing sent to the sub-agent. The routing decision is real, not a formality.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Observability: What the Trace Shows
&lt;/h2&gt;

&lt;p&gt;Cloud Trace picks up the A2A boundary crossing when the orchestrator initialises its connection to the sub-agent:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3fkegz1w3modi6xgatrc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3fkegz1w3modi6xgatrc.png" alt=" " width="793" height="52"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 9: The agent-card discovery call &lt;code&gt;RemoteA2aAgent&lt;/code&gt; makes on initialisation: &lt;code&gt;GET /a2a/app/.well-known/agent-card.json&lt;/code&gt;, 200, 9.561ms.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I want to be honest about what this does and doesn't show, rather than let the screenshot imply more than it does. It confirms the delegation happened and that the orchestrator authenticated successfully to the sub-agent, which is the useful part. It doesn't show the sub-agent's own LLM call as a separate span here: Cloud Trace is capturing the HTTP boundary crossing, not the work happening once the request lands. The path itself is also worth a note: &lt;code&gt;/a2a/app/.well-known/agent-card.json&lt;/code&gt;, not the &lt;code&gt;/.well-known/agent.json&lt;/code&gt; the A2A spec describes elsewhere. Took me a minute to find it the first time. &lt;code&gt;agents-cli&lt;/code&gt;'s Cloud Run scaffold nests it under the app route.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Blast Radius Proof
&lt;/h2&gt;

&lt;p&gt;Everything above is a claim about what the IAM bindings allow. Two things prove it.&lt;/p&gt;

&lt;p&gt;First, an unauthenticated call against the orchestrator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORCH_URL&lt;/span&gt;&lt;span class="s2"&gt;/run_sse"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"app_name":"app","user_id":"u","session_id":"s","new_message":{"role":"user","parts":[{"text":"test"}]}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;HTTP Status: %{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4c4lqyvyrph0ob8gi3sv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4c4lqyvyrph0ob8gi3sv.png" alt=" " width="800" height="268"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 10: 403. The authentication requirement is being enforced, not just configured and hoped for.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Second, and this is the one that actually answers the question the whole article is asking, who can invoke the orchestrator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run services get-iam-policy orchestrator-agent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west2 &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"table(bindings.members, bindings.role)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxcnqd4cjxdi0z6yd6j0g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxcnqd4cjxdi0z6yd6j0g.png" alt=" " width="800" height="112"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 11: Nothing. No member holds &lt;code&gt;run.invoker&lt;/code&gt; on the orchestrator, not the sub-agent's service account, not anything else.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That empty table is the blast radius proof. The sub-agent has no IAM path back to the orchestrator at all. Even if it were compromised and tried to escalate by calling the orchestrator directly, there's no binding that would let the request through. The boundary is sitting in IAM, not in how well-behaved the agent's code happens to be.&lt;/p&gt;




&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;RemoteA2aAgent&lt;/code&gt; is experimental.&lt;/strong&gt; ADK logs a &lt;code&gt;UserWarning&lt;/code&gt; on startup that its A2A support (&lt;code&gt;RemoteA2aAgent&lt;/code&gt;, &lt;code&gt;A2aAgentExecutor&lt;/code&gt;, and the surrounding pieces) is experimental and subject to breaking changes. Not a reason to avoid it, in my view. Just a reason to pin your ADK version and re-test after every upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent card discovery needed public access, and this is the one that bugged me most.&lt;/strong&gt; For the orchestrator to fetch &lt;code&gt;/a2a/app/.well-known/agent-card.json&lt;/code&gt; at initialisation, that endpoint had to be reachable without an identity token, meaning &lt;code&gt;allUsers&lt;/code&gt; gets &lt;code&gt;run.invoker&lt;/code&gt; on the sub-agent, loosening exactly the control this article spends most of its length arguing for. In production, the better path is a private VPC with internal-only A2A traffic, or an authenticated agent-card fetch once ADK supports one cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixing the default service account once doesn't make it disappear.&lt;/strong&gt; &lt;code&gt;agents-cli&lt;/code&gt; deploys onto the default Compute Engine service account, &lt;code&gt;roles/editor&lt;/code&gt; and all, unless you explicitly point it elsewhere. Creating dedicated accounts for these two agents doesn't strip that default account's standing access. Anything else in the project still using it keeps the wide blast radius alive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identity isn't the same as intent.&lt;/strong&gt; Dedicated service accounts define what each agent &lt;em&gt;can&lt;/em&gt; do. They don't define what each agent &lt;em&gt;should&lt;/em&gt; do. A sub-agent with perfectly scoped IAM can still be prompted into acting outside its intended purpose. That's a policy problem sitting above IAM, and it's what the Agent Registry and Agent Gateway are for, not this layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sessions are explicit, not automatic.&lt;/strong&gt; The ADK API on Cloud Run requires a session to be created via &lt;code&gt;POST /apps/{app_name}/users/{user_id}/sessions/{session_id}&lt;/code&gt; before &lt;code&gt;/run_sse&lt;/code&gt; will accept a message. Not a limitation so much as a deployment detail worth knowing before your first request mysteriously fails.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Two agents with separate identities isn't a complex architecture, and I don't think it needs to be sold as one. It's just the simplest correct setup for a multi-agent system that takes governance seriously. And it's not the default you get for free. &lt;code&gt;agents-cli&lt;/code&gt; handed both agents the same project-wide identity until I went and separated them myself, and I'd rather flag that gap between "deployed" and "correctly scoped" than pretend I caught it on the first pass.&lt;/p&gt;

&lt;p&gt;The orchestrator can call the sub-agent because IAM says so. The sub-agent can't call the orchestrator because IAM says so: the empty policy table proves it, not my say-so. Neither can exceed its own scope, because IAM says so, not because the code is well-behaved and not because nothing has gone wrong yet.&lt;/p&gt;

&lt;p&gt;That's not complete governance. But it's the right foundation, in the same spirit as &lt;a href="https://danhenderson.cloud/blog/bigquery-cloud-run-gemini-natural-language-analytics/" rel="noopener noreferrer"&gt;the identity work in the BigQuery build&lt;/a&gt;: get the identity layer right first, and catch the defaults that don't. Everything else gets built on top of it.&lt;/p&gt;

</description>
      <category>adk</category>
      <category>agentscli</category>
      <category>antigravity</category>
      <category>serverless</category>
    </item>
  </channel>
</rss>
