Serialization
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 — 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:
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 intactEncoding never needs registration. Decoding always does. Skip the call and every decode() of an ADK primitive throws E_UNDECODABLE_VALUE. See the Encoding battery for the full wiring, including durable stores.
The three tiers that round-trip
Pure value objects round-trip for free. Tokenizable, Registry, Identity, Memory, and Thought snapshot to their constructor input and rebuild with a constructor call that re-validates. Luxon DateTimes, bigints, Maps — the encoder handles those natively, so they survive without special handling.
Containers round-trip because the encoder recurses. A 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 results and 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 or SpooledArtifact 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:
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.
TurnGate is not encodable, on purpose
A TurnGate 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.
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 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.
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.
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
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
TurnRunnerConfigstorage callbacks are still yours; see Bring Your Own Storage. - Serializing live runtime objects — a
TurnGatemid-await, aToolhandler's captured services. Those bindings do not cross the boundary. - Smuggling bytes around your storage layer —
Media/SpooledArtifactencode the handle; the bytes must still live somewhere a resolver can re-open.