Skip to main content
RapidDev - Software Development Agency
build-ai-agent

How to Build an AI Agent from Scratch

Building an AI agent from scratch means writing the tool-calling loop yourself: call the model with a tools list, check whether stop_reason is tool_use, run the matching function, send a tool_result block back, and repeat until the model answers in plain text. This guide builds a GitHub issue triage agent with two hand-defined tools against the raw Anthropic Messages API, no framework. Budget 45-60 minutes and a few cents per test run.

What you'll learn

  • Write the raw tool-calling loop: call the model, inspect stop_reason, run tools, send tool_result blocks back
  • Define a tool's input_schema by hand as JSON Schema instead of relying on a decorator
  • Handle multiple tool_use blocks in a single response instead of assuming one call at a time
  • See exactly what a framework like LangChain or the Claude Agent SDK is doing for you, so you know when you actually need one
Book a free consultation
4.9Clutch rating ⭐
1,000+Happy partners
20+Countries served
200+Team members
Advanced10 min read45-60 minPython 3.10+, anthropic SDK, Claude API key, any OSLast updated November 2026RapidDev Engineering Team
TL;DR

Building an AI agent from scratch means writing the tool-calling loop yourself: call the model with a tools list, check whether stop_reason is tool_use, run the matching function, send a tool_result block back, and repeat until the model answers in plain text. This guide builds a GitHub issue triage agent with two hand-defined tools against the raw Anthropic Messages API, no framework. Budget 45-60 minutes and a few cents per test run.

Quick facts about this guide
FactValue
ToolAny LLM or framework
DifficultyAdvanced
Time required45-60 min
CompatibilityPython 3.10+, anthropic SDK, Claude API key, any OS
Last updatedNovember 2026

What you'll build

You'll build a GitHub issue triage agent: give it a repo and issue number, and it fetches the issue, decides whether it's a bug, a question, or an enhancement, and applies the label. No LangChain, no Claude Agent SDK, no OpenAI Agents SDK, just the anthropic Python package and a loop you write and own.

The loop itself is short: call client.messages.create() with a tools list, check the response's stop_reason. If it's tool_use, find every tool_use content block (there can be more than one in a single response), run the matching Python function, and send the results back as tool_result blocks in a new user message. Repeat until stop_reason comes back as anything else, which means the model is done and wrote its final answer as text.

Build from scratch when you need to see or control every step, when you're on a stack with no mature framework, or when you want zero dependency weight. Don't build from scratch because you distrust frameworks on principle, the loop above is exactly what create_agent, the Claude Agent SDK's query(), and the OpenAI Agents SDK's Runner all do internally, just with retries, parallel execution, and context management already handled. Once this runs, the pillar guide shows the same agent built with a framework in a third of the code.

Prerequisites

  • Python 3.10+ and a terminal
  • An Anthropic API key (ANTHROPIC_API_KEY) with available credits
  • pip install anthropic
  • Comfort reading JSON Schema and a basic for-loop

Step-by-step guide

1

Install the SDK and set your API key

Install the anthropic package, the only dependency this guide needs. Export your API key as an environment variable; the client reads it automatically. There's no framework here to hide a missing key behind a confusing error, you'll see an authentication error immediately if it's not set.

typescript
1pip install anthropic
2export ANTHROPIC_API_KEY="sk-ant-..."

Expected result: python -c "import anthropic; anthropic.Anthropic()" runs without raising.

2

Write the tool specs by hand as JSON Schema

Without a decorator to generate it for you, you write input_schema yourself: a JSON Schema object with type, properties, and required. Define fetch_issue (repo, issue_number) and apply_label (repo, issue_number, label, constrained to an enum). This is the exact information a framework's @tool decorator would have extracted from your type hints and docstring, just written explicitly.

