Skip to content
2 min read · 482 words

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 runs it in your browser.

Install

sh
npm install @nhtio/adk
sh
yarn add @nhtio/adk
sh
pnpm add @nhtio/adk
sh
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 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 25 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

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,
}

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.

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. PipelinehydrateMessages called ctx.fetchMessages(), which invoked your fetchMessagesCallback and dropped the seeded Message into ctx.turnMessages.
  2. ExecutormockExecutor 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: