How to Build a Claude AI Agent (The Way I Actually Did It)
A step-by-step guide to building a real Claude AI agent — from the agentic loop to Cloud Run deployment, written by someone who did it in production.
Three weeks in. I had a working WhatsApp bot deployed on Cloud Run — answering questions, pulling live data, calling external APIs. Driven by a 200-line Python loop I wrote from scratch. No framework. No magic wrapper. Just the raw mechanics of tool_use.
Here's how to do it.
The 5-Minute Mental Model
A Claude agent isn't a chatbot. Full stop.
A chatbot takes input and returns output. An agent takes input, decides what to do next, does it, checks the result, and loops back. That distinction is the whole ballgame.
The loop, simplified:
1. You send Claude a message + a list of available tools
2. Claude either answers directly — or asks for a tool
3. If it requests a tool, your code runs it and sends back the result
4. Claude gets the result and decides again: answer, or call another tool?
5. Repeat until Claude stops requesting tools
That's it. The agentic loop. It's deterministic. There's nothing clever hiding behind it.
The confusion? Frameworks that abstract this loop away before you've seen it once. I built without one first — it's the only way I actually understood what was happening.
Step 1: Set Up Your Environment
Three things. That's all you need before writing agent code:
pip install anthropic
export ANTHROPIC_API_KEY="sk-ant-..."
And a system prompt. The system prompt is the most underrated piece of the entire build. I spent about 30% of my total time on it — and it showed. Here's the skeleton from my WhatsApp bot:
SYSTEM = """You are a helpful assistant with access to specific tools.
When you need information you don't have, use a tool.
When you have enough information, answer directly — do not call tools unnecessarily.
Always be concise. Messages are read on mobile."""
Short. Opinionated. No filler.
The better framing: your system prompt is a contract between you and the model. Be explicit about when tools should and shouldn't fire, or Claude will improvise in ways you don't want.
Step 2: Define Your Tools
Claude needs to know what tools exist and exactly what they expect. This is JSON schema — not magic:
tools = [
{
"name": "search_database",
"description": "Look up a user record in the database by email address. Cannot search by name — email only.",
"input_schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "The exact email address to look up"
}
},
"required": ["email"]
}
}
]
Two rules I learned painfully:
- Descriptions must be specific. Claude reads them to decide whether to call the tool. Vague = wrong calls.
- Input schema must exactly match your function signatures. Mismatches break silently. Until 2am.
Step 3: The Agentic Loop in Python
This is the actual code. The whole thing:
import anthropic
import json
client = anthropic.Anthropic()
def run_agent(user_message: str) -> str:
messages = [{"role": "user", "content": user_message}]
while True:
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=4096,
system=SYSTEM,
tools=tools,
messages=messages
)
# Add Claude's response to the message history
messages.append({"role": "assistant", "content": response.content})
# If Claude is done — return the answer
if response.stop_reason == "end_turn":
for block in response.content:
if hasattr(block, "text"):
return block.text
# If Claude wants a tool — run it
if response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(result)
})
# Feed results back to Claude
messages.append({"role": "user", "content": tool_results})
Three things worth noting:
1. messages is the full conversation history. You append every turn — both Claude's responses and your tool results. This is how Claude knows what already happened.
2. The loop runs until stop_reason == "end_turn". Claude decides when it's done. Not you.
3. execute_tool() is your dispatcher. It maps tool names to actual Python functions. Keep it simple — a dict or a match statement.
Step 4: Deploy to Cloud Run
This is where most tutorials stop. Don't stop here.
Running locally is a prototype. Running on Cloud Run is a product. The diff:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "server.py"]
# cloudbuild.yaml
steps:
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'gcr.io/$PROJECT_ID/claude-agent', '.']
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'gcr.io/$PROJECT_ID/claude-agent']
- name: 'gcr.io/cloud-builders/gcloud'
args:
- 'run'
- 'deploy'
- 'claude-agent'
- '--image=gcr.io/$PROJECT_ID/claude-agent'
- '--region=us-central1'
- '--platform=managed'
- '--allow-unauthenticated'
- '--min-instances=1'
The --min-instances=1 matters if you're handling webhooks. Cold starts on Cloud Run can take 2–4 seconds — fine for async, brutal for real-time chat.
Store your ANTHROPIC_API_KEY in Secret Manager, not in the container image. Non-negotiable.
What I'd Do Differently After Six Months
1. Build the dispatcher before the tools.
I built tools first, dispatcher second. Got messy fast. Now I write the dispatcher skeleton — even with placeholder functions — before touching tool definitions.
2. Log every tool call.
Not the results. The calls. tool_name + input + timestamp in a structured log. When something breaks at 11pm, you'll thank yourself.
3. Put guardrails in the tool description, not the system prompt.
"Do not call this tool if X" belongs in the tool's description field, where Claude reads it at decision-time — not buried in a 500-word system prompt.
4. Test with adversarial inputs early.
What happens if the user asks for something your tools can't handle? What if they ask in a different language? What if the tool returns an error? Build for this before you're live.
The Part Nobody Talks About
Building the agent is the easy part.
The hard part is the system around the agent: how it fails, how you know it failed, how you roll back when it does something unexpected.
I run a daily health check on my bot. I have structured logs for every tool call. I have a fallback chain — if the main model is down, it routes to a backup. These aren't fancy features. They're table stakes for anything running in production.
If you're building a demo: ignore this paragraph.
If you're building something people will actually use: build the monitoring before you share the link.
FAQ
How is a Claude agent different from using the Claude API directly?
The raw API is request-response — one message in, one message out. An agent wraps the API in a loop that lets Claude request actions (tools), process results, and iterate until the job is done.
Do I need a framework like LangChain or LlamaIndex?
No. Frameworks add abstraction and convenience; they also add complexity and hidden behavior. For a first build, use the Anthropic SDK directly. Add a framework only when you have a specific reason.
What's tool_use in the Anthropic API?
It's the mechanism Claude uses to request a tool call. When Claude wants to run a function, it returns a response with stop_reason: "tool_use" and a structured block containing the tool name and input. Your code runs the function and returns the result.
How do I handle errors from tools?
Return the error as a string in the tool_result content. Claude will read it and either try again, try a different tool, or explain to the user that it can't complete the request. Don't throw exceptions — handle them and return a message.
What model should I use?
For production agents: claude-opus-4-5 for complex reasoning, claude-haiku-3-5 for speed and cost. I use Haiku for everything that doesn't require multi-step planning.
How do I handle long conversations?
The messages array grows with every turn. For long-running agents, you'll need a strategy: summarize old turns, truncate with a rolling window, or store history in a database and inject only the relevant context.
Is Cloud Run the right deployment target?
For most use cases, yes. It's serverless, scales to zero, and handles webhooks well. If you need persistent connections (WebSockets) or sub-100ms cold starts, look at GCE or a managed Kubernetes option.
What's the biggest mistake people make building Claude agents?
Skipping the system prompt. The default behavior is surprisingly good — good enough that you think you don't need one. You do. It's the difference between an agent that does what you meant and one that does what you said.
Can I run this locally?
Yes. The loop code runs anywhere Python runs. Cloud Run is just where I deploy it. For local development, python server.py with a tunneling tool like ngrok for webhook testing.
What's next after getting this working?
Memory. A stateless agent forgets everything between sessions. Add a simple key-value store (Redis, Firestore, even a JSON file) to persist context across conversations. That's the jump from "demo" to "product."
Build log
Get an email when I ship a new prototype or essay. No funnel — just the work.