Skip to content
12 min read · 2,493 words

What ADK is, and what it isn't

This page is about the library itself: what @nhtio/adk is for, what it refuses to do, and the small number of opinions it does hold and why.

ADK draws a single, deliberate line. On one side: the execution shape of a turn — input pipeline, dispatch loop, output pipeline, events, validated primitives. The ADK owns that shape and is strict about it. On the other side: every infrastructure choice a real application makes — which model you call, where state lives, how prompts are built, how retrieval works, which runtime you ship in. The ADK owns none of that and will not pretend to.

Everything below is a closer read of that line. If the vocabulary — turn, dispatch, iteration, tool, context, middleware — hasn't landed yet, How agents work is the plain-English orientation; come back here once those words feel concrete.

What ADK is

A kit, not a platform. ADK owns one shape — the turn — and the small set of seams that make a turn deterministic: input middleware, an executor for the model call, output middleware, validated primitives that travel through them, two event buses, and a tool registry with one collision policy. That's it. The Loop is the page-by-page tour of the shape.

A contract surface. Every seam has a typed signature, every primitive validates at construction, and every error has a stable code. If the ADK runs, the inputs were valid and the outputs are well-shaped — the parts of agent systems that fall over in production because they were "mostly valid" do not exist in this codebase.

A movement guarantee. The seams exist so you can swap what's behind them. Today's hosted-API executor is tomorrow's different-hosted-API executor is next quarter's local-model executor, and none of them require restructuring the loop. Same for storage, retrieval, memory, and tool catalogues.

Claims with a working proof

"Bring your own everything." "Runs anywhere TypeScript runs." Every agent framework prints those words. Almost none of them survive contact with a runtime that wasn't on the author's laptop. We got tired of the bluff, so we called it on ourselves.

Try "Ask ADK"

See the Ask ADK button in the top-right? Click it. Right now.

What just woke up is a language model running in your browser. Not a proxy. Not a server call wearing a costume. A real model, on your hardware, on a tab you can close, given tools by ADK so it can actually answer questions about this site. Pull your network cable. It still works. We'll wait.

We didn't ship that because it was easy. We shipped it because if ADK couldn't hold its contracts together with the model swapped, the storage swapped, the runtime swapped, and the network gone — then every word on this page would be marketing fiction and you'd be right to close the tab.

It held. Same TurnRunner. Same tools. Same events. The Showcase walks through how. Read it when you're done being skeptical.

What ADK isn't

Not a model client. ADK never calls a provider. The DispatchExecutorFn you write is the only place a model is called, and you write it. Bundled batteries are reference implementations of that seam — useful defaults, not the load-bearing path.

Not a database. ADK never persists anything. Storage callbacks (fetch*, store*, mutate*, delete*) are required because state has to live somewhere; where it lives is entirely your problem. In-memory map, SQLite, Postgres, IndexedDB, no-op for tests — ADK does not care.

Not a prompt template engine. The TurnContext.systemPrompt and standing instructions are Tokenizable text. The ADK doesn't run a templating language, doesn't inject variables, doesn't manage versions. If you want templates, bring a template engine. Most developers don't need one.

Not an orchestrator. ADK does not retry, queue, schedule, fan out, or manage backpressure. One TurnRunner.run call is one turn. Higher-level orchestration — retries, multi-turn flows, conversation persistence — lives in your code.

Not a hosted runtime. There is no ADK server, no ADK cloud, no ADK control plane. The library is a TypeScript package; the runtime is wherever you run it (browser, worker, Node, Electron, Deno, Bun, CLI). No hidden infrastructure.

Where ADK is opinionated

The opinions are deliberate and few. They exist because the parts of agent systems that don't have these opinions get the same bugs over and over.

Validation is eager, not eventual

The runner refuses to construct with an incomplete config. Tools refuse to construct with an invalid schema. Primitives refuse to construct with missing fields. There is no "we'll figure it out at call time" — if the inputs were wrong, you find out immediately, with a stable E_* exception code, at the seam where the bad input arrived.

ADK doesn't let you be vague by accident

You will not get a partial ADK, a partial tool, or a partial primitive. Construction either succeeds with all required pieces present, or it throws. There is no third state.

Required callbacks are required

If TurnRunnerConfig.fetchMessagesCallback is part of the contract, you supply it. The ADK will not synthesise an empty array on your behalf, will not log a warning and continue, will not pick "in-memory" by default. The absence of a callback is a config-validation failure.

