Agent Development Kit · Start

The next hour, recipe by recipe

You have run an agent in this page. These six recipes move it onto your machine, clone the sample that already does the hard part, give the agent the web, put it behind a browser UI and a terminal UI, and name the errors you will actually hit. Every shell command here is real and every quoted error is the string the package throws.

Audience · engineers 15 minutes into a hackathon Needs · Node ≥ 22; a key only for the live steps Package · @animahealth/adk (MIT)

Recipe 1

From this page to a project on your machine

Five commands. The ADK needs Node 22 or newer, and the code below uses top-level await, so the project is ESM.

mkdir math-agent
cd math-agent
npm init -y
npm pkg set type=module
npm install @animahealth/adk zod
npm install -D tsx typescript @types/node

zod is the ADK's one required peer, so your package manager installs it anyway — naming it makes your own import { z } from 'zod' honest. You do not need the OpenAI SDK to run: @animahealth/adk/openai ships it inside that subpath's bundle, which is why the cells on this site reach api.openai.com with nothing else installed. A TypeScript project does need it, because that subpath's type declarations reference the SDK's types — so either npm install -D openai or set skipLibCheck: true (the shipped sample does). The one path that loads the real SDK at runtime is the deprecated core-entry re-export, import { openai } from '@animahealth/adk'; the subpath is the supported import.

Then the key. It is read from the environment at the first model call, not at import — the adapter builds its endpoint list right before it needs one.

export OPENAI_API_KEY=sk-...

Now the program. This is the body of agent.ts, and it is the same agent this site's quickstart ran: one tool with a Zod schema, one agent that carries it. Press Run — the cell builds it here too.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

const app = adk()

const calculator = app.tool({
  name: 'calculate',
  description: 'Evaluate a mathematical expression',
  schema: z.object({ expression: z.string() }),
  execute: (ctx) => {
    const sanitized = ctx.args.expression.replace(/[^\d\s+*/().-]/g, '')
    return { result: Function(`"use strict"; return (${sanitized})`)() }
  },
})

const assistant = app.agent({
  name: 'math_assistant',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Use the calculator for arithmetic.'), app.context.history()],
  tools: [calculator],
})

assistant.name

A file needs two more lines than a cell does — the cell prints its last expression, your program has to say so:

const run = await app.run(assistant, 'What is 731 * 268, minus 17?')
console.log(run.output.text)
npx tsx agent.ts

That is the whole loop: no server, no config file, no framework directory. The last thing worth adding before you build on it is a test that needs no key — npm install -D vitest, then npx vitest run, with the model's turns scripted while your tools really execute. The testing chapter has the whole kit.

Recipe 2

Clone the Bookings sample and run it

Bookings is a slot-booking assistant that offers an appointment and then stops: booking is a yielding tool, so the session becomes a row in a SQLite file and a later command supplies the approval. It lives under sample/ and depends on the ADK beside it ("@animahealth/adk": "file:.."), so the repository root is built once first:

git clone https://github.com/mycontinuum-com/adk.git
cd adk
pnpm install && pnpm run build
cd sample
npm install
npx tsx src/cli.ts ask "I need physio on Tuesday afternoon, for Alex Doe"

ask calls a model, so export OPENAI_API_KEY first — then walk the three sources, the resume commands, and the suite that proves the pause with no key in the Bookings sample.

Recipe 3

Give the agent the web

Three web tools ship in the core entry as factories on app.tools. Each returns an ordinary tool, so it goes in the same tools: array your own tools do. The model sees them as web_search, fetch_page and take_screenshot.

const researcher = app.agent({
  name: 'researcher',
  model: openai('gpt-5.6-luna'),
  context: [
    app.context.system('Search before you answer. Fetch a page when the snippet is not enough.'),
    app.context.history(),
  ],
  tools: [
    app.tools.webSearch({ numResults: 5, searchType: 'web', country: 'GB' }),
    app.tools.fetchPage({ render: true }),
  ],
})

Search goes through Serper, and its key comes from SERPER_API_KEY. The provider is constructed when you build the tool, so a missing key throws while the agent is being assembled — before any model call, which is the good time to find out. Pass one explicitly instead with app.tools.webSearch({ provider: new SerperProvider(key) }), importing SerperProvider from @animahealth/adk/web.

fetch_page takes one URL or an array, and returns web pages as markdown, PDFs as documents and images as images — the media rides back with the tool result, so a vision model can read it. Its markdown conversion is done by three optional peers you have to install: npm install jsdom @mozilla/readability turndown. render: true and take_screenshot additionally need playwright plus npx playwright install, and screenshots need sharp. Install nothing and the failure is quiet — see recipe 6. The web tools chapter covers the configs; for tools someone else wrote, the ADK speaks MCP.

Recipe 4

Stream it into a browser

app.handler.agui({ agent }) returns a function from one request to an async iterable of AG-UI events — the protocol the off-the-shelf chat frontends already speak. Write each event down an SSE response and a browser has a streaming UI without you inventing a message format. Install the protocol's types first: npm install @ag-ui/core (an optional peer, pinned to one exact version in the package's peerDependencies).

import { createServer } from 'node:http'

// One handler, built once and called per request. The app's store and hooks come with it.
const stream = app.handler.agui({ agent: assistant })

createServer(async (request, response) => {
  const url = new URL(request.url ?? '/', 'http://localhost')

  response.writeHead(200, {
    'content-type': 'text/event-stream',
    'cache-control': 'no-cache',
    connection: 'keep-alive',
  })

  for await (const event of stream({
    // No session id starts a new session; passing one back continues that conversation.
    sessionId: url.searchParams.get('session') ?? undefined,
    input: { message: url.searchParams.get('q') ?? '' },
  })) {
    response.write(`data: ${JSON.stringify(event)}\n\n`)
  }

  response.end()
}).listen(3000)

