4 ways to deploy agents on Agent Platform
Copy as MarkdownSo you’ve built an ADK agent—in my case the Trading Agent from my post series, an agent that reads S&P 500 news, scores the sentiment, and places paper trades on Alpaca—and now you want it running on Agent Platform’s managed runtime instead of on your laptop. It turns out there are actually four different ways to package your agent in runtime, each with some trade offs. This post explores those options and when you might want to use each one.
In Deploy an ADK agent to Agent Platform Runtime we have a deploy script that hands the ADK SDK a live agent object and lets it figure everything out. Behind the scenes it’s serializing the live object graph, writing it to a storage bucket and deserializing in the manged runtime. This is enough for prototyping, but in production you likely want to deploy a container as a robust, reproducable artifact. So we have the live object graph at one end of the spectrum, a full container at the other. In between are two additional options that deploy the source code, the first that serializes a stub object, and the second a full source deploy. As you go from one end of the spectrum, you’re essentially trading off convenience for reliability and robustness.
For anything more than a prototype, I would recommend the container approach (option 4), or at least the full source deploy with no object serialization (option 3). For prototyping, it doesn’t really matter just be OK with it potentially breaking. Let’s dig into these options and the trade offs.
All four options deploy to the same place — the Agent Platform managed runtime (the API resource is a reasoningEngine; you may know the runtime by its old name, Vertex AI Agent Engine). This post isn’t about Cloud Run or GKE — those are a different path (the repo covers them in 06_DeployToCloudRun and 07_DeployToGKE), and I’ve got a separate post on wiring memory into those. Here we’re staying inside the managed runtime the whole way.
Two common threads run through all four options. The first is who declares the API surface — the class_methods list that tells the platform which methods to route :query versus :streamQuery to. The second is the scaffolding each option needs — a staging bucket, a source tarball, or a full container-plus-registry-plus-IAM setup. Watch those two as we go from convenient to production-grade.
Option 1: pass the live object (cloudpickle)
The most convenient way is to hand the SDK your live, instantiated app object and let it do everything.
from trading_agent.agent import app # the live AdkApp(agent=root_agent)
remote_agent = client.agent_engines.create(
agent=app,
config={
"staging_bucket": STAGING_BUCKET,
"display_name": "Trading Agent ADK",
"requirements": "trading_agent/requirements.txt",
"extra_packages": ["trading_agent/agent.py"],
"env_vars": {
"APCA_API_KEY_ID": os.getenv("APCA_API_KEY_ID", ""),
"APCA_API_SECRET_KEY": os.getenv("APCA_API_SECRET_KEY", ""),
"GOOGLE_GENAI_USE_VERTEXAI": "TRUE",
},
},
)
Under the hood, the SDK cloudpickles the entire live object graph — the AdkApp, the root_agent inside it, the tool closures, and the live Alpaca clients captured at import time — into an agent_engine.pkl in your GCS staging bucket. Your requirements.txt gets installed remotely, extra_packages ships agent.py, and the class_methods (the API surface) are auto-generated by introspecting that live object. You never declare the method list. Neat!
For convenience this is as easy as it gets: a few lines, no API surface to declare, and no infra beyond a staging bucket. The catch is robustness. Cloudpickling a live, stateful graph is exactly the kind of thing that’s fragile. If the Python or library versions on your machine don’t match the runtime, the remote un-pickle can fail. Unpicklable objects — sockets, locks, protobuf descriptors — break the dump outright, and the SDK will even warn you about protobuf objects imported at module level. And the .pkl is opaque: you can’t read it, diff it, or reproduce it.
Look at our Trading Agent’s agent.py and you can see the trap being set — it constructs live TradingClient, NewsClient, and StockHistoricalDataClient HTTP clients at module import time. That live client state is exactly the sort of thing that gets brittle inside a pickle. Great for a quick prototype; risky as your production path.
Option 2: the stub agent (ModuleAgent), still cloudpickle
If Option 1’s pickle is what’s biting you, here’s the low-effort upgrade. Instead of the live object, you pass a lightweight ModuleAgent that only records where to find the agent — the module name, the object name, and the operations to expose.
from vertexai import agent_engines
REGISTER_OPERATIONS = {
"": ["get_session", "list_sessions", "create_session", "delete_session"],
"async": ["async_get_session", "async_list_sessions", "async_create_session",
"async_delete_session", "async_add_session_to_memory", "async_search_memory"],
"stream": ["stream_query"],
"async_stream": ["async_stream_query", "streaming_agent_run_with_events"],
}
remote_agent = client.agent_engines.create(
agent=agent_engines.ModuleAgent(
module_name="trading_agent.agent", # imported on the server
agent_name="app", # the AdkApp instance in agent.py
register_operations=REGISTER_OPERATIONS,
),
config={
"staging_bucket": STAGING_BUCKET,
"requirements": "trading_agent/requirements.txt",
"extra_packages": ["trading_agent/agent.py"],
"env_vars": { ... },
},
)
This is still cloudpickled — but now the pickled thing is just some strings and a dict, not the live graph. The real code ships as source via extra_packages, and at runtime the platform imports app from trading_agent.agent and calls set_up(). The class_methods are still auto-generated, this time via a local dry-run that imports the module on your machine.
One detail that’ll trip you up: agent_name must point at the AdkApp ("app"), not the raw root_agent. ModuleAgent uses the object as-is with no auto-wrapping, and it’s the AdkApp — not the bare agent — that exposes create_session, stream_query, and the rest.
This is a small change from Option 1, and the robustness jump is big: the brittle live-object pickle is gone, and the stub is trivially portable across versions. That said, plenty hasn’t changed — it still uses cloudpickle (a .pkl is still written), it still needs a staging bucket, and the deploy still imports your agent locally to build class_methods. So the deploy box needs the deps and the env set, and agent.py’s sys.exit guard will fire at deploy time if your APCA_API_KEY_ID is unset. Dependency-version parity still matters too — but a mismatch is now a normal pip problem, not a corrupt pickle. This is the version currently in 01_TradingAgent/deploy.py.
Option 3: source deploy (no pickle)
Options 1 and 2 both still wrote a .pkl. Option 3 drops pickle entirely. You omit agent= and instead give the SDK your source packages and an entrypoint.
CLASS_METHODS = [
{"api_mode": "", "name": "create_session"},
{"api_mode": "", "name": "get_session"},
{"api_mode": "", "name": "list_sessions"},
{"api_mode": "", "name": "delete_session"},
{"api_mode": "stream", "name": "stream_query"},
{"api_mode": "async_stream", "name": "async_stream_query"},
# ... plus the async_* session + memory ops ...
]
remote_agent = client.agent_engines.create(
config={
"source_packages": ["trading_agent"],
"entrypoint_module": "trading_agent.agent",
"entrypoint_object": "root_agent", # platform wraps it in AdkApp
"agent_framework": "google-adk",
"requirements_file": "trading_agent/requirements.txt",
"class_methods": CLASS_METHODS,
"env_vars": { ... },
},
)
Under the hood the SDK tars and gzips your source and base64-inlines it right in the create request — no staging bucket, no pickle, nothing to provision. At runtime the platform imports entrypoint_object from entrypoint_module, and because we set agent_framework="google-adk", it wraps root_agent in an AdkApp for you. Notice we point at the raw root_agent here, not app — the platform does the wrapping this time.
The catch: because there’s no live object to introspect, you must declare class_methods yourself. But don’t let that scare you off — the minimal form is just {"api_mode": ..., "name": ...} pairs, the same short list the container deploy uses. It’s not hand-written OpenAPI parameter schemas. The full parameter schemas are optional metadata that only enrich the input form in the console playground; routing only needs the name and the api_mode. So it’s about a 13-line list you write once.
And here’s a nice one: this can be done with no Python at all. It’s a plain REST call, exactly like the container deploy, because the source archive is just a base64 gzip tarball.
ARCHIVE=$(tar czf - trading_agent | base64 -w0)
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
"https://${REGION}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/locations/${REGION}/reasoningEngines" \
-d @- <<JSON
{
"displayName": "Trading Agent ADK",
"spec": {
"agentFramework": "google-adk",
"classMethods": [ {"api_mode": "", "name": "create_session"}, {"api_mode": "stream", "name": "stream_query"} ],
"sourceCodeSpec": {
"inlineSource": { "sourceArchive": "${ARCHIVE}" },
"pythonSpec": {
"version": "3.12",
"entrypointModule": "trading_agent.agent",
"entrypointObject": "root_agent",
"requirementsFile": "trading_agent/requirements.txt"
}
},
"deploymentSpec": { "env": [ {"name": "GOOGLE_GENAI_USE_VERTEXAI", "value": "TRUE"} ] }
}
}
JSON
Convenience is medium — you declare that ~13-line method surface once, but there’s no pickle and no staging bucket, and it works from Python or pure bash. Robustness is strong. No pickle anywhere; if you pass class_methods explicitly you can even deploy without executing your agent code locally (so no sys.exit guard firing on you); and the source archive is transparent and reproducible. The only real risk left is dependency-version parity, which every option shares.
One caveat: the inline base64 source has a size ceiling, so for a big source tree it’ll get unwieldy. For large repos there’s a Git-backed variant (developer_connect_source) instead.
Option 4: bring your own container (BYOC)
At the production end, you build and push your own container image and Agent Platform runs it as a reasoningEngine, pulling it from Artifact Registry. This is the most moving parts, and the most control.
The runtime contract is simple. Agent Runtime forwards every :query / :streamQuery call to your container at two fixed routes, passing the method name in the body:
POST /api/reasoning_engine {"class_method": "...", "input": {...}} (sync, e.g. create_session)
POST /api/stream_reasoning_engine {"class_method": "...", "input": {...}} (streaming, e.g. stream_query)
Your FastAPI wrapper (main.py) dispatches those via getattr(adk_app, class_method) against an AdkApp(root_agent) you construct yourself — so you own the runtime wrapper. The classMethods you declare at deploy time have to match the methods main.py can dispatch. The dispatch wrapper and its two routes live in 08_ContainerizedForAgentPlatform/main.py in the repo; the container itself is short:
FROM python:3.12-slim
WORKDIR /app
COPY trading_agent/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN mkdir -p trading_agent
COPY trading_agent/agent.py trading_agent/
COPY main.py .
# Agent Runtime injects $PORT. Serve the BYOC FastAPI wrapper (not adk api_server)
# so the container speaks the :query / :streamQuery contract. Sessions + Memory
# come from SESSION_SERVICE_URI / MEMORY_SERVICE_URI in the deployment env.
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8080}"]
Here’s where the scaffolding really shows up, and it’s the whole point of this option. To deploy you need: a Dockerfile, the FastAPI dispatch wrapper, a build-and-push, an Artifact Registry repo, a custom service account, and the IAM to go with it. Two IAM bindings are easy to miss — the Reasoning Engine service agent (service-<PROJECT_NUMBER>@gcp-sa-aiplatform-re.iam.gserviceaccount.com) needs artifactregistry.reader so the platform can pull your image, and the deployer needs iam.serviceAccountUser on the runtime service account.
The deploy itself is a REST call — all bash, no Python SDK — in 09_DeployContainerToAgentPlatform/deploy_byoc.sh. It’s a create POST to reasoningEngines carrying spec.containerSpec.imageUri, spec.deploymentSpec (env plus resourceLimits), spec.agentFramework="google-adk", spec.serviceAccount, and spec.classMethods — the same short name-plus-api_mode list.
curl -s -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
"https://${REGION}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/locations/${REGION}/reasoningEngines" \
-d @- <<JSON
{
"displayName": "Trading Agent ADK",
"spec": {
"agentFramework": "google-adk",
"serviceAccount": "${GSA_EMAIL}",
"classMethods": [
{"api_mode": "", "name": "get_session"},
{"api_mode": "", "name": "create_session"},
{"api_mode": "stream", "name": "stream_query"},
{"api_mode": "async_stream", "name": "async_stream_query"}
],
"containerSpec": { "imageUri": "${IMAGE_URI}" },
"deploymentSpec": {
"env": [ {"name": "GOOGLE_GENAI_USE_VERTEXAI", "value": "TRUE"} ],
"resourceLimits": { "cpu": "2", "memory": "2Gi" }
}
}
}
JSON
Two gotchas worth calling out. GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are reserved — the platform injects them — so do NOT list them in env. And the script fails fast if the image isn’t in Artifact Registry yet, because otherwise Agent Runtime happily accepts the deploy and only fails the image pull minutes later.
Convenience is the lowest of the four — container, wrapper, registry, IAM, the works. Robustness is as good as it gets. You own the Python version, the base image, and the exact deps. You get a reproducible image in Artifact Registry, it drops into normal container CI/CD, and there’s no pickle anywhere. The trade-off is that you also own and maintain the container, the dispatch wrapper, and the infra.
Comparing the four
| Approach | Setup / convenience | Uses cloudpickle? | Who declares the API surface | Extra infra / scaffolding | Robustness |
|---|---|---|---|---|---|
| 1. Live object | Highest — a few lines | Yes, the whole live graph | Auto (introspects live object) | Staging bucket | Weak — brittle pickle |
2. Stub / ModuleAgent |
High — small change from #1 | Yes, but only a stub | Auto (local dry-run import) | Staging bucket | Better — no live-object pickle |
| 3. Source deploy | Medium — declare ~13-line list once | No | By hand (short name + api_mode) |
None (or Git for large repos) | Strong — no pickle, reproducible; best balance |
| 4. Container (BYOC) | Lowest — most moving parts | No | By hand (same short list) | Dockerfile, wrapper, registry, SA, IAM | Highest — full control |
So the two threads land like this: the SDK writes class_methods for you in Options 1 and 2, you write it yourself in 3 and 4 — but it’s the same short name-and-api_mode list either way, never verbose schemas. And the scaffolding runs from a staging bucket (1, 2) to a bare source tarball (3) to the full container-plus-registry-plus-IAM kit (4).
Which one should you use
It depends on where you are.
If you’re prototyping and want the fastest path to a running agent, use Option 1 — pass the live object and go. The moment cloudpickle starts throwing errors on you (protobuf descriptors, version skew, an un-picklable client), switch to Option 2. It’s a tiny diff and it makes the pickle problem go away without asking you to declare anything.
For production while staying in the managed runtime with the least ops, I’d reach for Option 3, source deploy. There’s no pickle, the source archive is reproducible, you write the method list once, and you can do the whole thing from bash if you’d rather not touch the Python SDK. If your source tree is large enough to bump the inline size ceiling, wire up the Git-backed variant instead.
Go to Option 4, the container, when you need full control — a non-standard base image, a pinned Python, dependencies that don’t behave under source deploy — or when you already have container CI/CD and Artifact Registry set up and want your agent to ride the same rails. It’s a Dockerfile, a dispatch wrapper, and two IAM bindings to set up. In return you get a container image you can rebuild and pin exactly.
For the Trading Agent I run Option 2 day to day — it’s what’s in 01_TradingAgent/deploy.py. When I want the CI image from 08_ContainerizedForAgentPlatform, I ship it with 09_DeployContainerToAgentPlatform/deploy_byoc.sh.
The rest of this post was generated with input from my research into the various methods of deploying agents to Agent Platform. –William