Toolsets

Learn about toolset types, execution models, validation, structured failure recovery, and tool catalogs in Goa-AI.

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.go for registry-routed, service-side execution
  • Agents that Use these 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> with NewRegistration and typed call builders
  • Consumer-side helpers in agents that Use the 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=true so 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 example removed
  • 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
}
FieldDescription
ReturnedCount of items actually present in the result
TotalBest-effort count of total items before truncation (nil if unknown)
TruncatedTrue if any caps were applied (pagination, depth limits, size limits)
NextCursorNon-empty opaque cursor for the next page; valid only for a paging-enabled tool when Truncated is true
RefinementHintHuman-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:

  1. Applying truncation logic: Pagination, result limits, depth caps, time windows
  2. Populating runtime bounds metadata: Setting planner.ToolResult.Bounds
  3. 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 optional next_cursor)
  • tools.ToolSpec.Bounds stores the model-facing JSON field names. If the Goa DSL names a lower-camel attribute such as NextCursor("nextCursor"), codegen emits NextCursorField: "next_cursor" so schemas, runtime projection, and result-codec validation use one spelling.
  • ContinueWith generates a dedicated continuation relationship. The source result does not expose next_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 Cursor exposes next_cursor and 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.Returned and Bounds.Truncated must always be set on successful bounded tool results.
  • Bounds.Total and Bounds.RefinementHint are optional and should only be set when known.
  • Bounds.NextCursor is optional, but when present it must point to a non-empty string, the generated tool spec must declare paging, and Bounds.Truncated must 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:

  1. The runtime validates that a successful bounded tool returned planner.ToolResult.Bounds
  2. The runtime merges those bounds into the emitted JSON using the model-facing JSON field names generated from BoundedResult(...)
  3. 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
  4. If another tool in the same parallel batch requires finish recovery, 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
  5. For direct Cursor, the runtime projects the opaque cursor into next_cursor and the model supplies it on the next call
  6. 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:

  1. Hidden from LLM: the field is excluded from the JSON schema and the model-facing required list sent to the model provider.
  2. Validated at design time: the field must be a required String on the tool’s effective payload (the explicit Args() when given, otherwise the bound method’s payload).
  3. Meta-backed or label-backed: a name that Goify’s to one of the five fixed runtime.ToolCallMeta fields (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 a BindTo tool, because the registry wire protocol used by registry-served bound tools carries no run labels.
  4. 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):

  1. 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.
  2. The planner returns schema-valid ToolCalls []planner.ToolRequest with a generated tool name, canonical payload bytes, and an optional provider call ID.
  3. The runtime validates the whole plan, assigns each execution ID, and schedules ExecuteToolActivity.
  4. The activity decodes the already admitted payload. A decode failure is an internal invariant error, not correction evidence.
  5. The activity calls the toolset registration’s Execute(ctx, meta, *runtime.ToolCall) with canonical JSON and the runtime-assigned execution ID.
  6. 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:

  1. The runtime detects Inline=true on the toolset registration
  2. It injects the engine.WorkflowContext into ctx so the toolset’s Execute function can start the provider agent as a child workflow with its own RunID
  3. It calls the toolset’s Execute(ctx, meta, *runtime.ToolCall) with canonical JSON payload and tool metadata (including parent RunID and ToolCallID)
  4. The generated agent-tool executor builds nested agent messages (system + user) from the tool payload and runs the provider agent as a child run
  5. The nested agent executes a full plan/execute/resume loop in its own run; its RunOutput and tool events are aggregated into a parent planner.ToolResult that carries the result payload, aggregated telemetry, child ChildrenCount, and a RunLink pointing at the child run
  6. Stream subscribers emit both tool_start / tool_end for the parent tool call and a child_run_linked link 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:

  • ResultMaterializer runs 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 typed planner.ToolResult before the runtime encodes JSON for hooks, workflow boundaries, or callers.
  • Use it to attach result.ServerData or 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

FieldDescription
RunIDDurable workflow execution identifier of the run that owns this tool call. Stable across retries; used to correlate runtime records and telemetry.
SessionIDLogically groups related runs (e.g., a chat conversation). Services typically index memory and search attributes by session.
TurnIDIdentifies the conversational turn that produced this tool call. Event streams use it to order and group events.
ToolCallIDUniquely identifies this tool invocation. Used to correlate start/update/end events and parent/child relationships.
ParentToolCallIDIdentifier 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:

  1. The runtime schedules a Temporal Activity for the tool call.
  2. The agent workflow suspends execution (saving state).
  3. The activity executes (on a local worker, remote worker, or even a different cluster).
  4. 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.

FeatureImplicit AsyncPause & Resume
ScopeSingle Tool ExecutionEntire Agent Workflow
TriggerCalling any service-backed toolMissing arguments or Planner request
Policy RequiredNone (Default)InterruptsAllowed(true)
Use CaseSlow API, Batch Job, processingHuman-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 to planner.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 summary
  • Cause *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:

  • RecoveryCorrectCall keeps the failed tool available and supplies structured correction evidence.
  • RecoveryReplan removes the failed tool from the next planner turn.
  • RecoveryFinish allows 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:

  1. Design tools with strong payload schemas (Goa design)
  2. Treat executor decode failures as invariant errors because model-emitted invalid payloads and planner encoding failures stop before execution
  3. Return ToolFailure for domain failures after admission so a model-authored call may request correction when a valid payload violates a cross-field or business rule
  4. Teach your planner to inspect ToolOutput.Failure; the runtime uses its Recovery action 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 structs
  • codecs.go – JSON codecs (encode/decode typed payloads/results, enforce closed-object keys, and produce structured validation issues)
  • specs.go[]tools.ToolSpec entries with canonical tool ID, payload/result schemas, hints, plus one typed tools.TypedTool descriptor per tool (for example SummarizeDocTool) 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, tags
  • payload.schema and result.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 TypeSent to ModelStored/StreamedPurpose
Model-facing resultBounded summary the LLM reasons about
Timeline server-dataObserver-facing data for UIs, timelines, charts, maps, and tables
Evidence server-dataProvenance references or audit evidence
Internal server-dataDepends on consumerTool-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 schema
  • Result tools.TypeSpec – model-facing output schema
  • ServerData []*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 ToolFailure from 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.Failure handling a first-class part of the planner
  • Avoid re-validating inside services – Goa-AI assumes validation happens at the tool boundary

Next Steps