Internal Tool Registry

Markdown
Deploy a clustered gateway for cross-process toolset discovery and invocation.

The Internal Tool Registry is a clustered gateway service that enables toolset discovery and invocation across process boundaries. It’s designed for scenarios where toolsets are provided by separate services that may scale independently from the agents consuming them.

The catalog also stores native Agent tool declarations. These name an existing worker and an immutable application configuration. The consuming runtime starts a child workflow; provider leases, health pings, and Pulse invocation apply only to service tools.

The dynamic Agent APIs described here require Goa-AI v0.84.0 or later.

Overview

The registry acts as both a catalog and a gateway:

  • Catalog: Agents discover available toolsets, their schemas, and health status
  • Gateway: Tool calls are routed through the registry to providers via Pulse streams

This decouples agents from toolset providers, enabling independent scaling, deployment, and lifecycle management.

Tool Registry vs Prompt Registry

These are different systems with different responsibilities:

  • Internal Tool Registry (this page): cross-process discovery/invocation of toolsets and tool calls.
  • Runtime Prompt Registry (runtime.PromptRegistry): in-process prompt spec registration and rendering, optionally backed by a prompt override store (runtime.WithPromptStore).

The tool registry does not store prompt templates or resolve prompt overrides. Prompt rendering remains in the runtime/planner layer and emits prompt_rendered observability events.

Agent-Registry-Provider Topology

Multi-Node Clustering

Multiple registry nodes can participate in the same logical registry by using the same Name in their configuration and connecting to the same Redis instance.

Nodes with the same name automatically:

  • Share toolset registrations through directly read Redis state
  • Coordinate health check pings with expiring Redis leases, acquired separately for each toolset
  • Share provider health state across all nodes

This enables horizontal scaling and high availability. Clients can connect to any node and see the same registry state.

Registry Cluster Architecture

Quick Start

Library Usage

Create and run a registry node programmatically. registry.New initializes the Redis-backed catalog, call records, Pulse streams, and health scheduler. Run starts the gRPC server and blocks until shutdown. The example uses local development addresses; configure Redis and gRPC credentials for your deployment.

package main

import (
    "context"
    "log"

    "github.com/redis/go-redis/v9"
    "goa.design/goa-ai/registry"
)

func main() {
    ctx := context.Background()

    // Connect to Redis
    rdb := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })
    defer rdb.Close()

    // Create the registry
    reg, err := registry.New(ctx, registry.Config{
        Redis: rdb,
        Name:  "my-registry",  // Nodes with same name form a cluster
    })
    if err != nil {
        log.Fatal(err)
    }

    // Run the gRPC server (blocks until shutdown)
    log.Println("starting registry on :9090")
    if err := reg.Run(ctx, ":9090"); err != nil {
        log.Fatal(err)
    }
}

Example Binary

The registry package includes an example binary for quick deployment. All nodes with the same REGISTRY_NAME pointing to the same Redis instance automatically form a cluster—they share toolset registrations and coordinate health checks without additional configuration.

# Single node (development)
REDIS_URL=localhost:6379 go run ./registry/cmd/registry

# Multi-node cluster (production)
REGISTRY_NAME=prod REGISTRY_ADDR=:9090 REDIS_URL=redis:6379 ./registry
REGISTRY_NAME=prod REGISTRY_ADDR=:9091 REDIS_URL=redis:6379 ./registry
REGISTRY_NAME=prod REGISTRY_ADDR=:9092 REDIS_URL=redis:6379 ./registry

Environment Variables

VariableDescriptionDefault
REGISTRY_ADDRgRPC listen address:9090
REGISTRY_NAMERegistry cluster nameregistry
REDIS_URLRedis connection URLlocalhost:6379
REDIS_PASSWORDRedis password(none)
PING_INTERVALHealth check ping interval10s
MISSED_PING_THRESHOLDMissed pings before unhealthy3

Architecture

Registry Internal Architecture

Components

