Exposing a Go Agent to external systems through the A2A protocol is the first step in building a distributed Agent network. But “exposing” is not the same as “open” — in production, exposing an Agent means dealing with authentication and authorization, access control, rate limiting and circuit breaking, service discovery, version management, and a whole range of other concerns.
This article will walk through how to expose Go Agents safely, reliably, and scalably—from basic configuration to production-grade hardening.
Basic Exposure: From Minimum Viable to Production-Ready
Minimal Exposure Configuration
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"google.golang.org/adk/a2a"
"google.golang.org/adk/agent"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create the Agent
weatherAgent, err := agent.New(agent.Config{
Name: "weather-agent",
Model: model,
Instruction: "You are a weather query expert that provides accurate weather information.",
})
if err != nil {
log.Fatalf("failed to create agent: %v", err)
}
// Create the A2A server
server, err := a2a.NewServer(ctx,
a2a.WithAgent(weatherAgent),
a2a.WithPort(8080),
a2a.WithAgentCard(a2a.AgentCard{
Name: "weather-agent",
Version: "1.0.0",
Description: "Weather query expert supporting cities worldwide",
URL: "http://localhost:8080/a2a",
Capabilities: a2a.Capabilities{
Streaming: true,
PushNotifications: false,
},
Skills: []a2a.Skill{
{
ID: "current-weather",
Name: "Current Weather Query",
Description: "Query the current weather conditions of a specified city",
InputModes: []string{"text"},
OutputModes: []string{"text"},
},
{
ID: "forecast",
Name: "Weather Forecast",
Description: "Query the 7-day weather forecast",
InputModes: []string{"text"},
OutputModes: []string{"text", "file"},
},
},
}),
)
if err != nil {
log.Fatalf("failed to create server: %v", err)
}
// Graceful shutdown handling
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.Println("shutting down server...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
}
cancel()
}()
log.Println("A2A server starting on :8080")
if err := server.Serve(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}
Exposed Endpoints
The A2A server automatically exposes the following standard endpoints:
| Endpoint | Method | Description |
|---|---|---|
/.well-known/agent.json | GET | Agent Card (capability description) |
/a2a | POST | Main entry point of the A2A protocol |
/a2a/tasks | POST | Create a task |
/a2a/tasks/{id} | GET | Get task status |
/a2a/tasks/{id}/cancel | POST | Cancel a task |
/health | GET | Health check |
Service Registration: Making Agents Discoverable
Static Registration
In small systems, you can maintain the Agent registry manually:
// registry.go
type StaticRegistry struct {
agents map[string]*a2a.AgentCard
mu sync.RWMutex
}
func NewStaticRegistry() *StaticRegistry {
return &StaticRegistry{
agents: make(map[string]*a2a.AgentCard),
}
}
func (r *StaticRegistry) Register(card *a2a.AgentCard) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.agents[card.Name]; exists {
return fmt.Errorf("agent %s already registered", card.Name)
}
// Validate the Agent Card
if err := validateAgentCard(card); err != nil {
return fmt.Errorf("invalid agent card: %w", err)
}
r.agents[card.Name] = card
log.Printf("agent registered: %s v%s at %s", card.Name, card.Version, card.URL)
return nil
}
func (r *StaticRegistry) Discover(skill string) ([]*a2a.AgentCard, error) {
r.mu.RLock()
defer r.mu.RUnlock()
var result []*a2a.AgentCard
for _, card := range r.agents {
for _, s := range card.Skills {
if s.ID == skill || s.Name == skill {
result = append(result, card)
break
}
}
}
return result, nil
}
func (r *StaticRegistry) Get(name string) (*a2a.AgentCard, error) {
r.mu.RLock()
defer r.mu.RUnlock()
card, exists := r.agents[name]
if !exists {
return nil, fmt.Errorf("agent %s not found", name)
}
return card, nil
}
func validateAgentCard(card *a2a.AgentCard) error {
if card.Name == "" {
return fmt.Errorf("name is required")
}
if card.URL == "" {
return fmt.Errorf("URL is required")
}
if len(card.Skills) == 0 {
return fmt.Errorf("at least one skill is required")
}
for i, skill := range card.Skills {
if skill.ID == "" {
return fmt.Errorf("skill[%d].id is required", i)
}
if skill.Name == "" {
return fmt.Errorf("skill[%d].name is required", i)
}
}
return nil
}
Dynamic Service Discovery (Consul)
In large distributed systems, use Consul or etcd for dynamic service discovery:
type ConsulRegistry struct {
client *api.Client
prefix string
}
func NewConsulRegistry(addr string) (*ConsulRegistry, error) {
config := api.DefaultConfig()
config.Address = addr
client, err := api.NewClient(config)
if err != nil {
return nil, err
}
return &ConsulRegistry{
client: client,
prefix: "a2a/agents",
}, nil
}
func (r *ConsulRegistry) Register(ctx context.Context, card *a2a.AgentCard) error {
// Serialize the Agent Card
cardData, err := json.Marshal(card)
if err != nil {
return err
}
// Register the service
service := &api.AgentServiceRegistration{
ID: card.Name,
Name: "a2a-agent",
Tags: extractSkillTags(card.Skills),
Port: extractPort(card.URL),
Address: extractHost(card.URL),
Meta: map[string]string{
"version": card.Version,
"agent_card": string(cardData),
},
Check: &api.AgentServiceCheck{
HTTP: fmt.Sprintf("%s/health", card.URL),
Interval: "10s",
Timeout: "5s",
DeregisterCriticalServiceAfter: "1m",
},
}
if err := r.client.Agent().ServiceRegister(service); err != nil {
return fmt.Errorf("failed to register service: %w", err)
}
// Store the Agent Card in KV
key := fmt.Sprintf("%s/%s", r.prefix, card.Name)
_, err = r.client.KV().Put(&api.KVPair{
Key: key,
Value: cardData,
}, nil)
return err
}
func (r *ConsulRegistry) Discover(ctx context.Context, skill string) ([]*a2a.AgentCard, error) {
// Query healthy services
services, _, err := r.client.Health().Service("a2a-agent", skill, true, nil)
if err != nil {
return nil, err
}
var agents []*a2a.AgentCard
for _, svc := range services {
cardData := svc.Service.Meta["agent_card"]
if cardData == "" {
continue
}
var card a2a.AgentCard
if err := json.Unmarshal([]byte(cardData), &card); err != nil {
log.Printf("failed to unmarshal agent card for %s: %v", svc.Service.ID, err)
continue
}
agents = append(agents, &card)
}
return agents, nil
}
func (r *ConsulRegistry) Watch(ctx context.Context, callback func([]*a2a.AgentCard)) error {
// Use Consul Watch for real-time updates
plan, err := watch.Parse(map[string]interface{}{
"type": "service",
"service": "a2a-agent",
})
if err != nil {
return err
}
plan.Handler = func(idx uint64, raw interface{}) {
if raw == nil {
return
}
services, ok := raw.([]*api.ServiceEntry)
if !ok {
return
}
var agents []*a2a.AgentCard
for _, svc := range services {
cardData := svc.Service.Meta["agent_card"]
if cardData == "" {
continue
}
var card a2a.AgentCard
if err := json.Unmarshal([]byte(cardData), &card); err != nil {
continue
}
agents = append(agents, &card)
}
callback(agents)
}
return plan.RunWithClientAndLogger(r.client, nil)
}
Authentication Configuration: A Layered Defense System
API Key Authentication (Basics)
// Production-grade API Key authentication
func setupAPIKeyAuth() a2a.AuthMiddleware {
// Load valid keys from environment variables or Secret Manager
validKeys := loadValidAPIKeys()
return a2a.APIKeyAuth{
Header: "X-API-Key",
Validator: func(key string) (string, error) {
// 1. Check that the key exists
clientID, exists := validKeys[key]
if !exists {
return "", fmt.Errorf("invalid API key")
}
// 2. Check that the key hasn't expired
if isKeyExpired(key) {
return "", fmt.Errorf("API key expired")
}
// 3. Record usage (for audit and rate limiting)
recordKeyUsage(key)
return clientID, nil
},
}
}
func loadValidAPIKeys() map[string]string {
keys := make(map[string]string)
// Load from environment variables (development)
if envKeys := os.Getenv("A2A_API_KEYS"); envKeys != "" {
for _, pair := range strings.Split(envKeys, ",") {
parts := strings.SplitN(pair, ":", 2)
if len(parts) == 2 {
keys[parts[0]] = parts[1]
}
}
}
// Production: load from Secret Manager
if metadata.OnGCE() {
ctx := context.Background()
client, err := secretmanager.NewClient(ctx)
if err == nil {
defer client.Close()
req := &secretmanagerpb.AccessSecretVersionRequest{
Name: "projects/my-project/secrets/a2a-api-keys/versions/latest",
}
result, err := client.AccessSecretVersion(ctx, req)
if err == nil {
var secretKeys map[string]string
if err := json.Unmarshal(result.Payload.Data, &secretKeys); err == nil {
for k, v := range secretKeys {
keys[k] = v
}
}
}
}
}
return keys
}
OAuth 2.0 / JWT Authentication (Enterprise)
import (
"github.com/golang-jwt/jwt/v5"
)
type JWTAuth struct {
publicKey *rsa.PublicKey
issuer string
audience string
}
func NewJWTAuth(publicKeyPEM []byte, issuer, audience string) (*JWTAuth, error) {
block, _ := pem.Decode(publicKeyPEM)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("not an RSA public key")
}
return &JWTAuth{
publicKey: rsaPub,
issuer: issuer,
audience: audience,
}, nil
}
func (a *JWTAuth) Validate(tokenString string) (*jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return a.publicKey, nil
},
jwt.WithIssuer(a.issuer),
jwt.WithAudience(a.audience),
jwt.WithValidMethods([]string{"RS256"}),
)
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
return &claims, nil
}
return nil, fmt.Errorf("invalid claims")
}
// Use in the A2A server
func setupOAuthAuth() a2a.AuthMiddleware {
publicKeyPEM := []byte(`-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----`)
jwtAuth, err := NewJWTAuth(publicKeyPEM, "auth.example.com", "a2a-agent")
if err != nil {
log.Fatal(err)
}
return a2a.BearerAuth{
Validator: func(token string) (string, error) {
claims, err := jwtAuth.Validate(token)
if err != nil {
return "", err
}
// Extract the user/client ID
sub, ok := (*claims)["sub"].(string)
if !ok {
return "", fmt.Errorf("missing sub claim")
}
// Check permissions
scopes, ok := (*claims)["scope"].(string)
if !ok || !strings.Contains(scopes, "a2a:invoke") {
return "", fmt.Errorf("insufficient scope")
}
return sub, nil
},
}
}
mTLS (Mutual TLS) Authentication (Highest Security)
func setupMTLS() (*tls.Config, error) {
// Load the server certificate
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
return nil, err
}
// Load the CA certificate (used to verify clients)
caCert, err := os.ReadFile("ca.crt")
if err != nil {
return nil, err
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
return &tls.Config{
Certificates: []tls.Certificate{cert},
ClientCAs: caCertPool,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
},
}, nil
}
// Use in the A2A server
server, err := a2a.NewServer(ctx,
a2a.WithAgent(myAgent),
a2a.WithPort(8443),
a2a.WithTLS(setupMTLS()),
)
Rate Limiting and Protection
Layered Rate Limiting Strategy
// 1. Global rate limiting (protects the whole service)
func setupGlobalRateLimit() a2a.Middleware {
limiter := rate.NewLimiter(rate.Limit(1000), 2000) // 1000/sec, burst 2000
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "service overloaded", http.StatusServiceUnavailable)
return
}
next.ServeHTTP(w, r)
})
}
}
// 2. Per-client rate limiting (prevents a single client from exhausting resources)
type ClientRateLimiter struct {
limiters sync.Map // map[string]*rate.Limiter
rps rate.Limit
burst int
}
func NewClientRateLimiter(rps rate.Limit, burst int) *ClientRateLimiter {
return &ClientRateLimiter{
rps: rps,
burst: burst,
}
}
func (l *ClientRateLimiter) getLimiter(clientID string) *rate.Limiter {
limiter, exists := l.limiters.Load(clientID)
if exists {
return limiter.(*rate.Limiter)
}
newLimiter := rate.NewLimiter(l.rps, l.burst)
actual, loaded := l.limiters.LoadOrStore(clientID, newLimiter)
if loaded {
return actual.(*rate.Limiter)
}
return newLimiter
}
func (l *ClientRateLimiter) Middleware() a2a.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientID := r.Context().Value("client_id").(string)
if clientID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
limiter := l.getLimiter(clientID)
if !limiter.Allow() {
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%v", l.rps))
w.Header().Set("X-RateLimit-Remaining", "0")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
// 3. Per-skill rate limiting (protects specific capabilities)
type SkillRateLimiter struct {
limits map[string]rate.Limit
}
func (l *SkillRateLimiter) Middleware() a2a.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the skill from the request
skill := r.Header.Get("X-A2A-Skill")
if skill == "" {
next.ServeHTTP(w, r)
return
}
// Check the skill rate limit
if limit, exists := l.limits[skill]; exists {
// ... rate limiting logic
}
next.ServeHTTP(w, r)
})
}
}
Request Size Limiting
func setupRequestSizeLimit(maxSize int64) a2a.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
next.ServeHTTP(w, r)
})
}
}
server, err := a2a.NewServer(ctx,
a2a.WithAgent(myAgent),
a2a.WithMiddleware(
setupGlobalRateLimit(),
NewClientRateLimiter(10, 20).Middleware(), // 10 rps per client
setupRequestSizeLimit(10*1024*1024), // max 10MB
),
)
Version Management and Compatibility
Semantic Versioning
type Version struct {
Major int
Minor int
Patch int
}
func (v Version) String() string {
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
}
func (v Version) CompatibleWith(other Version) bool {
// Same major version means compatible
return v.Major == other.Major
}
// Version info in the Agent Card
type AgentCard struct {
Name string `json:"name"`
Version string `json:"version"`
MinVersion string `json:"minVersion,omitempty"` // minimum compatible version
Deprecated bool `json:"deprecated,omitempty"`
Deprecation string `json:"deprecation,omitempty"` // deprecation notice
}
Running Multiple Versions Side by Side
// Expose multiple versions at the same time
func main() {
ctx := context.Background()
// v1 Agent
agentV1, _ := agent.New(agent.Config{
Name: "weather-agent",
Version: "1.0.0",
})
// v2 Agent (new features)
agentV2, _ := agent.New(agent.Config{
Name: "weather-agent",
Version: "2.0.0",
})
// v1 server
serverV1, _ := a2a.NewServer(ctx,
a2a.WithAgent(agentV1),
a2a.WithPort(8081),
a2a.WithPathPrefix("/v1"),
)
// v2 server
serverV2, _ := a2a.NewServer(ctx,
a2a.WithAgent(agentV2),
a2a.WithPort(8082),
a2a.WithPathPrefix("/v2"),
)
// Start both versions
go serverV1.Serve()
go serverV2.Serve()
// Use Nginx or an API Gateway for version routing
}
Deep Dive: Common Questions
Q: Is it safe to expose an Agent to the public internet?
Absolutely not, unless you have the following protections in place:
- Transport layer: enforce HTTPS/TLS 1.2+
- Authentication layer: at least one of API Key / OAuth / mTLS
- Rate limiting layer: prevent DDoS and resource exhaustion
- Network layer: use VPC and firewall rules to restrict access sources
- Audit layer: log every call for traceability
// Production-grade security configuration
server, err := a2a.NewServer(ctx,
a2a.WithAgent(myAgent),
a2a.WithPort(443),
a2a.WithTLS(tlsConfig), // TLS encryption
a2a.WithAuth(setupOAuthAuth()), // OAuth authentication
a2a.WithMiddleware(
setupGlobalRateLimit(), // global rate limiting
NewClientRateLimiter(10, 20).Middleware(), // per-client rate limiting
setupRequestSizeLimit(10*1024*1024), // request size limit
setupAuditLog(), // audit logging
),
a2a.WithIPWhitelist([]string{"10.0.0.0/8"}), // IP whitelist
)
Q: Can I limit the call rate?
You must. In production, configure at least three layers of rate limiting:
server, err := a2a.NewServer(ctx,
a2a.WithRateLimit(100, time.Minute), // global: 100 requests per minute
a2a.WithClientRateLimit(10, time.Minute), // per client: 10 requests per minute
a2a.WithBurstLimit(20), // burst request cap
)
Choosing a rate limiting strategy:
| Strategy | Best for | Implementation complexity |
|---|---|---|
| Fixed window | Simple scenarios | Low |
| Sliding window | Smooth rate limiting | Medium |
| Token bucket | Allows burst traffic | Medium |
| Leaky bucket | Strictly even pacing | Medium |
Next Steps
Exposing is done—next let’s look at how to consume external Agents.
Follow “Mengshou Programming” on WeChat for more hands-on Go ADK tutorials—weekly updates on practical Go / AI programming content.
