Internal Tool Registry
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.
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.
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
| Variable | Description | Default |
|---|---|---|
REGISTRY_ADDR | gRPC listen address | :9090 |
REGISTRY_NAME | Registry cluster name | registry |
REDIS_URL | Redis connection URL | localhost:6379 |
REDIS_PASSWORD | Redis password | (none) |
PING_INTERVAL | Health check ping interval | 10s |
MISSED_PING_THRESHOLD | Missed pings before unhealthy | 3 |
Architecture
Components
| Component | Description |
|---|---|
| Service | gRPC handlers for discovery and invocation |
| Catalog | Redis-backed tool schemas, admission tokens, provider leases, and retirement history |
| Health Tracker | Monitors provider liveness via ping/pong |
| Stream Manager | Manages Pulse streams for tool call routing |
| Call Record Store | Retains 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:
- 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. - Provider wait: An unpublished call waits for the active toolset to have a healthy provider, bounded by the call’s existing execution deadline.
- 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.
- Immutable execution: Successful publication fixes the provider assignment. The call can no longer move because an external effect may have begun.
- Result delivery:
CallToolreturns 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
- The health scheduler reads the active toolsets from the shared catalog.
- The node holding a toolset’s ping lease sends a ping while that toolset has a live provider accepting calls.
Pongupdates the catalog only when the reply matches the current registration, provider process, and health-check identity.- 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
| Method | Description |
|---|---|
Register | Admit a provider at startup with its generated tool definitions. A different contract waits until old leases end. |
RenewProvider | Extend the exact unexpired lease without sending definitions. Preserve draining and any longer settlement deadline; lost authority returns provider_lease_lost. |
DrainProvider | Make one provider lease unavailable for new calls while preserving its authority to finish calls it already owns. |
ReleaseProvider | Remove one exact provider lease after its process has settled accepted work. |
Unregister | Remove 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. |
Pong | Record provider health for the exact current lease and health-check epoch. |
ClaimToolCall | Grant execution of a published request to one exact provider lease. |
CompleteToolCall | Commit the canonical terminal result for the claimed call and publish it to the result stream. |
PublishToolOutputDelta | Publish a bounded, best-effort progress fragment for a claimed call. |
ReportToolCallOverload | Record bounded retry control before a provider executes an overloaded call. |
Native Agent operations
| Method | Description |
|---|---|
RegisterAgentToolset | Create a native Agent declaration without a provider lease; an identical active registration succeeds. |
ReplaceAgentToolset | Replace or reactivate a native declaration using its current token. A stale token returns admission_conflict. |
Discovery Operations
| Method | Description |
|---|---|
ListToolsets | List all registered toolsets (with optional tag filtering). Returns metadata only, not full schemas. |
GetToolset | Get full schema for a specific toolset, including all tool input/output schemas. |
ResolveToolset | Read an active definition and its exact registration token together; provider health is a separate check. |
CheckAdmission | Check whether an exact service registration has an unexpired, non-draining provider lease and a fresh health response. |
Search | Search toolsets by keyword matching name, description, or tags. |
Invocation Operations
| Method | Description |
|---|---|
CallTool | Validate 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. |
CallResolvedTool | Publish a service call only against its retained registration token; a replacement before publication records call_not_admitted. |
RetryTool | Republish the exact original admission after recorded provider overload. It never moves execution to a replacement provider. |
Best Practices
Deployment
- Use the same
Namefor 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
PingIntervalandMissedPingThresholdfor the desired health-check cadence and tolerated pong age. Seeregistry.Configfor 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
- Learn about Toolsets for defining tools
- Explore Production for deployment patterns
- Read about Agent Composition for cross-agent tool sharing
See Tool search and dynamic catalogs for current source resolution, generated contracts, provider behavior, and migration.