---
url: 'https://adk.nht.io/the-loop/primitives.md'
description: >-
  The validated data primitives ADK threads through every turn: Tokenizable,
  Identity, Message, Media, Memory, Retrievable, Thought, ToolCall.
---

# Primitives

## LLM summary — Primitives

Eight primitives. All validated at construction; bad input throws `E_INVALID_INITIAL_*_VALUE`. Instances are immutable — replace via `mutate*` callbacks, never patch in place. Every text-bearing field stores `Tokenizable`, not raw `string` (constructor wraps strings on input).

The page reads bottom-up — foundation primitive first, then the speaker label, then the dialogue surface, then the long- and short-term context loaded into a turn, then what happens during the dispatch (reasoning + actions), then the binary peer that crosses the last two.

* **`Tokenizable`** — string wrapper. Every text-bearing field on every other primitive stores this. `estimateTokens(encoding)` is exact for `gpt2`/`r50k_base`/`p50k_base`/`p50k_edit`/`cl100k_base`/`o200k_base` (via `js-tiktoken`), `gemini` (via `@lenml/tokenizer-gemini`), `llama2` (via `llama-tokenizer-js`); heuristic ~3.5 chars/token for `claude`; heuristic `ceil(length/4)` otherwise. Lazy per-encoding cache cleared on `.set(newString)`. Coerces to string via `String(t)`.
* **`Identity`** — `{ identifier: string | number, representation: Tokenizable }`. `identifier` is system-facing (DB key); `representation` is model-facing (display name). Never collapse the two.
* **`Message`** — `{ id, role: 'user' | 'assistant', content?, attachments?, identity, createdAt, updatedAt }`. **No `'system'`, no `'tool'` role.** System content lives in `systemPrompt`/`standingInstructions` on `TurnContext`; tool results live in `ToolCall`. `content` and `attachments` are both optional individually but the cross-field rule requires **at least one** to be present — a message with neither throws `E_INVALID_INITIAL_MESSAGE_VALUE`. Both `user` and `assistant` roles may carry attachments. `identity` accepts a string at construction (resolved to `Identity{identifier=representation=string}`); a bare string is correct for single-user agents only.
* **`Media`** — `{ id, kind: 'image'|'audio'|'video'|'document', mimeType, filename, reader: MediaReader, trustTier, modalityHazard, source?, stash? }`. Dual-peer: silo-peer to `Tokenizable` (sits in `ToolCall.results`), handle-peer to `SpooledArtifact` (wraps a `MediaReader` contract — framework owns the contract, implementor owns the storage). Bytes are lazy — reached only via `media.stream()` / `asBytes()` / `asBase64()`. Two-axis trust model: `trustTier` (`'first-party'`/`'third-party-public'`/`'third-party-private'`) and `modalityHazard` (`'inert'`/`'extractable-instructions'`/`'opaque-perceptual'`) — **both required, no defaults**. `stash` is a free-form per-instance register (entries carry their own `trustTier` + `derivedFromMedia?` pointer); middleware appends OCR/captions/transcripts here as a text fallback for consumers that cannot decode the bytes natively.
* **`Memory`** — `{ id, content, confidence: [0,1], importance: [0,1], createdAt, updatedAt }`. `confidence` and `importance` are **required with no default**, but they are **retrieval-time scores**, not storage-time properties. The retrieval middleware that loads memories into the turn is the entity that decides what they are — your storage layer can keep raw content with no scores at all, or with last-known scores, or with anything in between. Do not silently default to `1` at retrieval.
* **`Retrievable`** — `{ id, content, trustTier: 'first-party' | 'third-party-public' | 'third-party-private', source?, kind?, score?, createdAt, updatedAt }`. **`trustTier` is required with no default.** No auto-classification from `source`. No `'unknown'` tier.
* **`Thought`** — `{ id, content, identity?, payload?, replayCompatibility?, createdAt, updatedAt }`. `identity` defaults to `'assistant'`. `payload` is an opaque vendor blob (OpenAI `encrypted_content`, Anthropic signed reasoning item, Gemini thought signatures). **If `payload` is set, `replayCompatibility` is required** — a tag describing the wire shape (e.g. `'openai-responses-encrypted-content-2025-10'`).
* **`ToolCall`** — `{ id, tool, args, results, inline?, isComplete: true, isError, checksum, fromArtifactTool?, createdAt, updatedAt, completedAt }`. `args` accepts a JSON string at construction. `checksum` is a **required input** — sha256(`tool`+canonicalized `args`), computed by the producer (the LLM battery or your executor) and passed to the constructor, which validates it but does not compute it. `results` is one of three silos: `Tokenizable` (the artifact-tool recursion-break carve-out, always singular), `SpooledArtifact | SpooledArtifact[]` (handle-eligible bytes from a normal tool), or `Media | Media[]` (the explicit-modality return path — images, audio, video, documents). `inline` defaults to `true`. `fromArtifactTool` marks calls emitted by `SpooledArtifact.forgeTools(ctx)` and breaks `artifact_*` recursion.