ComponentDescription
ServicegRPC handlers for discovery and invocation
CatalogRedis-backed tool schemas, admission tokens, provider leases, and retirement history
Health TrackerMonitors provider liveness via ping/pong
Stream ManagerManages Pulse streams for tool call routing
Call Record StoreRetains each call’s request identity, provider assignment, deadline, publication state, and canonical terminal result

Tool Call Flow

When CallTool is invoked, the registry performs these steps in sequence:

  1. Identity and schema validation: The registry validates the payload and derives one run-scoped tool_use_id. An exact retry attaches to the same retained record.
  2. Provider wait: An unpublished call waits for the active toolset to have a healthy provider, bounded by the call’s existing execution deadline.
  3. Atomic publication: One Redis operation verifies that the selected provider is still current and non-draining, then appends the request exactly once. If a rollout changed providers after the health check, the unpublished call selects the replacement and tries again within the same deadline.
  4. Immutable execution: Successful publication fixes the provider assignment. The call can no longer move because an external effect may have begun.
  5. Result delivery: CallTool returns the exact provider token, result-stream identity, execution deadline, and retention deadline. The executor reads that stream until the provider returns a terminal result or the execution deadline settles the call.

If the execution deadline expires before publication, the registry records call_not_admitted, which proves that the executor may choose another plan. A published call with an uncertain result returns outcome_unknown and may not be replaced.

Provider Integration (Service-Side)

Registry routing is only half of the story: providers must run a tool execution loop in the toolset-owning service process. Before invoking a handler, the provider calls ClaimToolCall using its worker lifecycle context and existing bounded claim timeout, independently of the message’s execution deadline. The registry decides whether the call has expired, already has a final result, or is owned by another delivery. For these outcomes, the provider acknowledges the message without invoking the handler or stopping its execution loop. Only after an execute decision does the provider invoke the handler with the message’s original execution deadline, without extending it.

For service-owned, method-backed toolsets (tools declared with BindTo(...)), code generation emits a provider adapter at:

  • gen/<service>/toolsets/<toolset>/provider.go

The generated provider:

  • Decodes the incoming tool payload JSON using the generated payload codec
  • Builds the Goa method payload using generated transforms
  • Calls the bound service method
  • Encodes the tool result JSON together with any declared server-data using the generated result codec

The example below uses module example.com/registry-provider, service catalog, and its method-backed toolset search, registered as catalog.search. Replace the two application import paths and the toolset name with your generated values. NewProvider, ToolSchemas, and SchemaFingerprint come from the generated toolset package; keep the generated schemas intact. The registration callbacks follow the Service-Side Tool Providers example in the module’s generated AGENTS_QUICKSTART.md (Quickstart).

Pass your service implementation, a Pulse client constructed with pulse.New(pulse.Options{Redis: rdb}), and a registry gRPC connection created with grpc.NewClient using your deployment’s credentials. Supply a stable providerID for this process and toolset, unique among active replicas, and the required deployment-issued admissionRevision shared by replicas of the same registration. Serve creates the incarnation ID and supplies it to the callbacks. Bound service methods must honor context cancellation. Run serveTools as part of your service lifecycle and wait for it to return before closing either client. On shutdown, the provider stops intake and settles claimed calls, results, and acknowledgements within Options.ShutdownTimeout. Only successful settlement permits release of the exact lease, using the separate Registration.ReleaseTimeout budget. Failed settlement leaves ownership to end through lease expiry. Preserve and report settlement or release errors even when the returned error also matches context.Canceled. Every required registration callback is wired below:

package providers

import (
	"context"
	"encoding/json"
	"time"

	gencatalog "example.com/registry-provider/gen/catalog"
	gensearch "example.com/registry-provider/gen/catalog/toolsets/search"
	"goa.design/goa-ai/features/stream/pulse/clients/pulse"
	genregistrygrpc "goa.design/goa-ai/registry/gen/grpc/registry/client"
	genregistry "goa.design/goa-ai/registry/gen/registry"
	registrywire "goa.design/goa-ai/runtime/toolregistry"
	"goa.design/goa-ai/runtime/toolregistry/provider"
	"google.golang.org/grpc"
)