TurnRunnerConfig specifically demands twenty-five storage callbacks at construction time: seven retrieval callbacks (TurnRunnerConfig.fetchMemoriesCallback, TurnRunnerConfig.fetchMessagesCallback, TurnRunnerConfig.fetchThoughtsCallback, TurnRunnerConfig.fetchToolCallsCallback, TurnRunnerConfig.fetchRetrievablesCallback, TurnRunnerConfig.fetchToolsCallback, TurnRunnerConfig.refreshStandingInstructionsCallback) and eighteen persistence callbacks (store / mutate / delete for each of memories, messages, thoughts, tool calls, retrievables, and standing instructions). There is no construction path that omits persistence — every ADK must have a storage layer wired up at the boundary, even if that layer is an in-memory stub for testing.

If you want a no-op, you have to say so out loud

Passing async () => [] is fine. Passing nothing is not. The ADK refuses to guess which of those you meant — because half the time the guess is wrong, and you don't notice until production.

Immutability by default

Constructed objects expose read-only properties. Mutation happens only through explicit controlled APIs — Registry.set, Tokenizable.set, ctx.storeMemory(m) (see TurnContext.storeMemory), ctx.mutateMessage(id, patch) (see TurnContext.mutateMessage). There is no "sometimes the object is frozen, sometimes it isn't." Read-only getters return the actual mutable Set instances (so ctx.turnMemories.add(m) works) but you cannot assign ctx.turnMemories = new Set() (see TurnContext.turnMemories) — structural replacement is forbidden, in-place mutation through the documented APIs is the path.

Cross-realm safety is structural

Every class identity guard uses the isInstanceOf helper (which runs instanceof, then Symbol.hasInstance, then constructor.name) rather than a bare instanceof. This is load-bearing: a consumer's bundle can end up with two copies of the ADK in memory (one in node_modules, one bundled by a downstream library), and bare instanceof will return false for instances created against the "other" copy. The ADK treats that as a foreseeable runtime, not an edge case.

run() returns Promise<void> — intentionally and permanently

This is not a gap to be filled later. All meaningful output surfaces via events: message, thought, toolCall, error, turnStart, turnEnd. Awaiting run() only signals that the pipeline finished; it carries no data. Streaming responses arrive incrementally mid-turn, tool calls are dispatched and settled before the turn ends, and callers may want to act on output before the turn completes. Returning data from run() would force callers to wait for completion before they could act, which is the wrong model for an agent loop.

Two budgets, always

Every artifact and every primitive in the library is designed against two simultaneous constraints: runtime memory and LLM context window. Both are finite; violating either produces a failure — an OOM crash or a truncated model call. SpooledArtifact holds a SpoolReader, not bytes; SpooledMarkdownArtifact caches only structural metadata, never the document body; SpooledArtifact.cat fetches only the requested range. Token-aware design isn't an afterthought, it's the reason Tokenizable exists as the string primitive everywhere prompts, instructions, and memory content appear.

No safety net — primitives, not policies

DispatchRunner has no maxIterations, no maxToolCallChecksumRepeats, no timeout. The primitives — ctx.iteration (DispatchContext.iteration), ctx.toolCallCount(checksum) (DispatchContext.toolCallCount), ctx.ack() (DispatchContext.ack), ctx.nack() (DispatchContext.nack), ctx.abortSignal (DispatchContext.abortSignal) — are sufficient for any termination bound you need. Some developers want an iteration cap; some want both iteration and checksum bounds; some want a wall-clock timeout via an external AbortController. The runner provides the primitives and stays out of the policy decision.

There is no default cap. None.

If your executor never calls DispatchContext.ack, the loop runs until the abort signal fires or the process is killed. This is intentional — the ADK has no opinion about how long your agent should think — but it means you must write the bound. LLM Dispatch documents the patterns.

Tools are schema-owned

Every tool has one @nhtio/validation object schema. That schema validates arguments at call time and produces the description the model sees in its tool definition. There is no separate "JSON schema for the model" and "runtime validator for the handler" — those are the same artifact, by construction. The model cannot be told one contract while the handler enforces another.

Trust is structural

Content rendered into the prompt is wrapped in distinct envelopes per trust tier (developer policy, trusted tool output, untrusted text, retrieved context), and the closing tags carry nonces bound to the producing primitive's id. This is not decoration — it is the load-bearing defence against prompt-injection attacks. Trust Tiers covers the mechanism; its per-tier research sub-pages (e.g. envelopes research, media research) carry the threat model and citations.

Errors emit, they do not throw (mostly)

