Agent Development Kit · Prove & ship

CLI: the terminal UI

app.cli(runnable) renders your agent into a full-screen terminal app: the transcript, every event as it streams, the exact context each model call saw, and a form for answering a tool that stopped to ask. It is a call your program makes, not a binary you install — the agent you drive in it is the agent you ship.

Needs · a real terminal, plus three optional peers No cells here · a TTY is not something a page can offer

Step 1

A call, not a command

The UI is built with ink and React. Both, plus ink-text-input, are optional peer dependencies: the core never pulls them, so install them beside the ADK when you want the terminal.

npm install ink ink-text-input react

The declared ranges are ink 5, ink-text-input 6, and React 18 or 19. Miss one and there is no friendly message — the module is loaded lazily at the moment you call app.cli, so Node's own resolver reports it, one package at a time:

Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'ink' imported from
  node_modules/@animahealth/adk/dist/cli/index.mjs

Then the program. app.cli takes the runnable and, optionally, a first message — it is sent the moment the UI mounts.

// chat.ts
import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'

const app = adk({ name: 'chat' })

const assistant = app.agent({
  name: 'assistant',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Be brief.'), app.context.history()],
})

app.cli(assistant, 'What is 731 * 268, minus 17?')
npx tsx chat.ts

The third overload takes a config object instead of the message. Everything the UI does is configured there:

const result = await app.cli(assistant, {
  input: 'What is 731 * 268, minus 17?',
  options: { exitOnComplete: true, showIds: true },
})

process.stdout.write(result.output.text + '\n')
Config field Default What it does
input First message, sent as the UI mounts. Omit it and the CLI opens on a prompt.
session a fresh in-memory session The session the run appends to. See step 4.
sessionService the app's own Only used to build the default runner.
runner built from the app Run with your own Runner, carrying its own adapters and hooks.
options Display and lifecycle, below.
Option Default What it does
defaultMode 'debug' Which of the three views opens first: content, debug, or logging.
exitOnComplete false Leave the UI once the run finishes, instead of staying to be read.
showDurations true Closes each block with [1.9s]. Turn it off and the block closes with its state, [completed].
showIds false Prints the invocation id beside the agent's name: ┌─ assistant (inv_fa0d1df5…).
logBufferSize 1000 How many captured log lines the logs view keeps.
hooks Extra hooks for this run, after the app's own. Ignored if you pass your own runner.

The call returns a handle, not a promise: await it for the RunResult, or read runner, session and runnable off it. The first two are populated once the UI has loaded, so read them after the await — synchronously they are still undefined.

Two things the terminal takes over. It needs a real TTY: pipe the program's output and ink refuses with Raw mode is not supported on the current process.stdin. And from the moment app.cli is called, console.log and its siblings are captured into the logs view rather than printed — for the rest of the process, including after the UI exits. Write to process.stdout directly when you want something on the terminal afterwards.

Step 2

What the screen shows

One invocation is one bracketed block: the agent's name on top, its events indented inside, the duration on the bottom. Sub-agents nest as inner blocks. Each model call gets its own model bracket, so a tool-using turn shows two of them.

● debug [d] ○ content [c] ○ logs [l]
 ┌─ booker ◆ agent
  ▸├─ user     book tuesday 3pm
   ┌─ model
     ├─ call     book_slot {"slot":"tuesday 3pm"}
     ├─ yield    book_slot {"slot":"tuesday 3pm"}
     ├─ input    book_slot {"approved":true}
     ├─ result   book_slot → {"booked":true,"slot":"tuesday 3pm"}
   └─ 2ms
   ├─ yield    awaiting 1 call
   ├─ resume
   ┌─ model
     ├─ output   Booked tuesday 3pm.
   └─ 1ms
 └─ 1.9s
scroll [↑↓] • jump [←→] • open [Enter] • close [Esc] • exit [Ctrl+C]

Every line is one event from the session's ledger, under a short label: user, think for reasoning, output for the assistant, then call, yield, input, result and state. Assistant and reasoning text stream in as deltas, with a spinner on the line still being written. marks the selection, and the footer always names the keys that apply right now.

Three views share that screen, each one key away. debug is everything above. content keeps only the conversation — user, assistant, reasoning, tool calls and the answers you typed. logs is the console output the UI captured, with a timestamp and level per line, so a chatty tool cannot smear the trace.

○ debug [d] ○ content [c] ● logs [l]
  18:08:42.438 LOG   a log line from the tool
▸ 18:08:42.439 WARN  and a warning
scroll [↑↓] • page [←→] • open [Enter] • exit [Ctrl+C]
Key Browsing the trace In the logs view
Move the selection — or scroll the open detail pane. Select a log line.
Jump to the start / end of the current block. Page through the lines.
PageUp PageDown Page the trace, or the open detail pane.
Enter Space Open the selected event's detail. On a model bracket, expand the context that call was given. Open the selected line.
r c Inside the detail pane: the raw event, or the readable rendering.
Esc Close the detail, collapse an expanded context, or leave the prompt to browse. Close the open line.
d c l Switch view: debug, content, logs.
i Answer a yielded tool — or take the prompt back after Esc.
Ctrl+C Leave.

One exception governs the whole table: while the prompt has focus, every key is text. Esc hands focus back to the trace, and i returns it to the prompt.

Step 3

Answering a yielded tool

This is the reason to reach for the terminal over console.log. A tool declared yields: true stops the run and waits for a human. The CLI is that human: the top bar turns up input [i] in yellow, and i opens a form built from the tool's yieldSchema.

Event • yield • ○ clean [c] ○ raw [r] ● input [i]
book_slot yielded
args: {
  "slot": "tuesday 3pm"
}

{
 "approved": ● true ○ false,
 "note": "" ?
}
[↑↓] field • [←→] value • submit [Enter] • cancel [Esc] • exit [Ctrl+C]

↑↓ moves between fields, ←→ toggles a boolean or cycles an enum, strings and numbers are typed, and Enter submits. The answer is delivered to the tool's execute as ctx.input, the run resumes in place, and the ledger keeps yield, input and result as three separate events — the same three the yielding chapter resumes over HTTP.

The form is generated only for a schema the terminal can lay out: at most five fields, each a string, number, boolean, enum or literal — or an array of objects whose fields are those. Anything deeper falls back to one line — Enter result (JSON or string) — parsed as JSON, or taken verbatim if it is not JSON. Optional fields left empty are dropped; required ones fall back to the schema's default.

Step 4

One launch, one conversation

The prompt appears before the first run and after a yielded message — not after a completed one. So a launch is one invocation, plus however many pauses it takes to finish it; when the run completes the UI stays open for reading, and Ctrl+C ends it. Pass exitOnComplete: true to skip the reading.

The CLI never commits. Like a bare app.run, it accrues events on the session in memory and touches no store. Give it a session you loaded and commit that session yourself, and the conversation outlives the process.

import { adk } from '@animahealth/adk'
import { sqliteStore } from '@animahealth/adk/stores/sqlite'

const app = adk({ name: 'chat', store: sqliteStore('./chat.db') })

const id = 'kitchen-table'
const session = (await app.sessions.get(id)) ?? (await app.sessions.create({ sessionId: id }))

await app.cli(assistant, { session, input: 'where were we?', options: { exitOnComplete: true } })

await app.sessions.commit(session)
await app.close()

Run that twice and the second launch opens on the first launch's history, because app.context.history() reads the session it was handed. Without the commit, nothing is written and every launch starts empty.