Skip to content

Build a Claude-Code-Like AI Agent with Claude Agent SDK (TypeScript)

Updated on

If you’ve ever used Claude Code or other AI coding copilots, you know how magical it feels: you type questions into a panel, and an AI that “remembers” the session helps you reason about code, run tools, and get unstuck.

With Claude Agent SDK, you can build your own Claude-Code-style agent in Node.js with surprisingly little code.

In this guide, we’ll walk through:

  1. Setting up a TypeScript project
  2. Sending your first request with query()
  3. Keeping a long-lived conversation session
  4. Building a command-line chat experience
  5. Adding a math tool via MCP so the agent can “use a calculator”

All examples use TypeScript + Node.js and are intentionally structured and named to be easy to adapt to your own project.


1. Project Setup

Create a new project:

mkdir dev-assistant-agent
cd dev-assistant-agent
npm init -y

Install dependencies:

npm install @anthropic-ai/claude-agent-sdk
npm install typescript tsx @types/node
npx tsc --init

We’ll also use dotenv to load environment variables:

npm install dotenv

Create a .env file in the project root:

ANTHROPIC_API_KEY=your_api_key_here
ANTHROPIC_BASE_URL=https://api.anthropic.com

And a tiny config module:

// src/config/runtime.ts
import 'dotenv/config';
 
export const API_KEY = process.env.ANTHROPIC_API_KEY;
export const BASE_URL = process.env.ANTHROPIC_BASE_URL;
 
if (!API_KEY) {
  throw new Error('ANTHROPIC_API_KEY is not set.');
}

2. First Contact: a Minimal Agent Call

The core of Claude Agent SDK is the query() function. Instead of returning one big string, it returns an async iterator of messages (system messages, assistant messages, etc.). You can stream and handle them as they come in.

Create a simple “hello” example:

// src/agent/helloAgent.ts
import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
 
export async function runHelloAgent() {
  const stream: Query = query({
    prompt: 'Hi Claude, what can you do?',
  });
 
  for await (const item of stream) {
    if (item.type === 'assistant') {
      for (const chunk of item.message.content) {
        if (chunk.type === 'text') {
          console.log(chunk.text);
        }
      }
    }
  }
}

Entry point:

// src/main.ts
import { runHelloAgent } from './agent/helloAgent';
 
async function main() {
  console.log('Launching hello agent...');
  await runHelloAgent();
}
 
main().catch(console.error);

Run it:

npx tsx src/main.ts

You should see Claude describe what it can do — this confirms the SDK is wired up correctly.


3. Conversation Sessions: Letting the Agent Remember

A one-off reply is nice; a remembering assistant is nicer.

Claude Agent SDK supports sessions. Each conversation has a session_id. When you start a new query(), you can pass a resume option to continue an existing session.

Here’s a small abstraction that sends a prompt and returns any session ID the SDK gives us:

// src/agent/sessionClient.ts
import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
 
export async function sendMessageWithSession(
  userText: string,
  previousSessionId?: string
): Promise<{ sessionId?: string }> {
  let activeSessionId = previousSessionId;
 
  const stream: Query = query({
    prompt: userText,
    options: {
      resume: previousSessionId,
    },
  });
 
  for await (const item of stream) {
    switch (item.type) {
      case 'system':
        if (item.subtype === 'init') {
          activeSessionId = item.session_id;
          console.log(`(session started: ${activeSessionId})`);
        }
        break;
      case 'assistant':
        for (const piece of item.message.content) {
          if (piece.type === 'text') {
            console.log(`Claude: ${piece.text}`);
          }
        }
        break;
    }
  }
 
  return { sessionId: activeSessionId };
}

You could test it like this:

// src/examples/sessionDemo.ts
import { sendMessageWithSession } from '../agent/sessionClient';
 
export async function runSessionDemo() {
  let sessionId: string | undefined;
 
  // First question
  const first = await sendMessageWithSession('Hello, who are you?', sessionId);
  sessionId = first.sessionId;
 
  // Second question, same conversation
  const second = await sendMessageWithSession(
    'What did I just ask you?',
    sessionId
  );
  sessionId = second.sessionId;
}

Now Claude can answer questions about earlier turns, because you’re reusing the same session.


4. Building a CLI Chat Experience

Let’s turn this into a small command-line chat app.

We’ll:

  • Use Node’s readline module
  • Carry a sessionId across turns
  • Send each user message via sendMessageWithSession

Create a file just for the CLI loop:

// src/cli/chatLoop.ts
import readline from 'readline';
import { sendMessageWithSession } from '../agent/sessionClient';
 
export async function startCliConversation() {
  let sessionId: string | undefined;
 
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
    prompt: 'You: ',
  });
 
  console.log('Interactive Claude Agent CLI');
  console.log('Type your message and press Enter. Ctrl+C to quit.\n');
  rl.prompt();
 
  rl.on('line', async (line) => {
    const trimmed = line.trim();
    if (!trimmed) {
      rl.prompt();
      return;
    }
 
    try {
      const { sessionId: newSessionId } = await sendMessageWithSession(
        trimmed,
        sessionId
      );
      sessionId = newSessionId;
    } catch (err) {
      console.error('Error talking to agent:', err);
    }
 
    rl.prompt();
  });
 
  rl.on('close', () => {
    console.log('\nGoodbye!');
    process.exit(0);
  });
}

Change the entry point to use the CLI:

// src/main.ts
import { startCliConversation } from './cli/chatLoop';
 
startCliConversation().catch(console.error);

Run:

npx tsx src/main.ts

Now you have a simple “Claude Code without UI”: a terminal that maintains context across turns and answers in real time.


5. Giving the Agent Tools via MCP (Example: Math Tool)

