Skip to content
7 min read · 1,492 words

Bring your own storage

ADK does not persist anything. No default in-memory store, no hidden database, no polite little cache waiting behind the curtain. You provide the storage layer, or there is no storage layer.

ADK validates, routes, and orchestrates. It does not touch a database, write to disk, or cache in memory. When the executor writes a message via ctx.storeMessage(), ADK calls the callback you wired. Your callback does the write, and your storage system holds the state.

If you fail to wire all 25 required storage/context callbacks plus the required executorCallback, the runner throws an error during construction. The assembly is incomplete, and execution does not start.

The 25 Callbacks

All 25 storage/context callbacks are properties of the TurnRunnerConfig object. The runtime schema validates that they exist and enforces strict arity requirements:

  • Retrieval callbacks require exactly 1 parameter (ctx).
  • Persistence callbacks (store/mutate/delete) require exactly 2 parameters (ctx, target).

If you pass an arity-0 arrow function (like async () => []), the schema validator will reject your configuration and throw. The runtime validator counts your callback's function.length. Yes, that's how strict it is. No, you can't use a wrapper that hides the parameters.

Retrieval Callbacks (7)

These functions are exposed on the turn context. ADK does not call them automatically. When a turn starts, ctx.turnMemories, ctx.turnRetrievables, ctx.turnMessages, ctx.turnThoughts, and ctx.turnToolCalls start empty; ctx.standingInstructions is seeded from RawTurnContext, and ctx.tools is a ToolRegistry seeded from TurnRunnerConfig.

Your turnInputPipeline middleware should, if you want those records in the turn context before execution, call these retrieval methods and manually .add() each returned item into its corresponding context Set. Executors or dispatch middleware may also call them. If your pipeline omits this, the context remains empty, and the executor runs without history.

CallbackSignatureAvailable on context asReturns
fetchMemoriesCallback(ctx) => ...ctx.fetchMemories()Memory[]
fetchMessagesCallback(ctx) => ...ctx.fetchMessages()Message[]
fetchThoughtsCallback(ctx) => ...ctx.fetchThoughts()Thought[]
fetchToolCallsCallback(ctx) => ...ctx.fetchToolCalls()ToolCall[]
fetchToolsCallback(ctx) => ...ctx.fetchTools()Tool[]
fetchRetrievablesCallback(ctx) => ...ctx.fetchRetrievables()Retrievable[]
refreshStandingInstructionsCallback(ctx) => ...ctx.refreshStandingInstructions()(string | Tokenizable)[] | Promise<(string | Tokenizable)[]>

fetchToolsCallback is required by TurnRunnerConfig and maps to ctx.fetchTools(), but it is not a storage callback and does not correspond to one of the six persisted primitives.

Persistence Callbacks (18)

Three operations for each of the six primitives. All of these require exactly 2 parameters.

PrimitiveStore (ctx, val)Mutate (ctx, updated)Delete (ctx, key)
MessagesstoreMessageCallbackmutateMessageCallbackdeleteMessageCallback
MemoriesstoreMemoryCallbackmutateMemoryCallbackdeleteMemoryCallback
ThoughtsstoreThoughtCallbackmutateThoughtCallbackdeleteThoughtCallback
ToolCallsstoreToolCallCallbackmutateToolCallCallbackdeleteToolCallCallback
RetrievablesstoreRetrievableCallbackmutateRetrievableCallbackdeleteRetrievableCallback
Standing InstructionsstoreStandingInstructionCallbackmutateStandingInstructionCallbackdeleteStandingInstructionCallback

Complete your delete signatures

Even if your application never deletes records, your delete callbacks must declare both parameters to pass validation:

typescript
deleteMemoryCallback: async (_ctx, _id) => {}

The Six Primitives

Messages

User and assistant conversation entries are Message; tool results live on ToolCall.results. If you noop these, your agent has total amnesia. It cannot continue conversations or accumulate context across iterations.

Memories

Durable facts accumulated across turns (e.g., user preferences or long-term constraints). Noop Memory callbacks if your agent does not need cross-turn memory. If you implement them, your executor or your output middleware controls when they get written via ctx.storeMemory().

Thoughts

Reasoning traces produced by extended-thinking models. Stored for debugging and auditing. You can safely noop Thought callbacks if you do not expose or audit the agent's internal reasoning.

ToolCalls

Records of tool invocations: name, arguments, and execution results. If your agent uses tools, implement ToolCall persistence. Nooping these breaks tool execution tracking; the model will struggle to determine what ran or what failed.

