---
url: 'https://adk.nht.io/developer-tools/eslint-rules.md'
description: >-
  An importable ESLint plugin at @nhtio/adk/eslint that turns the harness's
  documented contracts into lint errors — catching the footguns TypeScript
  compiles without complaint.
---

# ESLint Rules

## LLM summary — ESLint Rules

* `@nhtio/adk` ships an importable ESLint plugin: `import adk from '@nhtio/adk/eslint'`. Rules also import individually from `@nhtio/adk/eslint/rules/<name>`.
* Register as `plugins: { adk: adk.plugin }`; enable everything at once with `adk.configs.recommended`.
* `@typescript-eslint/utils` and `eslint` are OPTIONAL peer dependencies — installed only by consumers who lint with this plugin.
* Rules are report-only (no autofix); each names the contract and the fix. Carve out exceptions inline: `// eslint-disable-next-line adk/<rule> -- <reason>`.
* The five rules and what they flag:
  * `adk/require-validator-any-required` — a `validator.any()` chain with no `.required()`/`.optional()`/`.default()`/`.forbidden()` (it silently admits null/undefined).
  * `adk/thought-payload-requires-replay-tag` — `new Thought({ payload })` without `replayCompatibility` (the opaque payload can never be safely replayed).
  * `adk/token-encoding-requires-context-window` — a Chat Completions adapter with non-null `tokenEncoding` but no `contextWindow` (token counting with no budget → overflow guard never runs).
  * `adk/artifact-tool-forbids-artifact-constructor` — `new ArtifactTool({ artifactConstructor })` (infinite re-wrapping).
  * `adk/no-model-in-tool-handler` — a model call inside a `Tool`/`ArtifactTool` handler, unless the handler wraps its own `new TurnRunner(...)`.
* Every rule encodes an invariant the TypeScript type system cannot express — they are enforced at runtime by Joi schemas or by convention, not by types.

Here is what nobody tells you about TypeScript: it catches the mistakes it can
see, and the rest are apparently your problem. A `validator.any()` that quietly
accepts the `null` you spent a whole schema trying to forbid compiles fine, green
check, no notes — because as far as the type system is concerned that is a
perfectly valid schema, and what it actually *does* at runtime is, and I quote,
not the type system's department. A `Thought` carrying a vendor `payload` with no
replay tag — a thing that can never be replayed, dead on arrival — type-checks
without so much as a raised eyebrow. So the code is correct and the code is wrong
at the same time, and you get to find out which at the worst possible moment.

That gap — between "compiles" and "actually works" — is the entire reason these
rules exist. Each one is a documented footgun turned into a lint error, so the
mistake fails your lint instead of your afternoon, rather than waiting to surface
later as a runtime throw, a silent fail-open, or output that is confidently,
articulately wrong.

::: info Why these are lint rules and not types
Every rule here guards an invariant TypeScript *structurally cannot* express — a
cross-field requirement, a value rejected only by a runtime Joi `.custom()` /
`.forbidden()` check, or a "do not call that from in here" boundary. If the type
system could have caught it, believe me, we would have let it, and saved
ourselves the trouble of writing a rule.
:::

## Install

`@nhtio/adk/eslint` needs `eslint` and `@typescript-eslint/utils`. Both are
**optional peer dependencies** of `@nhtio/adk` — the main library never imports
them, so you only install them if you lint with this plugin.

```sh
npm install -D eslint @typescript-eslint/utils
```

## Use it

The plugin is a normal flat-config ESLint plugin. Register it under the `adk`
namespace and turn on the rules you want — or take the whole set with the
bundled `recommended` config.

::: code-group

```js [Recommended — all rules]
// eslint.config.js
import adk from '@nhtio/adk/eslint'

export default [
  // Registers the plugin as `adk` and enables every rule at `error`.
  adk.configs.recommended,
]
```

```js [À la carte]
// eslint.config.js
import adk from '@nhtio/adk/eslint'

export default [
  {
    plugins: { adk: adk.plugin },
    rules: {
      'adk/require-validator-any-required': 'error',
      'adk/thought-payload-requires-replay-tag': 'error',
      // …enable only what you want
    },
  },
]
```

:::

Rules are **report-only** — no autofix, and that is on purpose. Each message
tells you exactly which contract you broke and how to fix it, and then it stops,
because the fix is a decision and the decision is yours; a linter that silently
rewrites a contract violation it does not actually understand is not help, it is
a second bug with good intentions. When you genuinely need to keep a flagged
shape, carve out the exception inline with a reason, so the decision is auditable
in the diff instead of buried in a config exception list nobody will ever read
again:

```ts
// eslint-disable-next-line adk/require-validator-any-required -- type-arg only, never validated
const schema = validator.array().items(validator.any())
```

## The rules

### `adk/require-validator-any-required`