typescript
1TOOLS = [
2 {
3 "name": "fetch_issue",
4 "description": "Fetch a GitHub issue's title, body, and existing labels.",
5 "input_schema": {
6 "type": "object",
7 "properties": {
8 "repo": {"type": "string"},
9 "issue_number": {"type": "integer"},
10 },
11 "required": ["repo", "issue_number"],
12 },
13 },
14 {
15 "name": "apply_label",
16 "description": "Apply exactly one label to a GitHub issue.",
17 "input_schema": {
18 "type": "object",
19 "properties": {
20 "repo": {"type": "string"},
21 "issue_number": {"type": "integer"},
22 "label": {"type": "string", "enum": ["bug", "question", "enhancement"]},
23 },
24 "required": ["repo", "issue_number", "label"],
25 },
26 },
27]

Expected result: A TOOLS list you'll pass straight into the tools parameter of every messages.create call.

3

Write the tool dispatch table

Map each tool name to the Python function that actually performs it, separate from the schema above. Keeping fetch_issue and apply_label as plain functions means you can unit-test them without touching the model at all, something that gets harder once a framework wraps them.

typescript
1ISSUES = {("acme/widgets", 42): {"title": "Crash on empty cart", "body": "App throws when cart has 0 items", "labels": []}}
2
3def fetch_issue(repo: str, issue_number: int) -> str:
4 issue = ISSUES.get((repo, issue_number))
5 if not issue:
6 raise ValueError(f"No issue {repo}#{issue_number}")
7 return f"{issue['title']}: {issue['body']} (labels: {issue['labels']})"
8
9def apply_label(repo: str, issue_number: int, label: str) -> str:
10 ISSUES[(repo, issue_number)]["labels"].append(label)
11 return f"Applied '{label}' to {repo}#{issue_number}"
12
13DISPATCH = {"fetch_issue": fetch_issue, "apply_label": apply_label}

Expected result: DISPATCH['fetch_issue'](repo='acme/widgets', issue_number=42) returns a string with no model involved.

4

Write the loop: call the model and branch on stop_reason

Call messages.create with your system prompt, tools, and the running messages list. stop_reason tells you what to do next: 'tool_use' means at least one tool_use block is in response.content and you must handle it before continuing; anything else (usually 'end_turn') means the model wrote its final text answer and the loop is done. Append the assistant's raw response.content to messages before you do anything else, Claude needs that exact content back in the next turn.

typescript
1messages = [{"role": "user", "content": "Triage issue #42 in acme/widgets."}]
2
3response = client.messages.create(
4 model="claude-sonnet-5-5",
5 max_tokens=1024,
6 system=SYSTEM_PROMPT,
7 tools=TOOLS,
8 messages=messages,
9)
10messages.append({"role": "assistant", "content": response.content})

Expected result: response.stop_reason prints 'tool_use' on the first call, since the model can't triage an issue it hasn't fetched yet.

5

Handle every tool_use block and send tool_result back

A single response can contain more than one tool_use block if the model decides to call several tools in parallel. Loop over response.content, run every tool_use block through DISPATCH, and collect one tool_result per call, each tagged with that block's tool_use_id so Claude can match results to requests. Send all of them back in a single new user message, then call messages.create again. Catch exceptions here, an uncaught one crashes your process instead of telling Claude what went wrong.

typescript
1tool_results = []
2for block in response.content:
3 if block.type != "tool_use":
4 continue
5 try:
6 result = DISPATCH[block.name](**block.input)
7 tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(result)})
8 except Exception as e:
9 tool_results.append({
10 "type": "tool_result", "tool_use_id": block.id,
11 "content": str(e), "is_error": True,
12 })
13messages.append({"role": "user", "content": tool_results})

Expected result: A new user message containing one tool_result per tool_use_id from the previous response, ready for the next messages.create call.

6

Add a turn cap and run the full loop

Wrap steps 4 and 5 in a for loop with a hard cap on turns (10 is plenty for this agent), the from-scratch equivalent of a framework's recursion_limit. Without it, a confused model that keeps calling tools in a cycle burns tokens indefinitely. Break out as soon as stop_reason isn't tool_use, then print the model's final text.

