Serving the Agent Platform API contract
Copy as MarkdownWith Agent Platform’s bring your own container (BYOC) deployment option, you need to serve the standard API contract yourself. In The Agent Platform API contract: what your agent must expose, and why, I covered why the contract exists and walked through the thirteen methods that AdkApp exposes. Now let’s put a serving layer around them and call the deployed agent end to end.
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 receives the camelCase classMethod envelope at :query / :streamQuery, changes that field to snake_case class_method, and forwards the call to your container at two fixed routes:
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.
Update: Google now documents the container routes, field names, and response shapes on a single Agent Platform runtime contract page. The ADK-specific method list is still tied to the AdkApp version you deploy; the list above is the one verified against this example.
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 '{"classMethod": "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 '{"classMethod": "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 five options — the contract at the end is the same either way.