Bring your own tools
A Tool is a validated callable capability. The schema the model sees and the schema your handler enforces are the same contract, written once. ADK validates arguments at the boundary and wraps your handler so execution errors are caught and reported uniformly. You define the tools; ADK enforces the boundary.
See Tools for the conceptual overview of what tools are and how they fit in the dispatch loop. This page is the implementation guide.
Constructing a Tool
Every Tool is constructed with strict validation. Vague schemas become vague tool calls. Computers are famously bad at vibes.
import { Tool } from '@nhtio/adk'
import { validator } from '@nhtio/validation'
const getWeather = new Tool({
name: 'get_weather',
description: 'Returns the current weather for a given city.',
inputSchema: validator.object({
city: validator.string().description('The city name').required(),
units: validator.string().valid('celsius', 'fahrenheit').default('celsius'),
}),
async handler({ city, units }) {
const data = await fetchWeatherApi(city, units)
return `${data.temp}° ${units} in ${city}. Conditions: ${data.description}.`
},
})import { Tool, Media, inMemoryMediaReader } from '@nhtio/adk'
import { validator } from '@nhtio/validation'
const renderChart = new Tool({
name: 'render_chart',
description: 'Renders the supplied data as a PNG.',
inputSchema: validator.object({
data: validator.array().items(validator.number()).required(),
}),
async handler({ data }) {
const buf: Uint8Array = await renderChartPng(data)
return Media.toolGenerated({
kind: 'image',
mimeType: 'image/png',
filename: 'chart.png',
reader: inMemoryMediaReader(buf),
})
},
})import { Tool, Media, fromFetch } from '@nhtio/adk'
import { validator } from '@nhtio/validation'
const fetchImage = new Tool({
name: 'fetch_image',
description: 'Fetches an image from the open web.',
inputSchema: validator.object({
url: validator.string().required(),
}),
async handler({ url }) {
return Media.retrievedPublic({
kind: 'image',
mimeType: 'image/jpeg',
filename: 'image.jpg',
source: url,
reader: fromFetch(url),
})
},
})import { Tool, Media, fromWebFile } from '@nhtio/adk'
import { validator } from '@nhtio/validation'
const inspectUpload = new Tool({
name: 'inspect_upload',
description: 'Reads metadata from a user-uploaded document.',
inputSchema: validator.object({
fileHandle: validator.any().required(),
}),
async handler({ fileHandle }) {
return Media.userAttachment({
kind: 'document',
mimeType: fileHandle.type,
filename: fileHandle.name,
reader: fromWebFile(fileHandle),
})
},
})inputSchema is the single source of truth
The inputSchema validates arguments at call time and generates the tool definition the model sees. The model cannot be told one contract while your handler enforces another.
Use .description(), .note(), and .example() on schema fields to produce rich, model-readable definitions. The model relies on these descriptions to select and populate parameters.
trusted controls the output envelope
When trusted: false (the default), inline textual/spooled tool results are wrapped in the untrusted content envelope before being rendered into the next prompt. When trusted: true, inline textual/spooled results are wrapped in the trusted content envelope. Media and Media[] results bypass Tool.trusted and are rendered from each Media.trustTier; inline: false spooled handles are always rendered as untrusted queryable-data handles.
Set trusted: true only when the tool's output comes from developer-authored content or explicit user intent—Q&A tools surfacing operator-authored answers, configuration tools returning hardcoded constants, or human-in-the-loop approval gates. Tools that call external APIs, query databases with user-influenced parameters, or return content from the open web are not trusted sources.
Trust is a property of the tool's inline textual/spooled output, not of how a battery is configured. The flag travels with the tool wherever it is registered.
Mis-declaring trust is a security vulnerability
A tool declared trusted: true that returns third-party or user-influenced inline textual/spooled content bypasses the untrusted fence. Prompt injection attacks become trivial—the model reads that inline output with the same authority as developer instructions. Media outputs ignore Tool.trusted, and spooled handle rendering with inline: false is untrusted regardless. Leave it at false unless you are absolutely certain the inline textual/spooled output is developer-controlled.
Wiring Tools into the Runner
You have two paths to expose tools to the runtime:
Baseline tools — pass an array to TurnRunnerConfig.tools. The TurnRunner instantiates a fresh ToolRegistry on every single turn using these baseline tools. They are not static across the life of the runner process.
import { TurnRunner } from '@nhtio/adk'
const runner = new TurnRunner({
...storageCallbacks,
executorCallback: myExecutor,
tools: [getWeather, searchDocs, createTicket],
})Dynamic tools per turn — return them from your custom fetchToolsCallback. ADK does NOT automatically call this or merge these dynamic tools behind your back. It merely exposes the callback under the runner configuration. You must invoke ctx.fetchTools() inside your input pipeline middleware and register the output. See Context Hydration in Pipelines for the canonical explanation.
import type { TurnContext } from '@nhtio/adk'
const fetchAndRegisterToolsMiddleware = async (ctx: TurnContext, next: () => Promise<void>) => {
// ADK does not call this for you. Call it yourself.
const dynamicTools = await ctx.fetchTools()
for (const tool of dynamicTools) {
ctx.tools.register(tool)
}
await next()
}The executor accesses the merged, active registry via ctx.tools.
ToolRegistry
ToolRegistry holds the tools available for a given turn.
There is no static ToolRegistry.fromTools() method. To instantiate a registry manually, pass the tool array directly to the constructor:
const registry = new ToolRegistry([getWeather, searchDocs])Merging registries
ToolRegistry.merge(registries) combines multiple registries into one:
const combined = ToolRegistry.merge([baseRegistry, tenantRegistry, forgedRegistry])Collision policy is controlled by the per-tool onCollision field ('throw' / 'replace' / 'keep') and the merge-level options.onCollision fallback.
bindContext for ephemeral tools in long-lived registries
Ephemeral tools (ephemeral: true) have a strictly bounded lifecycle. When a tool is flagged as ephemeral, it must be pruned from any long-lived registry when the dispatch iteration finishes.
If you are using the Chat Completions battery, this is already handled: it merges forged tools per-iteration locally. It does not leak them into your long-lived registry, so you do not need to call bindContext yourself.
However, if you are maintaining a persistent, long-lived registry across multiple iterations and you forge ephemeral tools directly into it, call registry.bindContext(ctx). If you merge them and omit bindContext on the long-lived registry, ephemeral tools will accumulate silently, polluting subsequent iterations with stale callId enums. Pruning is registered to run synchronously when ctx.ack() is called (it does NOT run on ctx.nack()).
Handler Return Types
A tool handler may return any of the following shapes (or a Promise resolving to them). Tool.executor() returns these values raw; wrapping, spooling, and persistence are the executor/battery's responsibility.
| Return type | What Chat Completions batteries / your executor should do |
|---|---|
string | Wrap in tool.artifactConstructor?.() ?? SpooledArtifact and store the wrapped result on ToolCall.results |
Uint8Array | Write bytes to the spool store, construct tool.artifactConstructor?.() ?? SpooledArtifact, and store the wrapped result on ToolCall.results |
Media | Do NOT wrap — land it on ToolCall.results directly as a handle |
Media[] | Same — each Media lands directly |
Return Media when you know the output is a specific modality (image, audio, video, document) that the provider can render natively. The trust tier is declared on the Media, not on the Tool.
Returning Media from a Tool
A tool handler returns Media when it produces typed binary content—an image, audio clip, PDF, video, or other document. The factory methods (Media.userAttachment, Media.toolGenerated, Media.retrievedPublic, Media.retrievedPrivate) force the trust-pair labelling decision at the call site.
Media.trustTier—not Tool.trusted—is the trust source when rendering Media results. See Trust tiers → Media for the full two-axis composition table.
Out of scope: byte hygiene
DLP and antivirus scanning of media bytes are strongly recommended for production tools that ingest user-supplied or third-party bytes, but the library defines no scanning hook. Tool authors who need scanning must wire it at the point of ingest—before constructing the Media—and decide their own policy on what to do with positives.
Forging Artifact Tools — wiring sketch
Using the Chat Completions battery?
When prior turn tool calls contain SpooledArtifact results, the battery automatically forges and merges artifact tools per iteration locally. There is nothing to wire here unless you are writing your own executor.
If you are writing a custom executor and need to forge artifact tools manually: SpooledArtifact.forgeTools(ctx) produces a fresh ToolRegistry of ArtifactTool instances. These let the model query a prior tool call's artifact.
Here is the raw sketch of how to wire this in a custom executor:
import { SpooledArtifact, ToolRegistry, type DispatchExecutorFn } from '@nhtio/adk'
const executor: DispatchExecutorFn = async (ctx, helpers) => {
try {
const forged = SpooledArtifact.forgeTools(ctx)
// If this merged registry is discarded before the next iteration, it will not leak ephemeral tools:
const merged = ToolRegistry.merge([ctx.tools, forged])
merged.bindContext(ctx)
// Build provider request using merged.all() for tool schemas
// ... handle streaming, tool calls, etc.
ctx.ack()
} catch (error) {
ctx.nack(error instanceof Error ? error : new Error(String(error)))
}
}If you are registering ephemeral tools directly into a long-lived, persistent registry that survives across iterations, call registry.bindContext(ctx) on that specific registry so it prunes them on ctx.ack():
// Only do this if 'persistentRegistry' is a long-lived object that you manually register forged tools into:
for (const tool of forged.all()) {
persistentRegistry.register(tool)
}
persistentRegistry.bindContext(ctx) // Pruning runs on ctx.ack()Common mistake: Omitting bindContext on persistent registries
If you register ephemeral tools into a long-lived registry and omit bindContext, those tools accumulate silently. On the next iteration, the model will see stale tool definitions pointing to expired tool call IDs, causing bizarre reasoning loops and silent failures. Remember: pruning only fires on ctx.ack(), not ctx.nack().
Every tool emitted by SpooledArtifact.forgeTools carries onCollision: 'replace'. Overlapping base-method tools resolve silently. In practice, use only the most-derived subclass—SpooledMarkdownArtifact.forgeTools(ctx) already includes the base descriptors verbatim.
For the rationale behind the callId enum snapshot, ctx-completion as the lifecycle hook, and the recursion-breaking filter on ToolCall.fromArtifactTool ToolCalls, see Artifacts → Ephemeral forgeTools and ctx-completion.
Tool Execution in the Executor
Tools run in the executor. When the model requests a tool call, your executor:
- Finds the tool:
const tool = ctx.tools.get(toolName) - Executes it:
const raw = await tool.executor(ctx)(args). Call correlation is emitted/computed by the tool executor. - Wraps or spools raw
string/Uint8Arrayresults into aToolCallResultsvalue such astool.artifactConstructor?.() ?? SpooledArtifact;MediaandMedia[]can be used directly asToolCall.results. - Reports it:
helpers.reportToolCall(callId, { tool: toolName, args, results, isComplete: true }) - Persists it:
await ctx.storeToolCall(new ToolCall({ id: callId, tool: toolName, args, checksum, isComplete: true, isError: false, results, createdAt, updatedAt, completedAt })) - Appends to local history and continues the loop
See Bring your own LLM for a complete tool-capable executor example.