The system prompt and standing instructions are **not** primitives — they are `Tokenizable` fields on `TurnContext`, read-only inputs to prompt assembly, never stored.

When asked "where do I put X":

* Tool result → `ToolCall.results`, never a `Message` with `role:'user'`.
* Reasoning trace → `Thought`, never `Message`.
* Retrieved doc → `Retrievable` with declared `trustTier`.
* System instruction → `TurnContext.systemPrompt` or a `StandingInstruction`, not a `Message`.
* Image / audio / video / document from a tool → `Media` on `ToolCall.results`. Image / audio attached to a user or assistant turn → `Media` on `Message.attachments`. Never base64-encode bytes into a `Tokenizable` and lie to the model about what is in the string.

ADK threads eight primitives through every turn, and nothing else gets a free pass. If the loop needs to know about a message, memory, tool call, retrieved document, binary asset, or reasoning trace, it enters as one of these shapes or it does not enter.

This is opinionated on purpose. A vague `Record<string, unknown>` does not survive contact with three providers, four storage layers, and a year of feature pressure. A `Message` with a typed `role`, a `Tokenizable` body, and a required `Identity` does. The primitives are small because the library is small about what counts.

## Four rules every primitive obeys

* **Validation runs at construction.** Pass bad input, get `E_INVALID_INITIAL_*_VALUE` before the instance exists. There is no partially valid `Memory`, no half-built `ToolCall` waiting to be filled in.
* **Instances are immutable.** You don't edit fields on the object you already have; you call the matching `ctx.mutate*` persistence callback ([`mutateMessage`](./turn-runner#turncontext), [`TurnContext.mutateMemory`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/TurnContext#property-mutatememory), and the rest), which delegates to the consumer-implemented store. Whether subsequent reads see the new instance is up to that store — the ADK does not transparently swap the instance behind the same id. The instance you were already holding stays valid for the read you were doing; constructing a new instance with the updated fields and persisting it is the canonical pattern.
* **Text fields store [`Tokenizable`](#tokenizable), not `string`.** Strings work at construction; the constructor wraps them. After that, you have a `Tokenizable` that can `.estimateTokens(encoding)` on demand. This is the difference between guessing your context budget and knowing it.
* **Date fields accept many input types and store [Luxon `DateTime`](https://moment.github.io/luxon/).** The full list of accepted inputs is in the [API reference](../api/). The API reference is the property catalogue. This page is the map of why each primitive exists and what mistake it prevents.

::: danger Required fields are required
There is no "we'll fill it in later." Every primitive's constructor checks the full set of required fields up front and throws `E_INVALID_INITIAL_*_VALUE` if anything is missing or wrong. You do not get a half-built `Memory` waiting on a score, a `Retrievable` waiting on a tier, or a `Message` waiting on an identity. The instance either exists with every contract satisfied, or it doesn't exist at all.
:::

::: info How this page is ordered
Foundation first, then the dialogue surface, then the contents of a turn, then what happens during the dispatch. [`Tokenizable`](#tokenizable) is the wrapper every other primitive's text field stores, so it comes first. [`Identity`](#identity) is the speaker label a [`Message`](#message) carries, so it comes next. [`Message`](#message) is the visible dialogue surface, and [`Media`](#media) is the binary peer that rides on `Message.attachments` (and later, on `ToolCall.results`) — introduced here so every later reference to attachments points backwards instead of forwards. [`Memory`](#memory) is what an agent recalls from previous turns; [`Retrievable`](#retrievable) is what it pulls in fresh for this one. [`Thought`](#thought) and [`ToolCall`](#toolcall) are what happens *during* the dispatch — reasoning and action — and `ToolCall.results` is the second surface that can carry a `Media`.
:::

## Tokenizable

The string wrapper every text-bearing field on every other primitive stores. `estimateTokens(encoding)` is exact for the encodings whose tokenizers are publicly available and a conservative heuristic for everything else, so the budget logic upstream can treat token cost as a first-class property of content.

→ Continue reading: [Tokenizable](./primitives/tokenizable)

## Identity

The two-view bridge between your application's notion of who a participant is ([`Identity.identifier`](https://adk.nht.io/api/@nhtio/adk/common/classes/Identity#property-identifier)) and the model's notion of who is speaking ([`Identity.representation`](https://adk.nht.io/api/@nhtio/adk/common/classes/Identity#property-representation), as [`Tokenizable`](https://adk.nht.io/api/@nhtio/adk/common/classes/Tokenizable)). Two views of one entity, kept on the same record so they cannot drift.

→ Continue reading: [Identity](./primitives/identity)

## Message

One unit of dialogue, attributed to a speaker, shaped for the model's next read. Two roles only — `'user'` and `'assistant'`. System content, tool results, reasoning, retrieved docs, and durable memories each live in their own primitive.

→ Continue reading: [Message](./primitives/message)

## Media

The typed handle for a binary asset — image, audio, video, document — that rides on [`Message.attachments`](https://adk.nht.io/api/@nhtio/adk/common/classes/Message#property-attachments) and [`ToolCall.results`](https://adk.nht.io/api/@nhtio/adk/forge/classes/ToolCall#property-results). Dual-peer to [`Tokenizable`](https://adk.nht.io/api/@nhtio/adk/common/classes/Tokenizable) (silo) and [`SpooledArtifact`](https://adk.nht.io/api/@nhtio/adk/spooled_artifact/classes/SpooledArtifact) (handle). Two-axis trust model ([`Media.trustTier`](https://adk.nht.io/api/@nhtio/adk/common/classes/Media#property-trusttier) + [`Media.modalityHazard`](https://adk.nht.io/api/@nhtio/adk/common/classes/Media#property-modalityhazard)), both required.

→ Continue reading: [Media](./primitives/media)

## Memory

Long-term memory: what was learned in previous conversations that should still inform this one. Carries [`Memory.confidence`](https://adk.nht.io/api/@nhtio/adk/common/classes/Memory#property-confidence) and [`Memory.importance`](https://adk.nht.io/api/@nhtio/adk/common/classes/Memory#property-importance) scores in `[0,1]` — required, no default, and the retrieval middleware (not the storage layer) is what decides them.

→ Continue reading: [Memory](./primitives/memory)

## Retrievable

Content the agent pulled in fresh for this turn — RAG chunks, web results, KB snippets. The required field is [`Retrievable.trustTier`](https://adk.nht.io/api/@nhtio/adk/common/classes/Retrievable#property-trusttier), the single most opinionated thing in the entire primitives set: no `'unknown'`, no auto-classification, no safe default.

→ Continue reading: [Retrievable](./primitives/retrievable)

## Thought

Reasoning, kept deliberately separate from dialogue. Text plus an optional vendor-shaped [`Thought.payload`](https://adk.nht.io/api/@nhtio/adk/common/classes/Thought#property-payload) — when the payload is set, a [`Thought.replayCompatibility`](https://adk.nht.io/api/@nhtio/adk/common/classes/Thought#property-replaycompatibility) tag is required so a future executor can recognise the wire shape.

→ Continue reading: [Thought](./primitives/thought)

## ToolCall

One resolved tool invocation: tool name, validated args, [`ToolCall.results`](https://adk.nht.io/api/@nhtio/adk/forge/classes/ToolCall#property-results) (a [`SpooledArtifact`](https://adk.nht.io/api/@nhtio/adk/spooled_artifact/classes/SpooledArtifact), a [`Tokenizable`](https://adk.nht.io/api/@nhtio/adk/common/classes/Tokenizable) for [`ArtifactTool`](https://adk.nht.io/api/@nhtio/adk/forge/classes/ArtifactTool) calls, or a [`Media`](https://adk.nht.io/api/@nhtio/adk/common/classes/Media)), and a stable [`ToolCall.checksum`](https://adk.nht.io/api/@nhtio/adk/forge/classes/ToolCall#property-checksum) the rest of the loop uses to correlate. The [`ToolCall.inline`](https://adk.nht.io/api/@nhtio/adk/forge/classes/ToolCall#property-inline) flag is the rendering hint that travels with the call.

→ Continue reading: [ToolCall](./primitives/toolcall)

## What is *not* a primitive

The system prompt and standing instructions are [`Tokenizable`](https://adk.nht.io/api/@nhtio/adk/common/classes/Tokenizable), live on [`TurnContext`](./turn-runner#turncontext), and are not primitive classes. An executor that opts into developer-policy framing renders them through its own pattern (see [Trust tiers → Envelopes](./trust-tiers/envelopes)), but the ADK treats them as read-only inputs to prompt assembly — never stored, never mutated, never re-emitted as records. Wrapping them in a class would imply a lifecycle they do not have.