// serveTools runs the generated catalog provider until shutdown or a provider error.
// The caller owns the clients, service implementation, and deployment identifiers.
func serveTools(ctx context.Context, pulseClient pulse.Client, conn *grpc.ClientConn,
	serviceImpl gencatalog.Service, providerID, admissionRevision string) error {
	const toolsetID = "catalog.search"
	transport := genregistrygrpc.NewClient(conn, grpc.WaitForReady(true))
	registryClient := genregistry.NewClient(
		transport.Register(),
		transport.RenewProvider(),
		transport.ReleaseProvider(),
		transport.DrainProvider(),
		transport.Unregister(),
		transport.Pong(),
		transport.RegisterAgentToolset(),
		transport.ReplaceAgentToolset(),
		transport.ListToolsets(),
		transport.GetToolset(),
		transport.ResolveToolset(),
		transport.CheckAdmission(),
		transport.Search(),
		transport.CallTool(),
		transport.CallResolvedTool(),
		transport.RetryTool(),
		transport.CompleteToolCall(),
		transport.PublishToolOutputDelta(),
		transport.ReportToolCallOverload(),
		transport.ClaimToolCall(),
	)
	toolSchemas := gensearch.ToolSchemas()
	handler := gensearch.NewProvider(serviceImpl)
	return provider.Serve(ctx, pulseClient, toolsetID, handler,
		provider.Registration{
			AdmissionRevision: admissionRevision,
			Register: func(ctx context.Context, toolset, providerID, incarnationID, admissionRevision string) (provider.RegistrationLease, error) {
				schemaFingerprint, err := gensearch.SchemaFingerprint(toolset)
				if err != nil {
					return provider.RegistrationLease{}, err
				}
				result, err := registryClient.Register(ctx, &genregistry.RegisterPayload{
					Name:                  toolset,
					Tools:                 toolSchemas,
					ProviderID:            providerID,
					ProviderIncarnationID: incarnationID,
					AdmissionRevision:     admissionRevision,
					WireProtocolVersion:   registrywire.WireProtocolVersion,
					SchemaFingerprint:     schemaFingerprint,
				})
				if err != nil {
					return provider.RegistrationLease{}, err
				}
				return provider.RegistrationLease{
					RegistrationToken: result.RegistrationToken,
					Duration:          time.Duration(result.LeaseDurationMs) * time.Millisecond,
				}, nil
			},
			Renew: func(ctx context.Context, toolset, providerID, incarnationID, expectedToken string) (time.Duration, error) {
				result, err := registryClient.RenewProvider(ctx, &genregistry.RenewProviderPayload{
					Name: toolset,
					ProviderID: providerID,
					ProviderIncarnationID: incarnationID,
					ExpectedRegistrationToken: expectedToken,
				})
				if err != nil {
					return 0, err
				}
				return time.Duration(result.LeaseDurationMs) * time.Millisecond, nil
			},
			Drain: func(ctx context.Context, toolset, providerID, incarnationID, expectedToken string, settlementDuration time.Duration) error {
				return registryClient.DrainProvider(ctx, &genregistry.DrainProviderPayload{
					Name:                      toolset,
					ProviderID:                providerID,
					ProviderIncarnationID:     incarnationID,
					ExpectedRegistrationToken: expectedToken,
					SettlementDurationMs:      settlementDuration.Milliseconds(),
				})
			},
			Release: func(ctx context.Context, toolset, providerID, incarnationID, expectedToken string) error {
				return registryClient.ReleaseProvider(ctx, &genregistry.ReleaseProviderPayload{
					Name:                      toolset,
					ProviderID:                providerID,
					ProviderIncarnationID:     incarnationID,
					ExpectedRegistrationToken: expectedToken,
				})
			},
			Complete: func(ctx context.Context, toolset, providerID, incarnationID, providerToken, requestEventID string, result registrywire.ToolResultMessage) error {
				resultJSON, err := json.Marshal(result)
				if err != nil {
					return err
				}
				return registryClient.CompleteToolCall(ctx, &genregistry.CompleteToolCallPayload{
					Toolset:                   toolset,
					ProviderID:                providerID,
					ProviderIncarnationID:     incarnationID,
					RegistrationToken:         result.RegistrationToken,
					ToolUseID:                 result.ToolUseID,
					ResultJSON:                resultJSON,
					RequestEventID:            requestEventID,
					ProviderRegistrationToken: providerToken,
				})
			},
			PublishOutputDelta: func(ctx context.Context, toolset, providerID, incarnationID, providerToken, callToken, toolUseID, requestEventID, stream, delta string) error {
				return registryClient.PublishToolOutputDelta(ctx, &genregistry.PublishToolOutputDeltaPayload{
					Toolset:                   toolset,
					ProviderID:                providerID,
					ProviderIncarnationID:     incarnationID,
					ProviderRegistrationToken: providerToken,
					CallRegistrationToken:     callToken,
					ToolUseID:                 toolUseID,
					RequestEventID:            requestEventID,
					Stream:                    stream,
					Delta:                     delta,
				})
			},
			ReportOverload: func(ctx context.Context, toolset, providerID, incarnationID, providerToken, callToken, toolUseID, requestEventID string) error {
				return registryClient.ReportToolCallOverload(ctx, &genregistry.ProviderToolCallClaimPayload{
					Toolset:                   toolset,
					ProviderID:                providerID,
					ProviderIncarnationID:     incarnationID,
					ProviderRegistrationToken: providerToken,
					CallRegistrationToken:     callToken,
					ToolUseID:                 toolUseID,
					RequestEventID:            requestEventID,
				})
			},
			Claim: func(ctx context.Context, claim provider.ClaimRequest) (provider.ClaimDisposition, error) {
				result, err := registryClient.ClaimToolCall(ctx, &genregistry.ClaimToolCallPayload{
					Toolset:                   claim.Toolset,
					ProviderID:                claim.ProviderID,
					ProviderIncarnationID:     claim.ProviderIncarnationID,
					ProviderRegistrationToken: claim.ProviderRegistrationToken,
					CallRegistrationToken:     claim.CallRegistrationToken,
					ToolUseID:                 claim.ToolUseID,
					RequestEventID:            claim.RequestEventID,
					ClaimOperationID:          claim.OperationID,
				})
				if err != nil {
					return "", err
				}
				return provider.ClaimDisposition(result.Disposition), nil
			},
		},
		provider.Options{
			ProviderID: providerID,
			Pong: func(ctx context.Context, providerID, incarnationID, pingID string) error {
				return registryClient.Pong(ctx, &genregistry.PongPayload{
					PingID:                pingID,
					Toolset:               toolsetID,
					ProviderID:            providerID,
					ProviderIncarnationID: incarnationID,
				})
			},
		},
	)
}