ADK does not call mutate automatically. If your executor registers a pending tool call and then updates it with execution output, the executor (or the LLM adapter battery) must explicitly call ctx.mutateToolCall().

Retrievables

Knowledge chunks injected via RAG. Safe to noop unless you are implementing retrieval pipelines that persist or retrieve context blocks. See Bring your own retrieval.

Standing Instructions

Operator instructions or tenant-level system prompts. These are raw string or Tokenizable instances—there is no StandingInstruction class. The delete signature is unique because it accepts the instruction value itself rather than an ID: (ctx, value: string | Tokenizable).

Store, Mutate, Delete Semantics

These three operations have distinct execution boundaries:

  • Store creates a new record. On a TurnContext, calling ctx.storeMessage(message) or another ctx.store* method delegates directly to the callback you wired. On a dispatch context, ctx.store* updates the dispatch-local Sets immediately, then the callback work is queued and flushed to the parent turn through the dispatch runner only after a successful iteration/ack.
  • Mutate updates state. When your executor or adapter explicitly calls ctx.mutateToolCall(updatedToolCall) or another ctx.mutate* method, ADK passes exactly that updated primitive or standing-instruction value to your callback. ADK does not enforce a patch format or diffing strategy; any patching or merging is adapter-local before calling the context method.
  • Delete removes records. The delete callback receives the identifier (or the value itself, in the case of standing instructions) and must invalidate the record.

The No-Op Contract

Every callback can be a legal no-op as long as it respects the arity constraints:

  • Retrieval callbacks: async (_ctx) => []
  • Store/Mutate/Delete callbacks: async (_ctx, _valOrId) => {}

No-ops must be explicit

You cannot omit a callback from the configuration object. The runtime validator checks for the existence of all 25 keys and verifies their parameter lengths. If any key is missing or has incorrect arity, initialization fails.

Minimum Viable Setup for Prototyping

For your first working agent, implement all Message and ToolCall callbacks (fetch/store/mutate/delete). Noop the other 17 callbacks using the pattern below. This gives you an agent that tracks conversations and tools.

The Storage Adapter Pattern

While inline lambdas are legal, they quickly become unreadable. Group all 25 callbacks into a single object literal—your storage adapter—and spread it into your configuration.

We recommend exporting a base noop adapter and overriding only what your application requires:

typescript
import { TurnRunner } from '@nhtio/adk'
import { noopStorageAdapter } from './noop-storage'
import { myMessagesAdapter } from './messages-adapter'

const runner = new TurnRunner({
  executorCallback: myExecutor,

  // 1. Establish the 25-callback baseline
  ...noopStorageAdapter,

  // 2. Override specific components
  fetchMessagesCallback: myMessagesAdapter.fetch,
  storeMessageCallback: myMessagesAdapter.store,
  mutateMessageCallback: myMessagesAdapter.mutate,
  deleteMessageCallback: myMessagesAdapter.delete,
})

The Noop Reference Adapter

This is the canonical baseline. Maintain this file in your codebase to satisfy testing and partial implementation requirements.

ts
// The 25-callback no-op storage adapter.
//
// Spread this into TurnRunnerConfig as a baseline, then override only the
// callbacks you actually want to do work. The runtime validator requires the
// declared arity (1 for fetch, 2 for store/mutate/delete); a zero-arity
// callback will throw E_INVALID_TURN_RUNNER_CONFIG at construction.

import type {
  MemoryRetrievalFn,
  MessageRetrievalFn,
  ThoughtRetrievalFn,
  ToolCallRetrievalFn,
  ToolsRetrievalFn,
  RetrievableRetrievalFn,
  StandingInstructionsRefreshFn,
  MemoryStoreFn,
  MemoryMutateFn,
  MemoryDeleteFn,
  MessageStoreFn,
  MessageMutateFn,
  MessageDeleteFn,
  ThoughtStoreFn,
  ThoughtMutateFn,
  ThoughtDeleteFn,
  ToolCallStoreFn,
  ToolCallMutateFn,
  ToolCallDeleteFn,
  RetrievableStoreFn,
  RetrievableMutateFn,
  RetrievableDeleteFn,
  StandingInstructionStoreFn,
  StandingInstructionMutateFn,
  StandingInstructionDeleteFn,
} from '@nhtio/adk'

