---
url: 'https://adk.nht.io/assembly/batteries-encoding.md'
description: >-
  Opt-in serialization for the ADK primitives via @nhtio/encoder:
  registerAdkEncodables(), reader-handle resolvers, and the durable-store wiring
  you own.
---

# Encoding battery

## LLM summary — Encoding battery

* `@nhtio/adk/batteries/encoding`. Optional peer dependency: `@nhtio/encoder`. The ONLY code in the ADK that imports the encoder — primitives carry the contract via raw `Symbol.for()` keys, so core is encoder-free.
* `registerAdkEncodables()` — call once at startup, before the first `decode()`. Registers all 15 encodable primitives with the decoder AND auto-registers the binding-free reader resolvers (`media:in-memory`, `media:fetch`, `spool:in-memory`). Idempotent.
* Encoding never needs this; decoding always does (decoder maps `custom:<ClassName>` → constructor by name).
* Durable reader handles (flydrive `spool:flydrive`, OPFS `spool:opfs`) are NOT auto-registered — register them yourself with `registerSpoolReaderResolver(tag, fn)` / `registerMediaReaderResolver(tag, fn)`, where `fn` closes over the live `Disk`/OPFS root. The serialized locator carries the key, not the binding.
* Re-exports `registerMediaReaderResolver`, `registerSpoolReaderResolver`, `resolveMediaReader`, `resolveSpoolReader` and the `ReaderDescriptor` / `LocatorValue` / resolver types from one place.
* Does NOT persist conversation state (that's the 27 TurnRunnerConfig callbacks). Does NOT serialize closures (Tool handlers are source-text-only) or live readers it can't describe (`fromWebFile` Media → `E_READER_NOT_DESCRIBABLE`). Cannot encode `TurnGate`.

::: danger This battery serializes the conversation graph — not your storage, and not your closures
It turns a live primitive tree into a string and back. It does **not** persist anything: durable storage of `Message`/`Memory`/`ToolCall` records is still the 27 [`TurnRunnerConfig`](./byo-storage) callbacks, which you wire yourself. It does **not** make closures portable — a [`Tool`](../the-loop/tools/what-a-tool-is) handler serializes by source text only. And it cannot serialize a [`TurnGate`](../the-loop/gates) (a live Promise has no serialized form) or a `Media` whose reader can't describe a re-openable handle. See [Serialization](../the-loop/serialization) for the full contract.
:::

## One call turns it on

The ADK primitives already carry the [`@nhtio/encoder`](https://encoder.nht.io) contract with zero dependency on the encoder. This battery wires the half that genuinely needs it — the decoder's class registry — and is the only code in the ADK that imports `@nhtio/encoder`.

Install the optional peer:

```bash
npm install @nhtio/encoder
```

Then register, once, at startup — before your first `decode()`:

```typescript
import { encode, decode } from '@nhtio/encoder'
import { registerAdkEncodables } from '@nhtio/adk/batteries/encoding'

registerAdkEncodables()

const wire = encode(message)            // string
const restored = decode<Message>(wire)  // Message, nested primitives intact
```

`registerAdkEncodables()` does two things and is safe to call more than once:

1. Registers all 15 encodable primitives with the decoder so a `custom:<ClassName>` wire tag resolves to its constructor.
2. Auto-registers the reader resolvers that need **no live binding** — `media:in-memory` (bytes inlined in the handle), `media:fetch` (URL re-issued), and `spool:in-memory` (string inlined).

Encoding never needs any of this. Decoding needs all of it, because the decoder cannot conjure a constructor — or a live reader — out of a string.

## Durable reader handles: you own the binding

A [`Media`](../the-loop/primitives/media) or [`SpooledArtifact`](../the-loop/artifacts) backed by a **durable** store (flydrive, OPFS) serializes its handle as `{ tag, locator }` where the locator is just a key. The key alone cannot re-open the bytes — that takes the live `Disk` or OPFS root, which is not serializable. So you register the resolver that carries it:

::: code-group

```typescript [In-memory (auto)]
// Nothing to do — registerAdkEncodables() already wired:
//   media:in-memory, media:fetch, spool:in-memory
// In-memory readers inline their bytes; the fetch reader re-issues its URL.
import { registerAdkEncodables } from '@nhtio/adk/batteries/encoding'

registerAdkEncodables()
```

```typescript [Flydrive (you wire)]
import { Disk } from 'flydrive'
import { FlydriveSpoolReader } from '@nhtio/adk/batteries/storage/flydrive'
import {
  registerAdkEncodables,
  registerSpoolReaderResolver,
} from '@nhtio/adk/batteries/encoding'

registerAdkEncodables()

const disk = new Disk(/* your driver */)
registerSpoolReaderResolver('spool:flydrive', (locator) => {
  const { key, streamThresholdBytes } = locator as { key: string; streamThresholdBytes?: number }
  return new FlydriveSpoolReader(disk, key, { streamThresholdBytes }) // disk = the live binding
})
```

```typescript [OPFS (you wire)]
import { OpfsSpoolReader } from '@nhtio/adk/batteries/storage/opfs'
import {
  registerAdkEncodables,
  registerSpoolReaderResolver,
} from '@nhtio/adk/batteries/encoding'

registerAdkEncodables()

// A SpoolReaderResolver is SYNCHRONOUS — `(locator) => SpoolReader` — exactly like the flydrive
// example above, where the resolver closes over a `const disk` resolved up front. Do the same for
// OPFS: resolve the root ONCE before registering (it is stable for the session), then the resolver
// builds the reader from the already-live root. Hand OpfsSpoolReader a lazy handle whose `getFile()`
// opens the file on the reader's first byte-read — so decode itself does no I/O.
const root = await navigator.storage.getDirectory()

registerSpoolReaderResolver('spool:opfs', (locator) => {
  const { name } = locator as { name: string }
  const handle = {
    kind: 'file' as const,
    name,
    getFile: async () => (await root.getFileHandle(name)).getFile(),
    createWritable: () => {
      throw new Error('rehydrated handles are read-only')
    },
  }
  return new OpfsSpoolReader(handle as never, { name })
})
```

:::

Register the resolver with the **same store** the bytes were written to, before you `decode()`. Forget, and decode throws `E_NO_READER_RESOLVER` — the handle was recorded faithfully; there is just nothing registered to re-open it.

::: warning The decoder keys on class names
A `custom:Message` tag resolves to the `Message` constructor by `constructor.name`. If your build minifies class names, the tag stops resolving (and same-named classes collide). Preserve class names (`keep_names` or equivalent) wherever you decode ADK primitives. The ADK's own dist ships unminified.
:::

## What round-trips

| Tier | Primitives | How |
| :--- | :--- | :--- |
| Value objects | `Tokenizable`, `Registry`, `Identity`, `Memory`, `Thought` | Snapshot → constructor re-validation. Free. |
| Containers | `Message`, `ToolCall`, `Retrievable` | Encoder recurses into nested primitives. Free. |
| Reader-backed | `Media`, `SpooledArtifact`, `SpooledJsonArtifact`, `SpooledMarkdownArtifact` | Serialize the **handle**; resolver re-binds on decode. |

**Use this for:**

* Persisting an in-flight conversation graph across a process, queue, or cache boundary.
* Snapshotting turn state for replay, audit, or worker/main-thread transfer.

**Do not use this for:**

* Durable storage of session records — that's [Bring Your Own Storage](./byo-storage), the 27 callbacks.
* Serializing `TurnGate`s or closure-capturing `Tool` handlers — see [Serialization](../the-loop/serialization) for the deliberate non-goals.
* `fromWebFile`-backed `Media` — not describable; persist to a store and re-wrap first.

See [Serialization](../the-loop/serialization) for the conceptual model and [Storage batteries](./batteries-storage) for the spool stores whose readers these resolvers re-bind.
