Skip to content
3 min read · 548 words

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:

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 bindingmedia: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 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:

typescript
// 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
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
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

TierPrimitivesHow
Value objectsTokenizable, Registry, Identity, Memory, ThoughtSnapshot → constructor re-validation. Free.
ContainersMessage, ToolCall, RetrievableEncoder recurses into nested primitives. Free.
Reader-backedMedia, SpooledArtifact, SpooledJsonArtifact, SpooledMarkdownArtifactSerialize 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-capturing Tool handlers — see Serialization for the deliberate non-goals.
  • fromWebFile-backed Media — 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.