In `@nhtio/validation`, `validator.any()` **admits `null` and `undefined`**
unless you make it `.required()`. Read that again, because it is the opposite of
what you assumed: the schema you wrote to reject missing values is, by default,
waving them right through, and every `.custom()` refinement you layered on top
gets skipped entirely the moment the value is absent. The truly infuriating case
is `validator.any().valid(null)` — you wrote "must be exactly null," a human
being would read it as "must be exactly null," and it accepts `null` *and*
`undefined`, because `.any()` lets `undefined` past the door before `.valid()`
is even awake.

The fix is to say what you meant. Every `.any()` must end in one of `.required()`
(reject null/undefined), `.optional()` (deliberately allow them), `.default(x)`
(allow absence with a fallback), or `.forbidden()` (must be absent) — the rule
does not care which, only that you picked one on purpose. This holds even when
the `.any()` is nested inside `items(...)` or `alternatives(...)`: an enclosing
`.required()` governs the enclosure, not the `.any()` sitting inside it, however
much you might hope otherwise.

### `adk/thought-payload-requires-replay-tag`

If you set `payload` on a [`Thought`](https://adk.nht.io/api/@nhtio/adk/common/classes/Thought), you must also set
`replayCompatibility`. `payload` is the vendor-shaped opaque blob — OpenAI
`encrypted_content`, Anthropic signed reasoning, Gemini thought signatures.
These are not portable. They cannot be replayed into a different provider's API
without knowing the wire format they were produced for. `replayCompatibility` is
that knowledge, captured as a tag like `'openai-responses-encrypted-content-2025-10'`.
Without it, the payload is a frozen vendor-locked artifact that might silently
fail or silently produce wrong output when replayed.

The ADK does not know what vendor shapes exist, and it is not going to guess. All
it knows is that a `payload` with no tag is a `Thought` that can never be safely
replayed — a sealed box with no label, that you will hand to some other provider
months from now and watch detonate. The constructor rejects it at runtime; this
rule catches it at the construction site, before it has the chance.

### `adk/token-encoding-requires-context-window`

The Chat Completions batteries only do local token counting and context-overflow
protection when **both** `tokenEncoding` (how to count) and `contextWindow` (the
budget to count against) are set. Set `tokenEncoding` by itself and you have
built the most pointless machine in the building: it counts tokens diligently,
against no limit, enforcing nothing — all of the cost, none of the protection,
the overflow guard standing there with nothing to guard. The adapters throw on
this mismatch at iteration time; this rule flags it on the adapter options
literal, before it ever gets that far.

### `adk/artifact-tool-forbids-artifact-constructor`

An [`ArtifactTool`](https://adk.nht.io/api/@nhtio/adk/forge/classes/ArtifactTool) answers queries *against* a spooled artifact — it emits a
[`Tokenizable`](https://adk.nht.io/api/@nhtio/adk/common/classes/Tokenizable) directly and must not itself return a `SpooledArtifact`.
Because think about what happens if it does: the result gets wrapped into another
artifact, whose query tools are themselves `ArtifactTool`s, which return
artifacts, which get wrapped, forever, all the way down, like a hall of mirrors
nobody asked for. The base `Tool` accepts an `artifactConstructor`; `ArtifactTool`
explicitly forbids it, and the constructor rejects it at runtime. This rule flags
`new ArtifactTool({ artifactConstructor })` at the construction site, before the
recursion gets a chance to be clever.

### `adk/no-model-in-tool-handler`

The executor owns the dispatch loop. A tool handler runs as one step the model
proposed — it does work, it returns a result, that is the job. What it does not
do is quietly start a second reasoning loop of its own, off the books, where the
harness can't see it: no token accounting, no event surfacing, no abort
propagation, just a model call freelancing inside a tool call. The one legitimate
exception is a tool that is genuinely a sub-agent, running its own scoped dispatch
— so this rule allows a handler that constructs a `new TurnRunner(...)` or calls
the lower-level `DispatchRunner.dispatch(...)`, and flags provider SDK
constructors and chat/completion/message calls everywhere else inside a `Tool` /
`ArtifactTool` handler.

::: tip The rules we deliberately didn't write
You will notice there is no "no model call in pipeline middleware" rule and no
"executor must signal exactly once" rule. Those are real contracts — see
[Pipelines](../assembly/pipelines) and [BYO LLM](../assembly/byo-llm) — and we
left them alone on purpose, because the AST cannot actually decide either one.
Pipeline middleware calls models for perfectly good reasons (tool routers,
planners, query rewriters), so a rule banning it would spend most of its life
yelling at correct code; and "exactly once on every path" needs control-flow
analysis a linter does not have and has no business pretending to. And here is
the thing about a rule that cries wolf: you start ignoring it, and then you
ignore it the one time it was right. A lint rule you have trained yourself to
scroll past is worse than no rule at all. So those two stayed prose, where the
judgment they require actually lives.
:::