Stream IDs are deterministic:

  • Tool calls: toolset:<toolsetID>:requests
  • Results: result:<toolUseID>

Configuration

Registry Options

The library example shows the minimal configuration: pass the application-owned Redis client in Redis and choose a shared Name for the registry cluster. Nodes using the same name and Redis database share the catalog, call records, and health-check coordination. Each node reads compact catalog state directly from Redis; full definitions are stored separately.

See registry.Config for the complete API and defaults. PingInterval and MissedPingThreshold control health checks; ExecutionTimeout bounds newly admitted execution; ResultStreamTTL controls result retention; and ProviderLeaseDuration controls provider registration renewal. ExpectedToolsets records required catalog names in telemetry without rejecting registrations or calls. Logger receives call-settlement failures. Configure these options when constructing the registry.

Redis Storage

Redis stores the catalog’s tool schemas, admission identities, provider leases, health timestamps, and retirement history. Call records and Pulse request and result streams also use Redis. Use durable Redis so registry replicas and process restarts observe the same registrations and call decisions. The application owns the Redis client and closes it after the registry stops.

Provider renewal and storage upgrades

Providers send generated schemas once during startup. The required Renew callback calls RenewProvider with the toolset, provider ID, incarnation ID, and expected registration token. It returns only the granted lease duration. Renewal never creates a lease, changes its token, or clears draining. A missing, expired, replaced, or retired lease returns provider_lease_lost, and Serve stops. Temporary communication failures retry only within the existing lease cutoff; stream/group and ping coordination repair remain supported.

