cd /news/ai-agents/claude-agent-sdk-build-your-own-ai-t… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-19124] src=mager.co pub= topic=ai-agents verified=true sentiment=↑ positive

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

Anthropic released the Claude Agent SDK, providing the same engine that powers Claude Code as a programmable tool for developers. The SDK allows users to build custom terminal interfaces in about 10 minutes, handling the agent loop for file reading, bash execution, web search, and code editing without requiring developers to implement tool execution themselves.

read12 min publishedMay 31, 2026

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.

── more in #ai-agents 4 stories Β· sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/claude-agent-sdk-bui…] indexed:0 read:12min 2026-05-31 Β· β€”