AWS Builder Center

Building Local AI Agents from Scratch: A 5-Challenge Sprint with Strands SDK and Ollama

Built 5 AI agents in one sprint using AWS Strands SDK + Ollama llama3.2:3b — fully local, zero API keys. From a simple chatbot to a memory-powered full agent with MCP tool servers. Every bug, every fix, every lesson documented.

Here's a full article for the AWS Builder Center:

Building Local AI Agents from Scratch: A Hands-On Journey with Strands SDK, Ollama, and MCP

Author: Mugunthan S Category: AI/ML · Developer Experience · Open Source Level: Beginner to Intermediate

Introduction

What if you could build a fully functional AI agent — one that remembers you, uses tools, and communicates via open protocols — without a single API key, without sending data to the cloud, and without writing hundreds of lines of boilerplate code?
That's exactly what the Builders Skill Sprint challenged me to do across five progressive challenges using the Strands Agents SDK, Ollama, Mem0, FAISS, and the Model Context Protocol (MCP).
In this article I'll walk through each challenge, what I built, what I learned, and the real debugging moments that taught me more than any tutorial could.

The Stack

Everything runs 100% locally on a personal machine — no AWS account required for the core challenges:
ToolRole
Strands SDKAWS open-source agent framework
Ollama + llama3.2:3bLocal LLM runner — no API key
Mem0 + FAISSPersistent vector memory
MCP / FastMCPModel Context Protocol — tool servers

Challenge 1 — Simple AI Agent

The first challenge was deliberately minimal: connect the Strands Agent class to a locally running Ollama model and have a conversation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from strands import Agent
from strands.models.ollama import OllamaModel

ollama_model = OllamaModel(
host="http://localhost:11434",
model_id="llama3.2:3b",
temperature=0.7,
)

agent = Agent(
model=ollama_model,
system_prompt="You are a helpful assistant. Answer clearly and concisely.",
)

agent("Hello! Who are you and what can you help me with?")
That's it. Three imports, one model config, one agent, one call. Strands handles the conversation loop, history management, and streaming output automatically.
What I learned: The system_prompt is the agent's personality. It's read before every message. Even a one-line prompt makes a significant difference in response quality.

Challenge 2 — Tools Agent

Challenge 2 introduced the @tool decorator — the mechanism that turns a plain Python function into something the LLM can call autonomously.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from strands.tools import tool

@tool
def calculator(expression: str) -> str:
"""
Evaluate a mathematical expression and return the result.
Examples: '2 + 2', 'sqrt(144)', '2 ** 16'

Args:
expression: A math expression as a plain string.
"""

# safe eval — only math functions allowed
...

agent = Agent(model=ollama_model, tools=[calculator, get_weather, calculate_age])
Three tools were built: a safe math evaluator, a simulated weather service, and an age calculator using Python's datetime module.
The key insight here is how Strands uses the function docstring and type hints to automatically generate the JSON schema the LLM uses to decide when and how to call the tool. The docstring is not just documentation — it's the tool's interface to the model.
What I learned: Small models like llama3.2:3b are surprisingly good at tool selection when the docstrings are clear and specific. Vague descriptions lead to random tool calls.

Challenge 3 — Memory Agent

This was the most technically involved challenge. The goal: give the agent persistent memory that survives restarts.
The architecture uses three local components wired together via Mem0's configuration API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
MEM0_CONFIG = {
"vector_store": {
"provider": "faiss",
"config": {
"embedding_model_dims": 768, # critical — set before first write
"distance_strategy": "cosine",
"path": "./memory_store",
},
},
"llm": {
"provider": "ollama",
"config": { "model": "llama3.2:3b", "temperature": 0 },
},
"embedder": {
"provider": "ollama",
"config": { "model": "nomic-embed-text", "embedding_dims": 768 },
},
}

memory = Memory.from_config(MEM0_CONFIG)
When a user says "My name is Mugu", Mem0's internal LLM reads the conversation and extracts the structured fact "User's name is Mugu". That fact is converted into a 768-dimensional vector by nomic-embed-text and stored in the FAISS index on disk.
On the next turn — or even the next day after restarting — when the user asks "What is my name?", the query is embedded, FAISS finds the closest matching memory, and it's injected into the prompt before the agent responds.

The Bug That Taught Me the Most

