---
url: 'https://adk.nht.io/assembly/byo-tools.md'
description: >-
  Define Tool instances, wire them into TurnRunnerConfig, handle Media return
  types, and forge ephemeral artifact tools.
---

# Bring your own tools

## LLM summary — Bring your own tools

* A `Tool` is a validated callable capability: name, description, inputSchema, handler, and optional metadata.
* `Tool` constructor fields: `name` (string), `description` (string), `inputSchema` (Schema from `@nhtio/validation`), `handler` (function returning string, Uint8Array, Media, Media\[] or Promise of these), `trusted` (boolean, default false), `ephemeral` (boolean, default false), `artifactConstructor` (optional), `meta` (optional object), `onCollision` (optional).
* `inputSchema` is the single source of truth: it validates arguments at call time AND generates the tool definition the model sees. There is no separate "JSON schema for the model."
* `trusted: true` routes inline textual/spooled tool results through the trusted content envelope. Default `false` routes inline textual/spooled results through the untrusted envelope. This is a property of the tool's inline output, not the battery configuration. Media is governed by `Media.trustTier`; `inline: false` spooled handles render as untrusted queryable-data handles.
* Tools configured on `TurnRunnerConfig.tools` are baseline tools. The runner instantiates a fresh `ToolRegistry` per turn.
* `fetchToolsCallback` is NOT auto-called by ADK. Your middleware must fetch and register/merge these tools manually.
* `ToolRegistry` holds registered tools. It has no `fromTools` static method; construct it with `new ToolRegistry([...])`.
* `ToolRegistry.merge(registries)` combines multiple registries. Collision policy: per-tool `onCollision` field or registry-level option.
* `ToolRegistry.bindContext(ctx)` registers a pruning handler for ephemeral tools in long-lived registries. Pruning fires when `ctx.ack()` runs. If forged tools are merged into a local registry per iteration and that merged registry is discarded before the next iteration, `bindContext` is unnecessary.
* Tool execution belongs in the executor. The executor invokes `tool.executor(ctx)(args)`, handles/wraps/spools raw results as needed, persists the result via `ctx.storeToolCall()`, and continues the loop. Call correlation is emitted/computed by the tool executor.
* Handler return types: `string`, `Uint8Array`, `Media`, or `Media[]` (or Promises resolving to these). String and Uint8Array are returned raw by `Tool.executor()`; the Chat Completions batteries or your executor should wrap/spool them into SpooledArtifact (or `artifactConstructor?.() ?? SpooledArtifact`) before persistence. Media is NOT wrapped — it lands on `ToolCall.results` directly.
* `Media` trust tier is declared at construction time via factory methods: `Media.toolGenerated()`, `Media.retrievedPublic()`, `Media.retrievedPrivate()`, `Media.userAttachment()`. The factory determines the trust envelope.
* `Media.trustTier` — not `Tool.trusted` — is the trust source when rendering Media results.
* Ephemeral artifact tools: `SpooledArtifact.forgeTools(ctx)` produces a ToolRegistry of ArtifactTool instances. The Chat Completions adapter merges these per-iteration locally when prior turn tool calls contain SpooledArtifact results.
* `ArtifactTool` extends `Tool`. It is produced by `SpooledArtifact.forgeTools(ctx)` — not constructed directly by consumers.

A [`Tool`](https://adk.nht.io/api/@nhtio/adk/forge/classes/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](../the-loop/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`](https://adk.nht.io/api/@nhtio/adk/forge/classes/Tool) is constructed with strict validation. Vague schemas become vague tool calls. Computers are famously bad at vibes.

::: code-group

```ts [Text Tool]
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 [Media Tool (In-Memory)]
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 [Streaming Media Tool]
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 [User Attachment Tool]
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.

::: danger 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`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner) instantiates a fresh [`ToolRegistry`](https://adk.nht.io/api/@nhtio/adk/forge/classes/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](./pipelines#context-hydration) 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`](https://adk.nht.io/api/@nhtio/adk/forge/classes/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`](https://adk.nht.io/api/@nhtio/adk/forge/classes/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.

### `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`](https://adk.nht.io/api/@nhtio/adk/common/classes/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`](https://adk.nht.io/api/@nhtio/adk/common/classes/Media) when it produces typed binary content—an image, audio clip, PDF, video, or other document. The factory methods ([`Media.userAttachment`](https://adk.nht.io/api/@nhtio/adk/common/classes/Media#userattachment), [`Media.toolGenerated`](https://adk.nht.io/api/@nhtio/adk/common/classes/Media#toolgenerated), [`Media.retrievedPublic`](https://adk.nht.io/api/@nhtio/adk/common/classes/Media#retrievedpublic), [`Media.retrievedPrivate`](https://adk.nht.io/api/@nhtio/adk/common/classes/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](../the-loop/trust-tiers#media) for the full two-axis composition table.

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

::: tip 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`](https://adk.nht.io/api/@nhtio/adk/spooled_artifact/classes/SpooledArtifact#forgetools)`(ctx)` produces a fresh [`ToolRegistry`](https://adk.nht.io/api/@nhtio/adk/forge/classes/ToolRegistry) of [`ArtifactTool`](https://adk.nht.io/api/@nhtio/adk/forge/classes/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:

```typescript
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()`:

```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()
```

::: danger 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](../the-loop/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](./byo-llm) for a complete tool-capable executor example.
