Arbitrary WebSockets on Agent Runtime with BYOC

Copy as Markdown

In 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.

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

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:

  1. 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.
  2. 10-minute stream limit: Connections on :bidiStreamQuery have 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.

A WebSocket client connects to the public endpoint with a Google OAuth access token identifying the calling user or service account. The URL automatically selects Agent Runtime's bidiInvokeReasoningEngine operation, which checks IAM and forwards /voice/stream to the BYOC container's FastAPI endpoint on $PORT, default 8080. Text, JSON, and binary data flow in both directions.
Scroll horizontally to explore. Open full-size diagram

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:

  1. Inbound leg (Client ↔ Agent Runtime container): Unlike :bidiStreamQuery’s 10-minute cap, bidiInvokeReasoningEngine has a 60-minute connection ceiling. Your caller’s WebSocket connection to Agent Runtime stays open for up to an hour.
  2. 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_resumption and context_window_compression in ADK’s Bidi-streaming RunConfig so 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 the reasoningEngine resource name and the path forwarded to your container. A request to .../reasoningEngines/RESOURCE_ID/api/voice/stream arrives at your container as /voice/stream (unless you also mount your routes under /api in FastAPI, so watch your prefixes).
  • IAM still guards the front door: Agent Runtime enforces Google Cloud IAM (aiplatform.reasoningEngines.query) on the HTTP Upgrade: websocket handshake, 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 pass Authorization: Bearer <TOKEN> in the initial upgrade headers—but if your caller can’t set custom HTTP headers on the WebSocket upgrade (like a browser new 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.