The registry stores compact admission, lease, health, and discovery state separately from full definitions and permanent retired tokens. Registration updates affected records atomically. Health checks and lease operations transfer only compact state. Each process reuses definitions by fingerprint and retains compiled execution validators; Get and Resolve return independent full values.

Upgrading from the combined catalog format requires a coordinated maintenance cutover. Stop new work, settle accepted calls, gracefully stop providers while the old registry can drain and release them, then stop every old registry writer. Back up and convert the catalog offline, preserving every definition, identity, lease, timestamp, and retired token. Leave calls, streams, settlement indexes, and retention deadlines intact. Start the new registry and updated providers; resume only after strict startup validation and exact CheckAdmission checks pass. This storage change does not change tool fingerprints or the provider message version. Add the required Renew callback and regenerate clients. Never run old and new registry writers together. Restore the untouched backup only while all writers remain stopped and no new writes have resumed; afterward, recover forward. Normal startup has no legacy decoder.

Health Monitoring

The registry sends health pings over Pulse streams. Providers reply through the Pong gRPC method.

How It Works

  1. The health scheduler reads the active toolsets from the shared catalog.
  2. The node holding a toolset’s ping lease sends a ping while that toolset has a live provider accepting calls.
  3. Pong updates the catalog only when the reply matches the current registration, provider process, and health-check identity.
  4. Routing requires an unexpired provider lease that accepts new calls and a sufficiently recent accepted pong.

Health is derived from the catalog using Redis time. The last accepted pong must be no older than (MissedPingThreshold + 1) × PingInterval. An unpublished call waits for a healthy provider only within its existing execution deadline.

Distributed Coordination

Each registry node runs a local scheduler and competes for an expiring Redis lease for each toolset. The node that acquires the lease performs that health check; after it expires, another node can acquire it. Lease names are scoped to the registry cluster.

Provider leases, the current health-check identity, and the last accepted pong are stored together in the catalog. Every node derives health from that record, so a delayed reply from an obsolete provider cannot make the current registration healthy.

Client Integration

Use the generated registry service client for provider and invocation APIs. For catalog discovery, runtime/registry.NewClient wraps that same generated client and exposes ListToolsets, GetToolset, and Search, including the resource types used by runtime/registry.Manager.

The example lists the catalog and retrieves one named toolset’s full schema. Pass a connection created with grpc.NewClient and your deployment’s credentials; the caller retains ownership of that connection. The generated client is wired with every endpoint, as in the provider example above.

package discovery

import (
	"context"

	genregistrygrpc "goa.design/goa-ai/registry/gen/grpc/registry/client"
	genregistry "goa.design/goa-ai/registry/gen/registry"
	runtimeregistry "goa.design/goa-ai/runtime/registry"
	"google.golang.org/grpc"
)

// discoverTools lists the catalog and retrieves the schema of the named toolset.
// The caller creates the gRPC connection and keeps it open during discovery.
func discoverTools(ctx context.Context, conn *grpc.ClientConn, toolsetName string) (
	[]*runtimeregistry.ToolsetInfo, *runtimeregistry.ToolsetSchema, error,
) {
	transport := genregistrygrpc.NewClient(conn, grpc.WaitForReady(true))
	generated := genregistry.NewClient(
		transport.Register(),
		transport.RenewProvider(),
		transport.ReleaseProvider(),
		transport.DrainProvider(),
		transport.Unregister(),
		transport.Pong(),
		transport.RegisterAgentToolset(),
		transport.ReplaceAgentToolset(),
		transport.ListToolsets(),
		transport.GetToolset(),
		transport.ResolveToolset(),
		transport.CheckAdmission(),
		transport.Search(),
		transport.CallTool(),
		transport.CallResolvedTool(),
		transport.RetryTool(),
		transport.CompleteToolCall(),
		transport.PublishToolOutputDelta(),
		transport.ReportToolCallOverload(),
		transport.ClaimToolCall(),
	)
	client := runtimeregistry.NewClient(generated)
	toolsets, err := client.ListToolsets(ctx)
	if err != nil {
		return nil, nil, err
	}
	schema, err := client.GetToolset(ctx, toolsetName)
	if err != nil {
		return nil, nil, err
	}
	return toolsets, schema, nil
}

