Agent Protocol Interop — A2A, AG-UI, and A2UI¶
Table of Contents¶
- Introduction - The Agent Protocol Stack
- Protocol Comparison
- A2A (Agent-to-Agent) Protocol
- AG-UI (Agent-User Interaction) Protocol
- A2UI (Agent to UI) Protocol
- Complete Examples
- Cookbook
- Troubleshooting
1. Introduction - The Agent Protocol Stack¶
The Problem¶
Modern AI systems need to communicate in multiple directions: agents talk to other agents, agents talk to users through chat interfaces, and agents render rich interactive UIs. Each of these communication patterns has different requirements for transport, data format, and interaction model. Without standardized protocols, every integration becomes a custom project.
Helix's Answer¶
Helix implements four complementary agent protocols as native integration components, each serving a distinct role in the agent ecosystem:
The Helix Agent Protocol Stack
┌──────────────────────────────────────────────────────────┐
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ MCP │ │ A2A │ │ AG-UI │ │ A2UI │ │
│ │ Server │ │ Server │ │ Server │ │ Server │ │
│ │ │ │ │ │ │ │ │ │
│ │ Tools for│ │ Agent-to-│ │ Chat SSE │ │ Rich │ │
│ │ LLM apps │ │ Agent │ │ for chat │ │ UI │ │
│ │ │ │ comms │ │ frontends│ │ render │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───┬────┘ │
│ │ │ │ │ │
│ ┌────┴─────────────┴─────────────┴─────────────┴────┐ │
│ │ Helix Engine + Routes │ │
│ │ (Processors, Interceptors, Orchestration Patterns) │ │
│ └────┬─────────────┬─────────────────────────────────┘ │
│ │ │ │
│ ┌────┴─────┐ ┌────┴─────┐ │
│ │ MCP │ │ A2A │ │
│ │ Client │ │ Client │ │
│ │ │ │ │ │
│ │ Call ext │ │ Call ext │ │
│ │ MCP tools│ │ A2A agts │ │
│ └──────────┘ └──────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
External connections:
Claude Desktop ──► MCP Server (tool discovery + invocation)
Other AI Agent ──► A2A Server (JSON-RPC task execution)
Chat Frontend ──► AG-UI Server (SSE streaming chat)
Rich UI Client ──► A2UI Server (declarative UI rendering)
Helix Workflow ──► MCP Client ──► External MCP Server
Helix Workflow ──► A2A Client ──► External A2A Agent
Why Four Protocols?¶
Each protocol addresses a different integration surface:
- MCP (Model Context Protocol): Lets LLM applications like Claude Desktop discover and call your workflows as tools. Covered in the MCP Guide.
- A2A (Agent-to-Agent): Lets AI agents communicate with each other using Google's open protocol. Supports task lifecycle and streaming.
- AG-UI (Agent-User Interaction): Provides a standardized SSE-based protocol for chat frontends like CopilotKit to stream agent responses.
- A2UI (Agent to UI): Enables agents to generate rich declarative UIs with components, data binding, and event handling.
This guide covers A2A, AG-UI, and A2UI in depth. For MCP, see the dedicated MCP Guide.
2. Protocol Comparison¶
| Feature | MCP | A2A | AG-UI | A2UI |
|---|---|---|---|---|
| Purpose | Tool exposure for LLMs | Agent-to-agent communication | Chat streaming for frontends | Rich UI rendering |
| Transport | HTTP / SSE / stdio | JSON-RPC over HTTP | SSE over HTTP POST | SSE (GET) + HTTP POST |
| Default Port | 3000 | 9000 | 4100 | 4200 |
| SDK | go-sdk v1.4.0 |
a2a-go v0.3.7 |
ag-ui community/go |
a2ui-go v0.3.0 |
| Scheme (Server) | mcp-server:// |
a2a-server:// |
agui:// |
a2ui:// |
| Scheme (Client) | mcp:// |
a2a:// |
N/A | N/A |
| Streaming | Via transport | Artifact events | TEXT_MESSAGE_CONTENT | updateComponents |
| Discovery | Tool listing | Agent Card | N/A | N/A |
| Direction | Bidirectional | Bidirectional | Server only | Server only |
| Primary Consumer | Claude, Cursor, IDEs | Other AI agents | CopilotKit, chat UIs | Custom UI renderers |
When to Use What¶
"I want Claude Desktop to call my workflows" → MCP
"I want my agent to talk to another agent" → A2A
"I want a chat UI that streams responses" → AG-UI
"I want my agent to render forms, cards, dashboards" → A2UI
"I want all of the above" → All four (see Section 6.1)
3. A2A (Agent-to-Agent) Protocol¶
3.1 What is A2A¶
The Agent-to-Agent (A2A) protocol is an open standard initiated by Google that enables AI agents to discover, communicate with, and delegate tasks to each other. It defines:
- Agent Cards: Machine-readable descriptions of an agent's capabilities, published at
/.well-known/agent-card.json - Task Lifecycle: Agents send messages, receive status updates (
submitted->working->completed/failed/canceled), and stream artifact chunks - Transport: JSON-RPC 2.0 over HTTP, with support for streaming via server-sent events
Helix uses the official Go SDK:
This provides a2alib (protocol types), a2asrv (server framework), and a2aclient (client SDK).
3.2 A2A Server - Exposing Agents¶
The A2A Server component starts a JSON-RPC server that exposes your Helix agents and workflows as A2A-compliant agents. External agents can discover your capabilities via the Agent Card and send tasks.
Quick Start - YAML Config¶
The simplest way to expose agents via A2A:
helix.yaml
helix:
a2a:
servers:
- enabled: true
addr: "0.0.0.0:9000"
name: "support-agent"
description: "Customer support AI agent"
version: "1.0.0"
workflows:
- id: handle-support
description: "Handle customer support queries with AI"
trigger:
uri: direct://handle-support
steps:
- target: anthropic://claude-sonnet?maxTokens=1024
- log:
message: "Support response: ${body}"
Run it:
The agent is now discoverable at http://localhost:9000/.well-known/agent-card.json and accepts tasks at http://localhost:9000/invoke.
Quick Start - Workflow-Based¶
Define the A2A server as a workflow consumer:
workflows:
- id: a2a-bridge
trigger:
uri: a2a-server://0.0.0.0:9000?name=my-agent&description=My+AI+Agent&version=1.0.0
steps:
- target: anthropic://claude-sonnet?maxTokens=1024
This starts the A2A server as part of the workflow lifecycle. The workflow's processor pipeline handles all incoming A2A tasks.
Quick Start - Go DSL¶
package main
import (
"context"
"github.com/arvenza/helix/pkg/engine"
"github.com/arvenza/helix/pkg/exchange"
"github.com/arvenza/helix/pkg/component/a2a"
)
func main() {
h := engine.NewContext("a2a-demo")
// Register the A2A server and client components.
h.AddComponent("a2a-server", a2a.NewServer(h))
h.AddComponent("a2a", a2a.NewClient())
// Define a workflow that will become an A2A skill.
h.AddWorkflow(&engine.Workflow{
ID: "answer-question",
Description: "Answer general knowledge questions",
FromURI: "direct://answer-question",
Processors: []engine.Processor{
engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
question := ex.In().BodyAsString()
ex.In().SetBody("The answer to '" + question + "' is 42.")
return nil
}),
},
})
// Start the A2A server route.
h.AddWorkflow(&engine.Workflow{
ID: "a2a-server",
FromURI: "a2a-server://0.0.0.0:9000?name=qa-agent&description=Q%26A+Agent&version=1.0.0",
})
ctx := context.Background()
h.Start(ctx)
defer h.Stop(ctx)
select {} // Block forever
}
How Agent Discovery Works¶
When the A2A server starts, it automatically discovers capabilities from two sources:
-
Agent Component: If the
agentcomponent is registered, all agents returned byAgentIDs()are registered as A2A skills. Each agent's type (react, supervisor, plan-execute, etc.) is included as a tag. -
Invocable Routes: All routes with
direct://orseda://from-URIs are registered as skills. The workflow'sDescriptionfield becomes the skill description.
If no agents or invocable workflows are found, a default skill is created from the server's name and description.
Discovery priority:
1. Agent component agents → skill per agent
2. direct:// routes → skill per workflow
3. seda:// routes → skill per workflow
4. (fallback) → single default skill
Agent Card¶
The Agent Card is served at /.well-known/agent-card.json:
{
"name": "support-agent",
"description": "Customer support AI agent",
"url": "http://0.0.0.0:9000/invoke",
"version": "1.0.0",
"preferredTransport": "jsonrpc",
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"capabilities": {
"streaming": true
},
"skills": [
{
"id": "handle-support",
"name": "handle-support",
"description": "Handle customer support queries with AI",
"tags": ["route"]
}
]
}
External agents and agent discovery systems use this card to understand what your agent can do.
Streaming Support¶
The A2A server supports two streaming body types from the processor pipeline:
| Body Type | Behavior |
|---|---|
*llm.StreamReader |
LLM chunks streamed as A2A artifact update events |
<-chan string |
String channel streamed as A2A artifact update events |
| Any other type | Sent as a single final message |
Streaming event flow:
1. StatusUpdate(working) ← Agent starts processing
2. ArtifactEvent(first chunk) ← First artifact created
3. ArtifactUpdateEvent(chunk N) ← Subsequent chunks appended
4. ArtifactUpdateEvent(lastChunk) ← Final chunk marker
5. Message(full text) ← Complete response message
Task invocation flow:
- External agent sends a message via JSON-RPC
- Server creates an
ExchangewithInOutpattern - User message text is set as the exchange body
- Headers are set:
HelixA2ATaskID,HelixA2AContextID,HelixStream=true - Exchange runs through the processor pipeline (including interceptors)
- Result is streamed back as A2A events or returned as a final message
- On failure, a
TaskStateFailedstatus update is sent with the error message
3.3 A2A Client - Calling External Agents¶
The A2A Client component lets your Helix workflows send messages to external A2A agents. This enables agent delegation, multi-agent orchestration, and cross-system AI collaboration.
URI Format¶
| Part | Description |
|---|---|
server-address:port |
The A2A server's address (http:// prefix is added automatically) |
The client connects to http://server-address:port/invoke using JSON-RPC transport.
YAML Example¶
workflows:
- id: delegate-research
trigger:
uri: direct://research
steps:
# Set the question as the body
- transform:
starlang: |
payload.get("question", "What is the meaning of life?")
# Call the external A2A research agent
- target: a2a://research-agent.internal:9000
# Log the response
- log:
message: "Research result: ${body}"
Go DSL Example¶
h.AddWorkflow(&engine.Workflow{
ID: "call-agent",
FromURI: "direct://delegate",
ToURIs: []string{"a2a://expert-agent:9000"},
})
Streaming Mode¶
Set the HelixStream header to "true" to receive a streaming response:
workflows:
- id: stream-from-agent
trigger:
uri: direct://stream
steps:
- set-header:
name: HelixStream
constant: "true"
- target: a2a://streaming-agent:9000
# Body is now a <-chan string that yields chunks
When streaming is enabled:
- The client calls SendStreamingMessage instead of SendMessage
- A chan string is set as the exchange body
- Chunks arrive from artifact update events and status messages
- The channel is closed when the stream ends or an error occurs
Non-Streaming Response¶
Without the HelixStream header, the client uses SendMessage and waits for the complete response:
- If the result is a
Message, the text parts are extracted and set as the body - If the result is a
Task, artifacts are extracted first, then status message, then last history message - The
HelixA2ATaskIDandHelixA2ATaskStateheaders are set from the task result
Lazy Connection¶
The A2A client uses sync.Once for lazy initialization. The connection to the external agent is established on the first message, not at route startup. This means:
- Routes start instantly even if the target agent is not yet available
- Connection errors surface at first use, not at boot
- This matches the lazy-init pattern used by other Helix components (MCP, gRPC, Kafka)
Calling A2A Agents as Agent Tools (type: a2a)¶
Beyond using A2A in workflows with target: a2a://..., agents can call external A2A agents as tools during their reasoning loop. This is the recommended way to delegate work to remote agents from within an AI agent.
agents:
- id: coordinator
type: supervisor
trigger: http-server://0.0.0.0:8080/coordinate
model:
provider: openai
model: gpt-4o
system_prompt: |
You coordinate research and analysis tasks.
Use the research_agent for gathering information.
Use the analyst_agent for data analysis.
tools:
- name: research_agent
type: a2a
description: "Delegate research tasks to the research agent"
a2a_server:
url: "http://research-agent.internal:9000"
auth:
type: bearer
token: "${env:A2A_RESEARCH_TOKEN}"
- name: analyst_agent
type: a2a
description: "Send data analysis requests to the analyst agent"
a2a_server:
url: "http://analyst-agent.internal:9000"
auth:
type: oauth2
token_url: "https://auth.example.com/token"
client_id: "${env:OAUTH_CLIENT_ID}"
client_secret: "${env:OAUTH_CLIENT_SECRET}"
scopes: ["agent:invoke"]
How it works:
- The supervisor agent decides which tool to call based on its reasoning
- The agent sends the tool arguments as a message to the remote A2A agent
- The remote agent processes the request and returns a response
- The response becomes the tool result, and the supervisor continues reasoning
Auth types supported: bearer, api-key, oauth2, mtls. See the Security Guide for details.
When to use type: a2a vs sub_agents:
| Scenario | Mechanism |
|---|---|
| Agents in the same flow file | sub_agents: [agent-id] |
| Agents on remote servers (A2A protocol) | type: a2a tool with a2a_server.url |
3.4 Configuration Reference¶
helix.yaml - A2A Server Config¶
helix:
a2a:
servers:
- enabled: true # Enable this A2A server instance
addr: "0.0.0.0:9000" # Listen address (default port: 9000)
name: "my-agent" # Agent name in the Agent Card
description: "My AI agent" # Agent description in the Agent Card
version: "1.0.0" # Agent version in the Agent Card
URI Parameters - A2A Server¶
| Parameter | Default | Description |
|---|---|---|
name |
helix-a2a |
Agent name reported in the Agent Card |
description |
Helix AI Agent |
Agent description in the Agent Card |
version |
1.0.0 |
Agent version in the Agent Card |
URI Parameters - A2A Client¶
No additional parameters. The client always uses JSON-RPC transport to http://host:port/invoke.
3.5 Exchange Headers¶
Headers set during A2A interactions:
| Header | Direction | Description |
|---|---|---|
HelixA2ATaskID |
In/Out | A2A task ID |
HelixA2AContextID |
In | A2A context ID (server-side) |
HelixA2ATaskState |
Out | Task state after non-streaming call (client-side) |
HelixStream |
In | Set to "true" to enable streaming mode |
Content-Type |
Out | Set to text/event-stream in streaming mode |
4. AG-UI (Agent-User Interaction) Protocol¶
4.1 What is AG-UI¶
The AG-UI (Agent-User Interaction) protocol is a standard for streaming agent responses to chat-style frontend applications. It defines a set of SSE events that allow frontends to render agent messages progressively, similar to how ChatGPT streams responses.
AG-UI is the protocol used by CopilotKit and other agent UI frameworks. By implementing AG-UI, your Helix agents become compatible with any CopilotKit-powered frontend out of the box.
Helix uses the official community Go SDK:
This provides event types (events package), message types (types package), and SSE encoding (sse package).
4.2 AG-UI Server¶
The AG-UI server accepts HTTP POST requests containing RunAgentInput JSON and streams back AG-UI events as Server-Sent Events.
URI Format¶
| Part | Description |
|---|---|
host:port |
Listen address (default port: 4100) |
/path |
HTTP path for the endpoint (default: /) |
name |
Agent name (default: helix-agui) |
HTTP POST Endpoint¶
The server listens for POST requests at the configured path. The request body must be a RunAgentInput JSON object:
{
"threadId": "thread-123",
"runId": "run-456",
"messages": [
{"role": "user", "content": "What is the weather in Paris?"}
],
"tools": [],
"state": {}
}
The server extracts the last user message from the messages array and passes it to the processor pipeline.
SSE Event Flow¶
The server streams the following events in order:
Client AG-UI Server
│ │
│ POST /agent {RunAgentInput} │
│─────────────────────────────────────►│
│ │
│ SSE: RUN_STARTED │
│◄─────────────────────────────────────│
│ │
│ SSE: TEXT_MESSAGE_START │
│◄─────────────────────────────────────│
│ │
│ SSE: TEXT_MESSAGE_CONTENT (chunk 1) │
│◄─────────────────────────────────────│
│ │
│ SSE: TEXT_MESSAGE_CONTENT (chunk 2) │
│◄─────────────────────────────────────│
│ │
│ SSE: TEXT_MESSAGE_CONTENT (chunk N) │
│◄─────────────────────────────────────│
│ │
│ SSE: TEXT_MESSAGE_END │
│◄─────────────────────────────────────│
│ │
│ SSE: RUN_FINISHED │
│◄─────────────────────────────────────│
Each event is a JSON object with a type field:
{"type": "RUN_STARTED", "threadId": "thread-123", "runId": "run-456"}
{"type": "TEXT_MESSAGE_START", "messageId": "msg-789", "role": "assistant"}
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-789", "delta": "The weather"}
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-789", "delta": " in Paris is"}
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-789", "delta": " sunny today."}
{"type": "TEXT_MESSAGE_END", "messageId": "msg-789"}
{"type": "RUN_FINISHED", "threadId": "thread-123", "runId": "run-456"}
On error, a RUN_ERROR event is sent instead of the message events:
Streaming Support¶
The AG-UI server supports three response modes:
| Body Type | Behavior |
|---|---|
*llm.StreamReader |
LLM chunks streamed as individual TEXT_MESSAGE_CONTENT events |
<-chan string or chan string |
Channel values streamed as individual TEXT_MESSAGE_CONTENT events |
| Any other type | Body converted to string, sent as single TEXT_MESSAGE_CONTENT event |
The HelixStream header is automatically set to "true" on the exchange, so agent and LLM components use streaming mode.
CORS Headers¶
The server sets the following CORS headers on every response:
This allows any frontend application to connect to the AG-UI server.
YAML Config¶
helix.yaml
helix:
agui:
servers:
- enabled: true
addr: "0.0.0.0:4100"
path: "/agent"
name: "my-chatbot"
workflows:
- id: chat
description: "AI chat endpoint"
trigger:
uri: direct://chat
steps:
- target: anthropic://claude-sonnet?maxTokens=2048
Workflow-Based¶
workflows:
- id: agui-server
trigger:
uri: agui://0.0.0.0:4100/agent?name=my-chatbot
steps:
- target: anthropic://claude-sonnet?maxTokens=2048
Go DSL¶
package main
import (
"context"
"github.com/arvenza/helix/pkg/engine"
"github.com/arvenza/helix/pkg/exchange"
"github.com/arvenza/helix/pkg/component/agui"
)
func main() {
h := engine.NewContext("agui-demo")
h.AddComponent("agui", agui.NewServer(h))
h.AddWorkflow(&engine.Workflow{
ID: "chat-server",
FromURI: "agui://0.0.0.0:4100/agent?name=my-chatbot",
Processors: []engine.Processor{
engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
question := ex.In().BodyAsString()
ex.In().SetBody("You asked: " + question + ". Here is my answer...")
return nil
}),
},
})
ctx := context.Background()
h.Start(ctx)
defer h.Stop(ctx)
select {}
}
4.3 Configuration Reference¶
helix.yaml - AG-UI Server Config¶
helix:
agui:
servers:
- enabled: true # Enable this AG-UI server instance
addr: "0.0.0.0:4100" # Listen address (default port: 4100)
path: "/agent" # HTTP path for the endpoint
name: "my-chatbot" # Agent name (for logging/identification)
URI Parameters¶
| Parameter | Default | Description |
|---|---|---|
name |
helix-agui |
Agent name for logging |
4.4 Exchange Headers¶
Headers set during AG-UI interactions:
| Header | Direction | Description |
|---|---|---|
HelixStream |
In | Always set to "true" for streaming LLM responses |
HelixAGUIThreadID |
In | AG-UI thread ID from the request |
HelixAGUIRunID |
In | AG-UI run ID from the request |
HelixAGUIState |
In | JSON-encoded state from RunAgentInput.State |
HelixAGUITools |
In | JSON-encoded tools from RunAgentInput.Tools |
4.5 Working with CopilotKit¶
CopilotKit is a popular React framework for building AI-powered UIs. It natively speaks the AG-UI protocol, making Helix a natural backend.
Frontend Setup (React)¶
import { CopilotKit } from "@copilotkit/react-core";
import { CopilotChat } from "@copilotkit/react-ui";
function App() {
return (
<CopilotKit runtimeUrl="http://localhost:4100/agent">
<CopilotChat />
</CopilotKit>
);
}
Backend (helix.yaml)¶
workflows:
- id: copilot-backend
trigger:
uri: agui://0.0.0.0:4100/agent?name=copilot-agent
steps:
- target: anthropic://claude-sonnet?maxTokens=4096&systemPrompt=You+are+a+helpful+assistant
That is all that is required. CopilotKit sends RunAgentInput via POST, Helix processes it through the workflow pipeline, and streams back AG-UI events that CopilotKit renders as a chat interface.
Passing Frontend State¶
CopilotKit can send application state with each request. Access it from the exchange:
workflows:
- id: stateful-agent
trigger:
uri: agui://0.0.0.0:4100/agent?name=stateful
steps:
- process:
starlang: |
state_json = headers.get("HelixAGUIState", "{}")
state = read(state_json, "application/json")
current_page = state.get("currentPage", "home")
"User is on page: " ++ current_page ++ ". Question: " ++ payload
- target: anthropic://claude-sonnet?maxTokens=2048
5. A2UI (Agent to UI) Protocol¶
5.1 What is A2UI¶
The A2UI (Agent to UI) protocol enables agents to generate rich, declarative UIs that client applications render with native widgets. Unlike AG-UI, which streams text messages for chat interfaces, A2UI streams structured component trees - columns, rows, cards, text fields, buttons, and more.
Think of A2UI as the difference between a chatbot (AG-UI) and a dynamic dashboard (A2UI). With A2UI, an agent can render a form, display a data table, show a dashboard with live updates, or build any UI layout - all declaratively, without writing frontend code.
Helix uses:
This provides message types, component helpers (Column, Row, Card, Text, Button, etc.), and Surface builder utilities.
5.2 A2UI Server¶
The A2UI server exposes two HTTP endpoints at the configured path:
- GET - Initiates an SSE stream of A2UI JSONL messages (the agent's UI output)
- POST - Receives
userActionevents from the UI client (button clicks, form submissions, etc.)
URI Format¶
| Part | Description |
|---|---|
host:port |
Listen address (default port: 4200) |
/path |
HTTP path for the endpoint (default: /) |
name |
Agent name (default: helix-a2ui) |
GET Endpoint - SSE Stream¶
The GET endpoint accepts query parameters and streams A2UI messages as SSE:
| Query Parameter | Default | Description |
|---|---|---|
message |
(required) | The user's prompt/request text |
surface |
main |
The surface ID for the UI session |
The response is text/event-stream with JSONL A2UI messages:
data: {"beginRendering":{"surfaceId":"main","root":"root"}}
data: {"updateComponents":{"surfaceId":"main","components":[...]}}
POST Endpoint - User Events¶
The POST endpoint accepts client-to-server events (button clicks, form input, etc.):
{
"event": {
"surfaceId": "main",
"componentId": "submit-btn",
"type": "action",
"data": {"value": "clicked"}
}
}
The server creates an exchange with the event data and runs it through the processor pipeline. The response is returned as JSON.
A2UI Message Types¶
The protocol defines four message types:
| Message Type | Purpose |
|---|---|
beginRendering |
Starts a new surface rendering session. Specifies the surface ID and root component ID. |
updateComponents |
Sends a list of components to render or update. Components reference each other by ID to form a tree. |
dataModelUpdate |
Updates data bindings in the component tree without re-sending components. |
deleteSurface |
Removes a surface from the UI. |
Typical message flow:
Server Client
│ │
│ beginRendering(surfaceId, root) │
│───────────────────────────────────►│ Client creates surface
│ │
│ updateComponents([Column, Card, │
│ Text, Button, ...]) │
│───────────────────────────────────►│ Client renders components
│ │
│ updateComponents([Text(updated)]) │
│───────────────────────────────────►│ Client updates text
│ │
│ POST {event: click} │
│◄───────────────────────────────────│ User clicks button
│ │
│ updateComponents([Text(result)]) │
│───────────────────────────────────►│ Agent responds with new UI
Three Response Modes¶
The A2UI server handles three types of processor output:
1. Plain text (auto-wrapped)
If the processor sets a string body, it is automatically wrapped in a simple A2UI surface:
This generates:
{"beginRendering": {"surfaceId": "main", "root": "root"}}
{"updateComponents": {"surfaceId": "main", "components": [
{"type": "column", "id": "root", "children": ["response-card"]},
{"type": "card", "id": "response-card", "children": ["response-text"]},
{"type": "text", "id": "response-text", "content": "Hello, World!"}
]}}
2. Surface builder
Return an *a2ui.Surface for full control over the component tree:
surface := a2ui.NewSurface("main")
surface.AddAll(
a2ui.Column("root", "greeting"),
a2ui.TextWithHint("greeting", "Hello from A2UI!", a2ui.UsageHintH1),
)
ex.In().SetBody(surface)
3. Direct Messages
Return []a2ui.Message for complete control over the message stream:
msgs := []a2ui.Message{
{BeginRendering: &a2ui.BeginRendering{SurfaceID: "main", Root: "root"}},
{UpdateComponents: &a2ui.UpdateComponents{
SurfaceID: "main",
Components: []any{a2ui.TextStatic("root", "Direct control")},
}},
}
ex.In().SetBody(msgs)
Streaming: Progressive Surface Updates¶
When the processor pipeline produces a streaming body (*llm.StreamReader or <-chan string), the A2UI server sends progressive surface updates:
- First, a
beginRenderingmessage is sent - As each chunk arrives, an
updateComponentsmessage is sent with the accumulated text - The component tree is rebuilt with the full text on each update, so clients always see the latest state
Chunk 1: "Hello"
→ updateComponents: Column > Card > Text("Hello")
Chunk 2: " World"
→ updateComponents: Column > Card > Text("Hello World")
Chunk 3: "!"
→ updateComponents: Column > Card > Text("Hello World!")
YAML Config¶
helix.yaml
helix:
a2ui:
servers:
- enabled: true
addr: "0.0.0.0:4200"
path: "/surface"
name: "my-ui-agent"
workflows:
- id: dashboard
description: "AI-powered dashboard"
trigger:
uri: direct://dashboard
steps:
- process:
starlang: |
"Dashboard data for: " ++ payload
Workflow-Based¶
workflows:
- id: a2ui-server
trigger:
uri: a2ui://0.0.0.0:4200/surface?name=my-ui-agent
steps:
- process:
starlang: |
"Response to: " ++ payload
Go DSL¶
package main
import (
"context"
a2uilib "github.com/burka/a2ui-go"
"github.com/arvenza/helix/pkg/engine"
"github.com/arvenza/helix/pkg/exchange"
"github.com/arvenza/helix/pkg/component/a2ui"
)
func main() {
h := engine.NewContext("a2ui-demo")
h.AddComponent("a2ui", a2ui.NewServer(h))
h.AddWorkflow(&engine.Workflow{
ID: "dashboard",
FromURI: "a2ui://0.0.0.0:4200/surface?name=my-dashboard",
Processors: []engine.Processor{
engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
surface := a2uilib.NewSurface("main")
surface.AddAll(
a2uilib.Column("root", "header", "content"),
a2uilib.TextWithHint("header", "Sales Dashboard", a2uilib.UsageHintH1),
a2uilib.Card("content", "metric-row"),
a2uilib.Row("metric-row", "revenue", "orders", "customers"),
a2uilib.TextStatic("revenue", "Revenue: $42,000"),
a2uilib.TextStatic("orders", "Orders: 156"),
a2uilib.TextStatic("customers", "Customers: 89"),
)
ex.In().SetBody(surface)
return nil
}),
},
})
ctx := context.Background()
h.Start(ctx)
defer h.Stop(ctx)
select {}
}
5.3 Configuration Reference¶
helix.yaml - A2UI Server Config¶
helix:
a2ui:
servers:
- enabled: true # Enable this A2UI server instance
addr: "0.0.0.0:4200" # Listen address (default port: 4200)
path: "/surface" # HTTP path for the endpoint
name: "my-ui-agent" # Agent name (for logging/identification)
URI Parameters¶
| Parameter | Default | Description |
|---|---|---|
name |
helix-a2ui |
Agent name for logging |
5.4 Exchange Headers¶
Headers set during A2UI interactions:
| Header | Direction | Description |
|---|---|---|
HelixStream |
In | Set to "true" for streaming mode (GET requests) |
HelixA2UISurfaceID |
In | Surface ID from query param or event |
HelixA2UIEventType |
In | Event type (POST requests only) |
HelixA2UIComponentID |
In | Component that triggered the event (POST requests only) |
5.5 Building Rich UIs¶
The a2ui-go SDK provides component helper functions for building UI trees. Each component is identified by a unique ID and references its children by their IDs.
Component Helpers¶
| Helper | Description |
|---|---|
Column(id, children...) |
Vertical layout container |
Row(id, children...) |
Horizontal layout container |
Card(id, children...) |
Card container with visual border/shadow |
TextStatic(id, text) |
Static text display |
TextWithHint(id, text, hint) |
Text with usage hint (H1, H2, body, etc.) |
Button(id, label) |
Clickable button |
TextField(id, placeholder) |
Text input field |
Icon(id, iconType) |
Icon display |
Component Tree Structure¶
Components form a tree via parent-child ID references:
Column("root", "header", "body") ← root has two children
TextWithHint("header", "Title", H1) ← header is a text node
Card("body", "form-row") ← body is a card with one child
Row("form-row", "input", "submit") ← form-row has two children
TextField("input", "Enter name") ← input field
Button("submit", "Submit") ← submit button
Data Binding¶
Components can bind to data model paths. When the agent sends a dataModelUpdate message, bound components automatically update:
// Component with data binding
text := a2ui.TextWithBinding("metric", "revenue.total")
// Later, update the data model
dataUpdate := a2ui.Message{
DataModelUpdate: &a2ui.DataModelUpdate{
SurfaceID: "main",
Path: "revenue.total",
Value: "$42,000",
},
}
Event Handling¶
When a user interacts with a component (clicks a button, submits a form), the client sends a POST request to the A2UI server:
{
"event": {
"surfaceId": "main",
"componentId": "submit-btn",
"type": "action",
"data": {"formData": {"name": "Alice", "email": "alice@example.com"}}
}
}
The exchange body is set to the Event object, and the processor can inspect it:
workflows:
- id: handle-form
trigger:
uri: a2ui://0.0.0.0:4200/surface?name=form-agent
steps:
- choice:
when:
- starlang: |
headers.get("HelixA2UIEventType", "") == "action"
steps:
- process:
starlang: |
event = payload
form_data = event.get("data", {}).get("formData", {})
name = form_data.get("name", "Unknown")
{"status": "ok", "greeting": "Hello, " ++ name}
otherwise:
steps:
# Initial render (GET request)
- process:
starlang: |
"Welcome! Please fill out the form."
6. Complete Examples¶
6.1 Agent Exposed via All Four Protocols Simultaneously¶
A single agent exposed via MCP, A2A, AG-UI, and A2UI:
# helix.yaml — Multi-protocol agent
helix:
mcp:
servers:
- enabled: true
transport: http
addr: "0.0.0.0:3000"
name: "qa-tools"
tools:
ask-question:
description: "Ask a knowledge question"
input_schema: |
{"type":"object","properties":{"question":{"type":"string"}},"required":["question"]}
a2a:
servers:
- enabled: true
addr: "0.0.0.0:9000"
name: "qa-agent"
description: "Knowledge Q&A Agent"
version: "1.0.0"
workflows:
# The core route — shared by all protocols
- id: ask-question
description: "Answer a knowledge question using AI"
trigger:
uri: direct://ask-question
steps:
- transform:
starlang: |
question = payload
if type(payload) == "dict":
question = payload.get("question", str(payload))
question
- target: anthropic://claude-sonnet?maxTokens=2048&systemPrompt=You+are+a+helpful+knowledge+assistant
# AG-UI chat endpoint
- id: chat-ui
trigger:
uri: agui://0.0.0.0:4100/agent?name=qa-chatbot
steps:
- target: direct://ask-question
# A2UI rich endpoint
- id: rich-ui
trigger:
uri: a2ui://0.0.0.0:4200/surface?name=qa-dashboard
steps:
- target: direct://ask-question
Now the same agent is reachable four ways:
Claude Desktop → http://localhost:3000/mcp (MCP)
External agents → http://localhost:9000/invoke (A2A)
Chat frontend → POST http://localhost:4100/agent (AG-UI)
Rich UI client → GET http://localhost:4200/surface (A2UI)
6.2 A2A Agent Calling Another A2A Agent¶
Multi-agent delegation where a coordinator delegates to specialist agents:
# coordinator.yaml
helix:
a2a:
servers:
- enabled: true
addr: "0.0.0.0:9000"
name: "coordinator"
description: "Coordinator agent that delegates to specialists"
workflows:
- id: handle-request
trigger:
uri: direct://handle-request
steps:
# Classify the request
- transform:
starlang: |
question = payload
if "code" in question.lower() or "program" in question.lower():
"coding"
elif "math" in question.lower() or "calculate" in question.lower():
"math"
else:
"general"
# Route to the appropriate specialist
- choice:
when:
- starlang: |
payload == "coding"
steps:
- transform:
starlang: |
properties.get("original_question", "")
- target: a2a://coding-agent:9001
- starlang: |
payload == "math"
steps:
- transform:
starlang: |
properties.get("original_question", "")
- target: a2a://math-agent:9002
otherwise:
steps:
- transform:
starlang: |
properties.get("original_question", "")
- target: anthropic://claude-sonnet?maxTokens=2048
- log:
message: "Agent response: ${body}"
6.3 AG-UI Chatbot with Streaming¶
A full-featured chatbot that streams responses with context awareness:
# chatbot.yaml
workflows:
- id: chatbot
trigger:
uri: agui://0.0.0.0:4100/chat?name=smart-chatbot
steps:
# Extract state from the AG-UI request
- process:
starlang: |
state_json = headers.get("HelixAGUIState", "{}")
state = read(state_json, "application/json")
context = state.get("context", "")
# Build a prompt with context
prompt = payload
if context:
prompt = "Context: " ++ context ++ "\n\nUser: " ++ payload
prompt
# Call the LLM with streaming
- target: anthropic://claude-sonnet?maxTokens=4096&systemPrompt=You+are+a+helpful+assistant
# The LLM body is a StreamReader, which AG-UI automatically
# streams as TEXT_MESSAGE_CONTENT events
Frontend (React with CopilotKit):
import { CopilotKit } from "@copilotkit/react-core";
import { CopilotChat } from "@copilotkit/react-ui";
function App() {
const [context, setContext] = useState("User is viewing the billing page");
return (
<CopilotKit
runtimeUrl="http://localhost:4100/chat"
properties={{ context }}
>
<CopilotChat
labels={{ title: "Smart Assistant", initial: "How can I help you?" }}
/>
</CopilotKit>
);
}
6.4 A2UI Form-Based Agent Interface¶
An agent that renders a form and processes submissions:
# form-agent.yaml
workflows:
- id: form-ui
trigger:
uri: a2ui://0.0.0.0:4200/surface?name=ticket-form
steps:
- choice:
when:
# Handle form submission (POST event)
- starlang: |
headers.get("HelixA2UIEventType", "") == "action"
steps:
- process:
starlang: |
event = payload
form_data = event.get("data", {})
title = form_data.get("title", "")
description = form_data.get("description", "")
priority = form_data.get("priority", "medium")
# Process the ticket
{
"status": "created",
"ticket_id": "TKT-" ++ str(int(time.time())),
"title": title,
"priority": priority
}
otherwise:
# Initial render: show the form (GET request)
steps:
- process:
starlang: |
# Return plain text — A2UI auto-wraps it
"Create a new support ticket. Fill out the form below."
6.5 A2UI + A2A: Agent with Rich UI that Delegates to Other Agents¶
An A2UI dashboard agent that calls specialist A2A agents for different panels:
# dashboard.yaml
workflows:
- id: dashboard
trigger:
uri: a2ui://0.0.0.0:4200/dashboard?name=ops-dashboard
steps:
# Fetch data from specialist agents in parallel
- multicast:
strategy: parallel
steps:
- target: a2a://metrics-agent:9001
- target: a2a://alerts-agent:9002
# Aggregate results
- aggregate:
strategy: collect
completion:
size: 2
timeout: 10s
# Build dashboard surface from agent responses
- process:
starlang: |
results = payload
metrics = results[0] if sizeOf(results) > 0 else "No metrics"
alerts = results[1] if sizeOf(results) > 1 else "No alerts"
# Return as plain text; A2UI auto-wraps
"Metrics: " ++ str(metrics) ++ "\nAlerts: " ++ str(alerts)
7. Cookbook¶
Recipe 1: Expose Existing Routes via A2A¶
If you have existing Helix workflows, adding A2A exposure is one line:
# Existing workflows
workflows:
- id: process-order
description: "Process a customer order"
trigger:
uri: direct://process-order
steps:
- target: postgres://db:5432/orders?operation=insert&table=orders
- transform:
starlang: write({"status": "created", "orderId": payload["id"]}, "application/json")
- id: lookup-customer
description: "Look up customer by ID"
trigger:
uri: direct://lookup-customer
steps:
- target: postgres://db:5432/crm?operation=query
- transform:
starlang: write(payload, "application/json")
# Add A2A — one block, all direct:// workflows become skills automatically
helix:
a2a:
servers:
- enabled: true
addr: "0.0.0.0:9000"
name: "order-system"
description: "Order processing and customer management"
The A2A Agent Card will automatically list process-order and lookup-customer as skills.
Recipe 2: CopilotKit Frontend with AG-UI Backend¶
Backend (helix.yaml):
workflows:
- id: copilot
trigger:
uri: agui://0.0.0.0:4100/api/copilot?name=copilot-agent
steps:
# Use the tools from the frontend if provided
- process:
starlang: |
tools_json = headers.get("HelixAGUITools", "[]")
tools = read(tools_json, "application/json")
if sizeOf(tools) > 0:
# Include tool descriptions in the system prompt
tool_desc = "\n".join([t.get("name", "") ++ ": " ++ t.get("description", "") for t in tools])
properties["tool_context"] = tool_desc
- target: anthropic://claude-sonnet?maxTokens=4096
Frontend (React):
import { CopilotKit } from "@copilotkit/react-core";
import { CopilotChat } from "@copilotkit/react-ui";
import { useCopilotAction } from "@copilotkit/react-core";
function App() {
// Register client-side tools that the agent can call
useCopilotAction({
name: "showNotification",
description: "Show a notification to the user",
parameters: [
{ name: "message", type: "string", description: "Notification text" },
],
handler: ({ message }) => {
alert(message);
},
});
return (
<CopilotKit runtimeUrl="http://localhost:4100/api/copilot">
<CopilotChat />
</CopilotKit>
);
}
Recipe 3: A2UI Dashboard with Live Data¶
A dashboard that refreshes metrics from a database:
workflows:
- id: live-dashboard
trigger:
uri: a2ui://0.0.0.0:4200/metrics?name=metrics-dashboard
steps:
- process:
starlang: |
query = payload
if not query:
query = "overview"
# Fetch metrics from the database
- target: "postgres://db:5432/analytics?operation=query&query=SELECT+COUNT(*)+as+total_orders,SUM(amount)+as+revenue,AVG(amount)+as+avg_order+FROM+orders+WHERE+created_at>now()-interval+'24+hours'"
- transform:
starlang: |
row = payload[0] if sizeOf(payload) > 0 else {}
total = str(row.get("total_orders", 0))
revenue = "$" ++ str(row.get("revenue", 0))
avg = "$" ++ str(row.get("avg_order", 0))
# Return formatted text — A2UI auto-wraps in a card
"24h Metrics\nTotal Orders: " ++ total ++ "\nRevenue: " ++ revenue ++ "\nAvg Order: " ++ avg
Client request:
Recipe 4: Multi-Protocol Agent Gateway¶
A gateway that routes incoming requests from any protocol to the appropriate backend:
# gateway.yaml
helix:
mcp:
servers:
- enabled: true
transport: http
addr: "0.0.0.0:3000"
name: "gateway-tools"
a2a:
servers:
- enabled: true
addr: "0.0.0.0:9000"
name: "gateway-agent"
description: "Multi-service gateway agent"
workflows:
# Core service workflows
- id: search
description: "Search across all data sources"
trigger:
uri: direct://search
steps:
- multicast:
strategy: parallel
steps:
- target: elasticsearch://search-cluster:9200?index=documents
- target: postgres://db:5432/app?operation=query
- aggregate:
strategy: collect
completion:
size: 2
timeout: 5s
- transform:
starlang: write({"results": payload}, "application/json")
- id: summarize
description: "Summarize text using AI"
trigger:
uri: direct://summarize
steps:
- target: anthropic://claude-sonnet?maxTokens=1024&systemPrompt=Summarize+the+following+text+concisely
# AG-UI chat gateway
- id: chat-gateway
trigger:
uri: agui://0.0.0.0:4100/chat?name=gateway-chat
steps:
- target: anthropic://claude-sonnet?maxTokens=4096
# A2UI dashboard gateway
- id: dashboard-gateway
trigger:
uri: a2ui://0.0.0.0:4200/dashboard?name=gateway-ui
steps:
- target: direct://search
- transform:
starlang: |
results = read(payload, "application/json").get("results", [])
"Found " ++ str(sizeOf(results)) ++ " results"
This exposes the agent at four ports:
| Port | Protocol | Use Case |
|---|---|---|
| 3000 | MCP | Claude Desktop calls search and summarize tools |
| 9000 | A2A | Other agents delegate search and summarize tasks |
| 4100 | AG-UI | Chat frontend streams responses |
| 4200 | A2UI | Dashboard frontend renders search results |
8. Troubleshooting¶
A2A server starts but Agent Card has no skills¶
- Ensure workflows with
direct://orseda://from-URIs are defined before the A2A server starts - If using the agent component, verify agents are registered before the A2A server route
- Check the Agent Card directly:
curl http://localhost:9000/.well-known/agent-card.json - A default skill is always added if no agents or workflows are found
A2A client returns empty response¶
- Verify the target A2A server is running and accessible
- Check that the exchange body is not empty before calling the A2A client (empty body returns an error)
- For streaming mode, ensure you are reading from the
<-chan stringbody before it closes - Check Helix logs for
a2a: client init failedora2a: send failederrors
A2A streaming not working¶
- Confirm the
HelixStreamheader is set to"true"on the exchange - The target A2A server must have
streaming: truein its capabilities - Verify the client is using
SendStreamingMessage(check logs for artifact events) - If using the Helix A2A server, the processor pipeline must return a streaming body (
*llm.StreamReaderor<-chan string)
AG-UI returns 400 Bad Request¶
- The request must be a POST with valid
RunAgentInputJSON - At least one message with
role: "user"must be present in the messages array - GET requests return 405 (Method Not Allowed) - AG-UI only accepts POST
AG-UI events not rendering in CopilotKit¶
- Verify the
runtimeUrlin CopilotKit matches the AG-UI server address and path - Check that CORS headers are present (
Access-Control-Allow-Origin: *) - Inspect the SSE stream in browser DevTools (Network tab, filter by EventStream)
- Ensure the event types match the AG-UI spec:
RUN_STARTED,TEXT_MESSAGE_START,TEXT_MESSAGE_CONTENT,TEXT_MESSAGE_END,RUN_FINISHED
A2UI surface not rendering¶
- The GET request requires a
messagequery parameter:GET /surface?message=hello - Missing the
messageparameter returns 400 - Check SSE events for
beginRenderingfollowed byupdateComponents - Verify component IDs are consistent - child IDs must match parent references
A2UI POST events not reaching the processor¶
- POST body must be a valid
ClientMessageJSON with a non-nulleventfield - Missing
eventreturns 400 - The
HelixA2UIEventTypeheader is set fromevent.type- use it inchoicesteps
Common URI patterns¶
# A2A Server
a2a-server://0.0.0.0:9000 # Default settings
a2a-server://0.0.0.0:9000?name=my-agent # Custom name
a2a-server://0.0.0.0:9000?name=a&description=b&version=2.0 # All params
# A2A Client
a2a://agent-host:9000 # Call agent
# AG-UI Server
agui://0.0.0.0:4100/agent # Default
agui://0.0.0.0:4100/api/chat?name=my-chatbot # Custom path+name
# A2UI Server
a2ui://0.0.0.0:4200/surface # Default
a2ui://0.0.0.0:4200/dashboard?name=my-ui # Custom path+name
Port defaults¶
If no port is specified in the address:
| Component | Default Port |
|---|---|
| A2A Server | 9000 |
| AG-UI Server | 4100 |
| A2UI Server | 4200 |
Testing¶
Run the test suites for each protocol component:
# A2A tests (16 tests including streaming round-trip)
go test ./pkg/component/a2a/ -v
# AG-UI tests (12 tests including SSE streaming)
go test ./pkg/component/agui/ -v
# A2UI tests (15 tests including surface rendering and events)
go test ./pkg/component/a2ui/ -v
# All protocol tests with race detector
go test ./pkg/component/a2a/ ./pkg/component/agui/ ./pkg/component/a2ui/ ./pkg/component/mcp/ -race -v
WebSocket Protocol¶
Helix supports bidirectional WebSocket communication via the ws-server:// component:
workflows:
- id: ws-chat
trigger: { uri: ws-server://0.0.0.0:8200/ws }
steps:
- agent:
id: ws-agent
type: react
model: { provider: openai, model: gpt-4o-mini }
system_prompt: You are a WebSocket chat assistant.
Features: - RFC 6455 native Go implementation (no external library) - Request-response: client sends message, pipeline processes, response sent back - Text and binary frame support - Multiple concurrent clients per server - Multi-turn conversations (agent retains context per connection) - Connection lifecycle: clean connect/close/reconnect
Multi-Agent Multi-Protocol Architecture¶
Each agent can expose multiple protocol endpoints simultaneously:
agents:
- id: code-assistant
triggers:
- agui://0.0.0.0:4100/agent
- a2a-server://0.0.0.0:9100
- http-server://0.0.0.0:8100/api/chat
- id: math-agent
triggers:
- agui://0.0.0.0:4101/agent
- a2a-server://0.0.0.0:9101
- direct://math-agent
mcps:
- id: public-tools
addr: 0.0.0.0:3100
expose: [code-assistant, math-agent, summarizer]
api_keys: [mcp-key]
blocked_tools: [admin-tool]
workflows:
- id: ws-chat
trigger: { uri: ws-server://0.0.0.0:8200/ws }
steps:
- agent: { id: ws-agent, type: react, ... }
This creates 14+ protocol endpoints across AG-UI, A2A, MCP, WebSocket, and HTTP.
SynaptX Gateway Integration¶
When deployed behind SynaptX, all protocols gain enterprise security:
| Protocol | SynaptX Workflow | Security |
|---|---|---|
| A2A | /a2a/ |
Rate limit, audit |
| AG-UI | /agui/ |
Rate limit, session |
| MCP | /mcp/ |
allowed_tools, blocked_tools |
| AI Proxy | /ai/* |
Virtual keys, prompt guard, PII |
SynaptX adds virtual keys (per-agent budget/RPM), prompt injection guard (16 patterns), PII detection (SSN, credit cards), governance policies (ABAC/RBAC), and audit trail.