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.
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.