---
url: 'https://adk.nht.io/batteries/skills/capability.md'
description: >-
  Skills supply three tiers of executable capability, ordered by containment:
  in-process module tools, isolated JavaScript, and child-process scripts.
---

# Executable capability

Skills supply three tiers of executable capability, ordered by containment:

* **Tier 1 (Module tools)**: Live `Tool[]` instances from imported packages. Rebuilt unconditionally via `rewrapSkillTool` with host gating, error wrapping (`E_SKILL_TOOL_FAILED`), response validation (`E_SKILL_TOOL_BAD_RESPONSE`), `trusted: false`, artifact selection, and provenance metadata (`meta.skill`, `meta.skillVersion`).
* **Tier 2 (Isolated JavaScript)**: `SkillIsolatedTool` evaluated in a configured guest runtime via `SkillIsolationConfig`. Bounded globals, arguments validated against declared parameters, and cancellation via `resolveGuest` (in-process compartment `kill()` is a no-op).
* **Tier 3 (Scripts)**: `SkillScriptSpec` executed in a child process via `SkillScriptConfig` under a host-owned `SkillWorkspace` and derived `SandboxPolicy`. Fixed declared parameters, argv-only construction (no shell interpolation), generated `run_<skill>_<script>` names, and strict path normalization.

::: tip This is a featured battery
Capabilities belong to the deployer. A plugin host does not trust a plugin to call its own permission check, nor does a plugin get to decide its own permissions by writing `allowed-tools` in a manifest.
:::

A tool and a script are not the same risk, and a skill can supply three different kinds of executable capability:

* A **module tool** is a live `Tool` a source produced by importing a package — it runs in your process, on your heap.
* An **isolated tool** is JavaScript source the skill carries, evaluated inside a configured guest runtime.
* A **script** is a file the skill ships, run in a child process with a materialized per-skill directory and a derived policy.

They are ordered by containment, and that ordering is the whole point: a module tool is bounded by the wrapper but not confined; an isolated tool cannot escape its guest; a script cannot escape its policy. **Code you wrote belongs in the first. Code you downloaded belongs in the other two.** (The source and the API call these tiers 1, 2, and 3, in that order.)

## Module tools

A skill can ship executable tools, not just prose: a `SkillDescriptor` may carry live `Tool` instances that a source produced by importing a module. That is genuinely useful and genuinely dangerous. It is third-party code, and once loaded, the model can call it on any dispatch.

The rule is flat: every imported tool is rebuilt before it is ever registered. Not inspected, not validated against a policy flag, not trusted because it declares itself trustworthy — rebuilt, unconditionally, into a new wrapper the host owns. The original is never mutated and never reached by the model.

`runToolGate` is a convention a handler is supposed to call itself. A plugin author is precisely the party who will not — through malice, or far more often through simply not knowing they were meant to. A plugin host that trusts a plugin to run the host's own permission check has not implemented a permission check, so wrapping is neither optional nor configurable. The wrapper takes over the gate, error containment, response validation, artifact binding, provenance metadata (`meta.skill` and `meta.skillVersion`), and forcing `trusted: false`.

### Bounded, not safe

None of this makes a tool safe. It makes it *bounded*. An imported tool is still a function running in your process, on your heap, reaching whatever the rest of your process can reach — the wrapper governs how it is called and what it may return, not what it can touch once it is running. The wrapper is the host boundary, not a sandbox. For code you genuinely do not trust, the containment story is an isolated guest or a child-process script.

### The wrapper boundary

A source can return live tools from `descriptor(ref)`. That is intentionally a host-side seam: bytes and markdown cannot create a handler. Once the descriptor crosses into the manager, every imported tool is rebuilt.

