Skip to content
5 min read · 946 words

Minimal agent assembly

Minimal here means the smallest real-model assembly. The no-key scaffold was the Quickstart; this is where you plug in the engine. The two pages share src/noop-storage.ts and src/hydrate-messages.ts verbatim — only src/agent.ts changes, and inside it, only the executor slot.

This page gives you one linear setup that sends Hello to an assistant and streams the reply.

Install

Use Node 20+ and run this in a TypeScript project:

sh
npm install @nhtio/adk
npm install -D tsx typescript @types/node
sh
yarn add @nhtio/adk
yarn add -D tsx typescript @types/node
sh
pnpm add @nhtio/adk
pnpm add -D tsx typescript @types/node
sh
bun add @nhtio/adk
bun add -d tsx typescript @types/node

This guide assumes TypeScript ESM and uses top-level await, which tsx supports.

File structure

Create exactly these three files:

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

src/noop-storage.ts

Put this in src/noop-storage.ts; it supplies the complete no-op storage adapter with the required callback arities.

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

Put this in src/hydrate-messages.ts; it fetches messages and loads them into the turn before dispatch.

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

Put this in src/agent.ts; it wires storage, hydration, the OpenAI battery, a seeded user message, stream events, and one turn.

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

function requireEnv(name: string): string {
  const value = process.env[name]
  if (value === undefined || value.length === 0) {
    throw new Error(`Missing required environment variable: ${name}`)
  }
  return value
}

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

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

const openai = new OpenAIChatCompletionsAdapter({
  apiKey: requireEnv('OPENAI_API_KEY'),
  model: process.env.OPENAI_MODEL ?? 'gpt-4o',
  autoAck: true,
})

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

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: [],
})

autoAck: true tells the executor to call ctx.ack() automatically after a tool-call-free response; without it the executor stores the message but leaves turn completion to you, which means this turn would never end unless something else calls ctx.ack().

Run it

Set your OpenAI API key and execute the agent:

sh
OPENAI_API_KEY=sk-... npx tsx src/agent.ts
sh
OPENAI_API_KEY=sk-... yarn tsx src/agent.ts
sh
OPENAI_API_KEY=sk-... pnpm exec tsx src/agent.ts
sh
OPENAI_API_KEY=sk-... bunx tsx src/agent.ts

You should see the assistant response stream to stdout.

RawTurnContext

The TurnRunner takes a RawTurnContext when executing runner.run(rawCtx). It consists of exactly four fields:

FieldRequiredDescription
turnAbortControllerYesAn AbortController instance to handle cancellation.
systemPromptYesA string or Tokenizable containing system-level behavior guidelines.
standingInstructionsYesAn array of string or Tokenizable elements. The TypeScript interface demands this field, even though the underlying runtime schema defaults it to []. Pass [] explicitly in your code to satisfy the compiler.
stashNoA Record<string, unknown> to store arbitrary, turn-scoped state metadata. Defaults to {} in raw input and is exposed as ctx.stash: Registry.

Messages do not go in RawTurnContext

RawTurnContext has zero knowledge of conversation history or user messages. ADK does not automatically fetch or inject messages.

Your turnInputPipeline middleware is responsible for calling ctx.fetchMessages() and loading them into ctx.turnMessages. To pass a user message into the first turn, return it from fetchMessagesCallback and register the hydration middleware.

Why the hydrate middleware is required

ADK keeps message retrieval explicit, so fetchMessagesCallback is only used when your pipeline calls it.

In this assembly:

  1. fetchMessagesCallback returns the seeded Message.
  2. hydrateMessages calls ctx.fetchMessages().
  3. hydrateMessages loads the returned messages into ctx.turnMessages.
  4. The OpenAI executor reads ctx.turnMessages and streams a reply.
  5. The message listener writes each chunk.aDelta to stdout.

Using a manual executor

If you cannot or do not want to use the battery, see Bring your own LLM for the manual executor path.

What this assembly lacks

This is a structural baseline for one working streamed turn. Before using it as an application foundation, replace or extend:

  • Noop Storage — Messages and execution artifacts are not persisted. The seeded user message exists only in this process.
  • No Tools — The default configuration has no tools.
  • No Retrieval/Memory — There are no hooks loading long-term memory, documents, or vector search results.
  • Minimal Error Policy — Errors are observed and printed, but there is no retry, fallback model, timeout policy, or user-facing recovery path.

The upgrade path

Introduce capabilities one at a time:

  1. Replace the no-op callbacks with a real persistence layer — Bring your own storage
  2. Customize or replace the model executorBring your own LLM
  3. Equip your agent with capability functionsBring your own tools
  4. Wire context injection in turnInputPipelineBring your own retrieval
  5. Enforce business rules and rate limiting via middleware — Wiring the Pipelines

runner.run() returns Promise<void>

A common point of failure is expecting runner.run() to return the assistant response. It does not. All outputs are pushed asynchronously through event emitters, so register listeners before run().

Register listeners before run()

typescript
// CORRECT — the listener is ready before streaming starts.
runner.on('message', (chunk) => process.stdout.write(chunk.aDelta ?? ''))
await runner.run(rawCtx)

// WRONG — the turn may finish before this listener exists.
await runner.run(rawCtx)
runner.on('message', (chunk) => process.stdout.write(chunk.aDelta ?? ''))

Event reference for this assembly

BusEventWhen it fires
Functional (runner.on)messageEmitted when text chunks are streamed via helpers.reportMessage()
Functional (runner.on)thoughtEmitted when reasoning steps are streamed via helpers.reportThought()
Functional (runner.on)toolCallEmitted during tool execution transitions via helpers.reportToolCall()
Observability (runner.observe)errorEmitted for non-fatal turn pipeline errors and dispatch/executor failures.
Observability (runner.observe)turnStartEmitted when the runner initiates execution
Observability (runner.observe)turnEndEmitted immediately after a turn finishes, regardless of success or failure
Observability (runner.observe)turnGateOpenEmitted when the turn gate opens
Observability (runner.observe)turnGateClosedEmitted when the turn gate closes
Observability (runner.observe)toolExecutionStartEmitted when tool execution begins
Observability (runner.observe)toolExecutionEndEmitted when tool execution ends
Observability (runner.observe)dispatchStartEmitted when dispatch begins
Observability (runner.observe)dispatchEndEmitted when dispatch ends
Observability (runner.observe)iterationStartEmitted when an execution-loop iteration begins
Observability (runner.observe)iterationEndEmitted when an execution-loop iteration ends
Observability (runner.observe)logEmitted for runner log entries

For an in-depth exploration of message schemas, error boundaries, and telemetry channels, refer to Listening to the Assembly.