# Claude Agent SDK: Build Your Own AI Terminal in 10 Minutes

> Source: <https://www.mager.co/blog/2026-03-14-claude-agent-sdk-tui/>
> Published: 2026-05-31 10:52:42+00:00

# Claude Agent SDK: Build Your Own AI Terminal in 10 Minutes

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:

``` js
// 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:

``` js
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`tsx`

instead of`ts-node`

.`tsx`

is zero-config — it handles`.tsx`

, JSX, and ESM out of the box without loader flags. Also make sure`"type": "module"`

is in your`package.json`

— Ink's layout engine (`yoga-layout`

) uses top-level`await`

, which requires ESM mode. You'll hit a cryptic error without it.

### Step 1: The Message Stream

``` js
// 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'sThe`async function*`

?`*`

makes this agenerator function— instead of computing everything and returning at once, it hands you one value at a time via`yield`

, pausing between each.`async`

means it can also`await`

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](https://github.com/vadimdemedes/ink) gives us React-style components for the terminal. `Box`

handles layout, `Text`

handles output with color and style support.

``` python
// 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), and`user`

(echoed input). Log`msg.type`

during development to see everything flowing through.

### Step 3: The Entry Point

``` python
// 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:

``` js
// 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:

``` js
// 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:

``` js
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:

``` js
// 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**— the agent stream runs outside the view;`useEffect`

`app.update()`

pushes state changes inbuilt in — no manual blinking text`ui.spinner()`

**Semantic variants**(`info`

,`warning`

,`success`

) — Rezi handles the colors per-theme

The full Rezi version lives in [ rezi/rezi-app.ts](https://github.com/mager/claude-tui-demo/tree/main/rezi) 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](https://github.com/anthropics/claude-agent-sdk-demos/tree/main/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](https://github.com/mager/claude-tui-demo)** Docs:**[platform.claude.com/docs/en/agent-sdk/overview](https://platform.claude.com/docs/en/agent-sdk/overview)** Reference agents:**[github.com/anthropics/claude-agent-sdk-demos](https://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.