Fatal errors (invalid config, invalid context, half-built primitives) throw synchronously at the seam where they arrived. Non-fatal errors (middleware failures, executor failures, tool handler failures) emit on the observability bus as error events and let the turn settle. The split is intentional: programmer mistakes are loud; runtime failures are observable. You cannot swallow a non-fatal error by forgetting to wire runner.observe('error') — the ADK still settles cleanly, and your telemetry just doesn't see it.

The handle pattern

Large tool results don't get inlined into the next prompt. They become SpooledArtifact handles, and the model gets ephemeral artifact_* tools to query them by range. This keeps context windows survivable and memory bounded. Artifacts is the contract; Budgets is the why.

Where ADK is deliberately unopinionated

Everything not on the list above. To make the line crisp:

DecisionOwned by
Which model provider you callYou. The DispatchExecutorFn is yours.
How you format the request to that providerYou (or the LLM battery you picked).
Where messages, memories, retrievables liveYou. The storage callbacks are yours.
How you retrieve relevant documentsYou. Retrieval is an input-middleware concern.
How memory is scored, ranked, or forgottenYou. ADK defines the Memory primitive, not its lifecycle.
How tool calls are dispatched to handlersYou. The ADK emits toolCall events; middleware owns dispatch.
How many iterations a dispatch may runYou. There is no built-in cap.
Whether to retry on failureYou. ADK does not retry.
When and how often to start a turnYou. ADK has no conversation-loop manager.
How multiple agents coordinateYou. ADK has no multi-agent orchestrator.
How prompts are templatedYou. ADK is not a template engine.
How you observe the loopYou. The observability bus is plumbing; you bring Sentry / OpenTelemetry / pino / nothing.
Which runtime you run inYou. Browser, worker, Node, Electron, Deno, Bun, CLI — same contracts.
How you deployYou. ADK has no deployment story because there is nothing to deploy.

"Bring your own everything" is not a slogan — it's the API shape

The seams are what you compose. The batteries are what you import when a default would save you time. Everything else is yours and stays yours.

What's in the box, what isn't

The exact in-scope / out-of-scope line, from the package README:

In scope. Turn execution engine (TurnRunner) with paired input/output middleware pipelines around a user-supplied executor; validated immutable context threading (TurnContext); single LLM dispatch context (DispatchContext) with DispatchContext.ack / DispatchContext.nack lifecycle; single-use dispatch orchestrator (DispatchRunner); executor helpers (DispatchExecutorHelpers) for per-id streaming state; memory modelling (Memory shape and validation, not storage); multi-backend token counting (Tokenizable); cross-middleware key-value scratchpad (Registry); structured machine-readable exceptions with a ValidationException.fatal classification; event streaming for message chunks, reasoning traces, and tool call lifecycle; Chat Completions-compatible message contracts; schema-first tool definitions and registry (Tool, ToolRegistry); lazy, line-oriented artifact types (SpooledArtifact, SpooledJsonArtifact, SpooledMarkdownArtifact).

Out of scope. LLM API calls (no provider SDK in the dependency tree). Storage (no opinion on where memories or messages persist). Tool execution dispatch (no built-in function-call loop or result routing — middleware owns dispatch). Prompt templating. Conversation-loop management (the caller decides when and how often to invoke a turn). Multi-agent coordination.

Batteries included — but only the ones you ask for

The @nhtio/adk/batteries subpath ships pre-constructed compute tools (math, datetime, encoding, parsing, statistics, color, geo, text analysis, etc.) and storage helpers. They are not re-exported from the root entry, so a consumer who never imports them pays nothing in their bundle. Their third-party requirements (mathjs, chrono-node, papaparse, flydrive, etc.) are declared as optional peer dependenciespnpm install @nhtio/adk does not pull them in. The BYO-everything principle holds: a consumer who never imports the parsing battery never installs papaparse.

How to read the rest of the docs

  • Quickstart — install the package and wire the smallest possible turn. Get the muscle memory before you read the contracts.
  • The Loop — the contract surface. One page per seam.
  • Assembly — how to wire your executor, storage, tools, retrieval, and memory into a working agent.
  • Trust Tiers — the deepest of the opinions above, with per-tier research sub-pages carrying the threat models and citations to the security literature that informs them.
  • API reference — Typedoc-generated, never drifts.
  • Glossary — every term, in one place, with links into the pages that own the contract.

A reasonable test

If you can describe what the ADK owns, what it refuses to own, and which of your existing systems will fill each unowned seam — you are ready for Quickstart. If not, How agents work is the page that orients first.