Encoding battery
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 callbacks, which you wire yourself. It does not make closures portable — a Tool handler serializes by source text only. And it cannot serialize a TurnGate (a live Promise has no serialized form) or a Media whose reader can't describe a re-openable handle. See Serialization for the full contract.
One call turns it on
The ADK primitives already carry the @nhtio/encoder 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:
npm install @nhtio/encoderThen register, once, at startup — before your first decode():
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 intactregisterAdkEncodables() does two things and is safe to call more than once:
- Registers all 15 encodable primitives with the decoder so a
custom:<ClassName>wire tag resolves to its constructor. - Auto-registers the reader resolvers that need no live binding —
media:in-memory(bytes inlined in the handle),media:fetch(URL re-issued), andspool: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 or SpooledArtifact 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:
// 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()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
})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.
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, the 27 callbacks.
- Serializing
TurnGates or closure-capturingToolhandlers — see Serialization for the deliberate non-goals. fromWebFile-backedMedia— not describable; persist to a store and re-wrap first.
See Serialization for the conceptual model and Storage batteries for the spool stores whose readers these resolvers re-bind.