AI Build Your First LLM Application

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:

Browser UI

Node backend

Ollama local API

Local model

🎯 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.css

No 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:

Terminal window
node -v
npm -v

You 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:

Terminal window
ollama --version

If 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.

Terminal window
ollama run llama3.2

What 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 + D

or type:

/bye

If 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/api

Let us test the chat API from the terminal:

Terminal window
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.

Terminal window
mkdir local-llm-chat
cd local-llm-chat
mkdir public

Create these files:

local-llm-chat/
package.json
server.js
public/
index.html
app.js
styles.css

The idea is simple:

  • server.js runs the backend.
  • public/index.html is the page.
  • public/app.js sends the prompt to the backend.
  • public/styles.css makes the page easier to use.
  • package.json stores 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:

Terminal window
npm install

What 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 public becomes 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:

Terminal window
ollama run llama3.2

Ask one test question. If it answers, Ollama is ready.

Now open a second terminal inside your project folder:

Terminal window
npm run dev

You should see:

Local LLM chat app running at http://localhost:3000

Open this in your browser:

http://localhost:3000

Try 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.

Click button

Browser sends fetch request

Express route receives prompt

Backend validates input

Backend calls Ollama

Ollama runs local model

Backend returns answer

Browser shows answer

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 = 80
  • Max 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/chat route 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:

Terminal window
ollama run llama3.2:1b

The model name in your code must match a model available in Ollama.

You can list downloaded models:

Terminal window
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.3 and a max output around 300 are 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 at localhost: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

4 questions Show quiz Hide quiz

Test what you learned. Pick an answer for each question, then click Check.

  1. 1

    What is Ollama doing in this project?

    Why: Ollama runs the local model and gives our backend an API to call.

  2. 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. 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. 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.