Build a Private Local AI Stack (End-to-End)
You've seen the pieces separately: a local model, a local agent loop, tools exposed over MCP, and the Claude+local hybrid patterns. This is the capstone — the page that wires them into one working private assistant on your own machine: an open-weight model running locally, a model-agnostic agent loop that can call tools, those tools exposed through a local MCP server, a guardrail in front of the dangerous ones, and — optionally — Claude as an opt-in "smart layer" for the hardest 5% of steps. The through-line: everything sensitive stays on-device; the cloud is optional and reserved for the hard minority.
- See the whole stack as one diagram: local model + agent loop + local MCP tools + guardrail (+ optional Claude)
- Run an open-weight model locally and confirm it can do tool calling
- Stand up a minimal agent loop that is model-agnostic — same loop, swap the endpoint
- Expose a couple of tools through a local MCP server and let the agent call them
- Add one guardrail: approval for destructive actions, a loop/budget cap, and untrusted-result handling
- Optionally route only the hardest reasoning to Claude, keeping the default path fully local
The whole stack, in one picture
The mental model is a small number of boxes, each of which you already met on a sibling page. The assistant is just these boxes wired together:
Read it as a loop. The agent asks the local model what to do next. The model either answers, or emits a tool call. Every tool call passes through a guardrail before it reaches the local MCP server, which actually does the work (reads a file, runs a command, searches your notes) and returns a result. The agent feeds the result back to the model and repeats until the task is done. The dotted path to Claude is opt-in: the agent escalates only the steps the local model can't handle, and only when you allow it.
Three properties make this stack worth building:
- Local by default. The model, the loop, the tools, and your data all live on your hardware. Nothing leaves the box unless the optional Claude path fires — and even then, only what you choose to send.
- Model-agnostic loop. The agent talks to an OpenAI-shaped chat endpoint. Point it at Ollama's local endpoint today; point it at a different provider tomorrow without rewriting the loop.
- Tools behind one standard. Capabilities live in an MCP server, not hard-coded into the loop. Build a tool once and any MCP-speaking client (your agent, Claude Code, another app) can use it.
Step-by-step build
- Install Ollama and start a model that supports tool calling. ollama run downloads on first use and exposes a local OpenAI-compatible API on localhost:11434. This is your default 'brain' — private and offline. (Full setup: the Run Models Locally page.)
- Write a tiny loop: send messages + a tool schema to the chat endpoint, read the reply, if it contains tool_calls execute them, append the results, and loop until the model returns a final answer. The loop knows nothing about which model it talks to — only the OpenAI chat shape.
- Put your real capabilities (read a file, run a command, search notes) in a local MCP server over stdio instead of hard-coding them. The agent lists the server's tools, maps them into the model's tool schema, and calls them on demand. Build once, reuse across clients.
- Before any tool runs, gate it: auto-allow read-only tools, require explicit approval for destructive ones (run_shell, write_file, delete), cap the number of loop iterations and total tokens, and treat every tool result as untrusted input that could try to steer the model.
- Keep the local path as the default. When a step is genuinely hard — tricky multi-step reasoning, a plan the local model keeps botching — let the agent escalate just that step to the Claude API, then return to the local loop. This is the router / draft-then-refine idea from the hybrid page, applied to one step at a time.
1. The local model (your default brain)
Start the model and confirm the local endpoint is up. Pick a model that advertises tool calling — the agent loop depends on it.
Run a tool-capable local model + confirm the API
# Start a model that supports tool/function calling
ollama run llama3.1
# In another terminal, confirm the local OpenAI-compatible endpoint is live.
# Ollama serves it at http://localhost:11434/v1 — no internet required.
curl http://localhost:11434/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "llama3.1",
"messages": [{"role": "user", "content": "Reply with the single word: ready"}]
}'2. The model-agnostic agent loop
The loop is deliberately dumb: it forwards messages and a tool schema to the chat endpoint, and whenever the model asks to call a tool, it runs the tool and feeds the result back. Because it only speaks the OpenAI chat shape, the same loop works against the local endpoint now and a different provider later — you change a base_url, not the logic.
from openai import OpenAI
# Point at the LOCAL model. Swap base_url/api_key later to change providers —
# the loop below does not change. That is what "model-agnostic" means here.
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "llama3.1"
MAX_STEPS = 8 # hard cap on loop iterations (a guardrail — see step 4)
def run_agent(user_goal, tool_schemas, dispatch):
messages = [
{"role": "system", "content": "You are a local assistant. Use tools when needed."},
{"role": "user", "content": user_goal},
]
for _ in range(MAX_STEPS):
resp = client.chat.completions.create(
model=MODEL, messages=messages, tools=tool_schemas,
)
msg = resp.choices[0].message
if not msg.tool_calls:
return msg.content # model gave a final answer
messages.append(msg)
for call in msg.tool_calls:
result = dispatch(call) # runs through the guardrail + MCP server
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})
return "Stopped: hit the step cap." # never loop forever
tool_schemas is the list of tools (in the OpenAI function-calling format), and dispatch is the one function that decides whether and how to actually run a requested tool — that's where the guardrail and the MCP server live.
3. Tools via a local MCP server
Rather than hard-coding tools inside the loop, expose them through a local MCP server. MCP is an open standard for connecting an AI client to external tools; a local server runs as a small program on your machine and talks to the client over stdio, so your data and actions stay on the box. (Why this is the right boundary, and how to build a server, is covered on Connect Claude to Local Tools with MCP.)
A minimal Python MCP server that exposes one safe, read-only tool:
# server.py — a tiny local MCP server exposing one read-only tool.
# Run it over stdio; an MCP client (your agent, Claude Code, ...) connects to it.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("local-tools")
@mcp.tool()
def search_notes(query: str) -> str:
"""Search the user's local notes folder and return matching snippets."""
# ... read from a LOCAL directory only; never reach outside it ...
return f"(stub) matches for: {query}"
if __name__ == "__main__":
mcp.run() # stdio transport by default — local, no network
The agent connects to this server, asks it to list its tools, converts each into the OpenAI tool schema your loop already understands, and routes the model's tool calls to the server. Same loop, real capabilities — and the server is reusable by any MCP-speaking client.
4. The guardrail (do not skip this)
This is the difference between a toy and something you'd trust on your own machine. The dispatch function from step 2 is the single chokepoint where every tool call is inspected before it runs. Three jobs:
READ_ONLY = {"search_notes", "read_file", "list_dir"}
def dispatch(call):
name = call.function.name
args = call.function.arguments
# 1) APPROVAL: read-only tools auto-run; everything else asks a human first.
if name not in READ_ONLY:
if not human_approves(name, args): # destructive => require consent
return "DENIED by user."
# 2) The MCP server does the actual work (it, too, is sandboxed to safe paths).
result = call_mcp_tool(name, args)
# 3) UNTRUSTED RESULT: a tool result is data, not instructions. Do not let it
# silently become a new command to the model (prompt-injection defense).
return f"<tool_result name={name}>\n{result}\n</tool_result>"
Combine that with the loop/budget caps already in the loop (MAX_STEPS, plus a token ceiling you track per run) and you have the three controls that matter: a human in the loop for anything destructive, a hard stop so the agent can't spin or spend forever, and a habit of treating tool output as untrusted text.
5. Optional — Claude as the smart layer
By default, never call the cloud. But some steps are genuinely beyond a small local model — gnarly multi-step planning, a refactor that must be correct, a synthesis across long context. For those steps only, the agent can escalate to the Claude API, get a better answer, and drop back into the local loop. This is the router / draft-then-refine idea from Claude + Local Models, applied one step at a time.
import anthropic
cloud = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from env
def hard_step(prompt, allow_cloud=False):
"""Escalate ONE hard step to Claude — only when explicitly allowed."""
if not allow_cloud:
return None # default: stay fully local, send nothing off-device
msg = cloud.messages.create(
model="claude-sonnet-4-5", # check current model ids before pinning
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
return msg.content[0].text
Two rules keep this honest: the cloud path is opt-in (off by default), and you only send what that single step needs — not your whole context. The local model stays the workhorse; Claude is the specialist you call for the hard 5%. For the exact current model ids and pricing, see the verify note below.
- Local agents still take real actions on your machine — sandbox tools, require approval for destructive steps, cap loops/budget, and treat tool results as untrusted (prompt-injection).
Check yourself
Check yourself
0/4- A private assistant is four boxes wired into a loop: local model + model-agnostic agent + local MCP tools + a guardrail — with Claude as an optional fifth box
- Local is the default and the privacy guarantee: the model, the loop, the tools, and your data all stay on your machine unless YOU opt into the cloud path
- Keep the loop dumb and model-agnostic (OpenAI chat shape) and put real capabilities behind a local MCP server — build once, reuse across clients
- The guardrail is the part you cannot skip: approve destructive steps, cap loops/budget, sandbox tools, and treat tool results as untrusted
- Claude is the opt-in smart layer for the hard 5% — escalate one step at a time and send only what that step needs
- Volatile specifics (model names, ids, prices, SDK APIs) sit behind verify notes; the architecture is durable, the numbers are not
Sources & further reading
- Ollama — OpenAI-compatible API (localhost:11434, tools parameter)
- Ollama — tool support announcement
- Ollama model library (current tool-capable models)
- Model Context Protocol — introduction
- Model Context Protocol — official SDKs (Python, TypeScript)
- MCP Python SDK (GitHub)
- MCP TypeScript SDK (GitHub)
- Anthropic — Claude models & pricing