Claude Agent SDK로 나만의 CLI 에이전트 만들기
2026.04.15Claude Code를 쓰다가 든 욕심
Claude Code를 매일 쓰다 보면 어느 순간 욕심이 생긴다. "내 워크플로우 전용으로 살짝 다른 에이전트가 있으면 좋겠는데." 예를 들면 이런 것들이다.
- 사내 API 호출 도구만 있는 작은 에이전트
- 특정 코드베이스 한 곳만 수정하는 자동화 봇
- Slack/Discord에 박아두고 메시지로 말 거는 에이전트
이런 걸 만들려면 직접 호출 루프, 도구 핸들링, 권한 관리, 스트리밍을 다 짜야 했다. 그러다 Anthropic이 Claude Agent SDK를 공개했다. Claude Code의 엔진 부분을 라이브러리로 떼어낸 것이다.
이 글은 Agent SDK로 100줄 안쪽의 CLI 에이전트를 만드는 과정이다.
Agent SDK가 무엇을 자동화해주는가
LLM으로 에이전트를 짜본 사람이라면 다 안다. 단순 호출은 쉽지만, 에이전트 루프는 짜기 귀찮다. 보통 이런 흐름이다.
- 사용자 메시지를 받는다
- LLM에 보낸다
- LLM이 도구를 쓰겠다고 하면 그 도구를 실행한다
- 결과를 다시 LLM에 보낸다
- 도구 호출이 끝날 때까지 반복한다
여기에 권한 체크(이 도구를 정말 호출해도 되나?), 에러 복구, 스트리밍까지 붙으면 코드가 금방 수백 줄로 부풀어 오른다.
Agent SDK는 이 루프를 추상화한다. 우리는 도구 정의와 시스템 프롬프트만 넘기고, 실행 루프는 SDK가 돌린다.
설치와 기본 호출
npm install @anthropic-ai/claude-agent-sdk zod커스텀 도구를 정의할 때 Zod 스키마를 쓰므로 zod도 같이 설치한다. API 키는 ANTHROPIC_API_KEY 환경변수로 넣어둔다.
가장 단순한 형태:
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({
prompt: "이 디렉토리 파일 목록 보여줘",
options: {
model: "claude-opus-4-7",
systemPrompt: "You are a helpful coding assistant.",
},
})) {
if (message.type === "result") {
console.log(message.result);
}
}SDK의 진입점은 Agent 클래스가 아니라 query() 함수다. query()는 async 제너레이터를 돌려주고, for await로 돌리면 실행 과정이 메시지 스트림으로 흘러나온다. 기본 도구(파일 읽기, Bash 실행 등)가 자동으로 포함되고, SDK가 도구 호출 루프를 돌린다. type이 "result"인 마지막 메시지에 최종 텍스트 응답이 담긴다.
도구 직접 정의하기
본격적인 사용은 사용자 정의 도구부터다. 사내 API 하나를 도구로 노출해보자.
import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
const checkUserPermission = tool(
"check_user_permission",
"특정 사용자의 권한 목록을 조회한다. user_id는 UUID 형식.",
{ user_id: z.string() },
async ({ user_id }) => {
const res = await fetch(`https://internal.api/users/${user_id}/permissions`);
const data = await res.json();
return { content: [{ type: "text", text: JSON.stringify(data) }] };
},
);
const adminServer = createSdkMcpServer({
name: "admin-tools",
version: "1.0.0",
tools: [checkUserPermission],
});
for await (const message of query({
prompt: "user 12345의 권한 알려줘",
options: {
model: "claude-opus-4-7",
systemPrompt: "You are an admin assistant for our internal user system.",
mcpServers: {
"admin-tools": { type: "sdk", name: "admin-tools", instance: adminServer },
},
},
})) {
if (message.type === "result") console.log(message.result);
}tool()은 인자를 순서대로 받는다 — 이름, 설명, 입력 스키마(Zod), 핸들러. 핸들러는 { content: [...] } 형태로 결과를 돌려준다. 정의한 도구는 곧바로 query()에 넘기는 게 아니라 createSdkMcpServer()로 묶은 뒤 mcpServers 옵션에 등록한다. 형식이 MCP와 닮은 건 우연이 아니다 — SDK 커스텀 도구는 내부적으로 MCP 서버로 동작한다. AI는 description을 보고 언제 이 도구를 쓸지 결정한다.
권한 제어
도구가 위험할수록 권한 제어가 중요해진다. SDK는 두 단계로 막는다.
1. 화이트리스트
allowedTools로 자동 승인할 도구를, disallowedTools로 차단할 도구를 명시한다.
query({
prompt: "...",
options: {
allowedTools: ["Read", "Grep", "Glob"], // 읽기 도구만 자동 승인
disallowedTools: ["Bash", "Write"], // 실행·쓰기는 차단
},
});allowedTools에 있는 도구는 별도 확인 없이 실행되고, disallowedTools에 있는 도구는 어떤 권한 모드에서도 막힌다. 커스텀 도구를 지정할 때는 mcp__{서버이름}__{도구이름} 형식을 쓴다 — 예: mcp__admin-tools__check_user_permission.
2. 호출 직전 콜백
도구가 호출되기 직전에 검사하는 canUseTool 콜백을 건다.
query({
prompt: "...",
options: {
canUseTool: async (toolName, input) => {
if (toolName === "Bash" && String(input.command).includes("rm -rf")) {
return { behavior: "deny", message: "system path forbidden" };
}
return { behavior: "allow" };
},
},
});콜백은 { behavior: "allow" } 또는 { behavior: "deny", message }를 돌려준다. 조건부 차단이 가능하다. 사용자 입력으로 들어온 명령이 위험한지 검사한 다음 결정한다.
스트리밍 출력
CLI 에이전트라면 토큰이 흘러나오는 게 더 자연스럽다. includePartialMessages: true 옵션을 켜면 query()가 부분 응답을 stream_event 메시지로 흘려준다.
for await (const message of query({
prompt: "이 프로젝트 구조 분석해줘",
options: { includePartialMessages: true },
})) {
if (message.type === "stream_event") {
const event = message.event;
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
process.stdout.write(event.delta.text);
}
} else if (message.type === "assistant") {
for (const block of message.message.content) {
if (block.type === "tool_use") console.log(`\n[도구 호출] ${block.name}`);
}
}
}메시지 type으로 분기한다. stream_event의 텍스트 델타는 stdout에 흘리고, assistant 메시지에서 tool_use 블록을 골라 도구 호출을 별도 라인에 표시한다. 이것만으로 Claude Code 같은 인터랙티브 느낌이 난다.
100줄짜리 CLI 에이전트
위 조각들을 합쳐서 실제로 돌아가는 CLI 에이전트를 만들어본다.
import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import readline from "node:readline/promises";
import { execSync } from "node:child_process";
const runShell = tool(
"run_shell",
"Run a shell command in the current directory. Output is truncated to 4KB.",
{ command: z.string() },
async ({ command }) => {
try {
const output = execSync(command, { encoding: "utf-8", maxBuffer: 4096 });
return { content: [{ type: "text", text: output }] };
} catch (e) {
return { content: [{ type: "text", text: `error: ${(e as Error).message}` }] };
}
},
);
const shellServer = createSdkMcpServer({
name: "shell",
version: "1.0.0",
tools: [runShell],
});
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
// 사용자 입력을 메시지 스트림으로 흘려보내면 대화 맥락이 턴 사이에 유지된다
async function* prompts() {
while (true) {
const q = await rl.question("\n> ");
if (!q || q === "exit") break;
yield {
type: "user" as const,
message: { role: "user" as const, content: q },
parent_tool_use_id: null,
};
}
rl.close();
}
for await (const message of query({
prompt: prompts(),
options: {
model: "claude-opus-4-7",
systemPrompt:
"You are a CLI dev assistant. Use run_shell to inspect the project. Be concise.",
mcpServers: {
shell: { type: "sdk", name: "shell", instance: shellServer },
},
canUseTool: async (toolName, input) => {
if (toolName === "mcp__shell__run_shell") {
const danger = /rm -rf|sudo|dd if=/.test(String(input.command));
if (danger) return { behavior: "deny", message: "dangerous command blocked" };
}
return { behavior: "allow" };
},
includePartialMessages: true,
},
})) {
if (message.type === "stream_event") {
const event = message.event;
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
process.stdout.write(event.delta.text);
}
} else if (message.type === "assistant") {
for (const block of message.message.content) {
if (block.type === "tool_use") {
process.stdout.write(`\n[exec] ${(block.input as { command?: string }).command ?? block.name}\n`);
}
}
}
}100줄이 안 된다. npx tsx my-agent.ts로 실행하면 프롬프트가 뜨고, 사용자 입력을 받아서 셸 도구를 쓰는 작은 에이전트가 동작한다. 입력을 제너레이터로 흘려보내는 스트리밍 입력 모드라 턴이 바뀌어도 대화 맥락이 이어진다.
Claude Code와의 차이
여기서 자연스러운 질문 — "그냥 Claude Code 쓰면 되는 거 아닌가?"
맞는 질문이다. Claude Code가 더 강력하다. 하지만 Agent SDK가 적합한 자리가 따로 있다.
Agent SDK가 더 적합한 상황
- 도구를 강하게 제한해야 할 때 — 사내 봇은 셸을 막고 API만 노출하고 싶다
- 에이전트를 다른 시스템에 박을 때 — Slack 봇, Discord 봇, 사내 어드민 페이지
- 시스템 프롬프트를 완전히 통제할 때 — Claude Code의 기본 프롬프트가 방해되는 경우
- 결과를 프로그램이 받아 처리할 때 — JSON 출력, 파이프라인 통합
Claude Code가 더 적합한 상황
- 사람이 직접 코드를 짜면서 보조로 쓸 때
- 여러 프로젝트를 오가며 쓸 때
- 플러그인 생태계(OMC, Superpowers)를 활용할 때
비용 관리 팁
SDK도 결국 Claude API를 호출한다. 프롬프트 캐싱 글에서 다룬 캐싱 원칙이 그대로 적용된다 — 다만 직접 캐시 마커를 붙일 필요는 없다.
Agent SDK는 시스템 프롬프트와 도구 정의를 자동으로 캐시한다. 5분 안에 같은 시스템 프롬프트로 다시 호출되면 입력 비용이 크게 빠진다. 우리가 할 일은 시스템 프롬프트와 도구 정의를 호출할 때마다 흔들지 않는 것뿐이다. 시스템 프롬프트 앞쪽에 사용자별 정보 같은 가변 텍스트를 끼워 넣으면 캐시가 매번 깨진다.
정리
Claude Agent SDK는 에이전트 루프를 직접 짤 필요를 없애주는 SDK다. Claude Code 같은 인터랙티브 도구를 만들고 싶지만 사용처가 좁고 통제가 강해야 한다면, Claude Code 본체보다 SDK가 더 적합하다.
100줄짜리 시작점에서, 도구를 하나씩 늘리고 권한을 좁히고 스트리밍 UI를 다듬는 방식으로 키워간다. 만들고 보면 "내가 Claude Code의 작은 친척을 만들었구나" 하는 느낌이다.
내가 마지막으로 만든 건 PR 검토 봇이었다. PR diff를 받아서 우리 팀 컨벤션에 맞춰 리뷰 코멘트를 다는 SDK 기반 에이전트다. Claude Code로도 할 수 있지만, 매번 명령어를 치는 것보다 GitHub Actions에 박는 게 깔끔하다. SDK는 그런 "에이전트를 시스템에 박는" 자리에 답이 된다.