Toolsets
Toolsets are collections of tools that agents can use. Goa-AI supports several toolset types, each with different execution models and use cases.
Toolset Types
Service-Owned Toolsets (Method-Backed)
Declared via Toolset("name", func() { ... }); tools may BindTo Goa service methods or be implemented by custom executors.
- Codegen emits per-toolset specs/types/codecs/transforms under
gen/<service>/toolsets/<toolset>/ - When using the Internal Tool Registry, codegen also emits
gen/<service>/toolsets/<toolset>/provider.gofor registry-routed, service-side execution - Agents that
Usethese toolsets import the provider specs and get typed call builders and executor factories - Applications register executors that decode typed args (via runtime-provided codecs), optionally use transforms, call service clients, and return
ToolResult
If you deploy the Internal Tool Registry for cross-process invocation, the owning service runs a provider loop that subscribes to toolset:<toolsetID>:requests and publishes results to result:<toolUseID>. See the Registry docs for the provider wiring snippet.
Agent-Implemented Toolsets (Agent-as-Tool)
Defined in an agent Export block, and optionally Used by other agents.
- Ownership still lives with the service; the agent is the implementation
- Codegen emits provider-side export packages under
gen/<service>/agents/<agent>/exports/<export>withNewRegistrationand typed call builders - Consumer-side helpers in agents that
Usethe exported toolset delegate to provider helpers while keeping routing metadata centralized - Execution happens inline; payloads are passed as canonical JSON and decoded only at the boundary if needed for prompts
MCP Toolsets
Declared via Toolset(FromMCP(service, suite)) for Goa-backed MCP suites, or
Toolset("name", FromExternalMCP(service, suite), func() { ... }) for external
MCP servers with inline tool schemas.
- Generated registration sets
DecodeInExecutor=trueso raw JSON is passed through to the MCP executor - MCP executor decodes using its own codecs
- Generated wrappers handle JSON schemas, encoders, and HTTP or stdio transport with retries and tracing. HTTP accepts JSON and event-stream responses
When to Use BindTo vs Inline Implementations
Use BindTo when:
- The tool should call an existing Goa service method
- You want generated transforms between tool and method types
- The service method already has the business logic you need
- You want to reuse validation and error handling from the service layer
// Tool bound to existing service method
Tool("search", "Search documents", func() {
Args(SearchPayload)
Return(SearchResult)
BindTo("Search") // Calls the Search method on the same service
})
Use inline implementations when:
- The tool has custom logic not tied to a service method
- You need to orchestrate multiple service calls
- The tool is purely computational (no external calls)
- You want full control over the execution flow
// Tool with custom executor implementation
Tool("summarize", "Summarize multiple documents", func() {
Args(func() {
Attribute("doc_ids", ArrayOf(String), "Document IDs to summarize")
Required("doc_ids")
})
Return(func() {
Attribute("summary", String, "Combined summary")
Required("summary")
})
// No BindTo - implement in executor
})
For inline implementations, you write the executor logic directly:
func (e *Executor) Execute(
ctx context.Context,
meta *runtime.ToolCallMeta,
call *runtime.ToolCall,
) (*runtime.ToolExecutionResult, error) {
switch call.Name {
case specs.Summarize:
args, err := specs.SummarizeTool().Payload.FromJSON(call.Payload)
if err != nil {
return nil, fmt.Errorf("decode admitted %s payload: %w", call.Name, err)
}
// Custom logic: fetch multiple docs, combine, summarize
summary := e.summarizeDocuments(ctx, args.DocIDs)
return runtime.Executed(&planner.ToolResult{
Name: call.Name,
Result: &specs.SummarizeResult{Summary: summary},
}), nil
}
return runtime.Executed(&planner.ToolResult{
Name: call.Name,
Failure: &planner.ToolFailure{
Kind: planner.FailureInvalidCall,
Error: planner.NewToolError("unknown tool"),
Recovery: planner.RecoveryDirective{Action: planner.RecoveryReplan},
},
}), nil
}
Generated Tool Schemas and Examples
Goa-AI treats the generated tool spec as the canonical model-facing contract. For each tool payload, codegen derives JSON Schema from the Goa attribute and precomputes the provider projections adapters need:
- the annotated schema, including authored and field-level JSON Schema examples
- the same schema with only the root
exampleremoved - the raw authored top-level example JSON and parsed object example input
Only an authored top-level Goa Example(...) on the tool payload becomes a
provider-facing top-level tool example. Synthesized Goa examples may remain as
nested schema annotations, but they are not promoted to provider-native examples.
This prevents generated placeholder values from becoming model instructions.
Provider adapters choose the projection that matches the provider contract.
OpenAI-style tool calling can consume schema annotations directly. Direct
Anthropic and Bedrock Claude send the parsed examples as native
input_examples while using the schema without the root example; Bedrock carries
the Anthropic fields through additionalModelRequestFields when the relevant
beta contract applies.
If your application routes model requests through an inference service or proxy,
that boundary should carry the projections together as a provider-neutral
model.ToolInputContract. The boundary should not import generator-only
tools.TypeSpec, re-marshal decoded schemas, or know which provider consumes
which projection. Dropping the schema-without-root-example or parsed example
input prevents provider adapters from sending native input_examples, even
though the generated tool spec was correct.
The validated model client still needs an executable input validator. Build
local generated tools with model.ToolDefinitionFromSpec, which retains the
generated payload decoder. Use model.AdvertisedToolInputFromSchema for a
caller-authored schema. model.ToolInputFromContract reconstructs projections
after a gateway or proxy hop; it does not replace the generated decoder on the
planner-facing side.
Bounded Tool Results
Some tools naturally return large lists, graphs, or time-series windows. You can mark these as bounded views so that services remain responsible for trimming while the runtime enforces and surfaces the contract.
The agent.Bounds Contract
The agent.Bounds type describes how a tool result has been bounded relative to the full underlying data set. For paged tools, providers put the opaque next-page cursor in NextCursor. Whether the model sees that cursor depends on the paging contract: ContinueWith keeps it runtime-owned, while Cursor exposes it as part of a self-paging tool’s public interaction.
type Bounds struct {
Returned int // Number of items in the bounded view
Total *int // Best-effort total before truncation (optional)
Truncated bool // Whether any caps were applied (length, window, depth)
NextCursor *string // Non-empty opaque cursor for the next page (optional)
RefinementHint string // Guidance on how to narrow the query when truncated
}
| Field | Description |
|---|---|
Returned | Count of items actually present in the result |
Total | Best-effort count of total items before truncation (nil if unknown) |
Truncated | True if any caps were applied (pagination, depth limits, size limits) |
NextCursor | Non-empty opaque cursor for the next page; valid only for a paging-enabled tool when Truncated is true |
RefinementHint | Human-readable guidance for narrowing the query (e.g., “Add a date filter to reduce results”) |
Service Responsibility for Trimming
The runtime does not compute subsets or truncation itself—services are responsible for:
- Applying truncation logic: Pagination, result limits, depth caps, time windows
- Populating runtime bounds metadata: Setting
planner.ToolResult.Bounds - Providing refinement hints: Guiding users/models on how to narrow queries when results are truncated
This design keeps truncation logic where domain knowledge lives (in services) while providing a uniform contract for the runtime, planners, and UIs to consume.
Declaring Bounded Tools
Use the DSL helper BoundedResult() inside a Tool definition:
Tool("list_devices", "List devices with pagination", func() {
Args(func() {
Attribute("site_id", String, "Site identifier")
Required("site_id")
})
Return(func() {
Attribute("devices", ArrayOf(Device), "Matching devices")
Required("devices")
})
BoundedResult(func() {
ContinueWith("continue_devices", "cursor")
NextCursor("next_cursor")
})
BindTo("DeviceService", "ListDevices")
})
Tool("continue_devices", "Continue the available device results", func() {
Args(func() {
Attribute("cursor", String)
Required("cursor")
})
Return(func() {
Attribute("devices", ArrayOf(Device), "Matching devices")
Required("devices")
})
BoundedResult(func() {
Cursor("cursor")
NextCursor("next_cursor")
})
BindTo("DeviceService", "ContinueDevices")
})
The continuation tool’s cursor exists in its execution contract but is removed
from the model-facing schema. The runtime advertises the action only while one
unambiguous live chain head can continue, so the model calls it with {} and
does not copy a cursor or repeat the original query.
Code Generation
When a tool is marked with BoundedResult():
- The generated tool spec includes
tools.ToolSpec.Bounds - The generated JSON result schema includes the canonical bounded fields (
returned,total,truncated,refinement_hint, and optionalnext_cursor) tools.ToolSpec.Boundsstores the model-facing JSON field names. If the Goa DSL names a lower-camel attribute such asNextCursor("nextCursor"), codegen emitsNextCursorField: "next_cursor"so schemas, runtime projection, and result-codec validation use one spelling.ContinueWithgenerates a dedicated continuation relationship. The source result does not exposenext_cursor, and the continuation’s model-facing payload is an empty object. Independent source invocations may run in parallel; the continuation is available only when one invocation can be selected without another argument.- A tool declared directly with
Cursorexposesnext_cursorand accepts it on the next model-authored call. - The semantic Go result type stays domain-specific; it does not need to duplicate those fields
For method-backed BindTo tools, the bound service method result still needs to
carry the canonical bounded fields so the generated executor can build
planner.ToolResult.Bounds before runtime projection.
spec.Bounds = &tools.BoundsSpec{
Paging: &tools.PagingSpec{
ContinueTool: "tools.continue_devices",
CursorField: "cursor",
NextCursorField: "next_cursor",
},
}
Implementing Bounded Tools
Bounded tools are a hard contract: services implement truncation and populate bounds metadata on every successful code path.
Contract:
Bounds.ReturnedandBounds.Truncatedmust always be set on successful bounded tool results.Bounds.TotalandBounds.RefinementHintare optional and should only be set when known.Bounds.NextCursoris optional, but when present it must point to a non-empty string, the generated tool spec must declare paging, andBounds.Truncatedmust be true. Provider code sets it to the opaque cursor for the next page.
Executors implement truncation and populate bounds metadata:
func (e *DeviceExecutor) Execute(ctx context.Context, meta *runtime.ToolCallMeta, call *runtime.ToolCall) (*runtime.ToolExecutionResult, error) {
args, err := specs.ListDevicesTool().Payload.FromJSON(call.Payload)
if err != nil {
return nil, fmt.Errorf("decode admitted %s payload: %w", call.Name, err)
}
devices, total, nextCursor, truncated, err := e.repo.QueryDevices(ctx, args.SiteID, nil)
if err != nil {
return nil, err
}
return runtime.Executed(&planner.ToolResult{
Name: call.Name,
Result: &ListDevicesResult{
Devices: devices,
},
Bounds: &agent.Bounds{
Returned: len(devices),
Total: ptr(total),
Truncated: truncated,
NextCursor: nextCursor,
RefinementHint: "Add a status filter or reduce the site scope to see fewer results",
},
}), nil
}
Runtime Behavior
When a bounded tool executes:
- The runtime validates that a successful bounded tool returned
planner.ToolResult.Bounds - The runtime merges those bounds into the emitted JSON using the model-facing
JSON field names generated from
BoundedResult(...) - For
ContinueWith, the runtime exposes the empty continuation action only when result history has exactly one live chain head, then binds the cursor and retained query fields before execution. Exact cursor lineage advances sequential pages. Parallel source invocations remain valid; multiple live heads make the no-argument continuation unavailable - If another tool in the same parallel batch requires
finishrecovery, the failed tool cannot run again and no new domain work can start. Continuation actions remain available for successful queries that already returned a next-page cursor. Without such an action, finalization starts immediately - For direct
Cursor, the runtime projects the opaque cursor intonext_cursorand the model supplies it on the next call - Stream subscribers and finalizers access bounds for UI display, logging, or policy decisions
When a truncated result has no next-page cursor, the runtime reminder asks the model to state the view’s limits. Partial results can still support useful answers about the returned items when that scope is clear. Disclosing truncation or fetching another still-partial page does not establish facts about items still omitted. Provider-established full-query totals retain their full-query scope; later, independently complete evidence supports conclusions within its own scope. Extra pagination is not required when the available evidence already answers the question.
// In a stream subscriber
func handleToolEnd(event *stream.ToolEndEvent) {
if event.Bounds != nil && event.Bounds.Truncated {
log.Printf("Tool %s returned %d of %d results (truncated)",
event.ToolName, event.Bounds.Returned, *event.Bounds.Total)
if event.Bounds.RefinementHint != "" {
log.Printf("Hint: %s", event.Bounds.RefinementHint)
}
}
}
When to Use BoundedResult
Use BoundedResult() for tools that:
- Return paginated lists (devices, users, records, logs)
- Query large datasets with result limits
- Apply depth or size caps to nested structures (graphs, trees)
- Return time-windowed data (metrics, events)
The bounded contract helps:
- Models understand that results may be incomplete and can request refinement
- UIs display truncation indicators and pagination controls
- Policies enforce size limits and detect runaway queries
Injected Fields
The Inject DSL function marks specific payload fields as “injected”—server-side infrastructure values that are hidden from the LLM but populated by generated code before the tool executes. This is useful for session IDs, tenant/household scoping, and other runtime- or caller-provided values.
How Inject Works
When you mark a field with Inject, codegen resolves it to one of two generation-time sources:
- Hidden from LLM: the field is excluded from the JSON schema and the model-facing required list sent to the model provider.
- Validated at design time: the field must be a required
Stringon the tool’s effective payload (the explicitArgs()when given, otherwise the bound method’s payload). - Meta-backed or label-backed: a name that Goify’s to one of the five fixed
runtime.ToolCallMetafields (run_id/runId,session_id/sessionId,turn_id/turnId,tool_call_id/toolCallId,parent_tool_call_id/parentToolCallId) is meta-backed and compiles to a direct meta read. Every other name is label-backed: it compiles to a run-label lookup (the label key is the design name verbatim), with the field’s own declared validation (Pattern,Length, enum, …) applied to the label value. A label-backed field cannot be declared on aBindTotool, because the registry wire protocol used by registry-served bound tools carries no run labels. - Executor population: both execution topologies (local in-process executors and the registry-served provider) call the same generated
Inject<Tool>function between decode and execute, so population never diverges by where a tool runs.
DSL Declaration
Tool("get_user_data", "Get data for current user", func() {
Args(func() {
Attribute("session_id", String, "Current session ID")
Attribute("query", String, "Data query")
Required("session_id", "query")
})
Return(func() {
Attribute("data", ArrayOf(String), "Query results")
Required("data")
})
BindTo("UserService", "GetData")
Inject("session_id") // meta-backed: hidden from LLM, populated at runtime
})
Label-backed fields work the same way but are not runtime.ToolCallMeta names:
Tool("lookup_household", "Lookup scoped to a household", func() {
Args(func() {
Attribute("household_id", String, "Household to scope the search to.", func() {
Pattern("^[a-z0-9-]+$")
})
Attribute("query", String, "Search query.")
Required("household_id", "query")
})
Inject("household_id") // label-backed: set via WithLabels("household_id", ...)
})
Callers supply label values by starting the run with runtime.WithLabels(...):
out, err := client.Run(ctx, sessionID, messages,
runtime.WithLabels(map[string]string{"household_id": "house-42"}),
)
A toolset’s label-backed fields also contribute to a generated
RequiredLabels list, aggregated per agent. Runtime.Start/StartOneShot
validate the caller-supplied labels against this list before scheduling
any workflow or activity, failing fast with every missing key named in one
error. This check is a no-op for a process that only holds a
Runtime.ClientFor(route) gateway/orchestration client (no local agent
registration); in that topology a missing label is instead caught later,
per tool call.
Generated Code
Generated method-backed executors call one generated Inject<Tool> function
per injecting tool (in the toolset’s inject.go, beside its codecs), which
copies meta-backed fields from runtime.ToolCallMeta and label-backed
fields from the run’s labels onto the typed payload:
p, err := specs.InjectGetUserData(toolArgs, meta, labels)
Supported injected field names are not a fixed list: any name that
matches a runtime.ToolCallMeta field is meta-backed, and every other name
is label-backed.
Decoding Payloads in Custom Executors
Hand-written ToolCallExecutors (for tools with no BindTo, registered
directly with the runtime) have no generated dispatch to call
Inject<Tool> for them. Decode these tools’ payloads with the toolset’s
generated Decode<Tool> function instead of the raw payload codec:
p, err := specs.DecodeLookupHousehold(call.Payload, meta, call.Labels)
if err != nil {
// handle decode or injection failure (missing/invalid label, etc.)
}
Decode<Tool> composes <Tool>PayloadCodec.FromJSON with Inject<Tool> in
one call, so injection can never be silently skipped. Decoding with the
codec alone would leave injected fields at their Go zero value with no
error, since their wire tag is json:"-" (hidden from the model) and there
is no “missing key” signal.
Runtime Population via Generated Interceptors
Generated service executors also expose typed interceptor hooks, independent
of Inject(). Use them to derive method payload fields from request context
or other runtime state, in addition to or instead of design-declared
injected fields:
type SessionInterceptor struct{}
func (i *SessionInterceptor) Inject(ctx context.Context, payload any, meta *runtime.ToolCallMeta) error {
sessionID, ok := ctx.Value(sessionKey).(string)
if !ok {
return fmt.Errorf("session ID not found in context")
}
switch p := payload.(type) {
case *userservice.GetDataPayload:
p.SessionID = sessionID
}
return nil
}
exec := usertools.NewChatUserToolsExec(
usertools.WithClient(userClient),
usertools.WithInterceptors(&SessionInterceptor{}),
)
Registered interceptors run after the generated Inject<Tool> call, on the
already-decoded typed payload.
When to Use Inject
Use Inject for fields that:
- Are required by the service but shouldn’t be chosen by the LLM
- Come from runtime context (session, run/turn/tool-call IDs) or caller-supplied run labels (tenant, household, user)
- Contain sensitive values (auth tokens, API keys)
- Are infrastructure concerns (tracing IDs, correlation IDs)
Execution Models
Activity-Based Execution (Default)
Service-backed toolsets execute via Temporal activities (or equivalent in other engines):
- The validated model client rejects schema-invalid provider calls before
planner code receives them. Planner-authored calls use
planner.NewToolRequest, which returns encoding failures directly. - The planner returns schema-valid
ToolCalls []planner.ToolRequestwith a generated tool name, canonical payload bytes, and an optional provider call ID. - The runtime validates the whole plan, assigns each execution ID, and
schedules
ExecuteToolActivity. - The activity decodes the already admitted payload. A decode failure is an internal invariant error, not correction evidence.
- The activity calls the toolset registration’s
Execute(ctx, meta, *runtime.ToolCall)with canonical JSON and the runtime-assigned execution ID. - The activity re-encodes the result with the generated result codec.
Inline Execution (Agent-as-Tool)
Agent-as-tool toolsets execute inline from the planner’s perspective while the runtime runs the provider agent as a real child run:
- The runtime detects
Inline=trueon the toolset registration - It injects the
engine.WorkflowContextintoctxso the toolset’sExecutefunction can start the provider agent as a child workflow with its ownRunID - It calls the toolset’s
Execute(ctx, meta, *runtime.ToolCall)with canonical JSON payload and tool metadata (including parentRunIDandToolCallID) - The generated agent-tool executor builds nested agent messages (system + user) from the tool payload and runs the provider agent as a child run
- The nested agent executes a full plan/execute/resume loop in its own run; its
RunOutputand tool events are aggregated into a parentplanner.ToolResultthat carries the result payload, aggregated telemetry, childChildrenCount, and aRunLinkpointing at the child run - Stream subscribers emit both
tool_start/tool_endfor the parent tool call and achild_run_linkedlink event so UIs can build nested agent cards while consuming a single session stream
Result Materializers
Toolsets may register a typed result materializer:
reg := runtime.ToolsetRegistration{
Name: "chat.ask_question",
Execute: runtime.ToolCallExecutorFunc(func(
ctx context.Context,
meta *runtime.ToolCallMeta,
call *runtime.ToolCall,
) (*runtime.ToolExecutionResult, error) {
return runtime.Executed(&planner.ToolResult{
Name: call.Name,
Failure: &planner.ToolFailure{
Kind: planner.FailureUnavailable,
Error: planner.NewToolError("externally provided"),
Recovery: planner.RecoveryDirective{
Action: planner.RecoveryReplan,
},
},
}), nil
}),
Specs: []tools.ToolSpec{specs.SpecAskQuestion()},
ResultMaterializer: func(ctx context.Context, meta runtime.ToolCallMeta, call *runtime.ToolCall, result *planner.ToolResult) error {
// Attach deterministic, server-only sidecars here.
result.ServerData = buildServerData(call, result)
return nil
},
}
Contract:
ResultMaterializerruns on both the normal execution path and the externally provided-result await path.- It receives the validated
runtime.ToolCall, including its runtime-assigned execution ID, plus the typedplanner.ToolResultbefore the runtime encodes JSON for hooks, workflow boundaries, or callers. - Use it to attach
result.ServerDataor to normalize the semantic result shape in a deterministic way. - Keep it pure and deterministic; when it runs inside workflow code it must not perform I/O.
This is the canonical place to derive observer-only sidecars from the original tool payload and the typed result while keeping those sidecars invisible to model providers.
Executor-First Model
Generated service toolsets expose registration helpers that accept
runtime.ToolCallExecutor implementations for the toolsets an agent uses.
if err := chat.RegisterUsedToolsets(ctx, rt,
chat.WithSearchExecutor(searchExec),
chat.WithProfilesExecutor(profileExec),
); err != nil {
return err
}
Applications register an executor implementation for each consumed local
toolset. The executor decides how to run the tool (service client, custom
function, registry caller, etc.) and receives explicit per-call metadata via
ToolCallMeta.
Executor Example:
func Execute(ctx context.Context, meta *runtime.ToolCallMeta, call *runtime.ToolCall) (*runtime.ToolExecutionResult, error) {
switch call.Name {
case "orchestrator.profiles.upsert":
args, err := profilesspecs.UpsertTool().Payload.FromJSON(call.Payload)
if err != nil {
return nil, fmt.Errorf("decode admitted %s payload: %w", call.Name, err)
}
// Generated when the tool and bound method use compatible distinct types.
mp := profilesspecs.InitUpsertMethodPayload(args)
methodRes, err := client.Upsert(ctx, mp)
if err != nil {
return runtime.Executed(&planner.ToolResult{
Name: call.Name,
Failure: &planner.ToolFailure{
Kind: planner.FailureUnavailable,
Error: planner.ToolErrorFromError(err),
Recovery: planner.RecoveryDirective{Action: planner.RecoveryReplan},
},
}), nil
}
tr := profilesspecs.InitUpsertToolResult(methodRes)
return runtime.Executed(&planner.ToolResult{
Name: call.Name,
Result: tr,
}), nil
default:
return runtime.Executed(&planner.ToolResult{
Name: call.Name,
Failure: &planner.ToolFailure{
Kind: planner.FailureInvalidCall,
Error: planner.NewToolError("unknown tool"),
Recovery: planner.RecoveryDirective{Action: planner.RecoveryReplan},
},
}), nil
}
}
Tool Call Metadata
Tool executors receive explicit per-call metadata via ToolCallMeta rather than fishing values from context.Context. This provides direct access to run-scoped identifiers for correlation, telemetry, and parent/child relationships.
ToolCallMeta Fields
| Field | Description |
|---|---|
RunID | Durable workflow execution identifier of the run that owns this tool call. Stable across retries; used to correlate runtime records and telemetry. |
SessionID | Logically groups related runs (e.g., a chat conversation). Services typically index memory and search attributes by session. |
TurnID | Identifies the conversational turn that produced this tool call. Event streams use it to order and group events. |
ToolCallID | Uniquely identifies this tool invocation. Used to correlate start/update/end events and parent/child relationships. |
ParentToolCallID | Identifier of the parent tool call when this invocation is a child (e.g., a tool launched by an agent-tool). UIs and subscribers use it to reconstruct the call tree. |
Executor Signature
All tool executors receive ToolCallMeta as an explicit parameter:
func Execute(ctx context.Context, meta *runtime.ToolCallMeta, call *runtime.ToolCall) (*runtime.ToolExecutionResult, error) {
// Access run context directly from meta
log.Printf("Executing tool in run %s, session %s, turn %s",
meta.RunID, meta.SessionID, meta.TurnID)
// Use ToolCallID for correlation
span := tracer.StartSpan("tool.execute", trace.WithAttributes(
attribute.String("tool.call_id", meta.ToolCallID),
attribute.String("tool.parent_call_id", meta.ParentToolCallID),
))
defer span.End()
typedResult := buildTypedResult()
return runtime.Executed(&planner.ToolResult{Name: call.Name, Result: typedResult}), nil
}
Why Explicit Metadata?
The explicit metadata pattern provides several benefits:
- Type safety: Compile-time guarantees that required identifiers are available
- Testability: Easy to construct test metadata without mocking context
- Clarity: No hidden dependencies on context keys or middleware ordering
- Correlation: Direct access to parent/child relationships for nested agent-tool calls
- Traceability: Complete causal chain from user input to tool execution to final response
Async & Durable Execution
Goa-AI uses Temporal Activities for all service-backed tool executions. This “async-first” architecture is implicit and requires no special DSL.
Implicit Async
When a planner decides to call a tool, the runtime does not block the OS thread. Instead:
- The runtime schedules a Temporal Activity for the tool call.
- The agent workflow suspends execution (saving state).
- The activity executes (on a local worker, remote worker, or even a different cluster).
- When the activity completes, the workflow wakes up, restores state, and resumes with the result.
This means every tool call is automatically parallelizable, durable, and long-running. You do not need to configure InterruptsAllowed for this standard async behavior.
Pause & Resume (Agent-Level)
InterruptsAllowed(true) is distinct: it allows the Agent itself to pause and wait for an arbitrary external signal (like a user’s clarification) that is not tied to a currently running tool activity.
| Feature | Implicit Async | Pause & Resume |
|---|---|---|
| Scope | Single Tool Execution | Entire Agent Workflow |
| Trigger | Calling any service-backed tool | Missing arguments or Planner request |
| Policy Required | None (Default) | InterruptsAllowed(true) |
| Use Case | Slow API, Batch Job, processing | Human-in-the-loop, Clarification |
Ensure you verify that your use case requires agent-level pausing before enabling the policy; often, standard tool async is sufficient.
Non-Blocking Planners
From the perspective of the planner (LLM), the interaction feels synchronous: the model requests a tool, “pauses”, and then “sees” the result in the next turn.
From the perspective of the infrastructure, it is fully asynchronous and non-blocking. This allows a single small agent worker to manage thousands of concurrent long-running agent executions without running out of threads or memory.
Survival Across Restarts
Because execution is durable, you can restart your entire backend—including the agent workers—while tools are mid-execution. When the systems come back up:
- Pending tool activities will be picked up by workers.
- Completed tools will report results to their parent workflows.
- Agents will resume exactly where they left off.
This capability is essential for building robust, production-grade agentic systems that operate reliably in dynamic environments.
Transforms
When a tool is bound to a Goa method via BindTo, code generation analyzes the tool Arg/Return and the method Payload/Result. If the shapes are compatible, Goa emits type-safe transform helpers:
Init<Tool>MethodPayload(in <ToolPayload>) <MethodPayload>converts the generated tool payload into the bound Goa method payload.Init<Tool>ToolResult(in <MethodResult>) <ToolResult>converts the bound Goa method result into the generated tool result.
Transforms are emitted under the toolset owner package (for example
gen/<service>/toolsets/<toolset>/transforms.go) and use Goa’s GoTransform to
safely map fields. Each helper has one return value; generated Go type
references determine its exact pointer/value signature. If a transform isn’t
emitted, write an explicit mapper in the executor.
Tool Identity
Each toolset defines typed tool identifiers (tools.Ident) for all generated tools—including non-exported toolsets. Prefer these constants over ad-hoc strings:
import searchspecs "example.com/assistant/gen/orchestrator/toolsets/search"
// Use a generated constant instead of ad-hoc strings/casts
spec, _ := rt.ToolSpec(searchspecs.Search)
schemas, _ := rt.ToolSchema(searchspecs.Search)
For exported toolsets (agent-as-tool), Goa-AI generates export packages under gen/<service>/agents/<agent>/exports/<export> with:
- Typed tool IDs
- Alias payload/result types
- Codecs
- One typed
<Tool>Tool()descriptor pairing each tool ID with its payload and result codecs; pass it toplanner.NewToolRequest
Tool Validation and Recovery
Goa-AI combines Goa’s design-time validations with a structured tool error model to give LLM planners a powerful way to repair invalid tool calls automatically.
Core Types: ToolError and ToolFailure
ToolError (alias to runtime/agent/toolerrors.ToolError):
Message string– human-readable summaryCause *ToolError– optional nested cause (preserves chains across retries and agent-as-tool hops)- Constructors:
planner.NewToolError(msg),planner.NewToolErrorWithCause(msg, cause),planner.ToolErrorFromError(err),planner.ToolErrorf(format, args...)
ToolFailure keeps failure classification separate from the next legal planner transition:
type ToolFailure struct {
Kind FailureKind
Error *ToolError
Recovery RecoveryDirective
}
type RecoveryDirective struct {
Action RecoveryAction
Issues []*tools.FieldIssue
PriorInput rawjson.Message
ExampleJSON rawjson.Message
}
Failure kinds include invalid calls, domain rejection, unavailability, rate limits, timeouts, malformed results, and internal errors. Recovery has three explicit actions:
RecoveryCorrectCallkeeps the failed tool available and supplies structured correction evidence.RecoveryReplanremoves the failed tool from the next planner turn.RecoveryFinishallows only finalization from evidence already collected.
ToolResult carries either a typed result or one structured failure:
type ToolResult struct {
Name tools.Ident
Result any
ServerData rawjson.Message
ResultBytes int
ResultOmitted bool
ResultOmittedReason string
Bounds *agent.Bounds
Failure *ToolFailure
Telemetry *telemetry.ToolTelemetry
ToolCallID string
ChildrenCount int
RunLink *run.Handle
}
Recovering from Admitted Tool Failures
The recommended pattern:
- Design tools with strong payload schemas (Goa design)
- Treat executor decode failures as invariant errors because model-emitted invalid payloads and planner encoding failures stop before execution
- Return
ToolFailurefor domain failures after admission so a model-authored call may request correction when a valid payload violates a cross-field or business rule - Teach your planner to inspect
ToolOutput.Failure; the runtime uses itsRecoveryaction to decide whether the same tool remains available, is removed for replanning, or the run must finish
The validated model client uses generated codecs to reject unknown fields, JSON
type mismatches, and schema constraints before planner or executor code runs.
Those failures are output-contract errors, not ToolFailure values. The example
below instead starts with an admitted model-authored call whose decoded payload
violates validateUpsertRule, a domain rule that the payload schema cannot
express. When that failure requests RecoveryCorrectCall, the workflow derives
the prior input and example from the provider call and registered tool
specification; it ignores executor-authored PriorInput and ExampleJSON.
Example Executor:
func Execute(ctx context.Context, meta *runtime.ToolCallMeta, call *runtime.ToolCall) (*runtime.ToolExecutionResult, error) {
args, err := spec.UpsertTool().Payload.FromJSON(call.Payload)
if err != nil {
return nil, fmt.Errorf("decode admitted %s payload: %w", call.Name, err)
}
if err := validateUpsertRule(args); err != nil {
return runtime.Executed(&planner.ToolResult{
Name: call.Name,
Failure: &planner.ToolFailure{
Kind: planner.FailureInvalidCall,
Error: planner.ToolErrorFromError(err),
Recovery: planner.RecoveryDirective{
Action: planner.RecoveryCorrectCall,
},
},
}), nil
}
res, err := client.Upsert(ctx, args)
if err != nil {
return runtime.Executed(&planner.ToolResult{
Name: call.Name,
Failure: &planner.ToolFailure{
Kind: planner.FailureUnavailable,
Error: planner.ToolErrorFromError(err),
Recovery: planner.RecoveryDirective{
Action: planner.RecoveryReplan,
},
},
}), nil
}
return runtime.Executed(&planner.ToolResult{Name: call.Name, Result: res}), nil
}
PlanResumeInput.ToolOutputs contains the workflow-safe form of each call:
canonical payload/result bytes plus Failure. For RecoveryCorrectCall, field
issues, prior input, and example JSON let the next planner turn correct the
call. RecoveryReplan removes the failed tool from that next turn.
RecoveryFinish permits only finalization. The runtime enforces these
transitions; planners do not infer them from error text. Only provider-authored
calls can use RecoveryCorrectCall. Runtime-created continuations have no
model-authored input and must replan or finish instead of exposing their
execution payload.
Tool Catalogs and Schemas
Goa-AI agents generate a single, authoritative catalog of tools from your Goa designs. This catalog powers:
- Planner tool advertisement (which tools the model can call)
- UI discovery (tool lists, categories, schemas)
- External orchestrators (MCP, custom frontends) that need machine-readable specs
Generated Specs and tool_schemas.json
For each agent, Goa-AI emits a specs package and a JSON catalog:
Specs packages (gen/<service>/agents/<agent>/specs/...):
types.go– payload/result Go structscodecs.go– JSON codecs (encode/decode typed payloads/results, enforce closed-object keys, and produce structured validation issues)specs.go–[]tools.ToolSpecentries with canonical tool ID, payload/result schemas, hints, plus one typedtools.TypedTooldescriptor per tool (for exampleSummarizeDocTool) pairing the tool identifier with its typed payload and result codecs
Generated spec accessors return fresh copies. Application mutation of one
returned schema, example, or codec wrapper cannot alter later model requests.
Use <Tool>Tool().Payload.FromJSON(...) for ordinary payloads and the generated
Decode<Tool>(payload, meta, labels) helper for tools with injected fields.
Do not retain or mutate spec internals as shared runtime state.
No-Result Tools
A method-backed tool whose Goa service method has no result generates an empty
result TypeSpec: no schema and no result codec. Its executor reports success
without inventing a payload:
return runtime.Executed(&planner.ToolResult{Name: call.Name}), nil
The generated typed descriptor uses an empty tools.JSONCodec[any] for that
result. PlanResume observes successful completion with empty result bytes.
JSON catalog (tool_schemas.json):
Location: gen/<service>/agents/<agent>/specs/tool_schemas.json
Contains one entry per tool with:
id– canonical tool ID ("<service>.<toolset>.<tool>")service,toolset,title,description,tagspayload.schemaandresult.schema(JSON Schema)
This JSON file is ideal for feeding schemas to LLM providers, building UI forms/editors, and offline documentation tooling.
Runtime Introspection APIs
At runtime, you do not need to read tool_schemas.json from disk. The runtime exposes an introspection API:
agents := rt.ListAgents() // []agent.Ident
toolsets := rt.ListToolsets() // []string
spec, ok := rt.ToolSpec(toolID) // single ToolSpec
schemas, ok := rt.ToolSchema(toolID) // payload/result schemas
specs := rt.ToolSpecsForAgent(chat.AgentID) // []ToolSpec for one agent
Where toolID is a typed tools.Ident constant from a generated specs or agenttools package.
Server Data
Some tools need to return rich observer-facing output - full time series, topology graphs, large result sets, evidence references - that is useful for UIs and audit systems but too heavy for model providers. Goa-AI models that non-model output as server-data.
Model-Facing vs Server Data
The key distinction is what data flows where:
| Data Type | Sent to Model | Stored/Streamed | Purpose |
|---|---|---|---|
| Model-facing result | ✓ | ✓ | Bounded summary the LLM reasons about |
| Timeline server-data | ✗ | ✓ | Observer-facing data for UIs, timelines, charts, maps, and tables |
| Evidence server-data | ✗ | ✓ | Provenance references or audit evidence |
| Internal server-data | ✗ | Depends on consumer | Tool-composition attachments or server-only metadata |
This separation lets you:
- Keep model context windows bounded and focused
- Provide rich visualizations (charts, graphs, tables) without bloating LLM prompts
- Attach provenance and audit data that models don’t need to see
- Stream large datasets to UIs while the model works with summaries
Declaring ServerData in DSL
Use the ServerData(kind, schema) function inside a Tool definition:
Tool("get_time_series", "Get time series data", func() {
Args(func() {
Attribute("device_id", String, "Device identifier")
Attribute("start_time", String, "Start timestamp (RFC3339)")
Attribute("end_time", String, "End timestamp (RFC3339)")
Required("device_id", "start_time", "end_time")
})
// Model-facing result: bounded summary
Return(func() {
Attribute("summary", String, "Summary for the model")
Attribute("count", Int, "Number of data points")
Attribute("min_value", Float64, "Minimum value in range")
Attribute("max_value", Float64, "Maximum value in range")
Required("summary", "count")
})
// Server-data: full-fidelity data for observers (e.g., UIs)
ServerData("metrics.time_series", func() {
Attribute("data_points", ArrayOf(TimeSeriesPoint), "Full time series data")
Attribute("metadata", MapOf(String, String), "Additional metadata")
Required("data_points")
}, func() {
AudienceTimeline()
})
})
The kind parameter (e.g., "metrics.time_series") identifies the server-data kind so UIs can dispatch appropriate renderers.
The audience declares routing intent:
AudienceTimeline()for observer-facing timeline/UI payloads.AudienceEvidence()for provenance or audit evidence.AudienceInternal()for server-only composition payloads.
Use FromMethodResultField("field_name") with BindTo(...) tools when the
server-data payload is projected from a field on the bound service method result.
Generated Specs and Helpers
In the specs packages, each tools.ToolSpec entry includes:
Payload tools.TypeSpec– tool input schemaResult tools.TypeSpec– model-facing output schemaServerData []*tools.ServerDataSpec– server-only payloads emitted alongside the result
Server-data entries include generated schemas and codecs so subscribers can decode canonical JSON bytes without sending those bytes to model providers.
Runtime Usage Patterns
In tool executors, attach canonical server-data JSON to the tool result:
func (e *Executor) Execute(
ctx context.Context,
meta *runtime.ToolCallMeta,
call *runtime.ToolCall,
) (*runtime.ToolExecutionResult, error) {
args, err := specs.GetTimeSeriesTool().Payload.FromJSON(call.Payload)
if err != nil {
return nil, fmt.Errorf("decode admitted %s payload: %w", call.Name, err)
}
// Fetch full data
fullData, err := e.dataService.GetTimeSeries(ctx, args.DeviceID, args.StartTime, args.EndTime)
if err != nil {
return runtime.Executed(&planner.ToolResult{
Name: call.Name,
Failure: &planner.ToolFailure{
Kind: planner.FailureUnavailable,
Error: planner.ToolErrorFromError(err),
Recovery: planner.RecoveryDirective{Action: planner.RecoveryReplan},
},
}), nil
}
// Build bounded model-facing result
result := &specs.GetTimeSeriesResult{
Summary: fmt.Sprintf("Retrieved %d data points from %s to %s", len(fullData.Points), args.StartTime, args.EndTime),
Count: len(fullData.Points),
MinValue: fullData.Min,
MaxValue: fullData.Max,
}
// Build full-fidelity server-data for UIs
// Generated server-data codecs are named from the tool and kind, for example:
// specs.GetTimeSeriesMetricsTimeSeriesServerDataCodec.ToJSON(...)
serverData, err := buildCanonicalServerData("metrics.time_series", fullData)
if err != nil {
return nil, err
}
return runtime.Executed(&planner.ToolResult{
Name: call.Name,
Result: result,
ServerData: serverData,
}), nil
}
Method-backed tools can also attach server-data through generated providers and result materializers. A materializer is deterministic and runs on both normal execution and externally provided-result await paths:
reg := runtime.ToolsetRegistration{
Name: "orchestrator.metrics",
Specs: []tools.ToolSpec{specs.SpecGetTimeSeries()},
ResultMaterializer: func(ctx context.Context, meta runtime.ToolCallMeta, call *runtime.ToolCall, result *planner.ToolResult) error {
if len(result.ServerData) != 0 {
return nil
}
result.ServerData = buildServerData(call, result)
return nil
},
}
In stream subscribers or UI handlers, read ServerData from tool end events
or run logs and decode it with the generated codecs for the declared kinds:
func handleToolEnd(event stream.ToolEnd) {
if len(event.Data.ServerData) == 0 {
return
}
data, err := decodeTimeSeriesServerData(event.Data.ServerData)
if err != nil {
log.Printf("invalid server-data: %v", err)
return
}
renderTimeSeriesChart(data.DataPoints)
}
When to Use ServerData
Use server-data when:
- Tool results include data too large for model context (time series, logs, large tables)
- UIs need structured data for visualization (charts, graphs, maps)
- You want to separate what the model reasons about from what users see
- Downstream systems need full-fidelity data while the model works with summaries
Avoid server-data when:
- The full result fits comfortably in model context
- There’s no UI or downstream consumer that needs the full data
- The bounded result already contains everything needed
Best Practices
- Put validations in the design, not in planners – Use Goa’s attribute DSL (
Required,MinLength,Enum, etc.) - Return
ToolFailurefrom executors – Preserve the error cause and choose the exact recovery action instead of returning a plain error or panic - Keep correction evidence exact – Use generated field issues, canonical prior input, and a schema-compliant JSON example
- Teach planners to read failures – Make
ToolOutput.Failurehandling a first-class part of the planner - Avoid re-validating inside services – Goa-AI assumes validation happens at the tool boundary
Next Steps
- Agent Composition - Build complex systems with agent-as-tool patterns
- MCP Integration - Connect to external tool servers
- Runtime - Understand tool execution flow