Why Local AI
Every AI feature in a web app today routes through OpenAI, Anthropic, or Google. That means API latency (500ms–3s per call), per-token pricing, data privacy exposure, and a hard dependency on internet connectivity.
Local inference changes that calculation. Running a model on the same machine as your dev server cuts response latency to 50–150ms per token batch. No data leaves your network. No API key is needed. And for prototyping — where you iterate on prompts, tool definitions, and response parsing — the feedback loop drops from minutes to seconds.
Ollama makes local models trivial to run. It wraps llama.cpp, exposes an OpenAI-compatible HTTP API, and supports models from 1B to 70B parameters. For a frontend agent that calls tools and returns structured data, a 7B model like llama3.2 or mistral is sufficient — and runs comfortably on a MacBook or standard Linux workstation.
This tutorial builds a local AI agent that:
- Runs entirely on your machine with no cloud dependencies
- Calls tools (functions the model decides to execute)
- Streams responses to the browser in real time
- Handles cold starts, timeouts, and context limits
Prerequisites
Before writing code, you need:
- Ollama installed —
curl -fsSL https://ollama.com/install.sh | sh(Linux/macOS) or download from ollama.com - A model pulled —
ollama pull llama3.2(2.0GB, runs on 8GB RAM) - Node.js 20+ and a Next.js 16 project (
npx create-next-app@latest) @ai-sdk/openaiandaipackages
npm install ai @ai-sdk/openai zod
@ai-sdk/openai is the OpenAI-compatible provider. Since Ollama exposes an OpenAI-compatible endpoint at http://localhost:11434/v1, you can point this provider at your local instance. No actual OpenAI account needed.
Verify Ollama is running:
curl http://localhost:11434/api/tags
# Should return JSON with a "models" array
Setting Up the Route Handler
The Vercel AI SDK uses a Route Handler pattern: the frontend calls a /api/chat endpoint, which streams tokens from the model to the client.
Create app/api/chat/route.ts:
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai'
import { streamText, tool } from 'ai'
import { z } from 'zod'
// Point the provider at Ollama's local endpoint
const localModel = openai('llama3.2', {
baseURL: 'http://localhost:11434/v1',
})
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: localModel,
system: `You are a helpful assistant with access to tools.
Only call a tool when the user asks for information you cannot provide from your training data.
If you call a tool, wait for the result before responding.`,
messages,
tools: {
getWeather: tool({
description: 'Get the current weather for a city',
parameters: z.object({
city: z.string().describe('City name (e.g., London, Tokyo)'),
}),
execute: async ({ city }) => {
// Simulated weather fetch — replace with real API call
const conditions = ['sunny', 'cloudy', 'rainy', 'windy']
const temp = Math.round(10 + Math.random() * 25)
return {
city,
temperature: `${temp}°C`,
condition: conditions[Math.floor(Math.random() * conditions.length)],
}
},
}),
},
})
return result.toDataStreamResponse()
}
Key details:
baseURL: 'http://localhost:11434/v1'redirects the OpenAI SDK to Ollama. No API key needed.streamTextreturns tokens as a Server-Sent Events stream. The client reads them incrementally.- Tools are defined inline with Zod schemas. The model decides when to call them based on the user's request.
- The system prompt constrains tool usage — without it, small models may call tools unnecessarily.
Adding Tool Calling
Tool calling is what transforms a chat completion into an agent. The model generates a structured JSON request for a tool, the runtime executes it, and the result is fed back into the next model turn.
Weather Tool
The getWeather tool above returns a static object. In production, you'd call an actual weather API:
const getWeather = tool({
description: 'Get the current weather for a city using Open-Meteo API (free, no key)',
parameters: z.object({
latitude: z.number(),
longitude: z.number(),
}),
execute: async ({ latitude, longitude }) => {
const res = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true`
)
const data = await res.json()
return data.current_weather
},
})
Database Query Tool
For a more realistic agent, add a tool that queries a local SQLite database:
import Database from 'better-sqlite3'
const queryDatabase = tool({
description: 'Run a read-only SQL query against the local products database',
parameters: z.object({
sql: z.string().describe('SELECT query to execute'),
}),
execute: async ({ sql }) => {
// Read-only guard prevents accidental mutations
if (!/^select\b/i.test(sql.trim())) {
return { error: 'Only SELECT queries are allowed' }
}
const db = new Database('./data/products.db')
const rows = db.prepare(sql).all()
db.close()
return rows
},
})
Now add queryDatabase to the tools object in the route handler. The model will call it when the user asks questions like "Which products are out of stock?" or "Show me orders over $500."
How Tool Calling Works Under the Hood
- The model receives the user message plus tool definitions.
- If the model decides a tool is needed, it returns a
tool_callwith JSON arguments instead of a text response. - The AI SDK intercepts the
tool_call, runsexecute(), and sends the result back to the model as a new message. - The model generates a text response incorporating the tool result.
This loop repeats until the model decides it has enough information to respond. In practice, most agent interactions resolve in 1–2 tool calls.
Tool Choice and Multi-Tool Patterns
By default, the model decides which tool to call and when. You can override this with toolChoice:
// Force the model to always call a specific tool first
const result = streamText({
model: localModel,
messages,
tools: { getWeather, queryDatabase },
toolChoice: 'auto', // default — model decides
// toolChoice: 'required', // forces a tool call every turn
// toolChoice: { type: 'tool', toolName: 'getWeather' }, // force specific tool
})
toolChoice: 'required' is useful for classification pipelines where every user message should first be categorized (via a tool) before generating a response. For a search agent, you might force a database query tool on every turn and only generate text from the results.
Small models struggle with multi-tool decisions. If your agent has five tools, llama3.2 may call the wrong one or call a tool with malformed JSON arguments. Limit the tool set to 2-3 per agent and compose multiple specialized agents if you need more capabilities.
Structured Data Extraction with Tools
Tools aren't only for side effects. You can use them to extract structured data from the model's output:
const extractMeetingInfo = tool({
description: 'Extract date, time, and attendees from a meeting request',
parameters: z.object({
date: z.string().describe('Meeting date in YYYY-MM-DD format'),
time: z.string().describe('Meeting time in HH:MM format'),
attendees: z.array(z.string()).describe('List of attendee email addresses'),
title: z.string().max(100).describe('Meeting title'),
}),
execute: async (data) => data, // Pass through — model generates the structure
})
This pattern converts natural language into typed data without writing a parser. The Zod schema acts as the interface contract. If the model's output doesn't match the schema, the AI SDK returns a validation error and the model retries with corrected arguments.
Building the React Frontend
The frontend uses the useChat hook from the AI SDK. It handles the streaming connection, manages the message array, and exposes append for sending new messages.
// app/page.tsx
'use client'
import { useChat } from 'ai/react'
import { useRef, useEffect } from 'react'
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } =
useChat({
api: '/api/chat',
// Keep stream alive for local models that generate slowly
onError: (err) => console.error('Chat error:', err),
})
const bottomRef = useRef<HTMLDivElement>(null)
// Auto-scroll as tokens arrive
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
return (
<div className="max-w-2xl mx-auto p-4">
<div className="space-y-4 mb-4">
{messages.map((m) => (
<div
key={m.id}
className={`p-3 rounded-lg ${
m.role === 'user'
? 'bg-blue-100 ml-12'
: 'bg-gray-100 mr-12'
}`}
>
<div className="text-xs font-medium mb-1 text-gray-500">
{m.role === 'user' ? 'You' : 'Assistant'}
</div>
{/* Render tool invocations as expandable details */}
{m.parts?.map((part, i) => {
if (part.type === 'tool-invocation') {
return (
<details key={i} className="text-sm border rounded p-2 mb-2">
<summary className="cursor-pointer font-mono text-xs">
🛠 {part.toolInvocation.toolName}
</summary>
<pre className="text-xs mt-1 overflow-auto">
{JSON.stringify(part.toolInvocation.args, null, 2)}
</pre>
{part.toolInvocation.state === 'result' && (
<pre className="text-xs mt-1 bg-green-50 p-1 rounded">
→ {JSON.stringify(part.toolInvocation.result, null, 2)}
</pre>
)}
</details>
)
}
if (part.type === 'text') {
return <div key={i} className="whitespace-pre-wrap">{part.text}</div>
}
return null
})}
</div>
))}
{isLoading && (
<div className="text-sm text-gray-400 animate-pulse">
Thinking...
</div>
)}
{error && (
<div className="text-sm text-red-600 bg-red-50 p-3 rounded border border-red-200">
{error.message}
</div>
)}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask the local AI agent..."
className="flex-1 p-2 border rounded-lg"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
Send
</button>
</form>
</div>
)
}
This renders the full conversation. Each message displays text parts inline and tool invocations as collapsible <details> elements so the user can see the raw tool calls and responses — useful for debugging what the model is doing.
Rendering Streaming Text with Markdown
Raw token output is hard to read. Parse markdown in the stream and render it progressively:
import ReactMarkdown from 'react-markdown'
// Inside the message render loop:
{m.role === 'assistant' && m.parts?.map((part, i) => {
if (part.type === 'text') {
return (
<ReactMarkdown
key={i}
components={{
code: ({ className, children, ...props }) => {
const isInline = !className
if (isInline) {
return <code className="bg-gray-200 px-1 rounded text-sm">{children}</code>
}
return (
<pre className="bg-gray-900 text-gray-100 p-3 rounded-lg overflow-auto text-sm my-2">
<code className={className}>{children}</code>
</pre>
)
},
}}
>
{part.text}
</ReactMarkdown>
)
}
// ...tool invocation rendering from earlier
})}
The stream feeds partial markdown to the renderer. react-markdown handles incomplete tokens gracefully — a heading ## that hasn't received its text yet simply doesn't render until the tokens arrive. This is critical for local models where generation speed is slow enough that users watch text appear word by word.
Streaming Behavior
useChat connects to the Route Handler via fetch and reads the response as a stream. Each token from the model arrives as an SSE event and is appended to the last assistant message. The UI updates in real time without waiting for the full response.
For local models, this is important: llama3.2 generates at roughly 20–40 tokens/second on a modern laptop. Streaming means the user sees output as it's generated rather than staring at a spinner for 10 seconds.
Performance & Error Handling
Local inference has failure modes that cloud APIs don't. Handle them explicitly.
Model Cold Start
Ollama loads models on first request. The first call after ollama pull or after a period of inactivity takes 2–5 seconds while the model loads into memory. Subsequent calls are near-instant.
Mitigation: Warm the model on server startup or during the first request.
// app/api/chat/route.ts — warmup helper
let warmed = false
async function warmModel() {
if (warmed) return
await fetch('http://localhost:11434/api/generate', {
method: 'POST',
body: JSON.stringify({ model: 'llama3.2', prompt: 'hello', stream: false }),
})
warmed = true
}
// Call during app initialization
warmModel()
Streaming Timeout
Local models can stall on complex prompts, especially with tool calling. Set a timeout on the stream:
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30_000) // 30s max
const result = streamText({
model: localModel,
messages,
tools: { ... },
})
// Clean up timeout when stream completes
result.finally(() => clearTimeout(timeout))
return result.toDataStreamResponse({ signal: controller.signal })
Context Window Limits
llama3.2 has an 8K token context window. Tool definitions consume tokens — each tool's description, parameter schema, and invocation result counts against this limit. After ~5 tool calls with verbose responses, the model may begin to "forget" earlier conversation context.
Solutions:
- Keep tool descriptions under 100 characters.
- Use concise parameter names and descriptions.
- Truncate tool results that return large datasets. Return a summary instead of raw rows.
- If you exceed the window, Ollama silently truncates the oldest messages. Test your agent with worst-case conversation length.
Error Recovery and Retry Logic
Local models fail differently than cloud APIs. Instead of rate limits, you get OOM crashes. Instead of timeout errors, you get stalled token generation. Build a retry layer into the Route Handler:
export async function POST(req: Request) {
const { messages } = await req.json()
const maxRetries = 2
let attempt = 0
while (attempt <= maxRetries) {
try {
const result = streamText({
model: localModel,
messages,
tools: { getWeather, queryDatabase },
maxTokens: 2048,
// Stop generation if no tokens produced for 15s
experimental_telemetry: { isEnabled: true },
})
return result.toDataStreamResponse()
} catch (err) {
attempt++
if (attempt > maxRetries) {
return Response.json(
{ error: 'Model unavailable after retries. Reload Ollama and try again.' },
{ status: 503 }
)
}
// Wait before retry — Ollama may be loading the model
await new Promise((r) => setTimeout(r, 2000 * attempt))
}
}
}
On the frontend, the onError callback in useChat should offer a retry button instead of just displaying the error:
{error && (
<div className="text-sm text-red-600 bg-red-50 p-3 rounded border border-red-200">
<p>{error.message}</p>
<button
onClick={() => reload()}
className="mt-2 px-3 py-1 bg-red-600 text-white text-xs rounded"
>
Retry
</button>
</div>
)}
useChat exposes a reload function that re-sends the last message. This lets the user retry without retyping their prompt — essential for local models that may fail on the first cold start.
Memory Pressure
Each loaded model reserves VRAM or RAM:
llama3.2(3B): ~2.5GBmistral(7B): ~4.5GBllama3.1(8B): ~5.5GB
If the agent runs alongside your database, dev server, and browser, a 7B+ model may cause swapping. Monitor with ollama ps to check loaded models, and unload unused ones with ollama stop <model>.
Next Steps
This agent runs on localhost. Shipping it to production requires:
- A proper model serving stack — replace Ollama with a hosted inference endpoint (Together AI, Fireworks, or a GPU-backed server). Change only the
baseURL. - Auth — protect the
/api/chatroute so random visitors can't drain inference budget. - Observability — log tool calls, latency per turn, and context window utilization. The AI SDK's
onStepFinishcallback exposes this data. - Rate limiting — local models handle one concurrent request. Queue requests or reject excess with a 429.
The complete code for this tutorial is available in a single page — one Route Handler, one Client Component, one model config. That's the entire surface area of a local AI agent in 2026.