The browser end is three lines, because EventSource is doing the work:

const events = new EventSource(`/agent?q=${encodeURIComponent(question)}`)

events.onmessage = (message) => render(JSON.parse(message.data))

The event vocabulary is catalogued in serving it; the part that matters here is the pause — a run that stops at a yielding tool sends a CUSTOM event named RUN_INTERRUPTED, carrying reason: 'tool_yield' with the tool's name and arguments (or reason: 'input_required' when the agent is waiting for the next message), and then emits RUN_FINISHED twice, only the second of which carries result.

Two siblings, same config object. app.handler.rest({ agent }) awaits the whole turn and returns one JSON object — sessionId, status, output, yieldedTools when it paused, and optionally events, state and usage. app.handler.turn({ agent }) hands you the raw ADK event stream if you would rather define your own wire format. All three commit the session after every turn, so a run that pauses is durable the moment the request ends.

Recipe 5

Drive it from a terminal

The ADK ships an interactive terminal UI: app.cli(runnable), optionally with a first message. It is not a command you install — it is a call your program makes, so the agent you run in it is the one you ship.

// chat.ts — the agent from recipe 1, with a terminal in front of it.
app.cli(assistant, {
  input: 'What is 731 * 268, minus 17?',
  // Default is false: the UI stays open to be read after the run.
  options: { exitOnComplete: true },
})

Its React/Ink dependencies are optional peers, so install them beside the ADK:

npm install ink ink-text-input react
npx tsx chat.ts

It takes over the alternate screen and offers three views, switched by a single key: debug [d] — every event as it happens, the default; content [c] — just the conversation; logs [l] — anything the program wrote to the console, captured rather than smeared over the UI. Arrow keys and PageUp/PageDown move through the trace; in the debug view Enter or space opens the selected event's detail pane (r for its raw payload, c for the readable one); and Ctrl+C leaves.

The reason to reach for it over console.log is the pause. When a yielding tool stops the run, i opens an input built from that tool's yieldSchema — you answer the agent in the terminal and the run resumes, which is the sample's approve without the second process. The call returns a handle you can await for the RunResult, and it carries the runner and session it used. The whole surface is the terminal UI.

Recipe 6

The errors you will actually see

Three of these account for most first-hour dead ends. Each message below is the package's own text, verbatim.

No key. Thrown from app.run at the first model call — not at import, and not when the agent is built. The adapter reads the environment when it needs an endpoint, so exporting the key in the same shell after the process started will not help; the process has to start with it.

No OpenAI API key configured.

Set one of these environment variables:
  - OPENAI_API_KEY                                  (Standard OpenAI)
  - OPENAI_EU_API_KEY                               (OpenAI EU region)
  - AZURE_OPENAI_ENDPOINT + AZURE_OPENAI_API_KEY    (Azure OpenAI)

A missing optional peer. The core entry pulls in no provider and no backend, which is the point — and the cost is that each surface you reach for may want an install. Some say so in the ADK's own words; several do not, and surface as Node's ordinary module-resolution failure instead. The map:

What you used What to install What a miss looks like
@animahealth/adk/openai, /gemini, /claude nothing to run — each subpath bundles its provider SDK; a TypeScript project installs it anyway, or sets skipLibCheck types only — the subpath's declarations reference the SDK's types, so tsc without skipLibCheck wants openai or @google/genai. The deprecated core-entry factories (import { openai } from '@animahealth/adk') are the one path that loads the real SDK at runtime, and they need it installed.
@animahealth/adk/cli or app.cli() ink ink-text-input react a module-resolution error at import — no ADK message
app.handler.agui() @ag-ui/core a module-resolution error on the first request, when the adapter loads
@animahealth/adk/stores/sqlite better-sqlite3 a module-resolution error on the first read or write, not at import
app.tools.fetchPage() jsdom @mozilla/readability turndown, plus playwright for render: true quiet: the tool returns { success: false, error: 'network_error' } and the model reads that as a broken page
app.mcp (tools from an MCP server) @modelcontextprotocol/sdk MCP SDK not found. Install it with: npm install @modelcontextprotocol/sdk

A model name that does not exist. The ADK does not keep a list of model names: openai('…') is a descriptor, and the string goes to the provider as you typed it. So a typo is not caught locally — it comes back as the provider's own error at the first call. The adapter's only opinion about it is where to go next: an error whose message contains model not found or deployment not found is treated as worth retrying at the next configured endpoint, alongside rate limits, timeouts and 5xx. With one endpoint configured there is no next one, so the provider's error is what you see. On Azure the name you write is mapped to a deployment name, so check the modelMapping before you blame the model — the models chapter covers that mapping.

Two more with their own messages, both thrown early rather than mid-run. Building a web-search tool with no Serper key fails while the agent is being assembled:

No Serper API key configured.

Set the environment variable:
  - SERPER_API_KEY    (Get one at https://serper.dev)

Or pass directly to webSearch:
  webSearch({ provider: new SerperProvider('your-api-key') })

And the Bookings sample refuses to call a model without a key, pointing at the flow that needs none: OPENAI_API_KEY is not set. Run `npm test` for the same flow, scripted.

Where to go next

The chapters behind these recipes

Each recipe here is the shortest honest version of a chapter. Stopping to ask is the pause the sample is built on; where a sleeping agent lives is the store you swap SQLite for; serving it is the handler surface behind recipe 4; streaming and cost is what those events cost; testing agents and measuring agents are how the thing you built at hour one survives hour ten. When something behaves in a way no chapter explains, the package's source is the answer — it is the same code this page just ran.