# 5 ways to deploy agents on Agent Platform


So you've built an ADK agent—in my case the [Trading Agent](/ap/) 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](/ap/1-agent/1.2-deploy-to-ap-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.

Comment: The rest of this post was generated with input from my research into the various methods of deploying agents to Agent Platform. 
–William

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](/agents/other-services/). 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.

```python
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.

```python
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.

```python
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.

```shell
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.

```python
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:

```dockerfile
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}"]
```
*[Dockerfile](https://github.com/WilliamDenniss/agent-examples/blob/master/08_ContainerizedForAgentPlatform/Dockerfile)*

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.

```shell
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`.