typescript
1for _ in range(MAX_TURNS):
2 response = client.messages.create(model="claude-sonnet-5-5", max_tokens=1024,
3 system=SYSTEM_PROMPT, tools=TOOLS, messages=messages)
4 messages.append({"role": "assistant", "content": response.content})
5 if response.stop_reason != "tool_use":
6 break
7 # ... build and append tool_results as in step 5 ...
8else:
9 raise RuntimeError("Hit MAX_TURNS without finishing")

Expected result: The loop exits on its own once the model fetches the issue, applies a label, and reports back, well under the turn cap.

Complete working example

triage_agent.py
1"""GitHub issue triage agent, built from scratch against the raw Messages API.
2Run: python triage_agent.py (pip install anthropic; set ANTHROPIC_API_KEY)
3"""
4import anthropic
5
6client = anthropic.Anthropic()
7MAX_TURNS = 10
8
9ISSUES = {
10 ("acme/widgets", 42): {
11 "title": "Crash on empty cart",
12 "body": "App throws an unhandled exception when the cart has 0 items.",
13 "labels": [],
14 }
15}
16
17TOOLS = [
18 {
19 "name": "fetch_issue",
20 "description": "Fetch a GitHub issue's title, body, and existing labels.",
21 "input_schema": {
22 "type": "object",
23 "properties": {"repo": {"type": "string"}, "issue_number": {"type": "integer"}},
24 "required": ["repo", "issue_number"],
25 },
26 },
27 {
28 "name": "apply_label",
29 "description": "Apply exactly one label to a GitHub issue.",
30 "input_schema": {
31 "type": "object",
32 "properties": {
33 "repo": {"type": "string"},
34 "issue_number": {"type": "integer"},
35 "label": {"type": "string", "enum": ["bug", "question", "enhancement"]},
36 },
37 "required": ["repo", "issue_number", "label"],
38 },
39 },
40]
41
42
43def fetch_issue(repo: str, issue_number: int) -> str:
44 issue = ISSUES.get((repo, issue_number))
45 if not issue:
46 raise ValueError(f"No issue {repo}#{issue_number}")
47 return f"{issue['title']}: {issue['body']} (labels: {issue['labels']})"
48
49
50def apply_label(repo: str, issue_number: int, label: str) -> str:
51 ISSUES[(repo, issue_number)]["labels"].append(label)
52 return f"Applied '{label}' to {repo}#{issue_number}"
53
54
55DISPATCH = {"fetch_issue": fetch_issue, "apply_label": apply_label}
56
57SYSTEM_PROMPT = (
58 "You triage GitHub issues. Always fetch the issue before labeling it. "
59 "Apply exactly one label: bug, question, or enhancement."
60)
61
62
63def run(user_message: str) -> str:
64 messages = [{"role": "user", "content": user_message}]
65 for _ in range(MAX_TURNS):
66 response = client.messages.create(
67 model="claude-sonnet-5-5", max_tokens=1024,
68 system=SYSTEM_PROMPT, tools=TOOLS, messages=messages,
69 )
70 messages.append({"role": "assistant", "content": response.content})
71 if response.stop_reason != "tool_use":
72 return "".join(b.text for b in response.content if b.type == "text")
73
74 tool_results = []
75 for block in response.content:
76 if block.type != "tool_use":
77 continue
78 try:
79 result = DISPATCH[block.name](**block.input)
80 tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(result)})
81 except Exception as e:
82 tool_results.append({
83 "type": "tool_result", "tool_use_id": block.id,
84 "content": str(e), "is_error": True,
85 })
86 messages.append({"role": "user", "content": tool_results})
87 raise RuntimeError("Hit MAX_TURNS without finishing")
88
89
90if __name__ == "__main__":
91 print(run("Triage issue #42 in acme/widgets."))
92

Common mistakes

