Arbitrary WebSockets on Agent Runtime with BYOC
Copy as MarkdownIn The Agent Platform API contract and Serving the Agent Platform API contract, I covered how Agent Runtime dispatches :query and :streamQuery calls to the two fixed routes in a bring-your-own-container (BYOC) image: /api/reasoning_engine and /api/stream_reasoning_engine.
What if your client speaks its own WebSocket wire format—like a browser voice client, a custom mobile app, or a telephony stream—and you want it to connect straight to your agent? Or what if your sessions run longer than 10 minutes? With a container or Dockerfile deployment on Agent Runtime, the runtime exposes an underlying API passthrough that forwards arbitrary HTTP and WebSocket routes directly to your container unchanged, with a 60-minute connection ceiling.
Why :bidiStreamQuery Isn’t Always What You Want
When you deploy an agent using the managed object path, Agent Runtime builds the serving layer around your Python object and exposes bidirectional streaming through :bidiStreamQuery (currently in Preview, and only with the EXPERIMENTAL agent_server_mode). Because the platform owns the server, two constraints apply:
- Fixed wire format: You connect with the GenAI SDK’s live client, select the agent method via
class_method, and every reply comes back wrapped as{"bidiStreamOutput": {"output": ...}}—there’s no way to serve your own schema. - 10-minute stream limit: Connections on
:bidiStreamQueryhave a documented maximum timeout of 10 minutes.
That works well for short interactive sessions when you control the client and are calling the Agent Platform SDK. It doesn’t work when the caller has a fixed WebSocket schema of its own or needs sessions that run past 10 minutes—you’d otherwise have to run a separate adapter service just to translate payloads into :bidiStreamQuery’s JSON format.
Bring your own container, and the plumbing is yours.
Two Ways to Stream into Agent Runtime
Agent Runtime provides two distinct ingress paths depending on how you package and address your agent:
| Feature | Managed Contract (:query / :streamQuery / :bidiStreamQuery) |
Underlying API Passthrough (/api/** & /ws/v1/.../api/**) |
|---|---|---|
| Deployment Type | Object, source, Dockerfile, or prebuilt container (BYOC); :bidiStreamQuery is managed-serving only (Preview) |
Dockerfile or prebuilt container (BYOC) |
| Container Routes | Fixed (POST /api/reasoning_engine, POST /api/stream_reasoning_engine) |
Any custom HTTP or WebSocket route your server defines |
| Wire Format | Platform RPC envelope (class_method dispatch, bidiStreamOutput framing) |
Arbitrary JSON, text, or binary WebSocket frames |
| WebSocket Timeout | 10 minutes (:bidiStreamQuery) |
Up to 60 minutes (bidiInvokeReasoningEngine) |
| REST / RPC Method | reasoningEngines.query / streamQuery / bidiStreamQuery |
invokeReasoningEngine (HTTP) / bidiInvokeReasoningEngine (WebSocket) |
How the WebSocket Passthrough Works
Alongside the standard :query and :streamQuery methods, Agent Runtime exposes a passthrough surface documented under Use deployed agents through their underlying API. In the reasoningEngines.ws.v1 REST reference and Agent Platform RPC reference, these passthrough operations are named invokeReasoningEngine (for HTTP) and bidiInvokeReasoningEngine (for WebSockets)—though as a caller, you never put those method names in the URL yourself. Instead, Google Cloud’s API frontend maps any request matching the /api/** wildcard path directly to them.
Every path under /api/ belongs to your container. When you connect to the /reasoningEngines/ws/v1/.../api/** endpoint, Agent Runtime authenticates the request with IAM, strips the prefix up through /api, and upgrades the connection directly against your container on $PORT (default 8080):
Local container:
ws://localhost:8080/voice/stream
Deployed on Agent Runtime:
wss://LOCATION-aiplatform.googleapis.com/reasoningEngines/ws/v1/projects/PROJECT_NUMBER/locations/LOCATION/reasoningEngines/RESOURCE_ID/api/voice/stream
For standard HTTP routes (GET, POST, PUT, DELETE, PATCH), the equivalent passthrough path is /reasoningEngines/v1/projects/PROJECT_NUMBER/locations/LOCATION/reasoningEngines/RESOURCE_ID/api/<YOUR_PATH>.
Breaking Past the 10-Minute Streaming Limit
In a voice agent architecture, there are two separate WebSocket legs to keep in mind—and BYOC lets you run sessions up to 60 minutes:
- Inbound leg (Client ↔ Agent Runtime container): Unlike
:bidiStreamQuery’s 10-minute cap,bidiInvokeReasoningEnginehas a 60-minute connection ceiling. Your caller’s WebSocket connection to Agent Runtime stays open for up to an hour. - Outbound leg (Agent container ↔ Gemini Live API): Under the hood, the Gemini Live API enforces its own ~10-minute WebSocket connection limit (and a 15-minute default audio session cap). Because your container owns the inbound connection, you can enable
session_resumptionandcontext_window_compressionin ADK’s Bidi-streamingRunConfigso ADK transparently reconnects to Gemini Live in the background without ever dropping the caller’s inbound WebSocket.
Serving Both in One Container
You don’t have to choose between a custom WebSocket interface and the Agent Platform runtime contract. The same FastAPI app can serve /api/reasoning_engine and /api/stream_reasoning_engine—so the Google Cloud console playground, the Python SDK, and Gemini Enterprise continue to work—while exposing a raw WebSocket endpoint right beside them:
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from google.adk.agents.run_config import RunConfig
from google.genai import types
app = FastAPI()
# Keep outbound Gemini Live sessions running seamlessly past 10-15 minutes
run_config = RunConfig(
session_resumption=types.SessionResumptionConfig(transparent=True),
context_window_compression=types.ContextWindowCompressionConfig(
trigger_tokens=100000,
sliding_window=types.SlidingWindow(target_tokens=80000),
),
)
@app.get("/")
async def health():
return {"status": "ok"}
# Arbitrary WebSocket endpoint (up to 60m) reached via:
# wss://<LOCATION>-aiplatform.googleapis.com/reasoningEngines/ws/v1/{name}/api/voice/stream
@app.websocket("/voice/stream")
async def voice_stream(ws: WebSocket):
await ws.accept()
try:
while True:
# Receive raw text or binary frames in any protocol
message = await ws.receive_text()
# Forward to ADK LiveRequestQueue / run_live(run_config=run_config)
await ws.send_text(message)
except WebSocketDisconnect:
pass
Deploy the image using spec.containerSpec.imageUri (Option 5 in 5 ways to deploy agents on Agent Platform) or let Agent Runtime build your Dockerfile from source (Option 4).
Two Gotchas Worth Calling Out
- The
/api/segment in the public URL is mandatory: On the public URL,/api/marks the boundary between thereasoningEngineresource name and the path forwarded to your container. A request to.../reasoningEngines/RESOURCE_ID/api/voice/streamarrives at your container as/voice/stream(unless you also mount your routes under/apiin FastAPI, so watch your prefixes). - IAM still guards the front door: Agent Runtime enforces Google Cloud IAM (
aiplatform.reasoningEngines.query) on the HTTPUpgrade: websockethandshake, before the connection ever reaches your container. Any backend service you control can mint a Google Cloud OAuth token (via ADC, Workload Identity Federation, or a service account) and passAuthorization: Bearer <TOKEN>in the initial upgrade headers—but if your caller can’t set custom HTTP headers on the WebSocket upgrade (like a browsernew WebSocket()client, or a third-party media stream that only passes credentials in query parameters or post-connect JSON frames), that’s the one reason you’d still keep a lightweight auth proxy in front.
This post was generated with Gemini 4 Argon, based on research I was doing into websocket streaming. I believe it to be accurate based on documented platform capabilities and my existing knowledge, but I have not yet tested it out myself. –William