5 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 five 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 three additional options that deploy the source code: one that serializes a stub object, one that’s a full source deploy, and one that ships a Dockerfile alongside the source so the platform builds the container for you. 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 5), or at least the full source deploy with no object serialization (option 3 or 4). 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 five 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 five 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: source deploy with a Dockerfile (platform-built container)
This one is the newest of the five, and it’s a hybrid of the two options either side of it: you ship source exactly like Option 3, but with a Dockerfile in the tarball, and the platform builds the container image for you at deploy time. You control what’s in the image — the base image, the Python version, the server — without owning any of the build infrastructure.
The create call is Option 3’s with one swap: drop the entrypoint fields (entrypoint_module, entrypoint_object, requirements_file) and pass an image_spec instead.
remote_agent = client.agent_engines.create(
config={
"source_packages": ["trading_agent", "main.py", "Dockerfile"],
"image_spec": {}, # build from the Dockerfile in the source
"class_methods": CLASS_METHODS, # same short list as Option 3
"env_vars": { ... },
},
)
Under the hood it’s the same base64 gzip source tarball as Option 3 — on the wire, the request carries imageSpec where Option 3 had pythonSpec. The platform then runs a single build step against the Dockerfile at the root of your source. image_spec takes optional build_args (passed through as --build-arg flags), and there’s a build_spec.worker_pool field on the engine spec if you want the build to run on your own Cloud Build worker pool.
Here’s the catch, and also the point: because you supply the Dockerfile, you own the API server now. There’s no entrypoint_object for the platform to wrap in an AdkApp; your image has to serve the runtime contract itself — the same two fixed routes the container deploy uses (covered in Option 5, next). In practice the image contents here are identical to Option 5’s: the agent source, a dispatch wrapper like 08_ContainerizedForAgentPlatform/main.py, and its Dockerfile should drop straight in, with the tarball root as the build context. What disappears is everything around the image — no docker build, no push, no Artifact Registry repo, no artifactregistry.reader binding for the service agent. The platform builds and hosts the image itself.
Note: Google’s sample for this method doesn’t declare class_methods at all. I’d declare the same short list anyway — it’s what tells the platform (and the SDK’s generated client) which methods your container answers to, and it should match what your wrapper can dispatch, just like Option 5.
So convenience sits a notch below Option 3 — you’re maintaining a Dockerfile and a server wrapper now — and robustness is nearly Option 5’s: a real container, your exact deps, no pickle anywhere. What you give up versus BYOC is the build itself. It happens at create time (so deploys are slower), and the image lands in the platform’s hands rather than your own registry, so it doesn’t slot into existing container CI/CD. You also inherit Option 3’s inline-source size ceiling, with the same Git-backed escape hatch.
Option 5: 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 five — 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 five
| 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. Source + Dockerfile | Medium — Dockerfile and wrapper, but no build to run | No | By hand (same short list) | None — platform builds the image | Strong — a real container, platform-built |
| 5. 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 through 5 — 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, 4) to the full container-plus-registry-plus-IAM kit (5).
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.
If Option 3’s platform-managed image is what’s holding you back — you need a non-standard base image, a pinned Python, dependencies that don’t behave under source deploy, or your own server — Option 4 gets you a real container without the container ops. Ship the Dockerfile with your source and the platform builds it. The cost is a slower deploy (the image gets built at create time) and the same inline-source size ceiling as Option 3.
Go to Option 5, BYOC, when you want to own the build too — 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, and faster deploys than Option 4 since the platform pulls your image instead of building it.
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