The most instructive moment of the entire sprint was this error:
1
AssertionError: d == self.d
FAISS was creating its index with dimension 0 at startup (before any real embedding existed), then crashing when the first real 768-dim vector arrived. The fix was a single config key:
1
"embedding_model_dims": 768
This tells FAISS the correct index size at creation time, before any data arrives. Without it, FAISS initialises lazily and gets the dimension wrong.
I also discovered that Mem0's API changed significantly in v1.x. The user_id parameter moved into a filters={} dict for search() and get_all(), but stays as a direct kwarg for add() and delete_all(). Inconsistent, but once mapped out, simple to use correctly.
What I learned: Always inspect the actual method signatures of installed packages rather than relying on older documentation. python -c "import inspect; from mem0 import Memory; print(inspect.signature(Memory.search))" saved hours of guessing.

Challenge 4 — Full Agent

Challenge 4 combined everything: three tools from Challenge 2 plus persistent memory from Challenge 3 in a single agent.
The key design pattern is memory-augmented prompting. Before every agent call, the most relevant memories are retrieved from FAISS and prepended to the user's message:
1
2
3
4
5
6
7
8
relevant = recall_from_memory(user_input)

if relevant:
prompt = f"Relevant memories:\n{relevant}\n\nUser: {user_input}"
else:
prompt = user_input

agent(prompt)
This means when a user asks "How old am I?", the agent sees:
1
2
3
4
Relevant memories:
- User was born in 2006

User: How old am I?
It then calls calculate_age(birth_year=2006) automatically — connecting a stored memory to a tool call without any explicit wiring.
What I learned: Memory injection through the prompt is more reliable than expecting the model to maintain facts across a long conversation history, especially with smaller 3B-parameter models that have limited context windows.

Challenge 5 — MCP Chatbot

The final challenge introduced the Model Context Protocol — an open standard that separates the tool definitions (server) from the agent (client). Instead of defining tools inside the agent code, they live on a separate server the agent discovers at runtime.
The server is a standalone Python file using FastMCP:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("LocalToolsServer")

@mcp.tool()
def multiply(a: float, b: float) -> str:
"""Multiply two numbers."""
return f"{a} × {b} = {a * b}"

@mcp.tool()
def remember(key: str, value: str) -> str:
"""Store a piece of information in memory."""
_store[key] = value
return f"Remembered: {key} = {value}"

mcp.run(transport="stdio")
The client launches this server as a subprocess and connects over stdio:
1
2
3
4
5
6
7
8
9
10
11
12
13
from strands.tools.mcp import MCPClient
from mcp.client.stdio import stdio_client
from mcp import StdioServerParameters

server_params = StdioServerParameters(
command=sys.executable,
args=["mcp_server.py"],
)

with MCPClient(lambda: stdio_client(server_params)) as client:
mcp_tools = client.list_tools_sync()
agent = Agent(model=ollama_model, tools=mcp_tools)
agent("What is 128 * 7?")
list_tools_sync() asks the server "what tools do you have?" and Strands wraps each one into a native tool object. From the agent's perspective, MCP tools and @tool-decorated functions are identical.
What I learned: MCP's real value is decoupling. The agent doesn't know or care whether a tool is a local function or a remote MCP server. This means tool servers can be written in any language, versioned independently, and shared across multiple agents.

Key Takeaways for AWS Builders

1. Strands makes the agentic loop invisible The hardest part of building agents used to be managing the tool-call loop: send message → check if LLM wants a tool → call tool → send result back → repeat. Strands handles all of this automatically. You define tools, pass them to Agent(), and call agent(message). The loop runs inside.
2. Small models need explicit prompts llama3.2:3b is fast and free, but it's not GPT-4. Clear, explicit system prompts with strict rules ("NEVER call a tool unless the request clearly matches its purpose") dramatically improve reliability. Write prompts like instructions to a junior developer, not hints to an expert.
3. Local-first is production-viable for many use cases The entire sprint — agent, memory, tools, MCP server — runs on a laptop with no internet connection. For privacy-sensitive applications, regulated industries, or offline deployments, this architecture is directly applicable to production.
4. MCP is the USB-C of AI tooling Just as USB-C standardised physical device connections, MCP standardises how agents connect to tools. Building tool servers with FastMCP today means those same tools can be used by any MCP-compatible agent framework tomorrow — Strands, LangChain, Claude Desktop, or anything else.
5. Debug by reading actual source Every meaningful bug in this sprint was solved by inspecting the installed library's actual method signatures rather than guessing from documentation. inspect.signature() is your best friend when working with rapidly evolving SDKs.

Project Structure

1
2
3
4
5
6
7
builders-skill-sprint/
├── Challenge 1/ — Simple Agent
├── Challenge 2/ — Tools Agent
├── Challenge 3/ — Memory Agent
├── Challenge 4/ — Full Agent (Tools + Memory)
├── Challenge 5/ — MCP Chatbot
└── README.md
Full source code with line-by-line comments is available on GitHub.

Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article