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 deliberately closed:
stringUint8Array(anonymous bytes)Media, or an array ofMedia{ bytes, mimeType, filename? }— a framework-agnostic binary descriptor the host builds into a typedMedia{ retrievable: { content, source?, kind?, score?, inline? } }— a framework-agnostic retrievable descriptor
Anything else — undefined, a bare object that matches none of these shapes, 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. The typed-descriptor shapes are covered in Typed output without importing the framework below.
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.
Typed output without importing the framework
A skill tool is third-party code that should not have to import this library to say what it produced. So the wrapper is the adaptation boundary for output, not only for the gate and errors: a tool may return a plain-object descriptor and the host builds the primitive.
- Raw bytes stay raw. A
Uint8Arraystill passes through as anonymous binary — no MIME type, no filename. Fine when the binding already knows what the tool emits. - Typed binary —
{ bytes, mimeType, filename? }. The tool says it producedapplication/pdf; the host wraps the bytes in a reader viactx.storeMediaBytes, infers theMediaKindand a conservative modality hazard from the MIME type, and returns a realMedia. A PDF is a PDF and a WAV is a WAV, with no@nhtio/adk/commonimport in the tool. - A retrievable —
{ retrievable: { content, source?, kind?, score?, inline? } }. The tool contributes something the model can cite, search, and hold a handle to. The text is spooled behind a handle and the record is persisted durably throughctx.storeRetrievable— the same path the retrievables tool battery uses to store a RAG record — so it is id-collision-checked, written to the deployer's store, and also added to this turn's context. The tool's own result is a short acknowledgement string naming the new retrievable's id. It is durable work product, not the reclaimable instruction set a skill body is, and not the transient-within-dispatch stdout a script produces: a tool's explicit{ retrievable }is a declared knowledge record and outlives both the turn and anunload_skill. Media/Media[]still work for a tool that does import the framework and construct them.
Trust is floored, never elevated. A skill can never label its own output first-party: a first-party skill's typed output lands as third-party-private; both third-party tiers pass through unchanged. This is the same floor tier-3 scripts apply to their stdout.
Declare it for a sharper failure. A descriptor may set toolOutputs[toolName] to 'text', 'binary', 'media', or 'retrievable'. When declared, a runtime return whose shape disagrees fails E_SKILL_TOOL_BAD_RESPONSE naming the mismatch, instead of being silently reinterpreted; when omitted, the wrapper sniffs the returned shape. A prebuilt SpooledArtifact is still refused either way — that would bypass the deployer's binding.
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.
import { createSkillManager } from '@nhtio/adk/batteries/skills'
const manager = await createSkillManager({
sources: [source],
gate,
artifactKinds: {
report: () => ReportArtifact,
},
artifactBindings: {
reports: { render: 'report' },
},
})Resolution order:
- the consumer's explicit
artifactBindings - the descriptor's advisory
artifactKind - the raw fallback,
SpooledArtifact
A fuller configuration makes the precedence concrete:
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, including a tool's
{ retrievable }. 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.
We are about to state an opinion
Byte cleanup on a persistence failure is the consumer's, not this library's. When a tool returns { retrievable }, the record is stored through ctx.storeRetrievable, which spools the text through the consumer's own storeRetrievableBytes conduit and then invokes the consumer's own storeRetrievable callback. If that callback rejects, core has already spooled the bytes under a known id — and the consumer, holding both the id and the store, is the only party that can reconcile a spooled id whose record write failed. The library exposes no byte-delete conduit (deleteRetrievable removes the record, not the bytes) on purpose: adding one would push a failure the consumer already owns back across the boundary. This is not a gap we tolerate — it is where the responsibility belongs. Core does not roll back for any storeRetrievable caller, and the built-in retrievables tool battery makes the same choice; the skills battery is consistent with both. A consumer whose byte store must not accumulate orphans on a persistence failure cleans up inside its own storeRetrievable callback, where it has everything it needs to do so.
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.