```ts
import { createSkillManager } from '@nhtio/adk/batteries/skills'
import { Tool } from '@nhtio/adk/common'
import { validator } from '@nhtio/validation'

// In normal use createSkillManager performs the import and wrapping. A hostile
// tool that throws is exposed to the model as a skill-tool failure.
const source = /* a SkillSource whose descriptor returns { ...descriptor, tools: [hostile] } */ null as never
const hostile = new Tool({
  name: 'reports',
  description: 'Reports',
  inputSchema: validator.object({}).unknown(false),
  trusted: true,
  handler: () => { throw new Error('secret-error') },
})
const manager = await createSkillManager({
  sources: [source],
  gate: async () => undefined,
})
// After loading and calling the tool, the model receives E_SKILL_TOOL_FAILED;
// trusted is forced to false, and a returned SpooledArtifact would instead be
// rejected as E_SKILL_TOOL_BAD_RESPONSE.
```

### What the wrapper forces

The wrapper runs `runToolGate` before the original executor. A thrown error becomes `E_SKILL_TOOL_FAILED`; a bare string is not treated as a recoverable `Error`. Valid responses are strings, `Uint8Array`, `Media`, or arrays of `Media`. `undefined`, objects, functions, and prebuilt `SpooledArtifact` values are rejected as `E_SKILL_TOOL_BAD_RESPONSE`.

The manager call above is the minimal host boundary. In a host that supplies an artifact policy, the same call can make the binding explicit:

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

That configuration does not make the plugin trusted: it only chooses how an otherwise valid response is spooled (see [Tool results](./artifacts)). Setting `trusted: true` on the incoming tool is ignored and produces an untrusted wrapper; returning an object or an already-built artifact produces `E_SKILL_TOOL_BAD_RESPONSE`. A thrown handler produces `E_SKILL_TOOL_FAILED`, while a name collision produces `E_SKILL_TOOL_COLLISION` before registration.

Wrappers are untrusted by construction: `trusted` is false even if the supplied tool claimed true. The manager also preflights names against existing tools, reserved artifact readers, duplicate declarations (`E_SKILL_TOOL_DUPLICATE`), and the provider-safe name pattern. A collision prevents registration rather than allowing a plugin to replace a host capability.

The wrapper does not race an in-process handler against `ctx.abortSignal`. A handler that never settles hangs the turn. If cancellation matters, use an isolated guest or a script.

## Isolated JavaScript

A `SkillIsolationConfig` supplies guest globals, modules, limits, and either `resolveGuest` or the explicit unsafe in-process opt-in. The isolated source (`SkillIsolatedTool`) is a complete async arrow expression (`async (args) => { ... }`) and receives validated JSON arguments. The guest gets only declared capabilities; there is no ambient `fetch`, `process`, `require`, `Date.now`, or `Math.random` in the default compartment.

The caller's `ctx.abortSignal` is passed to guest creation. Evaluation has its separate timeout, and the battery calls `kill()` on timeout.

The default in-process compartment's `kill()` is a no-op, so real cancellation requires a real worker or child runtime via `resolveGuest`. Otherwise, manager construction refuses the configuration unless `unsafe.isolatedToolsInProcess` is set explicitly.

## Scripts

Configure `SkillScriptConfig` to enable tier-3 scripts. The following is the shape used by the passing manager integration test: the host supplies the child-process handle, workspace, policy translator, interpreter allow-list, and hard ceilings.

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

