Agent Development Kit · Prove & ship

Terminal: the terminal UI

app.terminal(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. The UI is loaded lazily, at the moment you call app.terminal, so a missing one surfaces there — as Terminal dependencies not found. Install them with: npm install ink ink-text-input react, not as a resolver stack trace.

Then the program. app.terminal 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.terminal(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.terminal(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 terminal 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.

The terminal takes over two things. 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.terminal 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 drops everything but 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 that declares a yieldSchema stops the run and waits for a human. The terminal 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.

To get the prompt back after every reply, hand app.terminal a loop that yields — app.loop({ runnable: assistant, yields: true, … }). That is the shape behind examples/assistant.ts, which ends the chat from its while predicate.

The terminal holds the session in memory. Like a bare app.run, it accrues events on the session and touches no store. To persist across runs, hand it a session you loaded and commit that session yourself when the launch ends.

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