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:

ToolWhat it does
ReadRead any file
WriteCreate files
EditPrecise edits
BashRun commands, git ops
GlobFind files by pattern
GrepRegex file search
WebSearchSearch the web
WebFetchFetch + 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

// 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 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 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), and user (echoed input). Log msg.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?"

Claude TUI running β€” tool calls stream in yellow, result in green

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.

REPL demo β€” asking Claude why Chicago dyes the river green on St. Patty's Day

REPL demo β€” follow-up question about St. Patty's Day traditions in the midwest

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.

Persistent session demo β€” same session ID across both turns, Turn 2 needs no tool calls

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:

InkRezi
Mental modelReact hooks + JSXState-driven, pure view fn
StateuseStateapp.update()
Side effectsuseEffectRun outside the view
StylingColor/bold propsSemantic variants + 6 built-in themes
Widget libraryMinimal (Text, Box)50+ (tables, modals, charts, command palette)
RenderingNode.jsNative C engine via Zireael
Best forQuick TUIs, React devsProduction 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

ScenarioUse
Daily development, one-off tasksClaude Code CLI
CI/CD pipelinesSDK
Custom team toolsSDK
Domain-specific workflowsSDK
Production automationSDK
Audit trails + permission controlSDK

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:

The terminal isn't going anywhere. Might as well make it yours.

Tags

AIAgentsClaudeTypeScriptTUITerminalRezi