에이전트로 도구 만들기 (Build a Tool Using Agent)¶
이 튜토리얼은 다섯 개의 동심원(ring)을 쌓아가며 캘린더 관리 에이전트를 만듭니다. 각 링은 하나의 개념만 새로 추가한, 그 자체로 실행 가능한 완전한 프로그램이에요. 다섯 개를 모두 따라가다 보면 에이전트 루프(agentic loop)를 손으로 직접 써 보고, 마지막에 Tool Runner SDK 추상화로 그 루프를 대체하게 됩니다.
예시 도구는 create_calendar_event입니다. 이 도구의 스키마에는 중첩된 객체(nested object), 배열(array), 선택 필드(optional field)가 들어 있어서, 단순한 문자열 하나가 아니라 실제 도구가 다루는 복잡한 입력 형태를 Claude가 어떻게 처리하는지 확인할 수 있어요.
Ring 1: 도구 하나, 한 번의 턴¶
가장 작은 도구 사용 프로그램입니다. 도구 하나, 사용자 메시지 하나, 도구 호출 한 번, 결과 하나로 구성돼요. 코드에 주석이 빽빽하게 달려 있어서 각 줄을 도구 사용 수명주기 문서에 대응해 볼 수 있습니다.
요청에는 사용자 메시지와 함께 tools 배열을 보냅니다. Claude가 도구 호출이 필요하다고 판단하면, 응답은 stop_reason: "tool_use"와 함께 돌아오고, tool_use 콘텐츠 블록 안에 도구 이름, 고유한 id, 구조화된 input이 담겨요. 코드는 도구를 실행한 뒤, 결과를 tool_result 블록으로 다시 보냅니다. 이때 tool_use_id가 호출의 id와 일치해야 해요.
# Ring 1: Single tool, single turn.
import json
import anthropic
# Create a client. It reads ANTHROPIC_API_KEY from the environment.
client = anthropic.Anthropic()
# Define one tool. The input_schema is a JSON Schema object describing
# the arguments Claude should pass when it calls this tool. This schema
# includes nested objects (recurrence), arrays (attendees), and optional
# fields, which is closer to real-world tools than a flat string argument.
tools = [
{
"name": "create_calendar_event",
"description": "Create a calendar event with attendees and optional recurrence.",
"input_schema": {
"type": "object",
"properties": {
"title": {"type": "string"},
"start": {"type": "string", "format": "date-time"},
"end": {"type": "string", "format": "date-time"},
"attendees": {
"type": "array",
"items": {"type": "string", "format": "email"},
},
"recurrence": {
"type": "object",
"properties": {
"frequency": {"enum": ["daily", "weekly", "monthly"]},
"count": {"type": "integer", "minimum": 1},
},
},
},
"required": ["title", "start", "end"],
},
}
]
# Send the user's request along with the tool definition. Claude decides
# whether to call the tool based on the request and the tool description.
response = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
tools=tools,
tool_choice={"type": "auto", "disable_parallel_tool_use": True},
messages=[
{
"role": "user",
"content": "Schedule a 30-minute sync with [email protected] and [email protected] on Monday, March 30, 2026 at 10am.",
}
],
)
# When Claude calls a tool, the response has stop_reason "tool_use"
# and the content array contains a tool_use block alongside any text.
print(f"stop_reason: {response.stop_reason}")
# Find the tool_use block. A response may contain text blocks before the
# tool_use block, so scan the content array rather than assuming position.
tool_use = next(block for block in response.content if block.type == "tool_use")
print(f"Tool: {tool_use.name}")
print(f"Input: {tool_use.input}")
# Execute the tool. In a real system this would call your calendar API.
# Here the result is hardcoded to keep the example self-contained.
result = {"event_id": "evt_123", "status": "created"}
# Send the result back. The tool_result block goes in a user message and
# its tool_use_id must match the id from the tool_use block above. The
# assistant's previous response is included so Claude has the full history.
followup = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
tools=tools,
tool_choice={"type": "auto", "disable_parallel_tool_use": True},
messages=[
{
"role": "user",
"content": "Schedule a 30-minute sync with [email protected] and [email protected] on Monday, March 30, 2026 at 10am.",
},
{"role": "assistant", "content": response.content},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": json.dumps(result),
}
],
},
],
)
# With the tool result in hand, Claude produces a final natural-language
# answer and stop_reason becomes "end_turn".
print(f"stop_reason: {followup.stop_reason}")
final_text = next(block for block in followup.content if block.type == "text")
print(final_text.text)
무엇이 나오는지
stop_reason: tool_use
Tool: create_calendar_event
Input: {'title': 'Sync', 'start': '2026-03-30T10:00:00', 'end': '2026-03-30T10:30:00', 'attendees': ['[email protected]', '[email protected]']}
stop_reason: end_turn
I've scheduled your 30-minute sync with Alice and Bob for Monday, March 30 at 10am.
첫 번째 stop_reason은 tool_use인데, Claude가 캘린더 실행 결과를 기다리고 있기 때문이에요. 결과를 보내고 나면 두 번째 stop_reason은 end_turn이 되고, 콘텐츠는 사용자를 위한 자연어로 돌아옵니다.
Ring 2: 에이전트 루프¶
Ring 1은 Claude가 도구를 정확히 한 번만 호출한다고 가정했어요. 하지만 실제 작업은 여러 번의 호출이 필요할 때가 많죠. Claude가 이벤트를 만들고, 확인 결과를 읽고, 다시 다른 이벤트를 만들 수도 있습니다. 해결책은 while 루프인데, stop_reason이 더 이상 "tool_use"가 아닐 때까지 도구를 계속 실행하고 결과를 다시 보내는 방식이에요.
또 하나 달라진 점은 대화 이력입니다. 매 요청마다 messages 배열을 처음부터 다시 만들지 않고, 실행 중인 리스트를 유지하면서 계속 추가해요. 그래야 매 턴마다 이전의 전체 맥락이 보이거든요.
# Ring 2: The agentic loop.
import json
import anthropic
client = anthropic.Anthropic()
tools = [
{
"name": "create_calendar_event",
"description": "Create a calendar event with attendees and optional recurrence.",
"input_schema": {
"type": "object",
"properties": {
"title": {"type": "string"},
"start": {"type": "string", "format": "date-time"},
"end": {"type": "string", "format": "date-time"},
"attendees": {
"type": "array",
"items": {"type": "string", "format": "email"},
},
"recurrence": {
"type": "object",
"properties": {
"frequency": {"enum": ["daily", "weekly", "monthly"]},
"count": {"type": "integer", "minimum": 1},
},
},
},
"required": ["title", "start", "end"],
},
}
]
def run_tool(name, tool_input):
if name == "create_calendar_event":
return {"event_id": "evt_123", "status": "created", "title": tool_input["title"]}
return {"error": f"Unknown tool: {name}"}
# Keep the full conversation history in a list so each turn sees prior context.
messages = [
{
"role": "user",
"content": "Schedule a weekly team standup every Monday at 9am for the next 4 weeks. Invite the whole team: [email protected], [email protected], [email protected].",
}
]
response = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
tools=tools,
tool_choice={"type": "auto", "disable_parallel_tool_use": True},
messages=messages,
)
# Loop until Claude stops asking for tools. Each iteration runs the requested
# tool, appends the result to history, and asks Claude to continue.
while response.stop_reason == "tool_use":
tool_use = next(block for block in response.content if block.type == "tool_use")
result = run_tool(tool_use.name, tool_use.input)
messages.append({"role": "assistant", "content": response.content})
messages.append(
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": json.dumps(result),
}
],
}
)
response = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
tools=tools,
tool_choice={"type": "auto", "disable_parallel_tool_use": True},
messages=messages,
)
final_text = next(block for block in response.content if block.type == "text")
print(final_text.text)
무엇이 나오는지
I've set up your weekly team standup for the next 4 Mondays at 9am with Alice, Bob, and Carol invited.
루프는 Claude가 작업을 어떻게 나누느냐에 따라 한 번만 실행될 수도, 여러 번 실행될 수도 있어요. 이제 코드는 미리 실행 횟수를 알 필요가 없습니다.
Ring 3: 여러 도구, 병렬 호출¶
에이전트가 능력 하나만 갖고 있을 때는 드물어요. list_calendar_events라는 두 번째 도구를 추가해서, 새 이벤트를 만들기 전에 기존 일정을 확인할 수 있게 해볼게요.
Claude가 독립적인 도구 호출을 여러 개 만들어야 할 때는, 하나의 응답에 tool_use 블록이 여러 개 돌아올 수 있습니다. 루프는 그 블록을 전부 처리하고, 결과도 모두 모아 한 개의 사용자 메시지로 함께 보내야 해요. response.content 안의 첫 번째 tool_use 블록만 보지 말고, 모든 tool_use 블록을 순회해야 합니다.
# Ring 3: Multiple tools, parallel calls.
import json
import anthropic
client = anthropic.Anthropic()
tools = [
{
"name": "create_calendar_event",
"description": "Create a calendar event with attendees and optional recurrence.",
"input_schema": {
"type": "object",
"properties": {
"title": {"type": "string"},
"start": {"type": "string", "format": "date-time"},
"end": {"type": "string", "format": "date-time"},
"attendees": {
"type": "array",
"items": {"type": "string", "format": "email"},
},
"recurrence": {
"type": "object",
"properties": {
"frequency": {"enum": ["daily", "weekly", "monthly"]},
"count": {"type": "integer", "minimum": 1},
},
},
},
"required": ["title", "start", "end"],
},
},
{
"name": "list_calendar_events",
"description": "List all calendar events on a given date.",
"input_schema": {
"type": "object",
"properties": {
"date": {"type": "string", "format": "date"},
},
"required": ["date"],
},
},
]
def run_tool(name, tool_input):
if name == "create_calendar_event":
return {"event_id": "evt_123", "status": "created", "title": tool_input["title"]}
if name == "list_calendar_events":
return {"events": [{"title": "Existing meeting", "start": "14:00", "end": "15:00"}]}
return {"error": f"Unknown tool: {name}"}
messages = [
{
"role": "user",
"content": "Check what I have next Monday, then schedule a planning session that avoids any conflicts.",
}
]
response = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
tools=tools,
messages=messages,
)
while response.stop_reason == "tool_use":
# A single response can contain multiple tool_use blocks. Process all of
# them and return all results together in one user message.
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = run_tool(block.name, block.input)
tool_results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
}
)
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
response = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
tools=tools,
messages=messages,
)
final_text = next(block for block in response.content if block.type == "text")
print(final_text.text)
무엇이 나오는지
I checked your calendar for next Monday and found an existing meeting from 2pm to 3pm. I've scheduled the planning session for 10am to 11am to avoid the conflict.
동시 실행과 순서 보장에 대해 더 알고 싶다면 병렬 도구 사용 문서를 참고하세요.
Ring 4: 오류 처리¶
도구도 실패합니다. 캘린더 API가 참석자가 너무 많은 이벤트를 거부할 수도 있고, 날짜 형식이 잘못됐을 수도 있어요. 도구에서 오류가 발생하면 프로세스를 멈추는 대신, is_error: true를 붙여 오류 메시지를 다시 보냅니다. Claude는 그 오류를 읽고, 입력을 고쳐 재시도하거나, 사용자에게 확인을 요청하거나, 제약을 설명할 수 있어요.
# Ring 4: Error handling.
import json
import anthropic
client = anthropic.Anthropic()
tools = [
{
"name": "create_calendar_event",
"description": "Create a calendar event with attendees and optional recurrence.",
"input_schema": {
"type": "object",
"properties": {
"title": {"type": "string"},
"start": {"type": "string", "format": "date-time"},
"end": {"type": "string", "format": "date-time"},
"attendees": {
"type": "array",
"items": {"type": "string", "format": "email"},
},
"recurrence": {
"type": "object",
"properties": {
"frequency": {"enum": ["daily", "weekly", "monthly"]},
"count": {"type": "integer", "minimum": 1},
},
},
},
"required": ["title", "start", "end"],
},
},
{
"name": "list_calendar_events",
"description": "List all calendar events on a given date.",
"input_schema": {
"type": "object",
"properties": {
"date": {"type": "string", "format": "date"},
},
"required": ["date"],
},
},
]
def run_tool(name, tool_input):
if name == "create_calendar_event":
if "attendees" in tool_input and len(tool_input["attendees"]) > 10:
raise ValueError("Too many attendees (max 10)")
return {"event_id": "evt_123", "status": "created", "title": tool_input["title"]}
if name == "list_calendar_events":
return {"events": [{"title": "Existing meeting", "start": "14:00", "end": "15:00"}]}
raise ValueError(f"Unknown tool: {name}")
messages = [
{
"role": "user",
"content": "Schedule an all-hands with everyone: " + ", ".join(f"user{i}@example.com" for i in range(15)),
}
]
response = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
tools=tools,
messages=messages,
)
while response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
try:
result = run_tool(block.name, block.input)
tool_results.append(
{"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result)}
)
except Exception as exc:
# Signal failure so Claude can retry or ask for clarification.
tool_results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": str(exc),
"is_error": True,
}
)
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
response = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
tools=tools,
messages=messages,
)
final_text = next(block for block in response.content if block.type == "text")
print(final_text.text)
무엇이 나오는지
I tried to schedule the all-hands but the calendar only allows 10 attendees per event. I can split this into two sessions, or you can let me know which 10 people to prioritize.
is_error 플래그가 성공 결과와의 유일한 차이점이에요. Claude는 이 플래그와 오류 텍스트를 보고 그에 맞게 응답합니다. 전체 오류 처리 레퍼런스는 도구 호출 처리 문서를 참고하세요.
Ring 5: Tool Runner SDK 추상화¶
Ring 2부터 Ring 4까지 같은 루프를 손으로 계속 써 왔어요. API를 호출하고, stop_reason을 확인하고, 도구를 실행하고, 결과를 추가하고, 다시 반복하는 방식이죠. Tool Runner가 이 작업을 대신해 줍니다. 각 도구를 함수로 정의하고, 그 리스트를 tool_runner에 넘긴 뒤, 루프가 끝나면 최종 메시지를 받아오면 돼요. 오류 감싸기, 결과 포매팅, 대화 관리가 내부에서 처리됩니다.
각 SDK는 평범한 함수를 실행 가능한 도구로 바꿔주고 함수 시그니처에서 입력 스키마를 자동으로 만들어내는 헬퍼를 제공해요. 아래 탭이 각 언어에 맞는 관용적인 형태입니다.
# Ring 5: The Tool Runner SDK abstraction.
import json
import anthropic
from anthropic import beta_tool
client = anthropic.Anthropic()
@beta_tool
def create_calendar_event(
title: str,
start: str,
end: str,
attendees: list[str] | None = None,
recurrence: dict | None = None,
) -> str:
"""Create a calendar event with attendees and optional recurrence.
Args:
title: Event title.
start: Start time in ISO 8601 format.
end: End time in ISO 8601 format.
attendees: Email addresses to invite.
recurrence: Dict with 'frequency' (daily, weekly, monthly) and 'count'.
"""
if attendees and len(attendees) > 10:
raise ValueError("Too many attendees (max 10)")
return json.dumps({"event_id": "evt_123", "status": "created", "title": title})
@beta_tool
def list_calendar_events(date: str) -> str:
"""List all calendar events on a given date.
Args:
date: Date in YYYY-MM-DD format.
"""
return json.dumps({"events": [{"title": "Existing meeting", "start": "14:00", "end": "15:00"}]})
final_message = client.beta.messages.tool_runner(
model="claude-opus-5",
max_tokens=1024,
tools=[create_calendar_event, list_calendar_events],
messages=[
{
"role": "user",
"content": "Check what I have next Monday, then schedule a planning session that avoids any conflicts.",
}
],
).until_done()
for block in final_message.content:
if block.type == "text":
print(block.text)
무엇이 나오는지
I checked your calendar for next Monday and found an existing meeting from 2pm to 3pm. I've scheduled the planning session for 10am to 11am to avoid the conflict.
출력은 Ring 3과 똑같아요. 달라진 건 코드 쪽입니다. 줄 수가 대략 절반으로 줄었고, 수동 루프가 사라졌으며, 스키마가 구현 바로 옆에 붙어 있어요.
무엇을 만들었는지¶
단일 하드코딩된 도구 호출에서 시작해, 여러 도구와 병렬 호출, 오류 처리까지 갖춘 프로덕션 형태의 에이전트로 끝났고, 마지막에는 그 전부를 Tool Runner로 압축했어요. 그 과정에서 도구 사용 프로토콜의 모든 조각을 만나 봤습니다. tool_use 블록, tool_result 블록, tool_use_id 매칭, stop_reason 확인, is_error 신호까지요.
다음 단계¶
스키마 명세와 모범 사례.
SDK 추상화의 전체 레퍼런스.
흔한 도구 사용 오류 고치기.