Skip to content
9 min read · 1,701 words

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.

ts
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}.`
  },
})
ts
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),
    })
  },
})
ts
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),
    })
  },
})
ts
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.

typescript
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.

typescript
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:

typescript
const registry = new ToolRegistry([getWeather, searchDocs])

Merging registries

ToolRegistry.merge(registries) combines multiple registries into one:

typescript
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.

Hidden state is also propagated: a tool that was hidden in any source registry stays hidden in the merged result, as long as it survived collision resolution. See Hidden tools below.

Hidden tools

A tool can be registered and callable without being immediately visible to the model. This is useful when you have a large tool surface and want the model to discover tools through a dedicated discovery tool rather than listing everything upfront.

Hidden state lives on the registry, not on the tool. The same tool wired into two registries can be visible in one and hidden in the other.

typescript
import { ToolRegistry } from '@nhtio/adk'

const registry = new ToolRegistry([searchDocs, getWeather, createTicket, listAllTools])

// Hide everything except the discovery tool
registry.hide('searchDocs', 'getWeather', 'createTicket')

registry.visible() // → [listAllTools] — only the discovery tool is visible to the model
registry.hidden()  // → [searchDocs, getWeather, createTicket] — callable but not advertised
registry.all()     // → [searchDocs, getWeather, createTicket, listAllTools] — everything

The model sees only listAllTools in its tool definition list. It calls it, reads the descriptions of the hidden tools, and decides to call searchDocs by name on the next iteration. The registry resolves it, the battery executes it, and the result comes back — no different from a tool that was visible the whole time.

When to hide, when to unregister

IntentWhat to do
The model should not see this tool right now, but may call it laterregistry.hide(name)
The model should never call this tool in this turnregistry.unregister(name)
The model should see everythingDo nothing — tools are visible by default

Hidden tools still resolve via registry.get(). Unregistered tools do not. If you want a tool to be uncallable, unregister it. If you want it to be callable but not advertised, hide it.

Unregistering a tool automatically removes it from the hidden set. Merge propagates hidden state from source registries.

Discovery tool pattern

Here is a complete sketch of the discovery pattern:

typescript
import { Tool, ToolRegistry, validator } from '@nhtio/adk'

// The discovery tool — visible to the model, lists everything hidden
const listTools = new Tool({
  name: 'list_tools',
  description: 'Lists all available tools with their descriptions and schemas.',
  inputSchema: validator.object({}),
  handler(_args, ctx) {
    const all = ctx.tools.all()
    return all.map(t => `${t.name}: ${t.description}`).join('\n')
  },
})

// Build the registry with all tools, then hide the ones the model should discover
const registry = new ToolRegistry([searchDocs, getWeather, createTicket, listTools])
registry.hide('searchDocs', 'getWeather', 'createTicket')

The model calls list_tools, reads the output, and calls searchDocs by name on the next iteration. The registry resolves it because get() checks the full registry, not just the visible subset.

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.

For artifact readers this is already handled: the core DispatchRunner forges them into ctx.tools and calls bindContext itself, once per iteration (prune-then-forge), so they never accumulate and their callId enum never goes stale. You do not call bindContext for artifact readers under any battery.

The seam remains for a CUSTOM consumer that forges its own ephemeral tools directly into a persistent, long-lived registry that survives across iterations: call registry.bindContext(ctx) on that registry. If you register ephemeral tools and omit bindContext, they accumulate silently, polluting subsequent iterations with stale callId enums. Pruning runs synchronously on ctx.ack() (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 typeWhat Chat Completions batteries / your executor should do
stringWrap in tool.artifactConstructor?.() ?? SpooledArtifact and store the wrapped result on ToolCall.results
Uint8ArrayWrite bytes to the spool store, construct tool.artifactConstructor?.() ?? SpooledArtifact, and store the wrapped result on ToolCall.results
MediaDo 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 — who does it

The core forges artifact readers for you

When prior-turn tool calls contain SpooledArtifact results, the core DispatchRunner forges the artifact-reader tools into ctx.tools (and binds pruning) once per iteration, BEFORE the input pipeline. There is nothing to wire in a battery or executor: read the already-forged ctx.tools for tool schemas and call resolution. This is what makes the readers visible to context-budget middleware — generation is a core concern, not a battery one.

SpooledArtifact.forgeTools(ctx) produces a fresh ToolRegistry of ArtifactTool instances that let the model query a prior tool call's artifact. You rarely call it directly — the core does. A custom executor simply reads ctx.tools:

typescript
import { type DispatchExecutorFn } from '@nhtio/adk'

const executor: DispatchExecutorFn = async (ctx, helpers) => {
  try {
    // ctx.tools already contains the forged artifact readers (the core forged them before the
    // input pipeline). Build the provider request from ctx.tools.visible(); resolve an incoming
    // tool call via ctx.tools.get(name).
    // ... handle streaming, tool calls, etc.
    ctx.ack()
  } catch (error) {
    ctx.nack(error instanceof Error ? error : new Error(String(error)))
  }
}

The manual SpooledArtifact.forgeTools(ctx) + bindContext path is only for a consumer forging its OWN ephemeral tools outside the core (e.g. a bespoke non-artifact ephemeral capability). If you register 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():

typescript
// 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:

  1. Finds the tool: const tool = ctx.tools.get(toolName)
  2. Executes it: const raw = await tool.executor(ctx)(args). Call correlation is emitted/computed by the tool executor.
  3. Wraps or spools raw string / Uint8Array results into a ToolCallResults value such as tool.artifactConstructor?.() ?? SpooledArtifact; Media and Media[] can be used directly as ToolCall.results.
  4. Reports it: helpers.reportToolCall(callId, { tool: toolName, args, results, isComplete: true })
  5. Persists it: await ctx.storeToolCall(new ToolCall({ id: callId, tool: toolName, args, checksum, isComplete: true, isError: false, results, createdAt, updatedAt, completedAt }))
  6. Appends to local history and continues the loop

See Bring your own LLM for a complete tool-capable executor example.