The Claude Agent SDK gives you the same engine that powers Claude Code, fully programmable. Here's how to build a custom TUI with it in 10 minutes.
You've used Claude Code from the terminal. Now build your own.
That's the pitch for the Claude Agent SDK β same engine that powers Claude Code, but programmable. You get the full agent loop β file reading, bash execution, web search, code editing β wrapped in a for await
loop you control.
The question everyone asks: why would I use this instead of just calling the Claude API directly?
The answer: you don't have to implement the tool loop yourself.
And the most compelling use case for that? Building your own TUI.
Demo repo:[github.com/mager/claude-tui-demo]β clone it and follow along.
The SDK vs. The API: What's the Actual Difference #
With the standard Anthropic client SDK, you implement tool execution yourself:
// You write this loop. Every time.
let response = await client.messages.create({ ...params });
while (response.stop_reason === "tool_use") {
const result = yourToolExecutor(response.tool_use);
response = await client.messages.create({ tool_result: result, ...params });
}
With the Agent SDK:
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({
prompt: "Find and fix the bug in auth.ts",
options: { allowedTools: ["Read", "Edit", "Bash"] }
})) {
console.log(message);
}
Claude reads the file, finds the bug, edits it. You stream the output. No tool loop, no executor, no boilerplate.
Built-in tools you get for free:
| Tool | What it does |
|---|---|
Read |
Read any file |
Write |
Create files |
Edit |
Precise edits |
Bash |
Run commands, git ops |
Glob |
Find files by pattern |
Grep |
Regex file search |
WebSearch |
Search the web |
WebFetch |
Fetch + parse URLs |
That's Claude Code's entire toolset, programmable.
Why a TUI? #
The Claude Code CLI is great for general use. But the moment you have a specific domain β a codebase with custom conventions, a workflow with specialized steps, a team with different permission needs β you want your own interface.
A custom TUI lets you:
Pre-load context your team cares about (architecture docs, style guides)Lock down toolsβ a read-only reviewer can't accidentally edit prod** Surface domain-specific shortcuts**β one keystroke to run your whole test suite** Pipe outputinto your CI/CD or logging infrastructure Add hooks**β audit every file change, block destructive operations, require approval
You're not replacing Claude Code. You're building the version of Claude Code that fits your workflow exactly.
Let's Build It #
Clone the demo and install:
git clone https://github.com/mager/claude-tui-demo.git
cd claude-tui-demo
npm install
export ANTHROPIC_API_KEY=your-key
API credits:You'll need an Anthropic API key with credits. Top up at[platform.claude.com/settings/billing].
I'm using ** Ink** for the terminal UI. If you know React, you already know Ink β same component model, same hooks (
useState
, useEffect
), same JSX. But instead of rendering to the DOM, it renders to your terminal. Box
is your div
. Text
is your span
. Flexbox and colors work exactly as you'd expect. It's the cleanest way to build interactive terminal UIs in TypeScript.
Note on the runner:The demo usestsx
instead ofts-node
.tsx
is zero-config β it handles.tsx
, JSX, and ESM out of the box without flags. Also make sure"type": "module"
is in yourpackage.json
β Ink's layout engine (yoga-layout
) uses top-levelawait
, which requires ESM mode. You'll hit a cryptic error without it.
Step 1: The Message Stream
// agent.ts
import { query } from "@anthropic-ai/claude-agent-sdk";
export async function* runAgent(prompt: string) {
for await (const message of query({
prompt,
options: {
allowedTools: ["Read", "Glob", "Grep", "Bash"],
},
})) {
yield message;
}
}
What'sTheasync function*
?*
makes this agenerator functionβ instead of computing everything and returning at once, it hands you one value at a time viayield
, pausing between each.async
means it can alsoawait
internally. On the consumer side,for await
handles the async stream one message at a time. This is how the tool calls and responses stream to your UI as they happen, not after everything finishes.
Step 2: The TUI Component
Ink gives us React-style components for the terminal. Box
handles layout, Text
handles output with color and style support.
// App.tsx
import React, { useState, useEffect } from "react";
import { Box, Text, useInput, useApp } from "ink";
import { runAgent } from "./agent.js";
type LogLine = { type: "user" | "agent" | "tool" | "result"; text: string };
function formatToolCall(block: any): string {
return `β ${block.name}(${JSON.stringify(block.input).slice(0, 60)})`;
}
function handleAssistantMessage(msg: any, setLines: React.Dispatch<React.SetStateAction<LogLine[]>>) {
for (const block of msg.message.content) {
if (block.type === "text") {
setLines((prev) => [...prev, { type: "agent", text: block.text }]);
}
if (block.type === "tool_use") {
setLines((prev) => [...prev, { type: "tool", text: formatToolCall(block) }]);
}
}
}
export function App({ prompt }: { prompt: string }) {
const [lines, setLines] = useState<LogLine[]>([]);
const [done, setDone] = useState(false);
const { exit } = useApp();
useEffect(() => {
setLines([{ type: "user", text: `> ${prompt}` }]);
(async () => {
for await (const msg of runAgent(prompt)) {
if (msg.type === "assistant") handleAssistantMessage(msg, setLines);
if (msg.type === "result") {
setLines((prev) => [...prev, { type: "result", text: `β ${msg.result}` }]);
setDone(true);
}
}
})();
}, []);
useInput((_, key) => {
if (key.escape || (key.ctrl && _.toLowerCase() === "c")) exit();
});
const colors: Record<LogLine["type"], string> = {
user: "cyan",
agent: "white",
tool: "yellow",
result: "green",
};
return (
<Box flexDirection="column" padding={1}>
<Box marginBottom={1}>
<Text bold color="cyan">β My AI Terminal</Text>
<Text color="gray"> (esc to quit)</Text>
</Box>
{lines.map((line, i) => (
<Text key={i} color={colors[line.type]}>{line.text}</Text>
))}
{!done && <Text color="gray">βΈ thinking...</Text>}
</Box>
);
}
Message types:The SDK streams several message types βassistant
(Claude's response),result
(final outcome),system
(init event with the session ID), anduser
(echoed input). Logmsg.type
during development to see everything flowing through.
Step 3: The Entry Point
// index.tsx
import React from "react";
import { render } from "ink";
import { App } from "./App.js";
const prompt = process.argv.slice(2).join(" ") || "What files are in this directory?";
render(<App prompt={prompt} />);
process.argv.slice(2)
grabs everything after node
and the script path β your actual typed arguments. .join(" ")
reassembles multi-word prompts. Seven lines. That's the whole entry point.
Run it:
npm start "What files are in this directory?"
You'll see Claude's tool calls stream in real-time β β Bash({"command":"ls"})
in yellow, the response in white, β done
in green. That's a working AI TUI in ~80 lines.
Level Up: The Forever Loop (REPL Mode) #
The single-prompt TUI is great for one-shot tasks. But what if you want Claude to just... keep responding? Like the real Claude Code experience β type a prompt, get a response, type another?
That's a REPL, and it's a while (true)
loop:
// repl.ts
import { query } from "@anthropic-ai/claude-agent-sdk";
import * as readline from "readline";
let sessionId: string | undefined;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (prompt: string) => new Promise<string>((resolve) => rl.question(prompt, resolve));
async function runTurn(userPrompt: string) {
for await (const msg of query({
prompt: userPrompt,
options: { allowedTools: ["Read", "Glob", "Grep", "Bash"], resume: sessionId },
})) {
if (msg.type === "system" && msg.subtype === "init") sessionId = msg.session_id;
if (msg.type === "assistant") {
for (const block of msg.message.content) {
if (block.type === "text") process.stdout.write(`\nπ€ ${block.text}\n`);
if (block.type === "tool_use") {
process.stdout.write(`β ${block.name}(${JSON.stringify(block.input).slice(0, 80)})\n`);
}
}
}
}
}
console.log("β Claude REPL β type your prompt, ctrl+c to quit\n");
while (true) {
const input = await ask("\n> ");
if (!input.trim()) continue;
await runTurn(input.trim());
}
Run it with npm run repl
. Type anything. Claude responds. Type again β it still has context from everything before. That's the resume: sessionId
doing its job.
Level Up: Hooks #
The real power is hooks β callbacks that fire at key points in the agent lifecycle. This is how you add audit logs, approval gates, or custom UI feedback:
// agent.ts (with hooks)
import { query } from "@anthropic-ai/claude-agent-sdk";
import { appendFile } from "fs/promises";
const auditHook = async (input: any) => {
const tool = input.tool_name ?? "unknown";
const args = JSON.stringify(input.tool_input ?? {}).slice(0, 100);
await appendFile("./audit.log", `${new Date().toISOString()} ${tool} ${args}\n`);
return {};
};
for await (const message of query({
prompt: "Refactor the auth module",
options: {
allowedTools: ["Read", "Edit", "Bash"],
hooks: {
PostToolUse: [{ matcher: ".*", hooks: [auditHook] }],
},
},
})) {
// render to your TUI
}
Every tool call gets logged to audit.log
with a timestamp. matcher: ".*"
catches everything β narrow to "Edit|Write"
if you only care about mutations.
Other hooks worth knowing: PreToolUse
to block operations before they run, Stop
to detect when the agent finishes, UserPromptSubmit
to pre-process or validate input.
Level Up: Persistent Sessions #
The agent remembers context across multiple query()
calls. Capture the session ID from the first run, pass it to the next:
let sessionId: string | undefined;
// First turn β Claude reads the file
for await (const msg of query({ prompt: "Read the auth module" })) {
if (msg.type === "system" && msg.subtype === "init") {
sessionId = msg.session_id;
}
}
// Second turn β zero tool calls, Claude already knows
for await (const msg of query({
prompt: "Now find everything that calls it",
options: { resume: sessionId },
})) {
// ...
}
The money detail: the second turn fires zero tool calls β Claude already has the file in context. No re-reading, no extra API calls. It just answers.
Run the demo: npm run session
.
Bonus: Level Up with Rezi #
Ink is great for quick TUIs. But if you want richer widgets β tables, command palettes, split panes, charts, modals β ** Rezi** is the upgrade path. Still TypeScript, still Node.js, but native-backed rendering through a C engine and 50+ built-in widgets.
Where Ink feels like React (hooks, JSX, component tree), Rezi is state-driven: you define a view
function that maps state β UI, and call app.update()
to change state. Same mental model as Bubble Tea's Elm Architecture, but in TypeScript.
Install it:
npm install @rezi-ui/core @rezi-ui/node
Here's the same Claude TUI rebuilt in Rezi:
// rezi/rezi-app.ts
import { ui } from "@rezi-ui/core";
import { createNodeApp } from "@rezi-ui/node";
import { query } from "@anthropic-ai/claude-agent-sdk";
type LineKind = "user" | "agent" | "tool" | "result";
type LogLine = { kind: LineKind; text: string };
type State = { lines: LogLine[]; done: boolean };
const prompt = process.argv.slice(2).join(" ") || "What files are in this directory?";
const app = createNodeApp<State>({
initialState: { lines: [{ kind: "user", text: `> ${prompt}` }], done: false },
});
const kindVariant: Record<LineKind, string> = {
user: "info",
agent: "body",
tool: "warning",
result: "success",
};
app.view((state) =>
ui.page({
p: 1,
gap: 1,
header: ui.header({ title: "β My AI Terminal", subtitle: "q to quit" }),
body: ui.panel("Output", [
...state.lines.map((line, i) =>
ui.text(line.text, { key: String(i), variant: kindVariant[line.kind] as any })
),
...(!state.done ? [ui.spinner({ label: "thinkingβ¦", key: "spinner" })] : []),
]),
})
);
app.keys({ q: () => app.stop(), escape: () => app.stop() });
// Kick off the agent stream
(async () => {
for await (const msg of query({
prompt,
options: { allowedTools: ["Read", "Glob", "Grep", "Bash"] },
})) {
if (msg.type === "assistant") {
for (const block of msg.message.content) {
if (block.type === "text") {
app.update((s) => ({ ...s, lines: [...s.lines, { kind: "agent", text: block.text }] }));
}
if (block.type === "tool_use") {
const preview = JSON.stringify(block.input).slice(0, 60);
app.update((s) => ({
...s,
lines: [...s.lines, { kind: "tool", text: `β ${block.name}(${preview})` }],
}));
}
}
}
if (msg.type === "result") {
app.update((s) => ({
...s,
lines: [...s.lines, { kind: "result", text: `β ${msg.result}` }],
done: true,
}));
}
}
})();
await app.start();
The key differences from the Ink version:
No Reactβapp.view()
is a pure function of state, not a component treeNoβ the agent stream runs outside the view;useEffect
app.update()
pushes state changes inbuilt in β no manual blinking textui.spinner()
Semantic variants(info
,warning
,success
) β Rezi handles the colors per-theme
The full Rezi version lives in rezi/rezi-app.ts in the demo repo:
cd rezi
npm install
export ANTHROPIC_API_KEY=your-key
npm start "What files are in this directory?"
Ink vs Rezi at a glance:
| Ink | Rezi | |
|---|---|---|
| Mental model | React hooks + JSX | State-driven, pure view fn |
| State | useState |
app.update() |
| Side effects | useEffect |
Run outside the view |
| Styling | Color/bold props | Semantic variants + 6 built-in themes |
| Widget library | Minimal (Text, Box) | 50+ (tables, modals, charts, command palette) |
| Rendering | Node.js | Native C engine via Zireael |
| Best for | Quick TUIs, React devs | Production tools, rich UIs |
Both work perfectly with the Agent SDK stream. Ink is the fastest on-ramp; Rezi is where you go when you outgrow it.
Real-World Example: The Email Agent #
Anthropic ships a reference implementation of this pattern β an email agent that reads your inbox, drafts replies, and sends them. It's a great study in how hooks + persistent sessions compose in production: PreToolUse
to require approval before sending, PostToolUse
to log every action, session resume to maintain context across a multi-step triage workflow. The same ~80-line skeleton we just built, extended into something genuinely useful.
When to Use the SDK vs. the CLI #
| Scenario | Use |
|---|---|
| Daily development, one-off tasks | Claude Code CLI |
| CI/CD pipelines | SDK |
| Custom team tools | SDK |
| Domain-specific workflows | SDK |
| Production automation | SDK |
| Audit trails + permission control | SDK |
The workflows translate directly. Anything Claude Code can do in the CLI, the SDK can do programmatically.
The Bigger Picture #
The Agent SDK is a general-purpose agent runtime β not just a coding tool. The built-in tools, the hooks system, the subagent delegation, the MCP support β it's a full agent platform.
The TUI is just one entry point. You could build:
- A Slack bot where Claude actually edits your codebase
- A CI/CD step that auto-fixes lint errors before merging
- An internal tool where junior devs prompt in plain English and senior devs approve tool calls
- A research agent with web search and file output
The pattern is always the same: for await (const message of query(...))
. Stream it, render it, hook into it.
Explore further:
Demo repo:github.com/mager/claude-tui-demo** Docs:platform.claude.com/docs/en/agent-sdk/overview Reference agents:github.com/anthropics/claude-agent-sdk-demos Python SDK:**pip install claude-agent-sdk
The terminal isn't going anywhere. Might as well make it yours.