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
npm install @nhtio/adkyarn add @nhtio/adkpnpm add @nhtio/adkbun add @nhtio/adkPlus tsx and TypeScript to run the example:
npm install -D tsx typescript @types/nodeFile structure
Create exactly these three files:
src/
noop-storage.ts
hydrate-messages.ts
agent.tsThe 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
// 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.
// 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.
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
npx tsx src/agent.tsHello from ADK. streams to stdout. [turn ended] <turnId> follows on stderr. No network, no key, no surprises.
What just happened
One turn, three seams:
- Pipeline —
hydrateMessagescalledctx.fetchMessages(), which invoked yourfetchMessagesCallbackand dropped the seededMessageintoctx.turnMessages. - Executor —
mockExecutorstreamed a chunk viahelpers.reportMessage(caught by yourrunner.on('message', ...)listener), persisted the assistantMessageviactx.storeMessage, and calledctx.ack()to end the dispatch. - 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 — the exact same setup, with the OpenAI Chat Completions battery in the executor slot.
- Bring your own LLM — write a custom executor for your provider.
- LLM batteries — skip the executor and use a bundled one.
- Bring your own storage — replace the noop callbacks with your database.
- Wiring the pipelines — sequential middleware for context, policy, and cleanup.
- Playground — the in-browser REPL, for poking at variations without leaving the page.
- What ADK Is — the full architecture.