gRPC API

The registry exposes the following gRPC methods:

Provider Operations

MethodDescription
RegisterAdmit a provider at startup with its generated tool definitions. A different contract waits until old leases end.
RenewProviderExtend the exact unexpired lease without sending definitions. Preserve draining and any longer settlement deadline; lost authority returns provider_lease_lost.
DrainProviderMake one provider lease unavailable for new calls while preserving its authority to finish calls it already owns.
ReleaseProviderRemove one exact provider lease after its process has settled accepted work.
UnregisterRemove the exact current registration from discovery. Service tokens are permanently retired; native declarations can be reactivated with ReplaceAgentToolset. Accepted native child calls retain their selected declaration.
PongRecord provider health for the exact current lease and health-check epoch.
ClaimToolCallGrant execution of a published request to one exact provider lease.
CompleteToolCallCommit the canonical terminal result for the claimed call and publish it to the result stream.
PublishToolOutputDeltaPublish a bounded, best-effort progress fragment for a claimed call.
ReportToolCallOverloadRecord bounded retry control before a provider executes an overloaded call.

Native Agent operations

MethodDescription
RegisterAgentToolsetCreate a native Agent declaration without a provider lease; an identical active registration succeeds.
ReplaceAgentToolsetReplace or reactivate a native declaration using its current token. A stale token returns admission_conflict.

Discovery Operations

MethodDescription
ListToolsetsList all registered toolsets (with optional tag filtering). Returns metadata only, not full schemas.
GetToolsetGet full schema for a specific toolset, including all tool input/output schemas.
ResolveToolsetRead an active definition and its exact registration token together; provider health is a separate check.
CheckAdmissionCheck whether an exact service registration has an unexpired, non-draining provider lease and a fresh health response.
SearchSearch toolsets by keyword matching name, description, or tags.

Invocation Operations

MethodDescription
CallToolValidate and publish one run-scoped call. The call waits for provider health within its existing deadline, follows a replacement only before publication, and then returns its exact immutable execution reference.
CallResolvedToolPublish a service call only against its retained registration token; a replacement before publication records call_not_admitted.
RetryToolRepublish the exact original admission after recorded provider overload. It never moves execution to a replacement provider.

Best Practices

Deployment

  • Use the same Name for all nodes in a cluster to share catalog and call state and coordinate health checks
  • Point to the same Redis instance for state coordination
  • Deploy behind a load balancer for client connections—all nodes serve identical state
  • Use durable Redis for the catalog, call records, and Pulse streams so registry replicas and process restarts observe the same decisions

Health Monitoring

  • Configure PingInterval and MissedPingThreshold for the desired health-check cadence and tolerated pong age. See registry.Config for defaults.
  • Observe catalog and health telemetry to distinguish missing toolsets from providers that cannot currently accept calls.
  • Keep the execution deadline: unpublished calls wait for provider recovery only until their existing deadline.

Scaling

  • Add nodes to handle more gRPC connections—each node can serve any request
  • Nodes coordinate health checks with expiring Redis leases for each toolset
  • No sticky sessions required—result streams use Redis for cross-node delivery, so a tool call can be initiated on one node and completed on another

Next Steps

See Tool search and dynamic catalogs for current source resolution, generated contracts, provider behavior, and migration.