# The Agent Platform API contract: what your agent must expose, and why Unlike when you deploy an agent to a general-purpose platform like GKE or Cloud Run, deploying to Agent Platform requires that you conform to a specific API contract. This common contract is what enables various features of the platform, from the playground interactions to features like gateway. This post explores that contract. Comment: This post was generated based on observations from my research into BYOC and the Agent Platform API contract. –William Deploy an agent to Agent Platform's managed runtime and what you get is a `reasoningEngine` resource (you may know the runtime by its old name, Vertex AI Agent Engine) with just two endpoints for calling it: `:query` and `:streamQuery`. That's the entire caller-facing API. There's no fixed chat schema — instead, every request carries the *name* of a method to invoke on your agent, and the `classMethods` list you declare at deploy time tells the platform which names exist and how to route them. Your agent's Python surface becomes the REST API, method by method. In [4 ways to deploy agents on Agent Platform](/agents/4-ways-to-deploy/) I looked at how to get an agent onto the runtime, and the `class_methods` list kept showing up as one of the threads to watch. This post is about the list itself: what the standard ADK agent surface actually is, what each method is *for* — the value it delivers to your users, not just its signature — and how to serve the contract, including from your own container. The running example is the [Trading Agent](/ap/) from my book, and the code is in [agent-examples](https://github.com/WilliamDenniss/agent-examples/) (`08_ContainerizedForAgentPlatform` has the container, `09_DeployContainerToAgentPlatform` the deploy and client scripts). ## How a call reaches your agent Every call to a deployed agent is a POST to one of two endpoints on the `reasoningEngine` resource, with a small envelope naming the method and carrying its arguments: ```shell curl -s -X POST \ -H "Authorization: Bearer $(gcloud auth print-access-token)" \ -H "Content-Type: application/json" \ "https://${REGION}-aiplatform.googleapis.com/v1/${RESOURCE_NAME}:query" \ -d '{"class_method": "create_session", "input": {"user_id": "user1"}}' ``` The `input` dict becomes the method's keyword arguments, and the result comes back wrapped in `output`: ```json {"output": {"id": "5390331552844087296", "userId": "user1", "appName": "...", "events": []}} ``` Which endpoint a method lives on is decided by the `api_mode` you declare for it: `""` (sync) and `"async"` methods are served by `:query`, while `"stream"` and `"async_stream"` methods are served by `:streamQuery`, which returns newline-delimited JSON events as they're produced (add `?alt=sse` if you'd rather have server-sent-events framing). Why a method router instead of a fixed chat API? Because agents don't share one shape. A LangGraph or LlamaIndex agent deploys with a single `query` method; an ADK agent brings a whole session-management suite along. Rather than force every framework through one schema, the platform publishes whatever methods your agent object has — you just declare the names and the routing mode. Tip: You can discover any deployed agent's surface by fetching the resource itself — a GET on the `reasoningEngine` returns `spec.classMethods`, the same list its deployer declared. ## The surface an ADK agent exposes Here's the full list our Trading Agent declares — it's the standard surface you get by wrapping any ADK agent in `AdkApp`: | Methods | `api_mode` | Served by | What they're for | |---|---|---|---| | `create_session`, `get_session`, `list_sessions`, `delete_session` | `""` | `:query` | Conversation lifecycle | | `async_create_session`, `async_get_session`, `async_list_sessions`, `async_delete_session` | `"async"` | `:query` | The same, for async callers | | `async_add_session_to_memory`, `async_search_memory` | `"async"` | `:query` | Long-term memory, across conversations | | `stream_query` | `"stream"` | `:streamQuery` | The conversation itself | | `async_stream_query`, `streaming_agent_run_with_events` | `"async_stream"` | `:streamQuery` | ADK-native streaming; the console playground | Notice what's *missing*: there's no plain `query`. For an ADK agent, talking to the agent is streaming-only — more on why below. Let's take the groups one at a time. ### Sessions: the conversation state A session is one conversation: the ordered history of events (user messages, tool calls, agent replies) plus its working state, stored server-side in the platform's Sessions service — not in your process. LLM calls are stateless, so the session is how the second question in a conversation makes any sense. Ask the Trading Agent "how did the portfolio change today?" and follow up with "so why did you sell HPQ?" — the follow-up only works because the first exchange is in the session the agent loads. Each method earns its place with a concrete user-facing job: - **`create_session`** starts a conversation for a `user_id` and returns the session id you'll pass to every subsequent query. The `user_id` scoping matters: it's what keeps your end users' conversations isolated from each other. - **`get_session`** fetches one conversation with its full event history — this is how you re-render the transcript when a user reopens a chat. - **`list_sessions`** returns everything a user has going. That's your chat-history sidebar, in one call. - **`delete_session`** means when the user hits delete, the conversation is actually gone — cleanup and privacy, not just hiding it in the UI. The `async_*` twins are the same operations for callers on the async SDK surface — over REST they hit the same `:query` endpoint, so declare both sets and both kinds of client work. There's a scaling payoff hiding in here too: because conversation state lives in the Sessions backend rather than in container memory, the runtime can scale your agent's instances up and down (or replace them) without conversations vanishing. Your container stays stateless. ### stream_query: the conversation itself `stream_query` is the method that actually talks to the agent — you pass `user_id`, `session_id`, and a `message`, and it streams back ADK events as they happen. Why streaming, and why is there no blocking `query` at all? Because an agent turn isn't one model call. A single turn of the Trading Agent is an entire trading cycle: pull ~24 hours of news across 500 tickers, score the sentiment, query the portfolio, then place up to five trades — dozens of tool calls and several minutes end to end. A blocking call would sit silent the whole time and then dump everything at once. The stream instead yields each event as it's produced — every tool call with its arguments, every tool result, every chunk of model text — so your UI can show the agent working rather than a spinner. And the events are more than progress theater. Each tool call arrives in the stream with its exact arguments, so you get a live audit trail: you can watch `place_trade_order` go out with the ticker and dollar amount before the confirmation comes back. When the tool in question moves money, that visibility is the feature. `async_stream_query` is the same stream served from an async generator — it's ADK's native mode, and both land on `:streamQuery`. ### Memory: what survives the conversation Sessions are short-term memory; they end. Memory Bank is the long-term layer — it distills conversations into durable memories keyed to the user, which the agent reads back through its memory tools on later sessions. That's how the Trading Agent can recall *why* it bought a stock last week when deciding whether to sell it this week. The two API methods put the client side of that loop in your hands: - **`async_add_session_to_memory`** hands a finished session over for distillation. Exposing it as an API method means *you* decide when a conversation is worth remembering — at the end of every chat, or from a nightly batch job that sweeps the day's sessions. - **`async_search_memory`** queries what's been remembered for a user. Handy for debugging ("what does the agent actually know?"), and it's the building block for a "here's what I remember about you" view — which your users will appreciate more than a black box. Our agent also writes memories from the inside (an `after_agent_callback` saves a structured record of each trade), so the API methods aren't the only path — but they're the path the *caller* controls. ### streaming_agent_run_with_events: the playground's entry point The last one is lower-level: it takes a full run-request JSON and streams raw ADK events back. You'll probably never call it yourself — it's what the Cloud console playground drives, which is also why we set `agentFramework: "google-adk"` at deploy time. Declare it and you get a free chat UI in the console for poking at your deployed agent. Pretty handy for testing. ## Serving the contract So that's the surface. How you *provide* it depends on how you deploy. On the managed paths (the pickle, stub, and source options from [the deploy post](/agents/4-ways-to-deploy/)), the platform builds the serving layer for you — `AdkApp` already has all thirteen methods, and the platform either introspects the list or you declare it. You never see the plumbing. Bring your own container, and the plumbing is yours. Agent Runtime forwards every `:query` / `:streamQuery` call to your container at two fixed routes, passing the same envelope through: ``` POST /api/reasoning_engine {"class_method": "...", "input": {...}} (sync + async) POST /api/stream_reasoning_engine {"class_method": "...", "input": {...}} (stream + async_stream) ``` Your job is a small dispatch wrapper: look up `class_method` on the `AdkApp`, call it with `input` as kwargs, and return the result in the shapes the platform expects — `{"output": ...}` as JSON for the sync route, newline-delimited JSON events for the streaming route. Here's the heart of ours (FastAPI): ```python adk_app = agent_engines.AdkApp(agent=root_agent) @app.post("/api/reasoning_engine") async def query(http_request: Request) -> responses.JSONResponse: request = await _parse_request(http_request) method = getattr(adk_app, request.class_method) output = await _invoke_callable_or_raise(method, request.input or {}) return responses.JSONResponse( content=encoders.jsonable_encoder({"output": output}) ) @app.post("/api/stream_reasoning_engine") async def stream_query(http_request: Request) -> responses.StreamingResponse: request = await _parse_request(http_request) method = getattr(adk_app, request.class_method) output = await _invoke_callable_or_raise(method, request.input or {}) return responses.StreamingResponse( content=json_generator(output), media_type="application/json", ) ``` The full wrapper — including handling both sync and async generators in the stream — is [main.py](https://github.com/WilliamDenniss/agent-examples/blob/b79d454c65d61a049aa57cd2b149de37dfc460be/08_ContainerizedForAgentPlatform/main.py) in the repo, with the [Dockerfile](https://github.com/WilliamDenniss/agent-examples/blob/b79d454c65d61a049aa57cd2b149de37dfc460be/08_ContainerizedForAgentPlatform/Dockerfile) alongside it. A few things the platform handles for you: it injects `$PORT` (listen on `0.0.0.0` there), and it injects `GOOGLE_CLOUD_AGENT_ENGINE_ID` — the engine's own resource id — which `AdkApp.set_up()` uses to wire sessions and Memory Bank to the agent's own resource. That's how the container stays stateless while `create_session` still works. One wrinkle I hit running this for real: Agent Runtime forwards the sync `:query` body to the container as a JSON-encoded *string*, not an object. So parse defensively — decode once, and if you've still got a string, decode again (that's what `_parse_request` does above). The other half is declaring the list at deploy time, since the platform can't introspect a container. It's the same short name-plus-`api_mode` list from the table, in the create request's `spec`: ```json "classMethods": [ {"api_mode": "", "name": "create_session"}, {"api_mode": "", "name": "get_session"}, {"api_mode": "stream", "name": "stream_query"}, {"api_mode": "async_stream", "name": "async_stream_query"} ] ``` The full thirteen-entry version, plus the IAM and Artifact Registry setup around it, is in [deploy_byoc.sh](https://github.com/WilliamDenniss/agent-examples/blob/master/09_DeployContainerToAgentPlatform/deploy_byoc.sh). The rule to remember: declare only what your wrapper can dispatch — the platform routes strictly off your list, and `getattr` does the rest. Note: the full contract isn't spelled out on any one docs page at the time of writing — the pieces are spread across the deploy docs and Google's BYOC tutorial notebook. What's above is the assembled picture, verified against a running deployment. ## Taking it for a spin The whole loop, from the caller's chair. Create a session, keep the id: ```shell SESSION_ID=$(curl -s -X POST \ -H "Authorization: Bearer $(gcloud auth print-access-token)" \ -H "Content-Type: application/json" \ "https://${REGION}-aiplatform.googleapis.com/v1/${RESOURCE_NAME}:query" \ -d '{"class_method": "create_session", "input": {"user_id": "user1"}}' \ | jq -r '.output.id') ``` Then run a trading cycle in that session, filtering the event stream down to the agent's text: ```shell curl -s -X POST \ -H "Authorization: Bearer $(gcloud auth print-access-token)" \ -H "Content-Type: application/json" \ "https://${REGION}-aiplatform.googleapis.com/v1/${RESOURCE_NAME}:streamQuery" \ -d '{"class_method": "stream_query", "input": {"user_id": "user1", "session_id": "'"$SESSION_ID"'", "message": "Run the trading cycle."}}' \ | jq -rj 'select(.content.parts) | .content.parts[] | select(.text) | .text' ``` Drop the `jq` filter and you'll see the raw events instead — the news-fetch tool calls going out, the sentiment analysis coming back, each `place_trade_order` with its ticker and amount, and finally the report. The runnable version with error handling is [trade.sh](https://github.com/WilliamDenniss/agent-examples/blob/master/09_DeployContainerToAgentPlatform/trade.sh), and it works against *any* of the deployment options, not just the container — same resource, same contract. So that's what's behind those two endpoints: a session suite so conversations persist, a streaming query so users watch the agent work, and memory methods so it learns across sessions. If you're deciding how to get your own agent up there, [the deploy post](/agents/4-ways-to-deploy/) walks the four options — the contract at the end is the same either way.