---
url: 'https://adk.nht.io/the-loop/serialization.md'
description: >-
  How the ADK primitives round-trip through @nhtio/encoder: the three tiers that
  work, the handles that need re-binding, and the three things that deliberately
  don't.
---

# Serialization

## LLM summary — Serialization

* The primitives implement the [`@nhtio/encoder`](https://encoder.nht.io) custom-class contract via raw `Symbol.for('@nhtio/encoder:toEncoded' | ':fromEncoded')` keys — **zero dependency on the encoder in core**. `encode(value)` → string; `decode(string)` → instance (not a plain object).
* **Decode needs registration.** Call `registerAdkEncodables()` (from `@nhtio/adk/batteries/encoding`) once at startup before the first `decode()`. Encoding never needs it. Without it, `decode()` throws `E_UNDECODABLE_VALUE` on every ADK primitive.
* **Three tiers that round-trip.** (1) Pure value objects — `Tokenizable`, `Registry`, `Identity`, `Memory`, `Thought` — free. (2) Containers — `Message`, `ToolCall`, `Retrievable` — free, because the encoder recurses into their nested primitives. (3) Reader-backed — `Media`, `SpooledArtifact` (+ `SpooledJsonArtifact`, `SpooledMarkdownArtifact`) — serialize the **handle**, not the bytes.
* **Reader handles = `{ tag, locator }`.** Each reader's optional `describe()` emits a tagged locator (a storage key, a URL, or inlined bytes for in-memory readers). On decode, a resolver registered for that `tag` re-binds it to a live reader. In-memory + fetch resolvers auto-register; durable stores (flydrive, OPFS) you register yourself with `registerSpoolReaderResolver` / `registerMediaReaderResolver`, supplying the live `Disk`/OPFS root — the locator carries the key, not the binding.
* **Three deliberate "no"s.** `TurnGate` (live Promise + AbortController — no serialized form). `Tool` handlers (serialize by **source text only** — closures over `ctx`/services read back `undefined`; `inputSchema` round-trips losslessly via `@nhtio/validation`'s own `encode`/`decode`). `fromWebFile`-backed `Media` (a `Blob` has no re-openable locator; the sync encoder can't drain it) → `E_READER_NOT_DESCRIBABLE`.
* **Errors.** `E_READER_NOT_DESCRIBABLE` (encode: reader has no `describe()`). `E_NO_READER_RESOLVER` (decode: no resolver for the tag). The encoder wraps both as the `cause` of its own `E_ENCODING_FAILED` / `E_UNDECODABLE_VALUE`.
* **Minification footgun.** The decoder keys on `constructor.name`. A consumer minifier that renames classes breaks decode — preserve class names (`keep_names`).

The primitives go in, a string comes out, the same primitives come back. That is the whole feature, and the whole feature is opinionated about what "the same" means.

A turn graph is not a bag of JSON. A `Message` carries an `Identity` and a `Tokenizable` and maybe a fistful of `Media`; a `ToolCall` carries results that might be a spooled artifact the size of a log file. Serialization that flattens all of that into `Record<string, unknown>` and calls it a day is lying to you — it hands back data that no longer knows what it is. The ADK refuses. `decode()` gives you back a `Message`, `instanceof Message`, with a real `Identity` and a real `Tokenizable` nested inside, or it throws. There is no third option where you get a plain object wearing a primitive's name.

## How it works

Every encodable primitive implements two well-known symbol methods from [`@nhtio/encoder`](https://encoder.nht.io) — an instance `[ENCODE_METHOD]()` that returns a snapshot, and a static `[DECODE_METHOD](data)` that rebuilds the instance. The primitives implement them via raw `Symbol.for()` keys, so **the core does not depend on `@nhtio/encoder` at all**. You can hold a `Message` in a project that has never heard of the encoder.

The encoder enters the picture for exactly one thing: **decoding**. The decoder needs to map a `custom:Message` wire tag back to the `Message` constructor, and it cannot conjure a constructor out of a string. So you tell it, once:

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

registerAdkEncodables() // once, at startup, before your first decode()

const wire = encode(message)            // string
const restored = decode<Message>(wire)  // Message — nested Identity, Tokenizable, Media all intact
```

Encoding never needs registration. Decoding always does. Skip the call and every `decode()` of an ADK primitive throws `E_UNDECODABLE_VALUE`. See the [Encoding battery](../assembly/batteries-encoding) for the full wiring, including durable stores.

## The three tiers that round-trip

**Pure value objects round-trip for free.** [`Tokenizable`](./primitives/tokenizable), `Registry`, [`Identity`](./primitives/identity), [`Memory`](./primitives/memory), and [`Thought`](./primitives/thought) snapshot to their constructor input and rebuild with a constructor call that re-validates. Luxon `DateTime`s, `bigint`s, `Map`s — the encoder handles those natively, so they survive without special handling.

**Containers round-trip because the encoder recurses.** A [`Message`](./primitives/message)'s snapshot holds the live `Identity`, `Tokenizable`, and `Media[]` — and because each of those is itself encodable, the encoder walks into them. Same for [`ToolCall`](./primitives/toolcall) results and [`Retrievable`](./primitives/retrievable) content. You do not flatten the graph; you hand the encoder the graph and it does the recursion.

**Reader-backed primitives serialize the handle, not the bytes.** This is the one that earns its own section.

## Media and artifacts: it serializes the handle. Never the bytes.

A [`Media`](./primitives/media) or [`SpooledArtifact`](./artifacts) is a *lazy view over re-openable bytes*. Its reader already knows how to re-open them — an in-memory reader from its buffer, a flydrive reader from its disk key, a fetch reader by re-issuing the request. Serialization leans on exactly that: it writes down the **handle** — a tagged, re-openable locator — and throws the bytes away.

Each reader exposes an optional `describe()` returning `{ tag, locator }`:

* in-memory readers inline their buffer (base64) — they own the bytes, there is no external store to point at;
* the fetch reader captures its URL;
* the flydrive reader captures its storage `key`;
* the OPFS reader captures its file `name`.

On decode, a **resolver** registered for that `tag` turns the locator back into a live reader. This split is the whole point, and it is also the sharp edge:

::: warning Decode is a no-op without the resolver — and durable resolvers carry the live binding
The locator is half the handle. The other half — the flydrive `Disk`, the OPFS root, the network — is a **live binding** that cannot be serialized. The in-memory and fetch resolvers need no binding, so the encoding battery auto-registers them. Durable stores are yours to wire: register a resolver that closes over the live store, with `registerSpoolReaderResolver` / `registerMediaReaderResolver`, before you `decode()`. Forget, and decode throws `E_NO_READER_RESOLVER` — the locator was written down faithfully and there is simply nothing registered to re-open it.
:::

## The three things that deliberately don't

Radical transparency demands these be stated first and plainly, not discovered in production.

::: danger TurnGate is not encodable, on purpose
A [`TurnGate`](./gates) wraps a **live pending Promise** plus an `AbortController` and the `resolve`/`reject` continuations some caller is, right now, awaiting. There is no serialized form of "a thing being awaited in another process." Encoding cannot capture it and decoding cannot resume it. So `TurnGate` does not implement the contract — the honest "no" of the set. If you need a record of *which* gates were open, persist their ids and reasons yourself; do not expect the live gate to survive the boundary.
:::

::: danger Tool handlers serialize by source text only — closures do not survive
The encoder serializes a function by `fn.toString()`. The source text crosses the boundary; the **lexical scope does not**. A [`Tool`](./tools/what-a-tool-is) handler that closes over `ctx`, a database client, or config will rehydrate with those variables reading back `undefined` — the body is intact, the world it referenced is gone. A `.bind()`-ed or native handler cannot be serialized at all and the encoder throws. The schema half is fine: `Tool.inputSchema` round-trips losslessly through `@nhtio/validation`'s own `encode`/`decode`. Tools are rarely serialized in practice; when you do it, write handlers that rebuild their dependencies inside the body, or do not serialize the tool.
:::

::: warning `fromWebFile`-backed Media throws on encode
A browser `Blob` has no re-openable locator — you cannot write down "where it lives" because it lives nowhere addressable — and the encoder is synchronous, so it cannot drain the bytes inline either. Encoding a `Media` built with `fromWebFile` throws `E_READER_NOT_DESCRIBABLE`. Persist the bytes to a media or spool store and wrap them in a describable reader first.
:::

::: warning Minification renames your classes, and the decoder is watching
The decoder maps the wire tag `custom:Message` back to a constructor by `constructor.name`. A consumer build that minifies class names rewrites `Message` to `M`, the tag no longer resolves, and decode breaks (or worse, two classes collapse to the same name and collide). Configure your bundler to preserve class names (`keep_names` / equivalent) anywhere you decode ADK primitives. The ADK's own dist ships unminified.
:::

## A full round-trip

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

registerAdkEncodables() // in-memory + fetch resolvers auto-register here

const message = new Message({
  id: 'msg-1',
  role: 'user',
  content: 'describe this image',
  identity: { identifier: 'u-42', representation: 'Alice' },
  attachments: [
    Media.userAttachment({
      kind: 'image',
      mimeType: 'image/png',
      filename: 'pixel.png',
      reader: inMemoryMediaReader(new Uint8Array([/* … */])),
    }),
  ],
  createdAt: new Date(),
  updatedAt: new Date(),
})

const restored = decode<Message>(encode(message))

restored instanceof Message                     // true
restored.identity.representation.toString()      // 'Alice'  (a real Tokenizable inside a real Identity)
restored.attachments[0] instanceof Media         // true  (the in-memory handle re-bound to a live reader)
```

**Use this for:**

* Persisting an in-flight conversation graph (messages, tool calls, retrievables, memories) across a process boundary, a queue, or a cache.
* Snapshotting turn state for replay, debugging, or audit.
* Moving a primitive graph between a worker and the main thread.

**Do not use this for:**

* Durable storage of conversation state — this is a wire format, not a database. The 27 `TurnRunnerConfig` storage callbacks are still yours; see [Bring Your Own Storage](../assembly/byo-storage).
* Serializing live runtime objects — a `TurnGate` mid-await, a `Tool` handler's captured services. Those bindings do not cross the boundary.
* Smuggling bytes around your storage layer — `Media`/`SpooledArtifact` encode the *handle*; the bytes must still live somewhere a resolver can re-open.
