Skip to content
4 min read · 746 words

Tool results

This is a featured battery

Raw output is the safe default. A descriptor may advise a kind; the consumer's explicit binding wins.

A skill tool cannot hand back arbitrary runtime values. The accepted response set is small:

  • string
  • Uint8Array
  • Media, or an array of Media

Anything else — undefined, a bare object, a function — fails response validation as E_SKILL_TOOL_BAD_RESPONSE before any artifact selection happens. For where that check sits in the wrapper, see Executable capability.

A prebuilt SpooledArtifact is also refused, deliberately. Handing back an already-instantiated artifact would let third-party code bypass the deployer's configured binding. Choosing how output is read is the host's decision, not the plugin's.

Anonymous bytes

Here is the honest limitation: bytes arrive anonymous. A Uint8Array carries no MIME type, no encoding, and no filename. To the host, a PDF and a WAV are indistinguishable — both are buffers. A tool that wants to return typed binary must construct and return a Media today.

That comes with a tax: constructing Media means importing @nhtio/adk/common, which is a real constraint on a tool that wants zero framework coupling. A framework-agnostic path is planned and not yet built. Until it exists, anonymous bytes stay raw bytes and typed binary requires the import.

What happens to the result

The result is spooled behind an artifact, and the artifact class decides which reader tools the model gets over that payload on subsequent dispatches.

The default is raw SpooledArtifact — line-oriented access that makes no claim about structure. Line ranges, grep, byte slicing, token estimation; no assumption that the payload is markdown, tabular, or an image.

Configuring it: the two registries

The host controls artifact assignment through two registries on SkillManagerConfig.

artifactKinds names constructors. Resolvers are resolved eagerly at manager construction, and may be a SpooledArtifactConstructor or a lazy function / default export. The built-in key 'SpooledArtifact' is always present; optional artifact batteries are not imported for you.

artifactBindings is consumer-owned policy, mapping skill id → tool name → kind name. It keeps authority in host configuration rather than letting a skill smuggle a renderer into the process.

ts
import { createSkillManager } from '@nhtio/adk/batteries/skills'

const manager = await createSkillManager({
  sources: [source],
  gate,
  artifactKinds: {
    report: () => ReportArtifact,
  },
  artifactBindings: {
    reports: { render: 'report' },
  },
})

Resolution order:

  1. the consumer's explicit artifactBindings
  2. the descriptor's advisory artifactKind
  3. the raw fallback, SpooledArtifact

A fuller configuration makes the precedence concrete:

ts
const manager = await createSkillManager({
  sources: [source],
  gate,
  artifactKinds: {
    report: () => ReportArtifact,
    image: ImageArtifact,
  },
  artifactBindings: {
    reports: { render: 'report' },
    charts: { preview: 'image' },
  },
})

For reports and render, the binding report wins over the descriptor's advisory artifactKind. If the named kind is not registered, loading fails with E_SKILL_ARTIFACT_UNAVAILABLE.

Why the raw default matters

Forgetting to register a named kind or misspelling a binding produces E_SKILL_ARTIFACT_UNAVAILABLE at load time; it does not silently fall back to a renderer. A descriptor's advisory artifactKind is not authority when the host has an explicit binding. And a tool returning an unsupported value fails as E_SKILL_TOOL_BAD_RESPONSE before artifact selection happens at all.

Raw output is deliberately boring: available without optional batteries, executes no plugin-provided constructor, and gives adapters a stable baseline. Opt into a richer kind only when the host owns and tests that constructor.

Protected reader namespace

Skill tools cannot claim names derived from the raw and markdown artifact method registries, including the readers core will forge for skill bodies. The manager preflights tool names against that reserved set before registration, even when no artifact exists yet — otherwise a skill could register artifact_grep in a quiet dispatch and be silently replaced by core the moment an artifact appears. A collision halts registration rather than letting a plugin shadow a host capability.

Ownership classes

Artifact reads fall into three classes across the skill lifecycle:

  • Instruction set — reads of a loaded skill's body artifact, reclaimed with the skill on unload.
  • Work — findings, script-output reads, and anything the agent produced. Durable.
  • Backing bytes — in the consumer's spool store, consumer-managed; the reader contract has no delete.

Deactivating a skill reclaims its instructions and its exported tools without discarding the work the agent produced while using it. For evicting large media results once they stop being relevant, that is what the context batteries are for — subtractToFit sheds media first when a dispatch is over budget.

Adapter reality

bedrock_converse and gemini_generate_content coerce tool results through JSON, do not honour artifactConstructor, and forge no readers; for those providers imported output is sent inline. Other adapters spool results into true artifacts. The battery cannot document a renderer into existence when the provider flattens results before the host can intervene.