---
url: 'https://adk.nht.io/quickstart.md'
description: >-
  Install @nhtio/adk and run your first turn — three files on disk, no API key,
  the executor seam in full view.
---

# Quickstart

Three files. One command. No API key, no remote model, no hidden defaults. By the end of this page a `TurnRunner` will stream a reply through code you wrote.

ADK is an execution chassis. It does not start until you wire it. This page wires the smallest legal version of it, on disk, so the seams are visible from the first line. If you want to watch the runtime move without writing anything yet, the [Playground](./playground) runs it in your browser.

## Install

::: code-group

```sh [npm]
npm install @nhtio/adk
```

```sh [yarn]
yarn add @nhtio/adk
```

```sh [pnpm]
pnpm add @nhtio/adk
```

```sh [bun]
bun add @nhtio/adk
```

:::

Plus `tsx` and TypeScript to run the example:

```bash
npm install -D tsx typescript @types/node
```

## File structure

Create exactly these three files:

```text
src/
  noop-storage.ts
  hydrate-messages.ts
  agent.ts
```

The first two are byte-identical to the ones [Minimal agent assembly](./assembly/minimal-assembly) uses against a real OpenAI model. Only `agent.ts` changes between the two pages, and inside it, only the executor.

## `src/noop-storage.ts`

