ESLint Rules
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.
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.
npm install -D eslint @typescript-eslint/utilsUse 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.
// 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,
]// 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:
// 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 andundefined, 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, 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 answers queries against a spooled artifact — it emits a 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 ArtifactTools, 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.
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 and 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.
Battery-scoped plugins
The rules above guard harness-core contracts, so they ship in the core plugin. Contracts that belong to a single battery ship with that battery, from the battery's own subpath — they only matter to deployments that compose it, and they version with the battery's API instead of the harness's.
The first of these is the media pipeline's plugin at @nhtio/adk/batteries/media/lint, registered under the adk-media namespace:
import adk from '@nhtio/adk/eslint'
import adkMedia from '@nhtio/adk/batteries/media/lint'
export default [
{ plugins: { adk: adk.plugin }, rules: adk.configs.recommended.rules },
{ plugins: { 'adk-media': adkMedia.plugin }, rules: adkMedia.configs.recommended.rules },
]Its four rules guard the engine-composition contracts:
adk-media/prefer-engine-resolver— a static value import of a bundled engine subpath (import { sharpEngine } from '…/engines/sharp'). The canonical supply form is the dynamic-import resolver; a static import puts the engine wrapper in every bundle, even when the engine is conditional. Type-only imports pass.adk-media/no-shadowed-engine— an engine in theenginesarray whose statically-known capabilities are entirely covered by an earlier engine. Dispatch is first-capable-wins, so the later engine is dead code: a narrow webp-only resizer listed aftersharpEngine()can never be selected, because sharp already covers everything it declares. The rule understands the bundled factories and literal inline declarations — anything it can't analyze statically it leaves alone rather than guess.adk-media/augment-contracts-module— aConvertOptionsdeclaration-merging block targeting any module other than@nhtio/adk/batteries/media/contracts. Augmenting the barrel compiles, looks right, and silently never merges — your keys just don't exist, and nothing tells you why. This is the nastiest failure mode on this page precisely because it produces no error anywhere.adk-media/require-engine-peers— a reference to a bundled engine subpath whose optional peer dependency cannot be resolved from the file. ComposesheetjsEngine()without installingxlsx, ortesseractJsEngine()withouttesseract.js, and the pipeline constructs cleanly but throwsE_INVALID_MEDIA_PIPELINE_CONFIGthe first time that engine runs — which, for an agent-driven pipeline, is in production, mid-turn, hours after the code shipped. The rule resolves each engine's peer (xlsx,jimp,exceljs,tesseract.js, …) from the linting file's own location, so it honors your realnode_moduleslayout, and reports the missing one at lint time with the exact install command (SheetJS gets its CDN-tarball command, sincenpm i xlsxinstalls a stale, CVE'd 0.18.5). Engines with no external peer (soffice, fs_workspace) have nothing to check. Opt a peer out with theignoreoption when you resolve it through a loader the linter can't see.
Field note: the shadow rule was wrong on day one
An ESLint rule cannot execute the factories it lints, so no-shadowed-engine reasons about the bundled engines from a hand-copied summary table. Within an hour of writing it, a drift test was added pinning that table to the live factory declarations — and it failed immediately: the hand-written sharp summary claimed a bmp encode the factory does not declare. Which meant the rule's own flagship example was backwards. We had confidently written "[sharp, jimp] means jimp never runs" in this very document, and it is false — jimp uniquely encodes bmp, so it stays reachable behind sharp. The summary, the rule's tests, and this page were all corrected the same hour. The lesson is not subtle: any lint rule that mirrors runtime facts by hand WILL drift, the drift will be invisible precisely because lint rules have no runtime, and the only honest fix is a test that compares the mirror to the real thing (lint_drift.node.spec.ts, plus a canary that fails when a new factory is summarized without a matching assertion). If you write a rule like this, write the pin first.