---
url: 'https://adk.nht.io/assembly/runtime-loading.md'
description: >-
  Consuming ADK as a runtime-loaded bundle via @nhtio/adk/shims — the
  async-resolver seam for keeping ADK out of your module graph.
---

# Runtime Loading

## LLM summary — Runtime Loading

* **The constraint**: some module graphs cannot hold ADK. Importing `@nhtio/adk`'s root barrel eagerly evaluates
  core — every primitive class, `TurnRunner`, `DispatchRunner` — the moment the `import` statement executes;
  battery code is not folded into that barrel, so a battery subpath (`@nhtio/adk/batteries/...`) is only evaluated
  when *that* subpath is separately imported. Even scoped to core, that eager evaluation is itself a failure on
  constrained runtimes — the motivating case is this repository's own docs site, which overflowed the JS call
  stack on iOS WebKit ("Maximum call stack size exceeded") from importing ADK source into the VitePress app's
  module graph. The same shape recurs for CDN / no-bundler pages, Worker threads, plugin systems injecting a host
  implementation, and version hot-swap.
* **The seam**: [`createAdkShim`](https://adk.nht.io/api/@nhtio/adk/shims/functions/createAdkShim)`(resolver)` from `@nhtio/adk/shims`. You supply a zero-argument
  [`AdkResolverFn`](https://adk.nht.io/api/@nhtio/adk/shims/type-aliases/AdkResolverFn) that produces a bundle (sync or async); the shim owns memoization. `@nhtio/adk/shims` is a leaf
  module — zero runtime imports from the rest of ADK, safe to import eagerly from anywhere, including the top of a
  resolver-calling module.
* Four ways to read a shim: `await shim.resolve()` (async, always-correct, single-flight — concurrent callers
  during an in-flight resolution share one resolver invocation), `shim.get()` (sync, throws
  [`E_SHIM_NOT_RESOLVED`](https://adk.nht.io/api/@nhtio/adk/shims/variables/E_SHIM_NOT_RESOLVED) if nothing resolved yet), `shim.proxy` (a `Proxy<TBundle>` that delegates property
  reads to the resolved bundle, throwing `E_SHIM_NOT_RESOLVED` naming the exact property touched too early — bound
  function properties stay bound to the bundle on destructure), `shim.resolved` (a live boolean, re-evaluated per
  read).
* **GC-safe memoization**: the resolved bundle is held via `WeakRef` (`TBundle extends object` is required),
  never strongly retained by the shim itself. Only the in-flight resolution promise is held strongly, and only
  for that one flight. If the bundle is collected, `resolved` flips to `false`, sync reads throw
  `E_SHIM_NOT_RESOLVED`, and the next `resolve()` transparently re-invokes the resolver (idempotent for dynamic
  import — the module registry itself caches strongly). Environments without `WeakRef` fall back to an ordinary
  strong reference for the shim's lifetime — a documented behavior, not a silent downgrade.
* **Typed failures**: `E_SHIM_NOT_RESOLVED` (sync read too early), [`E_SHIM_RESOLUTION_FAILED`](https://adk.nht.io/api/@nhtio/adk/shims/variables/E_SHIM_RESOLUTION_FAILED) (resolver
  rejected/threw — original error on `.cause`, memo cleared so the next `resolve()` retries), and the ambient-only
  [`E_SHIM_RESOLVER_ALREADY_RESOLVED`](https://adk.nht.io/api/@nhtio/adk/shims/variables/E_SHIM_RESOLVER_ALREADY_RESOLVED) (registering a new resolver after the ambient shim already resolved
  once — a split-brain guard).
* **Ambient variant**: [`registerAdkResolver`](https://adk.nht.io/api/@nhtio/adk/shims/functions/registerAdkResolver)`(resolver)` + the shared [`adk`](https://adk.nht.io/api/@nhtio/adk/shims/variables/adk) shim — for the "many
  files, one shared binding" ergonomics, so every file imports the same `adk` value instead of constructing (and
  separately resolving) its own shim. Re-registering before the first successful resolution silently overwrites
  (last writer wins, expected during bootstrap); re-registering after throws `E_SHIM_RESOLVER_ALREADY_RESOLVED`.
  Use a fresh `createAdkShim` instead when you need a second, independently swappable binding.
* **`TBundle` is a compile-time contract, not a runtime guarantee** — exactly like a real `import` statement whose
  resolved module doesn't match its `.d.ts`. Compose with `&` for a battery bundled alongside core:
  `AdkNamespace & typeof import('@nhtio/adk/batteries/context/thrift')`.
* **The bundle-build recipe**: a standalone Vite config that aliases `@nhtio/adk` to local `src/`, sets
  `build.lib` with `formats: ['es']`, disables minification (class-name-keyed decode breaks under esbuild's
  lib-mode minifier), and externalizes only what the host page already provides — everything else, including the
  ADK, gets bundled IN. This repo's own docs site runs on exactly this: the flagship agent and the interactive
  playground both consume the compiled REPL bundle through `createAdkShim`, not a source import.

You `import '@nhtio/adk'` the same way you'd import any package. On desktop Chrome it's fine. On an iOS user's
phone, their tab dies with "Maximum call stack size exceeded" — a stack overflow — before a single line of your
own code has run. This page is why, and what to do about it.

Most consumers `import` ADK directly and let the bundler wire it in — that's the right default, and if it works
for you, stop reading. This page is for the cases where it doesn't: where importing ADK's source into your
**module graph** — the tree of every module your code transitively imports, that your bundler resolves and
loads together — is itself the problem, not a step on the way to a working page.

## The constraint

Importing `@nhtio/adk`'s root barrel — the `.` export — **eagerly evaluates** the *core* module graph: every
primitive class, [`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/TurnRunner), [`DispatchRunner`](https://adk.nht.io/api/@nhtio/adk/dispatch_runner/classes/DispatchRunner), every transitive dependency core needs, gets
constructed and wired up the moment the `import` statement executes, whether or not you use any of it yet (the
opposite would be *lazy* evaluation — deferred until first use). Batteries are not part of that graph. Each
battery ships as its own package subpath export (`@nhtio/adk/batteries/llm/litert_lm`, and so on, generated from
an `@module` tag on the battery's own entry file) and is only evaluated when *that specific subpath* is imported
— importing the root package pulls in none of them. On most runtimes even the core-only evaluation is simply
"the app starts a little slower." On constrained runtimes it can be a hard failure.

The motivating case, in full, is this documentation site itself — the same story from the top of this page. The
docs app is a VitePress single-page application; at one point it imported `@nhtio/adk` source directly, the same
way any consumer would. On iOS WebKit, the failure manifested during that eager
graph evaluation — the JavaScript call stack overflowed ("Maximum call stack size exceeded") on every single
page, before the page had finished hydrating, and moving ADK out of the eagerly-evaluated graph is what fixed
it. The same underlying shape shows up anywhere a module needs to *reference* ADK types and values without
*eagerly linking* to a concrete ADK build at import time:

* **CDN / no-bundler pages** — a plain `<script type="module">` page with no build step has no bundler to defer
  the import for you; a bare top-level `import` runs immediately, full stop.
* **Worker threads** — a worker's module graph is separate from the main thread's; you often want the *main*
  thread to reference ADK's types without ever constructing the library there, while the actual instance lives in
  the worker.
* **Plugin systems** — a host application wants to inject its own ADK build (a specific version, a specific set
  of batteries) into a plugin's code without the plugin's own bundle carrying a second, possibly conflicting copy.
* **Version hot-swap** — retiring one loaded bundle for another without reloading every module that referenced it.

Before this seam existed, the docs app's fix was to hand-roll it: four separate `export let Foo: typeof
AdkModule.Foo` holder modules, one per consumer, about 590 lines total, each independently drifting from the
others as the set of symbols each consumer needed grew. `@nhtio/adk/shims` is that pattern, extracted once,
fixed.

## The seam: `createAdkShim`

[`createAdkShim`](https://adk.nht.io/api/@nhtio/adk/shims/functions/createAdkShim)`(resolver)` takes one thing from you: an [`AdkResolverFn`](https://adk.nht.io/api/@nhtio/adk/shims/type-aliases/AdkResolverFn) — a zero-argument function
that produces your bundle, synchronously or asynchronously. Everything environment-specific — where the bundle
lives, how it's fetched, whether it's cached upstream — lives inside that one function. The shim itself doesn't
know or care how `TBundle` gets produced; it owns exactly one job, memoization, and does it the same way
regardless of what your resolver does.

```ts
import { createAdkShim } from '@nhtio/adk/shims'
import type * as AdkModule from '@nhtio/adk'

const shim = createAdkShim<typeof AdkModule>(() => import('@nhtio/adk'))
```

That's the entire same-graph passthrough case — useful as a drop-in when a call site doesn't actually need
deferral (tests, for instance), while still giving every consumer the identical [`AdkShim`](https://adk.nht.io/api/@nhtio/adk/shims/interfaces/AdkShim) interface
regardless of which resolver backs it.

### Four ways to read it

* **`await shim.resolve()`** — the async, always-correct path. Concurrent callers during an in-flight resolution
  share that one in-flight promise rather than each triggering a fresh resolver invocation (**single-flight**: one
  resolution in flight at a time, everyone else waits on it instead of starting their own). Already-resolved
  calls return the cached bundle without touching the resolver at all.
* **`shim.get()`** — the sync path, for code that has already awaited `resolve()` at least once and now wants to
  read without paying for another `await`. Throws `E_SHIM_NOT_RESOLVED` if nothing is resolved yet — there is no
  synchronous equivalent of running an async resolver, so this never blocks and never returns a stale value.
* **`shim.proxy`** — a `Proxy<TBundle>` (`TBundle` is the type parameter for whatever your resolver produces —
  `typeof import('@nhtio/adk')`, say) that delegates every property read through to the resolved bundle once one
  exists, and throws `E_SHIM_NOT_RESOLVED` naming the *exact property* that was touched too early. This directly
  replaces the hand-rolled `export let Foo: typeof AdkModule.Foo` holder pattern: one `proxy` stands in for an
  entire bundle's worth of individually-declared holders. Reading a method off `proxy` returns it pre-bound to
  the resolved bundle, so destructuring — `const { Message } = shim.proxy` — behaves the same as `shim.get
  ().Message` and doesn't lose `this`.
* **`shim.resolved`** — a live boolean, re-evaluated on every read, for call sites that want to branch without
  risking a throw.

### GC-safe memoization

A resolved bundle is held via a `WeakRef` — a reference that lets the garbage collector reclaim the object it
points to, instead of keeping it alive forever — never strongly retained by the shim itself, which is why
`TBundle` must extend `object` (only objects are valid `WeakRef` targets). A shim you hold onto for the lifetime
of your app
cannot, on its own, keep an entire ADK bundle — and everything that bundle closes over — alive forever. Only the
in-flight resolution *promise* is held strongly, and only for the duration of that one flight.

If the bundle is collected — or, for the dynamic-import resolver, was never actually going to be collected in
practice, since the JS module registry itself caches an imported module strongly for the life of the realm; the
`WeakRef` here only releases the *shim's own handle* to it — the shim degrades predictably rather than silently:
`resolved` reports `false`, `get()` and `proxy` reads throw `E_SHIM_NOT_RESOLVED`, and the next `resolve()` call
transparently re-invokes the resolver. That re-invocation is idempotent for a dynamic import (re-importing an
already-loaded module resolves instantly from the registry cache) and is a correctness *requirement* for any
other resolver you supply, since this path can legitimately run more than once over a shim's lifetime.

Long-lived synchronous consumers — a timer callback, an event handler — that read `get()` / `proxy` without ever
`await`-ing `resolve()` again should either hold their own strong reference to the specific value they pulled out
of the bundle, or re-`await resolve()` before each synchronous read, rather than assuming a resolved bundle stays
resolved indefinitely.

Environments without `WeakRef` (checked once, at construction time) fall back to an ordinary strong reference for
that shim's lifetime. That is documented, intentional behavior for those environments — not a silent downgrade
you have to discover the hard way.

### Typed failures

Three exception codes cover the seam's failure surface:

* **`E_SHIM_NOT_RESOLVED`** — thrown by `get()` or a `proxy` property read attempted before anything has
  resolved. The message names the exact accessor (`"get()"`, or the touched property name), so the failure
  points at the offending call site instead of surfacing as a generic "cannot read property of undefined" a few
  frames downstream. Recoverable: `await shim.resolve()`, then retry the sync read.
* **`E_SHIM_RESOLUTION_FAILED`** — thrown when the resolver rejects or throws. The original failure is preserved
  on `.cause` — a failed `fetch`, a malformed bundle, a Worker handshake that never completed — and the memo is
  cleared on this path, so the very next `resolve()` call re-invokes the resolver and can still succeed. Treated
  as non-fatal by design: a resolver failure is an environmental condition, not a programming error.
* **`E_SHIM_RESOLVER_ALREADY_RESOLVED`** — ambient-shim-only (see below): thrown when `registerAdkResolver` is
  called again after the ambient shim has already resolved once.

## Example resolvers

These are illustrative — the module ships the mechanism, not a loading policy.

::: code-group

```ts [URL dynamic import, SSR-guarded]
const shim = createAdkShim(() => {
  if (typeof window === 'undefined') {
    throw new Error('this bundle is client-only (no SSR)')
  }
  const url = new URL('/repl/adk-repl.es.js', window.location.origin).href
  return import(/* @vite-ignore */ url)
})
```

```ts [Same-graph passthrough]
// No deferral at all — a drop-in for call sites that don't need one, e.g. tests.
const shim = createAdkShim(() => import('@nhtio/adk'))
```

```ts [Worker handshake sketch]
// The bundle LIVES in the worker; it cannot be postMessage'd back — a module
// namespace of classes and methods doesn't survive structured cloning. So the
// resolver waits for the worker to signal readiness, then resolves to a client
// -side RPC FAÇADE whose methods forward calls across the boundary. TBundle is
// the façade's shape here, not `typeof import('@nhtio/adk')`.
const shim = createAdkShim<AdkClientFacade>(
  () => new Promise((resolvePromise) => {
    worker.addEventListener('message', function onReady(e) {
      if (e.data?.type !== 'adk-ready') return
      worker.removeEventListener('message', onReady)
      resolvePromise(makeAdkFacade(worker)) // methods postMessage into the worker
    })
    worker.postMessage({ type: 'init-adk' }) // worker imports ADK on its own side
  })
)
```

:::

The worker case is the sharpest illustration of the seam staying out of your loading policy: the resolver's
job is only to *hand back a `TBundle`-shaped value once one exists*. Whether that value is a real ADK namespace
(same-graph, URL import) or a façade standing in for an instance that lives somewhere unclonable (a worker, a
different realm) is entirely yours to decide — the shim memoizes it either way and never inspects what it is.

## The ambient variant: `registerAdkResolver` + `adk`

`createAdkShim` gives you an independent, scoped binding — construct one per plugin version, per Worker, per
whatever unit needs its own swappable resolver. Most apps don't need that granularity; they need one shared
binding that every file in the module graph imports and reads the same way. That's what the ambient registry is
for:

```ts
import { registerAdkResolver, adk } from '@nhtio/adk/shims'

// Once, early, before anything reads `adk`:
registerAdkResolver(() => import('@nhtio/adk'))

// Anywhere else in the graph, after a resolve has completed at least once:
await adk.resolve()
const { Message } = adk.proxy
```

Call `registerAdkResolver` once, early, before anything reads `adk`. Re-registering **before** the ambient shim's
first successful resolution silently overwrites the previous registration — last writer wins, no throw — which is
the expected shape during application bootstrap, where a resolver might be registered speculatively and then
superseded before anything actually resolves. Re-registering **after** the first successful resolution throws
`E_SHIM_RESOLVER_ALREADY_RESOLVED`: swapping the resolver underneath already-resolved consumers risks two
different call sites silently observing two different ADK builds through the same shared handle, which is a far
harder bug to diagnose than a loud, immediate throw at the mis-timed registration site. If you need a second,
independently swappable binding, construct a fresh `createAdkShim` instance instead of fighting the ambient one.

Prefer `createAdkShim` directly when you want a scoped, non-ambient binding; reach for `registerAdkResolver` /
`adk` for the "one shared binding used across many files" ergonomics — which is exactly what this docs site's own
flagship agent does (see below).

## `TBundle` is a compile-time contract, not a runtime guarantee

Here's the failure this section exists to prevent: you type your shim's bundle as carrying the thrift battery
(`typeof import('@nhtio/adk/batteries/context/thrift')`, composed in below), call `subtractToFit` off it, and
ship. Your build's entry point never actually imported that battery, so the compiled artifact doesn't contain
it. TypeScript is satisfied — the type says the function exists — and the build succeeds. The runtime isn't:
`shim.resolve()` returns a bundle missing the function, and the call throws the first time a real user hits it.

The generic `TBundle` you supply to `createAdkShim` (or, at the ambient call sites, the default `AdkNamespace`)
tells the compiler what shape to expect back from your resolver. It performs **no runtime validation** of the
value your resolver actually produces — getting the contract right is on you, exactly as it would be for a real
`import` statement whose resolved module happens not to match its `.d.ts`. Compose it with `&` when your
resolver's bundle carries more than the root namespace — a battery bundled alongside core, say:

```ts
type MyBundle = typeof import('@nhtio/adk') & typeof import('@nhtio/adk/batteries/context/thrift')
const shim = createAdkShim<MyBundle>(() => loadMyPrecompiledBundle())
const { subtractToFit } = await shim.resolve()
```

## The bundle-build recipe

`createAdkShim` and `registerAdkResolver` are the consumption seam — they say nothing about how the bundle itself
gets built. For a URL dynamic-import resolver, you need an actual compiled artifact to import, and the way to
produce one is a standalone bundler config that compiles `@nhtio/adk` (plus whichever battery subpaths you use)
into a single self-contained module, independently of your host app's own bundler pass.

This repository's own docs playground does exactly that. A trimmed version of the real config
(`repl.vite.config.mts`):

```ts
import { resolve } from 'path'
import { defineConfig } from 'vite'

const SRC_DIR = resolve(__dirname, 'src')

export default defineConfig({
  resolve: {
    alias: {
      // Resolve @nhtio/adk to LOCAL source so this build compiles the library itself,
      // not a previously published package — cycles resolved by Rollup, exactly as a
      // real published build would be.
      '@nhtio/adk/': `${SRC_DIR}/`,
      '@nhtio/adk': resolve(SRC_DIR, 'index.ts'),
    },
  },
  build: {
    outDir: resolve(__dirname, 'docs', 'public', 'repl'),
    sourcemap: true,
    // Do NOT minify: a class-name-keyed encode/decode round-trip breaks under esbuild's
    // lib-mode minifier mangling constructor names. This asset is loaded by URL, so
    // correctness beats the size win.
    minify: false,
    lib: {
      entry: { 'adk-repl': resolve(__dirname, 'entry.ts') },
      formats: ['es'],
      fileName: (_format, entry) => `${entry}.es.js`,
    },
    rollupOptions: {
      // INVERTED externals vs. a normal app build: bundle @nhtio/adk IN (that's the whole
      // point). Externalize only what the host page already provides.
      external: ['vue'],
    },
  },
})
```

The shape that matters, independent of which bundler you use:

* **Alias `@nhtio/adk` to your library's real entry point** (source or a published package) so the compiled
  output is self-contained — resolving it once here, not deferred to whatever resolves imports at runtime, is the
  entire reason this is a *separate* build rather than a flag on your host app's own config.
* **Emit a single ESM file** (`build.lib` with `formats: ['es']`) so a plain `import()` by URL, with no import map
  and no host bundler involvement, can load it.
* **Disable minification if your persistence layer depends on constructor names** surviving intact (this repo's
  encoder does — decoding a `custom:<ClassName>` tag depends on `constructor.name`, and minification mangles it).
  If nothing in your bundle keys off runtime names, you can drop this constraint.
* **Externalize only what the loading page unconditionally already provides.** Anything the bundle needs that
  the page can't guarantee gets bundled in, not externalized — an externalized dependency the page doesn't
  actually have becomes an unresolvable import at runtime, since there's no bundler present to resolve it for you
  the way there would be inside a normal app build.

The output is a static asset your host app serves (this repo serves it from `docs/public/repl/`) and loads by URL
— which is precisely the shape the "URL dynamic import" resolver example above expects.

## `TBundle` and the build are two halves of one contract

Treat the bundle-build config and the `TBundle` type you hand to `createAdkShim` as one contract split across two
files: the build config decides which symbols actually end up in the compiled artifact, and `TBundle` is your
compile-time promise about what a successful resolution will contain. Neither half validates the other at
runtime. If you add a battery import to the build's entry point, update `TBundle` to match; if you widen
`TBundle`, make sure the entry point actually exports the new symbol. The compiler will happily let the two drift
apart — it's checking your code against the *type*, not against what your bundler actually emitted.

::: tip This site runs on its own advice
The flagship agent embedded in this documentation site, and the interactive code playground next to it, both
consume ADK through exactly this seam — `createAdkShim` wrapping a URL dynamic-import resolver against the
compiled `adk-repl.es.js` asset described above, accessed through the ambient `registerAdkResolver` / `adk`
pattern so every agent module shares one resolved binding. This isn't a hypothetical recipe; it's the mechanism
keeping this very page's interactive demos from re-triggering the iOS WebKit crash that motivated writing
`@nhtio/adk/shims` in the first place.
:::

## Where to go next

* [Bring your own tools](./byo-tools) — the next assembly concern once ADK is loaded: defining the `Tool`
  instances your runtime-loaded bundle will execute.
* [Assembly](./index) — the full map of where your stack plugs into ADK.
* [Token Thrift](/the-loop/token-thrift) and [Behavioral Rails](/the-loop/behavioral-rails) — the context and
  gate discipline the flagship agent (loaded through this exact seam) runs on top of.
