This guide is for developers building a Spring Voyage agent in .NET — either a fresh agent image or an extension to an existing one — who need the agent to expose actions beyond the platform's sv.* set and the tools any bound connector contributes. Read Tools first for the model; this page is the authoring how-to.
Most agents do not need image-tier tools. The platform ships sv.* (expertise search, message delivery, the small set of platform primitives), and a connector binding contributes everything the connector knows how to do against its external system. Reach for image-tier tools when:
- The agent integrates with a system that no shipped connector covers, and you do not want to author a full connector for it — for example a thin shim over a project-internal HTTP API.
- The agent needs a deterministic in-process helper (cryptography, calculation, local file inspection) that the LLM should be able to call by name.
- You are prototyping a tool surface and want it to ride along with one agent image before promoting it to a connector.
Image-tier tools are 1:1 with the subject that runs the image: there is no inheritance to children, no cross-agent reuse, and no portal toggle. If multiple units or agents need the same surface, the right home is usually a connector.
Cvoya.Spring.AgentSdk.IToolRegistry is the in-process seam an agent populates at startup. Each Register call attaches a ToolDefinition (the wire shape the platform persists on image_tools) and a handler that runs when the agent receives a tool call.
using System.Text.Json;
using Cvoya.Spring.AgentSdk;
using Cvoya.Spring.Core.Skills;
public static void RegisterAcmeTools(IToolRegistry registry)
{
registry.Register(
new ToolDefinition(
Name: "acme.echo",
Description: "Echoes the input string back.",
InputSchema: ParseSchema("""
{
"type": "object",
"required": ["value"],
"properties": {
"value": { "type": "string" }
}
}
""")),
handler: static (args, _) => Task.FromResult(args));
registry.Register(
new ToolDefinition(
Name: "acme.timestamp",
Description: "Returns the current UTC timestamp.",
InputSchema: ParseSchema("""
{ "type": "object", "properties": {} }
""")),
handler: static (_, _) =>
{
var element = JsonSerializer.SerializeToElement(new
{
utc = DateTimeOffset.UtcNow.ToString("O"),
});
return Task.FromResult(element);
});
}
static JsonElement ParseSchema(string raw)
{
using var doc = JsonDocument.Parse(raw);
return doc.RootElement.Clone();
}Three rules the registry enforces at registration time:
- Canonical naming. Each id must match
ToolNaming.Pattern(^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$) — lowercase dotted snake_case with a leading namespace segment. A non-canonical id throwsArgumentExceptionfrom theToolDefinitionconstructor; the registry never sees it. - No duplicates. Two registrations of the same id throw
InvalidOperationException. Choose the namespace before sharing the image —acme.*for an Acme-shop image,<your-org>.*for a one-off internal tool, neversv.*(reserved for the platform) and never the slug of a shipped connector. - Deterministic order. Tools come back in insertion order, so the JSON the platform caches on
image_toolsis stable and diff-able across deploys.
The handler is in-process: the platform never serialises it, never persists it, and only calls it when an inbound tool-call references the tool's id. Arguments are pre-validated by the runtime against the declared InputSchema; the handler does not need to re-validate.
The platform-side introspector calls GET /a2a/tools on the agent's HTTP listener at deploy and on image rotation. For an agent built on an ASP.NET Core minimal-API host, the SDK ships an extension that wires the route onto the existing listener — no extra port, no second process:
using Cvoya.Spring.AgentSdk;
var registry = new ToolRegistry();
RegisterAcmeTools(registry);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IToolRegistry>(registry);
var app = builder.Build();
app.MapToolsEndpoint(registry); // GET /a2a/tools
await app.RunAsync();The default path is /a2a/tools — the same prefix the A2A bridge uses, so the introspector hits the same endpoint regardless of whether the image is SDK-hosted or sidecar-wrapped. The extension returns a JSON array of ToolDefinition records (camelCase property names), which is the exact shape the platform persists on image_tools.
Agents that wrap an opaque CLI (Claude Code, Codex, Gemini, or a custom binary) run behind the A2A sidecar and have no in-process registry to point at. The sidecar reads the tool manifest from a file path named by the SPRING_TOOLS_MANIFEST environment variable:
- Bake the JSON manifest into the image at a known path (e.g.
/opt/agent/tools.json). - Set
SPRING_TOOLS_MANIFEST=/opt/agent/tools.jsonin the image so the sidecar finds it. - The manifest is the same array shape the SDK serialises — one entry per tool with
name,description, andinputSchema.
If the env var is unset or the file is missing, the sidecar serves an empty array; deploys of agents-without-tools succeed without per-image configuration. The A2A sidecar's README covers the surrounding contract (AGENT_PORT, AGENT_CMD, etc.) the manifest layers onto.
When an operator deploys the agent (spring agent deploy <id> or the equivalent portal action):
- The dispatcher starts the container and waits for
/.well-known/agent.jsonto come up on the agent port (default8999). - The platform-side introspector calls
GET /a2a/toolsagainst the same listener. - The returned array is cached onto the
image_toolsjsonb column of the subject's definition (agent_definitionsfor agents;unit_definitionsfor unit-runtime images). - The grant resolver merges the cached array into the subject's effective tool set with
provenance = "image:<digest>".
Image rotation re-runs the introspection step. A deploy that swaps the image but keeps the agent record refreshes image_tools from the new container's /a2a/tools response, so the operator sees the new surface without restarting anything else.
Every tool registered through this path surfaces in the portal's Config → Tools sub-tab on the agent (or on the unit, when the image runs a unit's runtime) under the Image section. The section is read-only: image-tier tools are tied to the running image, not to an operator toggle, so the only way to change the surface is to ship a new image.
The same data is available on the effectiveTools array of AgentResponse and UnitResponse — useful for programmatic consumers that need to inspect what a fleet of agents declares without iterating over the portal.
- 1:1 with the subject. Image-tier tools do not inherit to children and cannot be reused across agents that run different images. If you find yourself shipping the same tool definition in two images, that is the signal to promote the surface to a connector.
- Read-only in the portal. An operator cannot disable a single image-tier tool without rebuilding the image.
- Namespace discipline.
sv.*is reserved for the platform. Connector slugs are reserved for connectors. Pick an organisation-prefixed namespace for image tools so a future connector under the same slug does not collide with the image surface. - Handler purity. Handlers run in the agent process; they share its lifetime, its filesystem, and its credentials. Use the runtime's normal patterns (cancellation tokens, scoped DI) — the SDK does not add a side framework on top.
A minimal end-to-end image lives at samples/tools-agent-image — two acme.* tools, an SDK registry, and the minimal-API wiring above. The same sample is the deploy target for the platform's introspection integration test, so the manifest the sample produces is also the manifest the platform expects to see in the field.
- Tools — the three-tier effective tool model the platform resolves at dispatch.
- Agent runtime — The AgentSDK — the broader SDK contract (messaging client, environment variables, error model).
- Bring Your Own Image (BYOI) — image conformance, base images, and the A2A bridge.
samples/tools-agent-image/README.md— runnable reference for SDK tool registration.