The Agent Platform API contract: what your agent must expose, and why
Copy as MarkdownUnlike 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.
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 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 from my book, and the code is in 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:
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:
{"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_sessionstarts a conversation for auser_idand returns the session id you’ll pass to every subsequent query. Theuser_idscoping matters: it’s what keeps your end users’ conversations isolated from each other.get_sessionfetches one conversation with its full event history — this is how you re-render the transcript when a user reopens a chat.list_sessionsreturns everything a user has going. That’s your chat-history sidebar, in one call.delete_sessionmeans 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_memoryhands 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_memoryqueries 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), 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):
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 in the repo, with the 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:
"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. 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:
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:
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, 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 walks the four options — the contract at the end is the same either way.
This post was generated based on observations from my research into BYOC and the Agent Platform API contract. –William