Bridging ADK Go's A2A Handler to A2UI Format
How Google ADK Go's built-in A2A handler falls short for UI-consumable responses — and the custom Sublauncher pattern we built to return structured A2UI JSON instead of raw text.

The Problem
When using Google's ADK (Agent Development Kit) for Go to build an agent that generates images, we hit a wall: the built-in A2A launcher (a2a.NewLauncher()) registers a JSON-RPC endpoint at /a2a/invoke, but it doesn't return responses in a format that a UI can render.
A2A (Agent-to-Agent) is designed for agent-to-agent communication. The response comes back as raw text — great for another agent, useless for a frontend that needs to display an image with proper structure.
What we needed was A2UI — the Agent-to-UI protocol extension that wraps results in typed UI component descriptors:
{
"result": {
"role": "agent",
"parts": [{
"kind": "data",
"data": {
"type": "Image",
"props": {
"url": "https://storage.googleapis.com/.../image.png",
"alt": "purple clouds"
}
}
}]
}
}Without this, the frontend would receive plain text — forcing it to parse and guess at the content. Type-safe, component-driven UI rendering goes out the window.
The Investigation
When we hit POST /a2a/invoke with a standard A2A JSON-RPC request, we got nothing back — curl: (52) Empty reply from server.
The ADK's prod launcher chains universal -> web -> a2a sub-launchers:
universal.NewLauncher(
web.NewLauncher(
api.NewLauncher(),
a2a.NewLauncher(), // <-- built-in, not A2UI-aware
),
)The built-in a2a.NewLauncher() uses adka2a.Executor to wrap the agent and a2asrv.NewJSONRPCHandler to serve requests. It handles JSON-RPC framing correctly, but the response payload is whatever the LLM returns — unstructured text.
Two Root Causes
1. No A2UI response formatting. The built-in handler doesn't know about A2UI's kind/data/type/props structure. It returns the agent's raw text output, which the UI can't render as a typed component.
2. Write timeout. The ADK web server defaults to a 15-second --write-timeout. Our agent takes 15-20 seconds end-to-end (Gemini prompt enhancement → Stability AI image generation → GCS upload). By the time the handler tries to write the response, the server has already closed the connection. Result: empty reply.
The Solution: A Custom A2UI Sublauncher
We replaced the built-in a2a.NewLauncher() with a custom A2UILauncher that implements web.Sublauncher. This gave us full control over the HTTP handler and response formatting.
Architecture
universal.NewLauncher(
web.NewLauncher(
api.NewLauncher(),
server.NewA2UILauncher(), // <-- custom, A2UI-aware
),
)The Handler
The custom handler (server/handler.go) does three things:
1. Parses the JSON-RPC request — extracts the user's text prompt from the A2A message envelope
2. Runs the agent via ADK runner — creates a runner.Runner, feeds it the user content, and iterates over events
3. Extracts the image URL from agent events — checks both Part.Text (the LLM's final text response) and Part.FunctionResponse (the tool call result from Stability AI)
for event, err := range rnr.Run(ctx, "user", sessionID, content, agent.RunConfig{}) {
for _, part := range event.Content.Parts {
if part.Text != "" && strings.HasPrefix(part.Text, "https://storage.googleapis.com/") {
imageURL = part.Text
}
if part.FunctionResponse != nil {
if url, ok := part.FunctionResponse.Response["image_url"]; ok {
imageURL = url.(string)
}
}
}
}4. Wraps the URL in an A2UI response — returns {"kind":"data","data":{"type":"Image","props":{"url":"...","alt":"..."}}}
The Write Timeout Fix
We inject --write-timeout 120s into the web launcher's flags in main.go:
if arg == "web" {
newArgs = append(newArgs, "web", "--write-timeout", "120s")
continue
}This gives the agent a full 2 minutes to complete before the server closes the connection.
The Sublauncher
The A2UILauncher (server/a2ui_launcher.go) implements the web.Sublauncher interface:
It creates an AgentCard with application/json+a2ui as a default output mode — telling A2A clients that this agent speaks A2UI.
The Result
A2A requests now return properly structured A2UI responses:
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"kind": "message",
"messageId": "63da6585-6efb-4a59-85ce-1c86f17d205f",
"parts": [
{
"data": {
"props": {
"alt": "purple clouds",
"url": "https://storage.googleapis.com/illustra/images/1778152804712959000.png"
},
"type": "Image"
},
"kind": "data"
}
],
"role": "agent"
}
}The frontend receives a typed Image component descriptor — it knows exactly what to render without parsing or guessing.
Key Takeaways
1. ADK Go's A2A is agent-to-agent, not agent-to-UI. If you need structured UI responses, you'll need to build a custom Sublauncher.
2. Watch the write timeout. The 15s default is fine for LLM-only agents, but once you add external tool calls (Stability AI, file uploads, etc.), you'll hit it fast.
3. Extract from FunctionResponse, not just Text. The image URL can appear in two places — the LLM's final text or the tool's function response. Check both.
4. The Sublauncher pattern is clean. ADK Go's web.Sublauncher interface makes it easy to add custom HTTP handlers alongside built-in ones (like api). You get CORS, route registration, flag parsing, and startup messaging for free.
What's Next
tasks/get and tasks/cancel support for async job trackingThe full source code is at github.com/mnkrana/illustra — check the server/ package.