export const noopStorageAdapter = {
  // Memories
  fetchMemoriesCallback: (async (_ctx) => []) as MemoryRetrievalFn,
  storeMemoryCallback: (async (_ctx, _m) => {}) as MemoryStoreFn,
  mutateMemoryCallback: (async (_ctx, _m) => {}) as MemoryMutateFn,
  deleteMemoryCallback: (async (_ctx, _id) => {}) as MemoryDeleteFn,

  // Messages
  fetchMessagesCallback: (async (_ctx) => []) as MessageRetrievalFn,
  storeMessageCallback: (async (_ctx, _m) => {}) as MessageStoreFn,
  mutateMessageCallback: (async (_ctx, _m) => {}) as MessageMutateFn,
  deleteMessageCallback: (async (_ctx, _id) => {}) as MessageDeleteFn,

  // Thoughts
  fetchThoughtsCallback: (async (_ctx) => []) as ThoughtRetrievalFn,
  storeThoughtCallback: (async (_ctx, _t) => {}) as ThoughtStoreFn,
  mutateThoughtCallback: (async (_ctx, _t) => {}) as ThoughtMutateFn,
  deleteThoughtCallback: (async (_ctx, _id) => {}) as ThoughtDeleteFn,

  // ToolCalls
  fetchToolCallsCallback: (async (_ctx) => []) as ToolCallRetrievalFn,
  storeToolCallCallback: (async (_ctx, _tc) => {}) as ToolCallStoreFn,
  mutateToolCallCallback: (async (_ctx, _tc) => {}) as ToolCallMutateFn,
  deleteToolCallCallback: (async (_ctx, _id) => {}) as ToolCallDeleteFn,

  // Tools (supplementary tools the model can see, fetched per turn)
  fetchToolsCallback: (async (_ctx) => []) as ToolsRetrievalFn,

  // Retrievables
  fetchRetrievablesCallback: (async (_ctx) => []) as RetrievableRetrievalFn,
  storeRetrievableCallback: (async (_ctx, _r) => {}) as RetrievableStoreFn,
  mutateRetrievableCallback: (async (_ctx, _r) => {}) as RetrievableMutateFn,
  deleteRetrievableCallback: (async (_ctx, _id) => {}) as RetrievableDeleteFn,

  // Standing instructions (string | Tokenizable — no class primitive)
  refreshStandingInstructionsCallback: (async (_ctx) => []) as StandingInstructionsRefreshFn,
  storeStandingInstructionCallback: (async (_ctx, _v) => {}) as StandingInstructionStoreFn,
  mutateStandingInstructionCallback: (async (_ctx, _v) => {}) as StandingInstructionMutateFn,
  deleteStandingInstructionCallback: (async (_ctx, _v) => {}) as StandingInstructionDeleteFn,
}

The Batteries Alternative

The @nhtio/adk/batteries/storage package does not provide a 25-callback storage adapter. The storage batteries, including InMemorySpoolStore and deep-imported OpfsSpoolStore, are exclusively for persisting artifact bytes (SpooledArtifact), not the structured core primitives.

For structured database storage, write the adapter yourself using the 25-callback contract.

Callback Timing Reference

Operation TypeTrigger
fetch* CallbacksNever called automatically by ADK. Your turnInputPipeline middleware should call them (e.g., ctx.fetchMessages()) and manually insert the returned items into the context sets if you want those records available before execution.
store* CallbacksFired instantly when the executor calls ctx.storeMessage(message) or equivalent methods on a turn context; dispatch-context store calls update local Sets immediately and flush through the dispatch runner only after successful iteration/ack.
mutate* CallbacksFired when the executor or adapter explicitly calls ctx.mutateMessage(updatedMessage), ctx.mutateToolCall(updatedToolCall), or equivalent methods.
delete* CallbacksFired on explicit deletion triggers from your execution block.

Persisting Media

Media is a pointer, not a byte array. It wraps a streaming source, which does not survive JSON serialization. When persisting Message attachments or ToolCall results:

  1. Serialize Metadata Only: Media.toJSON() serializes metadata such as id, kind, MIME type, filename, source, trust tier, modality hazard, and stash. Store any hashes yourself. When restoring, your fetch callbacks must reconstruct the Media instance and its reader.
  2. Handle Byte Durability: If the media source is transient, you must drain media.stream() during the store callback and write the bytes to your own asset store. If the source is a permanent URI, you can safely store the URI string and re-instantiate the pointer during fetch.

Testing Your Storage Implementation

Verify your callbacks individually before mounting them to a runner:

  1. Call your store callback with a mocked primitive (e.g., new Message(...)). Confirm it inserts cleanly into your database.
  2. Call your mutate callback with the actual updated primitive or standing-instruction value. Confirm the target record updates without dropping adjacent fields.
  3. Call your fetch callback. Assert that the returned array matches the schema expectations and contains the records you inserted.