One of the coolest parts of Claude Code is tool use — the model can decide when to:

  • Call a calculator
  • Search files
  • Run commands
  • Query APIs

With Claude Agent SDK, you can add tools via the Model Context Protocol (MCP). Let’s implement a math tool so the agent can precisely evaluate expressions instead of doing mental math.

5.1 Install extra dependencies

We’ll use mathjs (opens in a new tab) and zod for schema validation:

npm install mathjs zod@3.25.76

(Using Zod 3.x avoids compatibility issues with newer Zod versions.)

5.2 A tiny math helper

// src/tools/mathEvaluator.ts
import * as math from 'mathjs';
 
export function evaluateMathExpression(expression: string): string {
  const result = math.evaluate(expression);
  return result.toString();
}

5.3 Defining a tool for the agent

The SDK exposes a tool helper. We’ll define a tool called numeric_calculator that takes an expression string.

// src/tools/mathTool.ts
import { tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
import { evaluateMathExpression } from './mathEvaluator';
 
export const numericCalculatorTool = tool(
  'numeric_calculator',
  'Evaluate a mathematical expression using mathjs, e.g. "(2 + 3) * 4".',
  {
    expression: z.string().describe('A valid math expression.'),
  },
  async (args) => {
    const output = evaluateMathExpression(args.expression);
 
    return {
      content: [
        {
          type: 'text',
          text: output,
        },
      ],
    };
  }
);

5.4 Exposing tools via an MCP server

Now we bundle one or more tools into an MCP server:

// src/mcp/toolkitServer.ts
import { createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
import { numericCalculatorTool } from '../tools/mathTool';
 
export const toolsetServer = createSdkMcpServer({
  name: 'toolset',
  version: '1.0.0',
  tools: [numericCalculatorTool],
});

5.5 Updating the CLI to support tools

We’ll create a tool-aware version of the message handler, and then swap it into our CLI loop.

// src/agent/toolEnabledClient.ts
import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
import { toolsetServer } from '../mcp/toolkitServer';
 
export async function sendMessageWithTools(
  userText: string,
  previousSessionId?: string
): Promise<{ sessionId?: string }> {
  let activeSessionId = previousSessionId;
 
  const stream: Query = query({
    prompt: userText,
    options: {
      resume: previousSessionId,
      systemPrompt:
        'You are a helpful assistant. When you need to do precise math, use the numeric_calculator tool instead of guessing.',
      mcpServers: {
        toolset: toolsetServer,
      },
      // Tool names follow: mcp__{server_name}__{tool_name}
      allowedTools: ['mcp__toolset__numeric_calculator'],
    },
  });
 
  for await (const item of stream) {
    switch (item.type) {
      case 'system':
        if (item.subtype === 'init') {
          activeSessionId = item.session_id;
          console.log(`(session: ${activeSessionId})`);
        }
        break;
 
      case 'assistant':
        for (const piece of item.message.content) {
          if (piece.type === 'text') {
            console.log(`Claude: ${piece.text}`);
          } else if (piece.type === 'tool_use') {
            console.log(
              `[tool call] ${piece.name} with input: ${JSON.stringify(
                piece.input
              )}`
            );
          }
        }
        break;
 
      case 'user':
        // Tool results come back as a special user message
        for (const piece of item.message.content) {
          if (piece.type === 'tool_result') {
            process.stdout.write('[tool result] ');
            for (const inner of piece.content) {
              if (inner.type === 'text') {
                process.stdout.write(inner.text);
              }
            }
            process.stdout.write('\n');
          }
        }
        break;
    }
  }
 
  return { sessionId: activeSessionId };
}

Now a separate CLI loop that uses the tool-enabled client:

// src/cli/chatWithTools.ts
import readline from 'readline';
import { sendMessageWithTools } from '../agent/toolEnabledClient';
 
export async function startCliWithTools() {
  let sessionId: string | undefined;
 
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
    prompt: 'You: ',
  });
 
  console.log('Claude Agent CLI (with math tool)');
  console.log('Ask me something like: (2454 + 23546)^2 / 32');
  rl.prompt();
 
  rl.on('line', async (line) => {
    const text = line.trim();
    if (!text) {
      rl.prompt();
      return;
    }
 
    try {
      const { sessionId: nextSessionId } = await sendMessageWithTools(
        text,
        sessionId
      );
      sessionId = nextSessionId;
    } catch (err) {
      console.error('Error:', err);
    }
 
    rl.prompt();
  });
 
  rl.on('close', () => {
    console.log('\nSession ended.');
    process.exit(0);
  });
}

Update src/main.ts to run this tool-enabled version:

// src/main.ts
import { startCliWithTools } from './cli/chatWithTools';
 
startCliWithTools().catch(console.error);

Now when you ask:

You: (2454 + 23546)^2 / 32

You should see:

  • A log that the tool was called
  • The numerical result printed as a tool result
  • A natural language explanation from Claude summarizing the answer

Congratulations — you now have a Claude-like agent that can chat, remember, and call tools.


6. Ideas for Extending Your Agent

Once this skeleton is working, you can start adding more Claude-Code-style features:

  • Codebase tools Tools that read files from your repository, search text, or summarize large files.

  • Execution tools Tools that run tests, scripts, or small code snippets in a controlled environment.

  • Project-aware system prompts Inject project metadata, architecture documents, or style guides into the systemPrompt to make the agent feel “project-native”.

  • Persistent sessions per user Store session_id in a database keyed to your user accounts, so each user has a long-lived assistant.

Claude Agent SDK gives you the building blocks. How “Claude Code–like” you want your agent to become is entirely up to the tools, prompts, and UI you put around it.