Why it's a problem: Appending only the final text instead of the full response.content list to messages

How to avoid: The next turn needs the exact tool_use blocks back in history. Always append response.content as returned, not a reconstructed version.

Why it's a problem: Assuming a response has at most one tool_use block

How to avoid: Claude can request several tool calls in one response. Loop over every block in response.content and send back one tool_result per tool_use_id.

Why it's a problem: Running the loop with no turn cap

How to avoid: A model stuck re-calling a tool with the same bad input will loop until you hit a context limit or a bill you didn't expect. Cap turns and raise on exceeding it.

Why it's a problem: Writing input_schema without "type": "object" at the top level

How to avoid: Every tool's input_schema must be a JSON Schema object type with a properties map. A malformed schema fails at request time, not quietly.

Why it's a problem: Letting a tool handler's exception propagate and crash the process

How to avoid: Catch it, set is_error: true on the tool_result, and let Claude see what went wrong. It can often recover (retry with different input, ask the user) if you give it the chance.

Best practices

  • Keep tool handlers as plain, independently testable functions; the loop should only call DISPATCH[name](**input), never contain business logic.
  • Always set is_error: true on a failed tool_result instead of stuffing an exception message into a normal one.
  • Cap turns explicitly and treat hitting the cap as a bug report, not an edge case to silently swallow.
  • Log every request and response pair to disk during development; replaying a failed run without re-calling the API saves real debugging time.
  • State tool sequencing rules in the system prompt as explicitly as you would in code comments ('always fetch before labeling').
  • Move to a framework the moment you're reimplementing retries, parallel tool execution, or context trimming that the framework already ships.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have a from-scratch Claude tool-calling loop in Python with fetch_issue and apply_label tools (code below). Add a third tool, add_comment, and update the system prompt so the agent posts a one-line triage summary as a comment after labeling. [paste complete_code]

Build AI Agents Prompt

In Claude Code, open triage_agent.py and ask: 'Add retry-with-backoff around the messages.create call for rate-limit errors, and a unit test for fetch_issue and apply_label that doesn't call the API.'

Frequently asked questions

Why build an AI agent from scratch instead of using a framework?

To see and control every step of the loop, to run on a stack with no mature framework yet, or to keep the dependency footprint at just the model's own SDK. Most production teams still move to a framework once the agent grows past one or two tools.

What exactly is a 'tool-calling loop'?

The cycle of calling the model with a tools list, checking whether it wants to call one (stop_reason == 'tool_use'), running that tool, sending the result back as a tool_result block, and repeating until the model answers in plain text instead.

What happens if I forget to send a tool_result for a tool_use block?

The next messages.create call fails or the model gets confused, because it's still waiting to see the result of a tool call it made. Every tool_use_id in a response must get exactly one matching tool_result in the next message.

Can a from-scratch agent call multiple tools at once?

Yes, Claude can return several tool_use blocks in a single response when it decides the calls are independent. Your loop needs to handle all of them, not just the first, or later calls silently get skipped.

How is this different from what LangChain's create_agent does internally?

Functionally, almost nothing, create_agent runs the same request-response-tool_result cycle. It adds retries, parallel tool execution, context trimming, and a checkpointer you'd otherwise write yourself, which is the entire case for using it once the agent gets more complex.

Can someone build this for me instead of doing it myself?

Yes. RapidDev builds production agents both from scratch and on frameworks like LangChain and the Claude Agent SDK, including the retry and audit-trail logic this guide leaves as an exercise. A free 30-minute consultation is the fastest way to scope your version.

RapidDev

Talk to an Expert

Our team has built 1,000+ apps. Get personalized help with your project.

Book a free consultation
Matt Graham

Written by

Matt Graham · CEO & Founder, RapidDev

1,000+ client projects delivered. Columbia University & Harvard Business School alumnus, U.S. Navy veteran. About the author →

Learning is great. Shipping is faster with help.

Our engineers have built 1,000+ apps on your platform and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.