AI Build Your First LLM Application
Table of Contents + β
In the last lesson, you learned AI Common Generative AI Terms. Now let us build a real local LLM app from zero.
We will use Ollama, so the model runs on your own computer.
Here is the full flow:
π― What We Are Building
We are building a small local AI chat app.
- Local model: Ollama will run the model on your machine.
- Frontend: A simple browser page where the user types a question.
- Backend: A Node.js server that receives the question and talks to Ollama.
- Model response: The backend sends the answer back to the browser.
- Controls: The user can change temperature and max output tokens.
- Usage info: The app shows simple token usage when Ollama returns it.
The final project will look like this:
local-llm-chat/ package.json server.js public/ index.html app.js styles.cssNo paid API key needed
This project uses Ollama locally. That means we do not need an OpenAI key, Anthropic key, or any cloud model key for this lesson.
π How This Relates to ChatGPT, Claude, and Gemini
This project is a small local version of the same app shape.
ChatGPT style UI
A user types a prompt, clicks send, waits for the model, and sees an answer on screen.
Model behind the app
ChatGPT, Claude, and Gemini use hosted models. Our project uses a local model through Ollama.
Same app pattern
Frontend sends input to backend, backend calls the model, then the answer returns to the browser.
π§ Why Use Ollama Here?
Ollama is a simple way to run open models locally.
- It downloads models for you. You do not manually download model files from random places.
- It gives a local API. Your app can call
http://localhost:11434/api. - It works well for learning. You can understand the app flow without worrying about cloud billing.
- It keeps the project simple. The backend calls your local computer instead of a remote provider.
This is perfect for a first LLM app because the main idea becomes clear:
User question |Your app |Local model |Answerπ§° What You Need Before Starting
You need only a few things.
| Tool | Why we need it |
|---|---|
| Ollama | Runs the local LLM and exposes the local API. |
| Node.js | Runs our backend server. |
| Browser | Opens the frontend UI. |
| Code editor | Helps you create and edit the project files. |
Check Node.js like this:
node -vnpm -vYou should see version numbers.
If Node.js is missing
Install Node.js from the official Node.js website, then close and reopen your terminal. After that, run node -v again.
π¦ 1. Install Ollama
First install Ollama on your computer.
- Go to
https://ollama.com/download. - Download Ollama for your operating system.
- Install it like a normal application.
- After installation, open a new terminal.
Now check if Ollama is available:
ollama --versionIf it prints a version, Ollama is installed.
What is Ollama doing?
Ollama is not the model itself.
- Think of Ollama as the local model runner.
- It downloads model files.
- It starts the model when needed.
- It gives your app an HTTP API to talk to the model.
So in our app, Node.js will not talk to a model file directly. Node.js will talk to Ollama.
βΆοΈ 2. Download and Run a Model
Now we need a model.
For this tutorial, use llama3.2.
ollama run llama3.2What happens now:
- If the model is not installed, Ollama downloads it first.
- After download, Ollama starts a chat in your terminal.
- You can type a question and press Enter.
- The model answers locally.
Try this:
Explain AI in 3 simple points.If you get an answer, your local model is working.
To leave the Ollama chat, press:
Ctrl + Dor type:
/byeIf your computer is slow
Try ollama run llama3.2:1b. The 1B model is smaller. It may answer with lower quality than the default model, but it can be easier for weaker machines.
π 3. Check the Local Ollama API
Ollama also runs a local API.
The default API base URL is:
http://localhost:11434/apiLet us test the chat API from the terminal:
curl http://localhost:11434/api/chat -d '{ "model": "llama3.2", "messages": [ { "role": "user", "content": "Say hello in one short sentence." } ], "stream": false}'You should get a JSON response.
It may look something like this:
{ "model": "llama3.2", "message": { "role": "assistant", "content": "Hello, nice to meet you!" }, "done": true}What this means:
model: tells Ollama which model to use.messages: sends the conversation to the model.role: tells the model who said the message.content: contains the actual text.stream: false: asks Ollama to return one full response instead of chunks.
Windows curl note
If this exact curl command does not work in PowerShell because of quotes, do not panic. The app we build below will still call the same API using JavaScript.
π 4. Create the Project Folder
Now create the app folder.
mkdir local-llm-chatcd local-llm-chatmkdir publicCreate these files:
local-llm-chat/ package.json server.js public/ index.html app.js styles.cssThe idea is simple:
server.jsruns the backend.public/index.htmlis the page.public/app.jssends the prompt to the backend.public/styles.cssmakes the page easier to use.package.jsonstores project commands and dependencies.
π¦ 5. Create package.json
Create package.json in the project root.
{ "name": "local-llm-chat", "version": "1.0.0", "type": "module", "scripts": { "dev": "node server.js" }, "dependencies": { "express": "^4.18.3" }}Now install dependencies:
npm installWhat this does:
- It reads
package.json. - It installs Express.
- Express helps us create backend routes like
/api/chat.
π§± 6. Create the Backend
Create server.js in the project root.
import express from "express";
const app = express();const PORT = 3000;const OLLAMA_CHAT_URL = "http://127.0.0.1:11434/api/chat";const MODEL_NAME = "llama3.2";
app.use(express.json());app.use(express.static("public"));
app.post("/api/chat", async (req, res) => { try { const { prompt, temperature, maxOutputTokens } = req.body;
const cleanPrompt = typeof prompt === "string" ? prompt.trim() : "";
if (!cleanPrompt) { return res.status(400).json({ error: "Please enter a question or instruction.", }); }
if (cleanPrompt.length > 4000) { return res.status(400).json({ error: "Prompt is too long. Please keep it under 4000 characters.", }); }
const safeTemperature = clampNumber(temperature, 0, 1, 0.3); const safeMaxOutputTokens = clampNumber(maxOutputTokens, 50, 800, 300); const startedAt = Date.now();
const ollamaResponse = await fetch(OLLAMA_CHAT_URL, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ model: MODEL_NAME, messages: [ { role: "system", content: "You are a helpful AI tutor. Explain clearly, use simple English, and keep answers practical.", }, { role: "user", content: cleanPrompt, }, ], stream: false, options: { temperature: safeTemperature, num_predict: safeMaxOutputTokens, }, }), });
if (!ollamaResponse.ok) { const errorText = await ollamaResponse.text();
return res.status(502).json({ error: "Ollama returned an error. Make sure Ollama is running and the model is installed.", details: errorText, }); }
const data = await ollamaResponse.json(); const answer = data.message?.content ?? "No answer returned."; const latencyMs = Date.now() - startedAt;
res.json({ answer, model: data.model ?? MODEL_NAME, latencyMs, usage: { inputTokens: data.prompt_eval_count ?? null, outputTokens: data.eval_count ?? null, }, }); } catch (error) { console.error(error);
res.status(500).json({ error: "Something went wrong while calling the local model.", }); }});
app.listen(PORT, () => { console.log(`Local LLM chat app running at http://localhost:${PORT}`);});
function clampNumber(value, min, max, fallback) { const number = Number(value);
if (Number.isNaN(number)) { return fallback; }
return Math.min(Math.max(number, min), max);}What this backend does:
- Starts an Express server: The app runs at
http://localhost:3000. - Serves frontend files: Anything inside
publicbecomes visible in the browser. - Creates
/api/chat: The browser sends the prompt to this route. - Validates the prompt: Empty and very large prompts are rejected.
- Calls Ollama: The backend sends the user message to
http://127.0.0.1:11434/api/chat. - Returns JSON: The frontend receives the answer, model name, latency, and token usage.
Why use a backend if Ollama is local?
For a local learning project, the browser could call Ollama directly in some setups. But we still use a backend because this teaches the real application shape. Later, if you use a cloud model, API keys and validation must stay on the backend.
π₯οΈ 7. Create the HTML Page
Create public/index.html.
<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Local LLM Chat</title> <link rel="stylesheet" href="/styles.css" /> </head> <body> <main class="app"> <section class="panel"> <p class="eyebrow">Local AI app</p> <h1>Chat with a local LLM</h1> <p class="intro"> Type a question, send it to your Node backend, and get an answer from Ollama. </p>
<label for="prompt">Your prompt</label> <textarea id="prompt" rows="7" placeholder="Example: Explain tokens in AI with a simple example." ></textarea>
<div class="controls"> <div> <label for="temperature">Temperature</label> <input id="temperature" type="range" min="0" max="1" step="0.1" value="0.3" /> <span id="temperatureValue">0.3</span> </div>
<div> <label for="maxOutputTokens">Max output tokens</label> <input id="maxOutputTokens" type="number" min="50" max="800" value="300" /> </div> </div>
<button id="askButton" type="button">Ask local model</button> <p id="status" class="status">Ready.</p> </section>
<section class="panel answer-panel"> <h2>Answer</h2> <pre id="answer">The model answer will appear here.</pre>
<div class="meta"> <p><strong>Model:</strong> <span id="model">-</span></p> <p><strong>Latency:</strong> <span id="latency">-</span></p> <p><strong>Usage:</strong> <span id="usage">-</span></p> </div> </section> </main>
<script src="/app.js"></script> </body></html>What this page contains:
- Textarea: The user writes a prompt.
- Temperature slider: Controls how predictable or varied the answer should be.
- Max output tokens input: Controls the rough maximum answer length.
- Button: Sends the prompt to the backend.
- Answer area: Shows the model response.
- Metadata area: Shows model name, latency, and token usage.
π¨ 8. Add Simple Styling
Create public/styles.css.
* { box-sizing: border-box;}
body { margin: 0; min-height: 100vh; background: #f6f8fb; color: #172033; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;}
.app { display: grid; grid-template-columns: minmax(0, 420px) minmax(0, 1fr); gap: 1rem; max-width: 1100px; margin: 0 auto; padding: 2rem 1rem;}
.panel { border: 1px solid #d9e2ef; border-radius: 0.75rem; background: #ffffff; padding: 1rem; box-shadow: 0 10px 30px rgba(31, 41, 55, 0.08);}
.eyebrow { margin: 0 0 0.25rem; color: #0a66c2; font-size: 0.8rem; font-weight: 700; text-transform: uppercase;}
h1,h2 { margin: 0 0 0.75rem;}
.intro { margin: 0 0 1rem; color: #526070;}
label { display: block; margin-bottom: 0.35rem; font-weight: 700;}
textarea,input { width: 100%; border: 1px solid #c8d3e0; border-radius: 0.5rem; padding: 0.75rem; font: inherit;}
textarea { resize: vertical;}
.controls { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem;}
#temperatureValue { display: inline-block; margin-top: 0.25rem; color: #526070; font-size: 0.9rem;}
button { width: 100%; margin-top: 1rem; border: 0; border-radius: 0.5rem; background: #0a66c2; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; padding: 0.8rem 1rem;}
button:hover { background: #084f95;}
button:disabled { background: #9fb6ce; cursor: not-allowed;}
.status { min-height: 1.5rem; margin: 0.75rem 0 0; color: #526070;}
.answer-panel { min-height: 500px;}
pre { min-height: 320px; white-space: pre-wrap; word-wrap: break-word; border: 1px solid #d9e2ef; border-radius: 0.5rem; background: #f9fbfd; padding: 1rem; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; line-height: 1.6;}
.meta { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem;}
.meta p { margin: 0; border-radius: 0.5rem; background: #f0f5fb; padding: 0.75rem;}
@media (max-width: 820px) { .app { grid-template-columns: 1fr; }
.controls, .meta { grid-template-columns: 1fr; }}This CSS keeps the app simple.
- The input side and answer side sit next to each other on desktop.
- On smaller screens, they stack one below the other.
- The button has a real hover state.
- The answer area preserves line breaks from the model.
π§© 9. Add Frontend JavaScript
Create public/app.js.
const promptInput = document.querySelector("#prompt");const temperatureInput = document.querySelector("#temperature");const temperatureValue = document.querySelector("#temperatureValue");const maxOutputTokensInput = document.querySelector("#maxOutputTokens");const askButton = document.querySelector("#askButton");const statusText = document.querySelector("#status");const answerBox = document.querySelector("#answer");const modelText = document.querySelector("#model");const latencyText = document.querySelector("#latency");const usageText = document.querySelector("#usage");
temperatureInput.addEventListener("input", () => { temperatureValue.textContent = temperatureInput.value;});
askButton.addEventListener("click", askLocalModel);
async function askLocalModel() { const prompt = promptInput.value.trim(); const temperature = Number(temperatureInput.value); const maxOutputTokens = Number(maxOutputTokensInput.value);
if (!prompt) { statusText.textContent = "Please enter a prompt first."; promptInput.focus(); return; }
setLoading(true);
try { const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ prompt, temperature, maxOutputTokens, }), });
const data = await response.json();
if (!response.ok) { throw new Error(data.error || "Request failed."); }
answerBox.textContent = data.answer; modelText.textContent = data.model; latencyText.textContent = `${data.latencyMs} ms`; usageText.textContent = formatUsage(data.usage); statusText.textContent = "Done."; } catch (error) { answerBox.textContent = ""; statusText.textContent = error.message; modelText.textContent = "-"; latencyText.textContent = "-"; usageText.textContent = "-"; } finally { setLoading(false); }}
function setLoading(isLoading) { askButton.disabled = isLoading; askButton.textContent = isLoading ? "Thinking..." : "Ask local model"; statusText.textContent = isLoading ? "Calling Ollama..." : statusText.textContent;}
function formatUsage(usage) { if (!usage) { return "Not available"; }
const input = usage.inputTokens === null ? "unknown input" : `${usage.inputTokens} input`; const output = usage.outputTokens === null ? "unknown output" : `${usage.outputTokens} output`;
return `${input}, ${output} tokens`;}What this frontend code does:
- Reads the prompt: It gets the text from the textarea.
- Reads the settings: It gets temperature and max output tokens.
- Calls the backend: It sends a POST request to
/api/chat. - Shows loading state: The button changes to
Thinking.... - Shows the answer: The response appears inside the answer box.
- Handles errors: If something fails, the user sees a clear message.
π 10. Run the Project
Before running the app, make sure Ollama is available.
Run this in one terminal:
ollama run llama3.2Ask one test question. If it answers, Ollama is ready.
Now open a second terminal inside your project folder:
npm run devYou should see:
Local LLM chat app running at http://localhost:3000Open this in your browser:
http://localhost:3000Try this prompt:
Explain what an API is using a simple website example.If everything is correct:
- The status changes to
Calling Ollama.... - The button changes to
Thinking.... - The model answer appears on the right side.
- Model, latency, and usage fields update.
π 11. Understand the Request Flow
When you click the button, this is what happens.
Let us break it down:
- The browser does not run the model. It only collects user input and shows output.
- The backend controls the model call. This is where validation and settings live.
- Ollama runs separately. It is listening on port
11434. - The app server runs on port
3000. That is the website and backend we created. - The model returns text. The frontend only displays that text.
This shape is the base of many AI apps.
π‘οΈ 12. Understand Temperature
Temperature controls how much variation the model can use.
- Low temperature, like
0.1: The answer is usually more stable and direct. - Medium temperature, like
0.3: Good for normal explanations. - High temperature, like
0.8: The answer may become more creative, but also less predictable.
Try the same prompt twice:
Give me 5 project ideas for learning JavaScript.Now test:
- First with temperature
0.1. - Then with temperature
0.8.
What you may notice:
- Low temperature may repeat similar ideas.
- Higher temperature may give more varied ideas.
- Higher temperature does not mean more correct.
- For learning apps, support bots, and document Q&A, lower values are often better.
π 13. Understand Max Output Tokens
Max output tokens controls how much the model is allowed to write.
In Ollama, we send this as:
options: { num_predict: safeMaxOutputTokens}What this means:
- Small number: The answer is shorter.
- Large number: The answer can be longer.
- Too small: The answer may stop before it finishes.
- Too large: The answer may take more time.
Try this prompt:
Explain frontend, backend, and database in simple points.Now test:
Max output tokens = 80Max output tokens = 400
You should see the answer length change.
π§Ύ 14. Understand Usage and Latency
Our backend returns this:
res.json({ answer, model: data.model ?? MODEL_NAME, latencyMs, usage: { inputTokens: data.prompt_eval_count ?? null, outputTokens: data.eval_count ?? null, },});These fields help you understand the request.
- Model: Which model answered.
- Latency: How many milliseconds the request took.
- Input tokens: Roughly how much text the model received.
- Output tokens: Roughly how much text the model generated.
In a local app, token usage does not directly create a cloud bill.
But it still matters because:
- More input takes more processing.
- More output takes more time.
- Larger prompts can make the app slower.
- Token counts help you debug long or expensive requests later.
π§― 15. Common Errors and Fixes
Here are common problems.
| Problem | What it usually means | How to fix it |
|---|---|---|
ollama command not found | Ollama is not installed, or the terminal did not refresh after installation. | Install Ollama, then close and reopen the terminal. |
| Backend says Ollama returned an error | Ollama may not be running, or the model may not be downloaded. | Run ollama run llama3.2 once and test it in the terminal. |
Browser cannot open localhost:3000 | The Node server is not running. | Run npm run dev inside the project folder. |
| Answer is very slow | Your machine may be using a larger model than it can run comfortably. | Try llama3.2:1b and lower max output tokens. |
| Answer stops too early | Max output tokens may be too low. | Increase the max output tokens value. |
How to know Ollama is running
Open http://localhost:11434 in your browser. If Ollama is running, you should see a short Ollama message instead of a browser connection error.
π 16. Why This Architecture Matters
This project uses local Ollama, so there is no API key.
But the architecture is still important.
- Frontend is for user interaction. It should collect input and show output.
- Backend is for control. It should validate input, choose model settings, and handle errors.
- Model runner is separate. Today it is Ollama. Later it could be a cloud model provider.
- Provider code is isolated. If the model call changes, most of the frontend stays the same.
If later you change from Ollama to a cloud provider:
- The frontend can stay almost the same.
- The
/api/chatroute can stay almost the same. - Only the model calling part changes.
- API keys can be added safely on the backend.
π§ͺ 17. Test the App Properly
Do not test only one happy path.
Try these checks:
- Empty prompt: Click the button without typing. It should show an error.
- Normal prompt: Ask a simple question. It should answer.
- Long prompt: Paste a large paragraph. It should still work unless it crosses your limit.
- Temperature change: Ask the same question at low and high temperature.
- Max token change: Compare short and longer outputs.
- Stop Ollama: Close Ollama and send a prompt. The app should show a helpful error.
This is real application thinking.
You are not only asking, βDoes the code run?β
You are also asking:
- What happens when the user makes a mistake?
- What happens when the model runner is not available?
- What happens when the prompt is too large?
- What does the user see while waiting?
πͺ΅ 18. Add Better Logs
Our current backend logs only unexpected errors.
For learning, you can add a simple request log inside /api/chat.
Place this after const startedAt = Date.now();:
console.log({ model: MODEL_NAME, promptLength: cleanPrompt.length, temperature: safeTemperature, maxOutputTokens: safeMaxOutputTokens,});This helps you see what your app is sending.
Good logs usually include:
- Model name: So you know which model answered.
- Prompt length: So you can spot very large prompts.
- Settings: Temperature and max output tokens affect the result.
- Latency: So you can notice slow requests.
- Errors: So failed requests are not silent.
Do not log private user data in a real app unless your privacy rules allow it.
π Optional: Add Streaming Later
Right now, our app waits for the full answer.
Streaming means:
- The model sends small chunks.
- The backend forwards those chunks.
- The browser shows text as it arrives.
- The answer feels faster because the user sees progress.
For the first project, normal response mode is better.
- Easier to debug.
- Easier to understand.
- Easier to handle errors.
- Enough to learn the full app shape.
After this version works, streaming is a good next improvement.
π§± Optional: Change the Model Name
The model name is currently set in server.js:
const MODEL_NAME = "llama3.2";If you want to use the smaller model, change it:
const MODEL_NAME = "llama3.2:1b";Then make sure the model is installed:
ollama run llama3.2:1bThe model name in your code must match a model available in Ollama.
You can list downloaded models:
ollama listβ Best Practices
- Start with one working request. First make prompt-in and answer-out work. If you add RAG, agents, memory, streaming, and file upload at the same time, you will not know which part caused the bug.
- Keep model code on the backend. Even with local Ollama, this teaches the right production habit. The frontend should not own model settings, secrets, validation, and provider details.
- Validate user input before calling the model. Empty prompts, very large prompts, and invalid settings waste time. In cloud apps, they can also waste money.
- Use safe defaults. A temperature like
0.3and a max output around300are fine for a learning app. The user can change them, but the app should not behave randomly by default.
β οΈ Common Mistakes
- Trying to build everything at once. First finish the basic local chat app. Then improve it.
- Forgetting to run Ollama. Your Node app can run perfectly, but it cannot answer if Ollama is not available.
- Using a model your machine cannot handle. If answers are too slow, use a smaller model like
llama3.2:1b. - Confusing Node server port and Ollama port. Your app opens at
localhost:3000. Ollama listens atlocalhost:11434.
π§© What Youβve Learned
- β You installed Ollama and ran a local model.
- β You created a complete Node.js backend and browser frontend.
- β You sent a prompt from the browser to the backend.
- β You called Ollama from the backend.
- β You displayed the model answer, latency, model name, and usage.
- β You learned why frontend, backend, and model runner are separate parts.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
What is Ollama doing in this project?
Why: Ollama runs the local model and gives our backend an API to call.
- 2
Which URL does the browser open for our app?
Why: Our Express app runs on port 3000, so the browser opens http://localhost:3000.
- 3
Why does the frontend call `/api/chat` instead of calling model logic directly?
Why: The backend is the control layer. It validates input, calls Ollama, and returns a clean response.
- 4
What should you try if the local model is too slow?
Why: A smaller model usually needs fewer computer resources and may run faster on weaker machines.
π Whatβs Next?
You now have the base shape of an LLM application: browser, backend, model runner, and response.
From here, this same project can grow into a document chatbot, support assistant, coding helper, RAG app, or agent-style workflow.