What a Tool is
Tools covers the overview and the per-section navigation.
A Tool is built out of three things the model sees and one thing the model never sees.
The model sees the name, the description, and the input schema. The name is the identifier the model uses to invoke the tool. Use lowercase snake_case: it is what every major provider's tool-definition format prefers and what survives round-tripping cleanly. Cute names become provider-specific bugs. The description is the prose the model reads when deciding whether this is the tool it wants — what it does, what it returns, when to reach for it. The input schema is a @nhtio/validation schema (object-shaped; the validator enforces that at construction) whose .describe() output the DispatchExecutorFn folds into the provider-specific tool definition: types, field descriptions, examples, notes, the whole annotation surface, captured once and rendered into whatever wire shape the provider expects.
The model does not see the handler. The handler is the function that runs when the tool is invoked, and it is deliberately not exposed as a public property on the tool — the only legitimate way to call it is through Tool.executor(ctx), which validates the arguments first, computes the stable callId the rest of the loop uses to correlate events, fires the start/end observability events, and wraps any thrown error in E_TOOL_DOWNSTREAM_ERROR so the failure surfaces through the right channel. A handler that throws on bad arguments is a tool you cannot debug; a handler whose call is not observable is a tool you cannot audit. The executor wrapper exists so both of those problems are solved once, the same way, for every tool.
The handler may return any of four shapes: string, Uint8Array, Media, or Media[]. The first two are the bytes-or-text path: the ADK writes the return value to the spool and wraps it via Tool.artifactConstructor?.() ?? SpooledArtifact, exactly as it does today — the model interacts with the result through the forged artifact_* handle tools, which is what you want for content the model needs to query (grep a log, page a JSON tree, walk a Markdown document by heading). The Media path is the explicit-modality path: when the tool already knows it has produced an image, an audio clip, a video, or a document the provider can render natively, the handler returns one or more Media instances directly and the ADK lands them on ToolCall.results without invoking Tool.artifactConstructor. This mirrors the precedent set by ArtifactTool — when the handler can declare the final result shape, the artifact wrap is skipped. The renderer reaches into each Media for bytes only at the wrap site (Media.stream for streaming uploads, Media.asBytes / Media.asBase64 for inline content blocks), so a tool that holds bytes lazily — backed by a fetch, a file handle, an S3 object — keeps them lazy all the way through middleware and persistence.
The handler's return shape is the most consequential choice in writing a tool — it decides whether the model gets the result as a queryable handle or an inline content block. Pick by what the model will do with it:
| Return | Best when | What the ADK does | How the model sees it |
|---|---|---|---|
string / Uint8Array | The model needs to work with the content — grep, page, query, walk a structure | Spools the bytes; wraps in SpooledArtifact (or the subclass your artifactConstructor resolves); forges artifact_* handle tools | Calls the forged handle tools to read the content lazily |
Media / Media[] | The provider can render the bytes natively (image, audio, document, video) | Lands the instance on ToolCall.results untouched; battery emits a provider-specific content block | Sees the asset inline as a content block |
Media is not "new artifacts." It is a different silo. A PDF the model must grep is an artifact. A PDF the provider can show inline is a Media. Pick wrong and you either hide queryable structure behind a blob or force a native asset through fake text tooling. A tool that produces both is free to return Media while staging the text-extracted form on Media.stash as a fallback for text-only consumers.
Three more fields shape how the tool behaves once it lands in a registry. artifactConstructor (SpooledArtifactConstructor) is the SpooledArtifact subclass to wrap this tool's results in, declared by the tool that produces those results (a tool returning JSON declares SpooledJsonArtifact; a tool returning Markdown declares SpooledMarkdownArtifact; a tool returning plain text leaves the default in place). It is a resolver — a zero-argument closure that returns the constructor — rather than a direct reference; the reason is a module-load cycle covered under Advanced details. meta is a free-form metadata bag the ADK stores in a Registry for dot-path access: RBAC scopes, feature flags, telemetry hints, whatever your middleware needs to inspect at dispatch time. The ADK does not interpret any of it; that is the entire point.
Tool.trusted, Tool.ephemeral, and Tool.onCollision are the three flags that change how the tool behaves at the seams: the trust envelope the executor renders the result in, whether the registry treats this tool as a one-dispatch citizen, and how the registry reconciles name clashes during a merge. Each is documented in the section that owns it: Trust on the tool, not on the battery, bindContext and ephemeral pruning, and Collision policy.
One mistake worth naming explicitly
Tool handlers normally run inside the executor — the DispatchExecutorFn you registered is the seam that resolves a model-requested tool call and invokes the handler. (A middleware can dispatch a tool call too, but that is the unusual path; the executor is where it happens by default.) Either way, it is tempting to capture state from the surrounding seam in the handler's closure. Don't. The handler is called per invocation; the executor is a long-lived seam closed over at runner construction, and a closure-captured value that survives across turns and leaks into a handler running in a later turn is the kind of bug that takes a week to find. Read state from TurnContext.stash instead — it is a Registry, so use ctx.stash.get('your-namespace') and ctx.stash.set('your-namespace', value) (dot-paths work too: ctx.stash.get('your-namespace.subkey')). That registry is exactly what cross-middleware scratchpad state is for.