const manager = await createSkillManager({
  sources: [source],
  gate,
  scripts: {
    handle: sandboxHandle,
    workspace: skillWorkspace,
    policy: sessionPolicy,
    translator: policyTranslator,
    interpreters: { sh: ['/bin/sh'] },
    interpreterReadPaths: [],
    maxTimeoutSeconds: 3,
    defaultTimeoutSeconds: 1,
    maxOutputBytes: 1_000,
    hostEnvIsolated: true,
  },
})
```

`hostEnvIsolated: true` is not cosmetic: without it, construction rejects the configuration as invalid. `interpreterReadPaths` is deliberately explicit, even when empty, so the host can review what the interpreter may read. The workspace and handle are host-owned resources and are disposed when the skill leaves the loaded set.

A descriptor's `scripts` entries declare `name`, source-relative `path`, `description`, `interpreter`, and optional typed `params` (`SkillScriptParam`). The battery builds argv; there is no free-form argv and values are never shell-interpolated. Optional positional parameters are rejected; flags are rendered as `--flag value`, and boolean flags are emitted only when true.

Generated tools use the naming convention `run_<skill>_<script>`, must match the provider-safe name pattern, and are limited to 64 characters. The script path is normalized and rejects absolute paths, traversal, and unsafe sandbox paths. Files are materialized through `SkillWorkspace`; the returned root is authoritative and disposed when the skill leaves the loaded set.

The tool requires `hostEnvIsolated: true`, passes `env: {}`, checks that the derived policy is a subset of the session policy, caps captured output at `maxOutputBytes` (spooled according to [Tool results](./artifacts)), and applies `timeout_seconds` between 1 and `maxTimeoutSeconds`. Timeout is an execution control, not a resource governor.

By default a nonzero exit code is a failure: the script's output is spooled as usual, but the tool throws `E_SKILL_SCRIPT_FAILED` (carrying the exit code and the retrievable id) rather than returning a success string, so a host can detect a broken or rejected script without parsing prose. Set `failOnNonzeroExit: false` for skills whose scripts use exit codes as ordinary signalling; the tool then returns the acknowledgement string — exit code, byte count, truncation flag, retrievable id — for any completed run.

## Failure boundaries

The gate runs before any handler or process execution. An explicit permission refusal is surfaced through the gate's normal denial path; a broken or unavailable gate never masquerades as a permission refusal. Handler and child-process failures follow distinct, documented error chains so the host can audit or retry failures without granting a plugin a way to disguise them:

* **Module tools (in-process)**: The gate runs before the handler. An explicit permission refusal is surfaced through the gate's normal denial path. A thrown handler error is caught and wrapped as `E_SKILL_TOOL_FAILED`. An invalid output shape (objects, functions, `undefined`, or prebuilt `SpooledArtifact` values) produces `E_SKILL_TOOL_BAD_RESPONSE`. Name collisions with existing host tools or reserved readers fail preflight with `E_SKILL_TOOL_COLLISION`, while duplicate declarations within the same skill fail with `E_SKILL_TOOL_DUPLICATE`.
* **Scripts (child-process)**: A path containing traversal, leading separators, or an absolute path produces `E_SKILL_SOURCE_PATH_REJECTED`. An interpreter absent from `interpreters` cannot be selected. An explicit policy refusal produces `E_SKILL_SCRIPT_DENIED`, whereas an unavailable or broken gate produces `E_SKILL_SCRIPT_GATE_UNAVAILABLE` (503, never misrouted as a 403 refusal). If policy derivation attempts to widen permissions beyond the session boundary, execution fails with `E_SKILL_SCRIPT_POLICY_WIDENED`. Exceeding a deadline produces `E_SKILL_SCRIPT_TIMEOUT`. A nonzero exit code produces `E_SKILL_SCRIPT_FAILED` unless `failOnNonzeroExit: false` is configured. Materialization and disposal failures use `E_SKILL_WORKSPACE_FAILED`.

These two error hierarchies are intentionally separate: `E_SKILL_TOOL_FAILED` describes an imported module tool's thrown exception, while the script exceptions isolate workspace, path, interpreter, and child-process policy boundaries. Neither is silently converted into a successful tool result.

## What is not enforced

No memory, CPU, or process-count limit is supplied by this battery. The model-supplied timeout is bounded for scripts and isolated evaluation, but it is not a general scheduler. In-process module handlers are not cancellable: one that never settles hangs the turn. A permissive script policy is available only when explicitly supplied as `unsafe.scriptsPermissivePolicy`; `allowed-tools` is carried nowhere as an enforcement rule.

For the body lifecycle and projection channels, see [Context channels](./context).
