Claude Agent SDK: Build Your Own AI Terminal 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 output into 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 uses
tsxinstead ofts-node.tsxis zero-config β it handles.tsx, JSX, and ESM out of the box without loader 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's
async function*? The*makes this a generator function β instead of computing everything and returning at once, it hands you one value at a time viayield, pausing between each.asyncmeans it can alsoawaitinternally. On the consumer side,for awaithandles 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.typeduring 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 tree - No
useEffectβ the agent stream runs outside the view;app.update()pushes state changes in ui.spinner()built in β no manual blinking text- 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.