Define your agents in code. Generate the infrastructure. Execute with confidence.
π Documentation Β· π Quickstart Β· π‘ Tutorials Β· β‘ Try It Now
Building AI agents shouldn't mean wrestling with JSON schemas, debugging brittle tool wiring, or losing progress when processes crash. Yet that's what most frameworks offerβimperative code scattered across files, no contracts, and "good luck" when things break.
Goa-AI takes a different approach. Define your agents, tools, and policies in a typed DSL. Let code generation handle the infrastructure. Run on a durable engine that survives failures. What you get:
| Pain Point | Goa-AI Solution |
|---|---|
| Hand-rolled JSON schemas | Type-safe tool definitions with validationsβschemas generated automatically |
| Brittle tool wiring | BindTo connects tools directly to Goa service methods. Zero glue code |
| Agents that crash and lose state | Temporal-backed durable execution with automatic retries |
| Messy multi-agent composition | First-class agent-as-tool with unified history and run trees |
| Schema drift between components | Single source of truth: DSL β generated codecs β runtime validation |
| Observability as afterthought | Built-in streaming, transcripts, traces, and metrics from day one |
| Manual MCP integration | Generated wrappers turn MCP servers into typed toolsets |
| Toolsets scattered across services | Clustered registry for dynamic discovery and health-monitored invocation |
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β 1. DESIGN β β β 2. GENERATE β β β 3. EXECUTE β
β β β β β β
β Agent DSL β β Tool specs β β Plan/Execute β
β Tool schemas β β Codecs β β Policy checks β
β Policies β β Workflows β β Streaming β
β MCP bindings β β Registries β β Memory β
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
design/ gen/ runtime/
-
Design β Express intent in Go: agents, tools, policies. Version-controlled, type-checked, reviewable.
-
Generate β
goa genproduces everything: tool specs with JSON schemas, type-safe codecs, workflow definitions, registry helpers. Never editgen/βregenerate on change. -
Execute β The runtime runs your agents: plan/execute loops, policy enforcement, memory persistence, event streaming. Swap engines (in-memory β Temporal) without changing agent code.
go install goa.design/goa/v3/cmd/goa@latestCreate design/design.go:
package design
import (
. "goa.design/goa/v3/dsl"
. "goa.design/goa-ai/dsl"
)
var _ = Service("demo", func() {
Agent("assistant", "A helpful assistant", func() {
Use("weather", func() {
Tool("get_weather", "Get current weather for a city", func() {
Args(func() {
Attribute("city", String, "City name", func() {
MinLength(2)
Example("Tokyo")
})
Required("city")
})
Return(func() {
Attribute("temperature", Int, "Temperature in Celsius")
Attribute("conditions", String, "Weather description")
Required("temperature", "conditions")
})
})
})
})
})mkdir myagent && cd myagent
go mod init myagent
go get goa.design/goa/v3@latest goa.design/goa-ai@latest
# Create design/design.go with the code above
goa gen myagent/designGenerated artifacts in gen/:
- Tool specs with JSON schemas for LLM function calling
- Type-safe codecs for payload/result serialization
- Agent registration helpers and typed clients
- Workflow definitions for durable execution
package main
import (
"context"
"fmt"
assistant "myagent/gen/demo/agents/assistant"
"goa.design/goa-ai/runtime/agent/model"
"goa.design/goa-ai/runtime/agent/planner"
"goa.design/goa-ai/runtime/agent/runtime"
)
func main() {
ctx := context.Background()
rt := runtime.New() // in-memory engine, zero dependencies
// Register the agent with a planner (decision-maker) and executor (tool runner)
assistant.RegisterAssistantAgent(ctx, rt, assistant.AssistantAgentConfig{
Planner: &MyPlanner{}, // Calls LLM to decide: tools or final answer?
Executor: &MyExecutor{}, // Runs tool logic when planner requests it
})
// Run with a user message
client := assistant.NewClient(rt)
out, _ := client.Run(ctx, []*model.Message{{
Role: model.ConversationRoleUser,
Parts: []model.Part{model.TextPart{Text: "What's the weather in Paris?"}},
}})
fmt.Println("RunID:", out.RunID)
// β Agent calls get_weather tool, receives result, synthesizes response
}Planner + Executor pattern: Planners decide what (final answer or which tools). Executors decide how (tool implementation). The runtime handles the loop, policy enforcement, and state.
Toolsets are collections of capabilities your agents can invoke. Define them with full type safety:
Agent("assistant", "Document assistant", func() {
Use("docs", func() {
Tool("search", "Search documents", func() {
Args(func() {
Attribute("query", String, "Search query", func() {
MinLength(1)
MaxLength(500)
})
Attribute("limit", Int, "Max results", func() {
Minimum(1)
Maximum(100)
Default(10)
})
Required("query")
})
Return(ArrayOf(Document))
})
})
})What you get:
- JSON Schema for LLM function calling (auto-generated)
- Validation at boundariesβinvalid calls get retry hints, not crashes
- Type-safe Go structs for payloads and results
Already have Goa services? Bind tools directly to methods:
// Your existing Goa service method
Method("search_documents", func() {
Payload(func() {
Attribute("query", String)
Attribute("session_id", String) // Infrastructure field
Required("query", "session_id")
})
Result(ArrayOf(Document))
})
Agent("assistant", "Document assistant", func() {
Use("docs", func() {
Tool("search", "Search documents", func() {
Args(func() {
Attribute("query", String, "What to search for")
Required("query")
})
Return(ArrayOf(Document))
BindTo("search_documents") // Auto-generated transform
Inject("session_id") // Hidden from LLM, filled at runtime
})
})
})BindTo gives you:
- Schema flexibilityβtool args can differ from method payload
- Auto-generated type-safe transforms between tool and service types
- Field injection for infrastructure concerns (auth, session IDs)
- Method validation still applies; errors become retry hints
Agents can invoke other agents. Define specialist agents, compose them into orchestrators:
// Specialist agent exports tools
Agent("researcher", "Research specialist", func() {
Export("research", func() {
Tool("deep_search", "Comprehensive research", func() {
Args(ResearchQuery)
Return(ResearchResult)
})
})
})
// Orchestrator uses the specialist
Agent("coordinator", "Main coordinator", func() {
Use(AgentToolset("svc", "researcher", "research"))
// coordinator can now call "research.deep_search" as a tool
})Agent-as-tool runs inline: The child agent executes within the parent's workflowβsingle transaction, unified history. The parent receives a ToolResult with a RunLink handle to the child run for debugging and UI rendering.
Set limits on what agents can do:
Agent("assistant", "Production assistant", func() {
RunPolicy(func() {
DefaultCaps(
MaxToolCalls(20), // Total tools per run
MaxConsecutiveFailedToolCalls(3), // Failures before abort
)
TimeBudget("5m") // Wall-clock limit
InterruptsAllowed(true) // Enable pause/resume
})
})Policies are enforced by the runtimeβnot just suggestions.
Agents are notoriously opaque. Goa-AI streams typed events throughout execution:
// Receive events as they happen
type MySink struct{}
func (s *MySink) Send(ctx context.Context, event stream.Event) error {
switch e := event.(type) {
case *stream.ToolStart:
fmt.Printf("π§ Starting: %s\n", e.Data.ToolName)
case *stream.ToolEnd:
fmt.Printf("β
Completed: %s\n", e.Data.ToolName)
case *stream.AssistantReply:
fmt.Print(e.Data.Text) // Stream text as it arrives
case *stream.PlannerThought:
fmt.Printf("π %s\n", e.Data.Content)
}
return nil
}
func (s *MySink) Close(ctx context.Context) error { return nil }
// Wire to runtime
rt := runtime.New(runtime.WithStream(&MySink{}))
// Or subscribe to a specific run
stop, _ := rt.SubscribeRun(ctx, runID, &MySink{})
defer stop()Stream profiles filter events for different audiences:
UserChatProfile()β End-user chat UIs with nested agent cardsAgentDebugProfile()β Flattened debug view with full event firehoseMetricsProfile()β Usage and workflow events only for telemetry
The in-memory engine is great for development. For production, swap in Temporal:
import (
"go.temporal.io/sdk/client"
runtimeTemporal "goa.design/goa-ai/runtime/agent/engine/temporal"
)
func main() {
// Production engine: Temporal for durability
eng, _ := runtimeTemporal.New(runtimeTemporal.Options{
ClientOptions: &client.Options{
HostPort: "localhost:7233",
Namespace: "default",
},
WorkerOptions: runtimeTemporal.WorkerOptions{
TaskQueue: "my-agents",
},
})
defer eng.Close()
rt := runtime.New(runtime.WithEngine(eng))
// ... register agents, same as before
}What Temporal gives you:
- Crash recovery β Workers restart; runs resume from last checkpoint
- Automatic retries β Failed tools retry without re-calling the LLM
- Rate limit handling β Exponential backoff absorbs API throttling
- Deployment safety β Rolling deploys don't lose in-flight work
Your agent code doesn't change. Just swap the engine.
Goa-AI is a two-way MCP bridge.
Consume MCP servers as typed toolsets:
var FilesystemTools = Toolset(FromMCP("filesystem", "filesystem-mcp"))
Agent("assistant", "File assistant", func() {
Use(FilesystemTools)
})Expose your services as MCP servers:
Service("calculator", func() {
MCP("calc", "1.0.0") // Enable MCP protocol
Method("add", func() {
Payload(func() { Attribute("a", Int); Attribute("b", Int) })
Result(Int)
Tool("add", "Add two numbers") // Export as MCP tool
})
})Generated wrappers handle transport (HTTP, SSE, stdio), retries, and tracing.
When toolsets live in separate services that scale independently, you need dynamic discovery. The Internal Tool Registry is a clustered gateway that enables toolset discovery and invocation across process boundaries.
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β Agent 1 β β Agent 2 β β Agent N β
ββββββββ¬βββββββ ββββββββ¬βββββββ ββββββββ¬βββββββ
β β β
βββββββββββββββββββββΌββββββββββββββββββββ
β gRPC
ββββββββΌβββββββ
β Registry ββββββ Cluster (same Name + Redis)
β Nodes β
ββββββββ¬βββββββ
β Pulse Streams
βββββββββββββββββββββΌββββββββββββββββββββ
β β β
ββββββββΌβββββββ ββββββββΌβββββββ ββββββββΌβββββββ
β Provider 1 β β Provider 2 β β Provider N β
β (Toolset) β β (Toolset) β β (Toolset) β
βββββββββββββββ βββββββββββββββ βββββββββββββββ
Run a registry node:
reg, _ := registry.New(ctx, registry.Config{
Redis: redisClient,
Name: "my-registry", // Nodes with same name form a cluster
})
// Blocks until shutdown
reg.Run(ctx, ":9090")Or use the binary:
# Single node
REDIS_URL=localhost:6379 go run ./registry/cmd/registry
# Multi-node cluster (run on different hosts)
REGISTRY_NAME=prod REGISTRY_ADDR=:9090 REDIS_URL=redis:6379 ./registry
REGISTRY_NAME=prod REGISTRY_ADDR=:9091 REDIS_URL=redis:6379 ./registryWhat clustering gives you:
- Shared registrations β Toolsets registered on any node are visible everywhere
- Coordinated health checks β Distributed tickers ensure exactly one node pings at a time
- Automatic failover β Connect to any node; they all serve identical state
- Horizontal scaling β Add nodes to handle more gRPC connections
A fully-wired production setup:
func main() {
// Durable execution
eng, _ := runtimeTemporal.New(runtimeTemporal.Options{
ClientOptions: &client.Options{HostPort: "temporal:7233"},
WorkerOptions: runtimeTemporal.WorkerOptions{TaskQueue: "agents"},
})
defer eng.Close()
// Persistence
mongoClient := newMongoClient()
redisClient := newRedisClient()
// Model provider
modelClient, _ := bedrock.New(bedrock.Options{
Region: "us-east-1",
Model: "anthropic.claude-sonnet-4-20250514-v1:0",
})
// Streaming
pulseSink, _ := pulse.NewSink(pulse.Options{Client: redisClient})
// Wire it all together
rt := runtime.New(
runtime.WithEngine(eng),
runtime.WithMemoryStore(memorymongo.New(mongoClient)),
runtime.WithRunStore(runmongo.New(mongoClient)),
runtime.WithStream(pulseSink),
runtime.WithModelClient("claude", modelClient),
runtime.WithPolicy(basicpolicy.New()),
runtime.WithLogger(telemetry.NewClueLogger()),
runtime.WithMetrics(telemetry.NewClueMetrics()),
runtime.WithTracer(telemetry.NewClueTracer()),
)
// Register agents
chat.RegisterChatAgent(ctx, rt, chat.ChatAgentConfig{
Planner: newChatPlanner(),
})
// Workers poll and execute; clients submit runs from anywhere
}| Package | Purpose |
|---|---|
| Model Providers | |
features/model/bedrock |
AWS Bedrock (Claude, Titan, etc.) |
features/model/openai |
OpenAI-compatible APIs |
features/model/anthropic |
Direct Anthropic Claude API |
features/model/gateway |
Remote model gateway for centralized serving |
features/model/middleware |
Rate limiting, logging, metrics middleware |
| Persistence | |
features/memory/mongo |
Transcript storage |
features/session/mongo |
Session state |
features/run/mongo |
Run metadata and search |
| Streaming & Integration | |
features/stream/pulse |
Pulse (Redis Streams) for real-time events |
features/policy/basic |
Policy engine for tool filtering |
registry |
Clustered gateway for cross-process toolset discovery |
runtime/mcp |
MCP callers (stdio, HTTP, SSE) for tool server integration |
Pause runs for human review, resume when ready:
// Pause a run
rt.PauseRun(ctx, interrupt.PauseRequest{
RunID: "run-123",
Reason: "requires_approval",
})
// Resume after review
rt.ResumeRun(ctx, interrupt.ResumeRequest{
RunID: "run-123",
Notes: "Approved by reviewer",
})The runtime updates run state and emits run_paused/run_resumed events for UI synchronization.
Design first β Put all schemas in the DSL. Add examples and validations. Let codegen own the infrastructure.
Never hand-encode β Use generated codecs everywhere. Avoid json.Marshal for tool payloads.
Keep planners focused β Planners decide what (which tools or final answer). Tool implementations handle how.
Compose with agent-as-tool β Prefer nested agents over brittle cross-service contracts. Single history, unified debugging.
Regenerate often β DSL change β goa gen β lint/test β run. Never edit gen/ manually.
| Guide | What You'll Learn |
|---|---|
| Quickstart | Installation and first agent in 10 minutes |
| DSL Reference | Complete DSL: agents, toolsets, policies, MCP |
| Runtime | Plan/execute loop, engines, memory stores |
| Toolsets | Service-backed tools, transforms, executors |
| Agent Composition | Agent-as-tool, run trees, streaming topology |
| MCP Integration | MCP servers, transports, generated wrappers |
| Registry | Clustered toolset discovery and invocation |
| Production | Temporal setup, streaming UI, model providers |
In-repo references:
docs/runtime.mdβ Runtime architecture deep-divedocs/dsl.mdβ DSL design patternsdocs/overview.mdβ System overview
- Go 1.24+
- Goa v3.22.2+ β
go install goa.design/goa/v3/cmd/goa@latest - Temporal (optional) β For durable execution in production
- MongoDB (optional) β Default memory/session/run store implementation
- Redis (optional) β For Pulse streaming and registry clustering
Issues and PRs welcome! Include a Goa design, failing test, or clear reproduction steps. See AGENTS.md for repository guidelines.
MIT License Β© Raphael Simon & Goa community.
Build agents that are a joy to develop and a breeze to operate.