All 27 storage callbacks are required at construction. There are no defaults. This snippet supplies them as noops so the runner can boot; replace a noop only when you are ready to own that moment in the lifecycle. → [Why every callback is required](./what-adk-is#required-callbacks-are-required)

```ts
// The 27-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, 3 for the byte
// conduits); a wrong-arity callback throws E_INVALID_TURN_RUNNER_CONFIG at
// construction.

import { isInstanceOf, inMemoryMediaReader } from '@nhtio/adk'
import { InMemorySpoolStore } from '@nhtio/adk/batteries/storage/in_memory'
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,
  MediaBytesStoreFn,
  RetrievableBytesStoreFn,
  ConduitBytes,
} from '@nhtio/adk'

// The byte-persistence conduits RETURN a reader — they are not void no-ops.
// The minimal honest implementation drains the bytes into an in-memory
// store/reader; swap these for your own ByteStore in production.
//
// ONE store instance, created once and reused across every call. Constructing a
// `new InMemorySpoolStore()` *inside* the callback would give each write its own
// throwaway store: the returned reader works for that call, but the bytes are not
// retained under any coherent store identity, so nothing written under one id is
// retrievable later. That is fake persistence that still passes validation —
// exactly the anti-pattern the byte-conduit contract exists to prevent. A real
// adapter swaps this for a durable `ByteStore` (e.g. `OpfsSpoolStore`), but it
// must still be a single, stable backing store, not one per call.
const retrievableSpool = new InMemorySpoolStore()

const conduitToBytes = async (bytes: ConduitBytes): Promise<Uint8Array> => {
  if (typeof bytes === 'string') return new TextEncoder().encode(bytes)
  if (!isInstanceOf(bytes, 'ReadableStream', ReadableStream)) return bytes
  const reader = bytes.getReader()
  const chunks: Uint8Array[] = []
  for (;;) {
    const { done, value } = await reader.read()
    if (done) break
    if (value) chunks.push(value)
  }
  const total = chunks.reduce((n, c) => n + c.byteLength, 0)
  const out = new Uint8Array(total)
  let offset = 0
  for (const c of chunks) {
    out.set(c, offset)
    offset += c.byteLength
  }
  return out
}

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,

  // Byte-persistence conduits (arity 3 — return a reader)
  storeMediaBytesCallback: (async (_ctx, _id, bytes) =>
    inMemoryMediaReader(await conduitToBytes(bytes))) as MediaBytesStoreFn,
  storeRetrievableBytesCallback: ((_ctx, id, bytes) =>
    retrievableSpool.write(id, bytes)) as RetrievableBytesStoreFn,
}

```

## `src/hydrate-messages.ts`

`fetchMessagesCallback` is never called automatically. Without a middleware like this one, `ctx.turnMessages` stays empty and the executor sees nothing. Put it first in `turnInputPipeline`.

```ts
// Canonical turnInputPipeline middleware: load conversation history into the
// turn context before the executor sees it.
//
// ADK does NOT auto-call fetchMessagesCallback. Until a middleware like this
// runs, ctx.turnMessages is an empty Set and the executor reasons about
// nothing. Put this first in turnInputPipeline.

import type { TurnPipelineMiddlewareFn } from '@nhtio/adk'

export const hydrateMessages: TurnPipelineMiddlewareFn = async (ctx, next) => {
  const messages = await ctx.fetchMessages()
  for (const m of messages) {
    ctx.turnMessages.add(m)
  }
  await next()
}

```

## `src/agent.ts`

The executor seam, in full view. The mock executor below is a scaffold: no model, no network, fifteen lines. It does exactly what the runner requires — stream a message via `helpers.reportMessage`, persist it via `ctx.storeMessage`, then call `ctx.ack()` exactly once. Swap it for a real model in [Minimal Assembly](./assembly/minimal-assembly).

```ts
import { Message, TurnRunner } from '@nhtio/adk'
import type { DispatchExecutorFn, MessageRetrievalFn } from '@nhtio/adk'
import { hydrateMessages } from './hydrate-messages'
import { noopStorageAdapter } from './noop-storage'

const initialUserMessage = new Message({
  id: crypto.randomUUID(),
  role: 'user',
  content: 'Hello',
  createdAt: new Date(),
  updatedAt: new Date(),
})

const fetchMessagesCallback: MessageRetrievalFn = async (_ctx) => {
  return [initialUserMessage]
}

// Temporary scaffold. The executor seam is `(ctx, helpers) => void | Promise<void>`,
// with exactly one ack/nack per dispatch. This one streams a hard-coded reply,
// persists the assistant Message, and acks. Replace with a real model —
// see /assembly/minimal-assembly for the OpenAI Chat Completions battery.
const mockExecutor: DispatchExecutorFn = async (ctx, helpers) => {
  const id = crypto.randomUUID()
  const reply = 'Hello from ADK.'
  helpers.reportMessage(id, reply, { isComplete: true })
  await ctx.storeMessage(
    new Message({
      id,
      role: 'assistant',
      content: reply,
      createdAt: new Date(),
      updatedAt: new Date(),
    })
  )
  ctx.ack()
}

const runner = new TurnRunner({
  ...noopStorageAdapter,
  fetchMessagesCallback,
  turnInputPipeline: [hydrateMessages],
  executorCallback: mockExecutor,
})

runner.on('message', (chunk) => {
  process.stdout.write(chunk.aDelta ?? '')
})

runner.observe('error', (err) => {
  console.error('[error]', err.message)
})

runner.observe('turnEnd', ({ turnId }) => {
  console.error(`\n[turn ended] ${turnId}`)
})

await runner.run({
  turnAbortController: new AbortController(),
  systemPrompt: 'You are a helpful assistant.',
  standingInstructions: [],
})
```

## Run it

```bash
npx tsx src/agent.ts
```

`Hello from ADK.` streams to stdout. `[turn ended] <turnId>` follows on stderr. No network, no key, no surprises.

## What just happened

One turn, three seams:

1. **Pipeline** — `hydrateMessages` called `ctx.fetchMessages()`, which invoked your `fetchMessagesCallback` and dropped the seeded `Message` into `ctx.turnMessages`.
2. **Executor** — `mockExecutor` streamed a chunk via `helpers.reportMessage` (caught by your `runner.on('message', ...)` listener), persisted the assistant `Message` via `ctx.storeMessage`, and called `ctx.ack()` to end the dispatch.
3. **Storage** — every persistence call routed through `noopStorageAdapter`. In a real app, those land in your database.

The runner fired `turnEnd` and the process exited. Each seam is replaceable on its own.

## Next

Swap the mock for a real engine without touching `noop-storage.ts` or `hydrate-messages.ts`:

* [Minimal agent assembly](./assembly/minimal-assembly) — the exact same setup, with the OpenAI Chat Completions battery in the executor slot.
* [Bring your own LLM](./assembly/byo-llm) — write a custom executor for your provider.
* [LLM batteries](./assembly/batteries-llm) — skip the executor and use a bundled one.
* [Bring your own storage](./assembly/byo-storage) — replace the noop callbacks with your database.
* [Wiring the pipelines](./assembly/pipelines) — sequential middleware for context, policy, and cleanup.
* [Playground](./playground) — the in-browser REPL, for poking at variations without leaving the page.
* [What ADK Is](./what-adk-is) — the full architecture.

## What the human is seeing on this page

This page is a three-file on-disk Quickstart. There is no embedded REPL on this page — the in-browser explorer lives at [Playground](./playground). The human is reading code blocks for three files (`src/noop-storage.ts`, `src/hydrate-messages.ts`, `src/agent.ts`) and a `npx tsx src/agent.ts` invocation. The example uses an inline mock executor (~15 lines) that streams a hard-coded reply, persists it, and calls `ctx.ack()` — no API key, no network calls.

The same `noop-storage.ts` and `hydrate-messages.ts` snippets are sourced verbatim into [Minimal Assembly](./assembly/minimal-assembly), so the upgrade path from this page to a real-model setup is a single-file swap of `agent.ts` (specifically, replacing `mockExecutor` with the OpenAI battery's `.executor()`).

## How to help a user on this page

**If they ask why there's no API key:** The mock executor in `agent.ts` is a deliberate scaffold. It exists to show the `DispatchExecutorFn` contract — exactly one `ack()` or `nack()` per dispatch, streamed output via `helpers.reportMessage`, persistence via `ctx.storeMessage`. To use a real model, send them to [Minimal Assembly](./assembly/minimal-assembly) or [Bring your own LLM](./assembly/byo-llm).

**If they ask about a callback:** Never guess. Explain the exact callback's execution point in the lifecycle and what state it receives. Consult `src/` or The Loop documentation pages if unsure.

**If they got a config error:** The runner validates eagerly at construction. Pinpoint the exact failing callback or expression. Give a corrected version. Keep the fix minimal.

**If they ask about `ack()` / `nack()`:** Call exactly one, exactly once, from inside the executor. Not returning does not end the turn. Missing both hangs the turn forever. Calling both throws. There is no default.

**If they ask about `reportMessage` vs `ctx.storeMessage`:** These are different contracts. `reportMessage` streams output to functional `runner.on('message', ...)` listeners in real time. `ctx.storeMessage` invokes the `storeMessageCallback` — durable persistence. Neither is automatic. The mock executor calls both because both are required for a complete turn.

**If they ask about `runner.on` vs `runner.observe`:** `runner.on` is the functional event bus — message output, thoughts, tool calls. `runner.observe` is instrumentation only — lifecycle events, errors, timing. Business logic belongs on `runner.on`. Observability belongs on `runner.observe`. Confusing them does not break immediately but causes subtle behavior gaps.

**If they ask why there are 27 callbacks and they're all noops:** The runner refuses hidden defaults. Magic defaults breed production disasters. The noop adapter satisfies the contract so the rest of the page can focus on the executor seam. Each noop is a deliberate placeholder waiting for the application to own it.

**If they want to wire a real LLM:** Send them to [Minimal Assembly](./assembly/minimal-assembly) (off-the-shelf battery) or [Bring your own LLM](./assembly/byo-llm) (custom executor). Do not describe the replacement steps here.

**If they want real storage:** Send them to [Bring your own storage](./assembly/byo-storage). Do not describe callback replacement here.

**If they want to see the runtime in action without writing code:** Send them to [Playground](./playground). It runs the real `TurnRunner` in the browser with pre-wired noop callbacks.

**What this Quickstart proves:** A real ADK application is the executor, the storage adapter, and one or more pipeline middlewares — assembled by you, on disk, with no hidden behavior. The mock executor is a teaching device; everything else on the page is what a production application also looks like.
