Build a Private LFM3 Daily Agent
Build a local AI agent that organizes notes, priorities, and daily plans.
Introduction
30 Second Summary
A to-do list is useful only when it remembers what you asked it to save. Private reminders can also feel less private when every request leaves your home.
In this project, you will build a private task assistant powered by LFM3-1.7B on your Linux laptop. A protected browser chat lets your Android phone manage persistent tasks over trusted Wi-Fi.
What You'll Build
Your phone adds a task that remains visible from the laptop after the application restarts.
By the end of this project, you'll have:
- A private LFM3 assistant that answers prompts on your laptop without sending them to a cloud service.
- A responsive browser chat you can open from either device on the same trusted network. Your phone reaches an authenticated gateway while the model API stays isolated on loopback.
- A persistent task list that LFM3 can add to, list, or complete through validated tool calls. Saved tasks remain in a local JSON file after page refreshes or application restarts.
- Secret Mission: Test authorized, unauthorized, and expired browser sessions to prove where the LAN security boundary holds.
Are there any prerequisites?
You need a Linux laptop with at least 8 GB of RAM. You also need an Android phone connected to the same trusted Wi-Fi network.
The project includes every software setup step. Internet access is only required for the initial downloads.
Before We Start
This first checkpoint defines the privacy boundary before any hands-on work begins. Your Linux laptop keeps the complete LFM3 task assistant local while your Android phone connects through authenticated access on trusted Wi-Fi.
Set Up LFM3 Locally
Your private task assistant needs a local model server before its browser or task tools can return a real result. This step creates that foundation with LFM3 running entirely on your laptop.
You'll finish with llama.cpp serving LFM3 through a loopback address. A direct API request will prove that the model can answer without a cloud API key.
In this step, get ready to:
- Confirm the required laptop tools and Android browser are available.
- Install llama.cpp with support for LFM3.
- Prove that the loopback-only LFM3 endpoint returns an answer.
Check your laptop and phone tools
Python runs the application gateway later in the project. The `curl` command tests local API endpoints.
GNU nano gives you a terminal-based editor for the project files. Google Chrome provides the chat interface on your Android phone.
- Open a terminal from your Linux application menu.
- Check the three laptop tools by running these commands:
python3 --version
curl --version
nano --version
What do these checks prove?
- The first command prints your installed Python version. This project requires Python 3.10 or higher.
- The remaining commands confirm that `curl` can send API requests. They also confirm that GNU nano can edit the project files.
Choose the tab that matches the output from your terminal.
✔️ Python 3.10 or higher and both tools respond
Your laptop has every base tool this project needs. That's the first checkpoint cleared.
ⓧ I see a Python version below 3.10
Your Python version is below the project's minimum. Use the tab for your Linux distribution to install its current Python package.
Ubuntu or Debian
- Install Python 3 with `curl` and GNU nano by running:
sudo apt install -y python3-full curl nano
python3 --version
curl --version
nano --version
What does this command do?
APT installs Ubuntu or Debian's complete default Python 3 environment. The final three lines print the installed versions for comparison.
Fedora
- Install Python 3 with `curl` and GNU nano by running:
- Approve the package installation when Fedora asks for confirmation.
sudo dnf install python3 curl nano
python3 --version
curl --version
nano --version
What does this command do?
DNF installs all three packages from Fedora's repositories. The version checks confirm that their commands are now available.
Arch Linux
- Update Arch Linux and install Python with `curl` and GNU nano by running:
- Approve the package installation when Arch Linux asks for confirmation.
sudo pacman -Syu python curl nano
python3 --version
curl --version
nano --version
What does this command do?
Pacman performs a full system upgrade before installing the three packages. This avoids the partial upgrade state that Arch Linux warns against.
Still seeing an older Python version?
Check that your Linux release still receives updates. An older release may provide a Python version below 3.10.
Use your distribution's supported upgrade path before continuing. Help me check why my Linux distribution still provides an older Python version.
ⓧ A command is missing
At least one prerequisite is missing from your laptop. Use the tab for your Linux distribution to install the complete set.
Ubuntu or Debian
- Install Python 3 with `curl` and GNU nano by running:
sudo apt install -y python3-full curl nano
python3 --version
curl --version
nano --version
What does this command do?
APT installs the missing packages from your configured repositories. The final commands prove that each tool is available.
Fedora
- Install Python 3 with `curl` and GNU nano by running:
- Approve the package installation when Fedora asks for confirmation.
sudo dnf install python3 curl nano
python3 --version
curl --version
nano --version
What does this command do?
DNF retrieves the missing packages from Fedora's repositories. The version checks prove that every command is ready.
Arch Linux
- Update Arch Linux and install Python with `curl` and GNU nano by running:
- Approve the package installation when Arch Linux asks for confirmation.
sudo pacman -Syu python curl nano
python3 --version
curl --version
nano --version
What does this command do?
Pacman updates the complete system before installing the missing packages. The final lines confirm that each command responds.
Package installation failing?
Check that the laptop has internet access. Also confirm that you selected the command for your Linux distribution.
If your package manager reports a repository problem, resolve that repository issue before retrying. Help me troubleshoot my Linux package installation.
- Open Google Play Store on your Android phone.
- Search for `Chrome`.
- Tap **Install** if Google Chrome is missing.
- Tap **Open** after the installation finishes.
- Tap **Open** directly if Google Chrome is already installed.
You'll see Google Chrome open to a browser screen. Your phone is ready for the authenticated chat interface later in the project.
Install llama.cpp
LFM3 needs an inference runtime that can load its quantized GGUF weights on your CPU. llama.cpp provides that runtime through the `llama serve` command.
- Check the installed llama.cpp version and server help by running:
llama --version
llama serve --help
What do these checks show?
- The version command reveals whether the installed build meets the LFM3 minimum of v0.8.0.
- The help command proves that the unified `llama` binary exposes the `serve` subcommand.
Choose the result that matches your terminal.
✔️ I see version v0.8.0 or higher
Good. Your llama.cpp build can run the selected LFM3 model through its server.
ⓧ I see an older version
Your current llama.cpp build predates the required LFM3 support. The canonical installer updates it to the current release.
- Update llama.cpp by running the official installer command:
curl -LsSf https://llama.app/install.sh | sh
What does this command do?
The command downloads the official llama.cpp installation script through HTTPS. The script installs the unified `llama` command locally.
- Confirm the updated version and server subcommand by running:
llama --version
llama serve --help
What should you confirm?
The version must be v0.8.0 or higher. The help output must describe the server options.
Still seeing the old version?
Start a fresh terminal window so your shell reloads its command path. Run both checks again in that window.
If the old build still appears, your shell may be finding another llama.cpp installation first. Help me find which llama command my shell is using.
ⓧ Command not found
llama.cpp is not installed yet. The official installer adds the unified `llama` command to your local environment.
- Install llama.cpp by running the official installer command:
curl -LsSf https://llama.app/install.sh | sh
What does this command do?
The command retrieves the official installer through HTTPS. The installer selects a compatible local build for your Linux laptop.
- Confirm the installed version and server subcommand by running:
llama --version
llama serve --help
What should you confirm?
The version must be v0.8.0 or higher. The help output must list options for the HTTP API server.
llama still not found?
Start a fresh terminal window after the installer completes. This lets your shell load the updated command path.
If the command remains unavailable, review the final lines printed by the installer for its local path. Help me make the installed llama command available.
Run and test LFM3
The model server turns LFM3 into an OpenAI-compatible local API. Binding it to `127.0.0.1` keeps the endpoint on your laptop's loopback interface.
Allow time for the first download
The selected `LiquidAI/LFM3-1.7B-GGUF:Q4_K_M` model file is approximately 1.1 GB. The software and model are free.
The first launch may take several minutes while the model downloads. A quiet terminal during part of that wait does not mean the process has stalled.
- Start the loopback-only LFM3 server by running this command:
llama serve -hf LiquidAI/LFM3-1.7B-GGUF:Q4_K_M --host 127.0.0.1 --port 9931 --ctx-size 4096 --alias lfm3-local --jinja
What does this server command do?
- The `-hf` value selects Liquid AI's Q4_K_M GGUF model. llama.cpp downloads it once before reusing the local cache.
- The `--host 127.0.0.1` and `--port 9931` options keep the model API on the laptop's loopback interface.
- The `--ctx-size 4096` option limits the working context for a CPU-friendly starting point.
- The `--alias lfm3-local` option gives the loaded model a stable API name. The `--jinja` option enables the model's chat template path.
- Keep this terminal running because it now hosts the model server.
- Wait until the terminal reports that the server is ready to accept requests.
- Press the **Super** key to open your Linux application search.
- Type `Terminal` and press **Enter** to open a second terminal window.
Before you send the request, do you expect a cloud service or your laptop to generate the answer?
- Send a chat-completions request to the local endpoint by running:
curl http://127.0.0.1:9931/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"lfm3-local","messages":[{"role":"user","content":"Reply with the words local LFM3 ready."}],"max_tokens":32}'
What does this request prove?
- The request targets the OpenAI-compatible `/v1/chat/completions` route on your laptop.
- The JSON body selects the `lfm3-local` alias. It also gives LFM3 a short prompt.
- A successful JSON response proves that llama.cpp loaded the model. It also proves that local inference is working.
You'll see a JSON response containing a `choices` entry with an LFM3-generated answer. The request never leaves your laptop because the endpoint uses `127.0.0.1`.
No JSON response?
- Check the first terminal for a completed model download. Keep that server process running while you retry the request.
- Confirm that the server command uses port `9931`. Confirm that the `curl` URL uses the same port.
- Check that another process is not already using port `9931`.
Ask for help: My loopback LFM3 request is not returning a JSON answer.
✔️ Awesome, my local endpoint responds
That's the runtime ready. Keep the LFM3 server terminal open for the next step.
ⓧ I'd like to double check the full commands
Compare your server command with this exact loopback-only configuration.
llama serve -hf LiquidAI/LFM3-1.7B-GGUF:Q4_K_M --host 127.0.0.1 --port 9931 --ctx-size 4096 --alias lfm3-local --jinja
Run this request from a separate terminal while the server remains active.
curl http://127.0.0.1:9931/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"lfm3-local","messages":[{"role":"user","content":"Reply with the words local LFM3 ready."}],"max_tokens":32}'
Your laptop now runs LFM3 through a loopback-only API. Next, you'll build the browser chat that turns this local endpoint into a visible assistant.
Build the Laptop Chat Interface
Your LFM3 server can answer requests through its loopback endpoint. A browser still needs a safe path for sending prompts to that endpoint.
In this step, you will build a laptop-only chat with the Python standard library. The gateway serves the page on 127.0.0.1:8000 while the model remains isolated on 127.0.0.1:9931.
In this step, get ready to:
- Create the Python gateway that forwards browser prompts to LFM3.
- Build a responsive chat with loading states and error messages.
- Send a laptop prompt and see LFM3 answer in the browser.
Create the Python gateway
The gateway connects two local services. Your browser sends a prompt to /api/chat on the gateway. The gateway forwards it to the loopback-only model endpoint.
Why use the Python standard library?
The standard library already includes a threaded HTTP server and an HTTP client. This keeps the first chat free from package installation work.
The gateway also gives the browser one narrow route. Later steps can add task tools and mobile access without exposing the model server directly.
- Open a second terminal window from your Linux application menu. Keep the LFM3 server running in its existing terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the private-lfm3-tasks project directory with its static subdirectory by running these commands:
mkdir -p private-lfm3-tasks/static
cd private-lfm3-tasks
- Confirm the new directory contains the static subdirectory by running:
ls
You should see static in the output.
- Create app.py inside private-lfm3-tasks by running:
nano app.py
- Paste this first chunk into app.py to define the gateway's imports and local addresses:
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.request import Request, urlopen
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
MODEL_URL = "http://127.0.0.1:9931/v1/chat/completions"
MODEL_NAME = "lfm3-local"
APP_HOST = "127.0.0.1"
APP_PORT = 8000
What does this code do?
- The imports provide JSON handling, file paths, HTTP serving, and outbound HTTP requests.
- The MODEL_URL value points to the LFM3 endpoint already running on loopback.
- The APP_HOST value keeps this first gateway available only from the laptop.
- Paste this next chunk directly below APP_PORT = 8000 to send prompts to LFM3:
def call_lfm3(prompt):
payload = {
"model": MODEL_NAME,
"messages": [
{"role": "user", "content": prompt}
],
"temperature": 0.1,
"top_k": 50,
"repeat_penalty": 1.1,
"max_tokens": 512,
"chat_template_kwargs": {"enable_thinking": False}
}
request = Request(
MODEL_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST"
)
with urlopen(request, timeout=180) as response:
result = json.loads(response.read().decode("utf-8"))
message = result["choices"][0]["message"]
return message.get("content") or "LFM3 returned an empty response."
How does the model request work?
- The payload contains the local model alias and the learner's prompt.
- The sampling values keep the response focused for a small local assistant.
- The Request sends the payload to the loopback model endpoint.
- The final two lines extract the assistant message from the returned response.
- Press Ctrl+O to save app.py.
- Press Enter to confirm the filename.
- Press Ctrl+X to return to the terminal.
- Verify the saved model endpoint by running:
python3 -c "import app; print(app.MODEL_URL)"
You should see http://127.0.0.1:9931/v1/chat/completions in the terminal.
Seeing a Python error?
- Check that the four import lines begin at the far left of app.py.
- Check that every quoted value has an opening quote and a closing quote.
Ask for help: Help me debug the imports, constants, and call_lfm3 function in my app.py file.
- Return to app.py in GNU nano by running:
nano app.py
- Move the cursor to the end of app.py.
- Paste this chunk below call_lfm3() to serve the browser files:
class AppHandler(BaseHTTPRequestHandler):
static_files = {
"/": ("index.html", "text/html; charset=utf-8"),
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
"/style.css": ("style.css", "text/css; charset=utf-8")
}
def send_bytes(self, status, content_type, data):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
self.end_headers()
self.wfile.write(data)
def send_json(self, status, payload):
self.send_bytes(status, "application/json; charset=utf-8", json.dumps(payload, ensure_ascii=False).encode("utf-8"))
def do_GET(self):
static_file = self.static_files.get(self.path)
if not static_file:
self.send_json(404, {"error": "Not found"})
return
filename, content_type = static_file
self.send_bytes(200, content_type, (STATIC_DIR / filename).read_bytes())
How are browser files served?
- The static_files map connects each browser path to a file inside static.
- The send_bytes() method sends file contents with response headers.
- The do_GET() method returns a known static file or a 404 response.
- Paste this chunk at the end of the AppHandler class to accept chat requests:
def do_POST(self):
if self.path != "/api/chat":
self.send_json(404, {"error": "Not found"})
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self.send_json(400, {"error": "Invalid Content-Length"})
return
if content_length < 1 or content_length > 8192:
self.send_json(413, {"error": "Request body must be between 1 and 8192 bytes"})
return
try:
payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
prompt = str(payload.get("prompt", "")).strip()
except (UnicodeDecodeError, json.JSONDecodeError):
self.send_json(400, {"error": "Request body must be valid UTF-8 JSON"})
return
if not prompt or len(prompt) > 1000:
self.send_json(400, {"error": "Prompt must contain 1 to 1000 characters"})
return
try:
answer = call_lfm3(prompt)
except Exception as error:
self.send_json(502, {"error": f"The local model request failed: {error}"})
return
self.send_json(200, {"answer": answer})
How is a chat request handled?
- The handler accepts posts only at /api/chat.
- The request body and prompt length checks reject malformed or oversized input.
- The validated prompt goes to call_lfm3().
- The browser receives either an answer field or an error field.
- Press Ctrl+O to save app.py.
- Press Enter to confirm the filename.
- Press Ctrl+X to return to the terminal.
- Verify the three static routes by running:
python3 -c "import app; print(sorted(app.AppHandler.static_files))"
You should see '/', '/app.js', and '/style.css' in the output.
- Return to app.py in GNU nano by running:
nano app.py
- Move the cursor to the end of app.py.
- Paste this final chunk to start the threaded laptop server:
if __name__ == "__main__":
server = ThreadingHTTPServer((APP_HOST, APP_PORT), AppHandler)
print(f"Laptop URL: http://127.0.0.1:{APP_PORT}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopping the assistant.")
finally:
server.server_close()
How does the gateway start?
- The ThreadingHTTPServer can process overlapping browser requests in separate threads.
- The server binds to the laptop-only address stored in APP_HOST.
- The cleanup block closes the server after you stop it with Ctrl+C.
- Press Ctrl+O to save app.py.
- Press Enter to confirm the filename.
- Press Ctrl+X to return to the terminal.
- Verify that the chat route exists by running:
python3 -c "import app; print(hasattr(app.AppHandler, 'do_POST'))"
You should see True.
Is the route check failing?
- Check that do_GET() and do_POST() use the same four-space indentation inside AppHandler.
- Check that the final if __name__ block begins at the far left.
Ask for help: Help me fix the AppHandler indentation in app.py.
Build the responsive chat
The browser needs structure, styling, and interaction logic. These responsibilities live in index.html, style.css, and app.js.
- Create static/index.html from the private-lfm3-tasks terminal by running:
nano static/index.html
- Paste this page structure into static/index.html.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Private LFM3 Tasks</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main class="app-shell">
<header>
<p class="eyebrow">Running on your laptop</p>
<h1>Private LFM3 Tasks</h1>
<p>Ask LFM3 to add, list, or complete a task.</p>
</header>
<section id="messages" class="messages" aria-live="polite">
<article class="message assistant">Try: Add test the mobile assistant to my tasks.</article>
</section>
<form id="chat-form" class="composer">
<label class="sr-only" for="prompt">Message</label>
<textarea id="prompt" rows="3" maxlength="1000" placeholder="Type a task request" required></textarea>
<button id="send-button" type="submit">Send</button>
</form>
</main>
<script src="/app.js"></script>
</body>
</html>
What does the page contain?
- The viewport setting lets the layout fit narrow screens.
- The messages section holds assistant, user, and error messages.
- The chat-form form collects a prompt with a maximum length of 1000 characters.
- The final two references load the stylesheet and browser script.
- Press Ctrl+O to save static/index.html.
- Press Enter to confirm the filename.
- Press Ctrl+X to return to the terminal.
- Confirm the page file exists by running:
ls static
You should see index.html in the output.
- Create static/style.css by running:
nano static/style.css
- Paste this responsive design into static/style.css.
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #08111f;
color: #e8eef8;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: linear-gradient(145deg, #08111f, #102542); }
button, input, textarea { font: inherit; }
.app-shell { width: min(760px, 100%); min-height: 100vh; margin: 0 auto; padding: 24px; display: grid; grid-template-rows: auto auto 1fr auto; gap: 18px; }
header h1 { margin: 4px 0 8px; font-size: clamp(2rem, 8vw, 3.5rem); }
header p { margin: 0; color: #b9c8dc; }
.eyebrow { color: #6ee7b7; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.access-panel, .composer { display: grid; gap: 8px; padding: 14px; border: 1px solid #294461; border-radius: 16px; background: rgba(8, 17, 31, 0.78); }
.access-panel input, .composer textarea { width: 100%; border: 1px solid #3b5877; border-radius: 12px; padding: 12px; background: #091522; color: #ffffff; }
.messages { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding: 4px; }
.message { max-width: 88%; padding: 12px 14px; border-radius: 16px; line-height: 1.5; white-space: pre-wrap; }
.message.user { align-self: flex-end; background: #2563eb; }
.message.assistant { align-self: flex-start; background: #18304c; }
.message.error { align-self: flex-start; background: #7f1d1d; }
.composer button { border: 0; border-radius: 12px; padding: 12px 18px; background: #34d399; color: #052014; font-weight: 800; cursor: pointer; }
.composer button:disabled { cursor: wait; opacity: 0.55; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 560px) {
.app-shell { padding: 16px; }
.message { max-width: 96%; }
}
How does the layout adapt?
- The app-shell centers the interface while allowing it to fill a narrow screen.
- The message classes place user and assistant messages on opposite sides.
- The disabled button style gives the learner a loading signal.
- The media query reduces padding and widens messages on small screens.
- Press Ctrl+O to save static/style.css.
- Press Enter to confirm the filename.
- Press Ctrl+X to return to the terminal.
- Confirm both browser files exist by running:
ls static
You should see index.html and style.css.
- Create static/app.js by running:
nano static/app.js
- Paste this first chunk into static/app.js to connect the page elements and render messages:
const form = document.querySelector("#chat-form");
const promptInput = document.querySelector("#prompt");
const messages = document.querySelector("#messages");
const sendButton = document.querySelector("#send-button");
function addMessage(role, text) {
const message = document.createElement("article");
message.className = `message ${role}`;
message.textContent = text;
messages.appendChild(message);
message.scrollIntoView({ behavior: "smooth", block: "end" });
return message;
}
How are messages rendered?
- The four constants store references to the form, prompt field, message list, and send button.
- The addMessage() function creates one message element with a role-specific class.
- The newest message scrolls into view after the browser adds it.
- Paste this second chunk below addMessage() to submit prompts and handle the response:
form.addEventListener("submit", async (event) => {
event.preventDefault();
const prompt = promptInput.value.trim();
addMessage("user", prompt);
promptInput.value = "";
sendButton.disabled = true;
const waiting = addMessage("assistant", "Thinking on your laptop...");
try {
const response = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ prompt })
});
const payload = await response.json();
waiting.remove();
if (!response.ok) {
addMessage("error", payload.error || `Request failed with status ${response.status}.`);
return;
}
addMessage("assistant", payload.answer);
} catch (error) {
waiting.remove();
addMessage("error", `Could not reach the laptop: ${error.message}`);
} finally {
sendButton.disabled = false;
promptInput.focus();
}
});
What happens after you press Send?
- The script adds the prompt to the chat and disables the send button.
- The waiting message shows that the local model request is running.
- The script posts the prompt to /api/chat and renders the returned answer.
- Network and server failures appear as red error messages inside the chat.
- Press Ctrl+O to save static/app.js.
- Press Enter to confirm the filename.
- Press Ctrl+X to return to the terminal.
- Confirm all three browser files exist by running:
ls static
You should see app.js, index.html, and style.css.
Missing a browser file?
- Check that the terminal is inside ~/Desktop/private-lfm3-tasks.
- Check that every browser filename appears inside the static subdirectory.
- Check that app.js ends with });.
Ask for help: Help me check my static file paths and browser script.
✔️ Awesome, I've got everything!
Your Python gateway and all three browser files are saved. Keep the LFM3 server running before you test the chat.
ⓧ I'd like to double check the full code
Compare these four files with the versions inside your private-lfm3-tasks directory.
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.request import Request, urlopen
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
MODEL_URL = "http://127.0.0.1:9931/v1/chat/completions"
MODEL_NAME = "lfm3-local"
APP_HOST = "127.0.0.1"
APP_PORT = 8000
def call_lfm3(prompt):
payload = {
"model": MODEL_NAME,
"messages": [
{"role": "user", "content": prompt}
],
"temperature": 0.1,
"top_k": 50,
"repeat_penalty": 1.1,
"max_tokens": 512,
"chat_template_kwargs": {"enable_thinking": False}
}
request = Request(
MODEL_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST"
)
with urlopen(request, timeout=180) as response:
result = json.loads(response.read().decode("utf-8"))
message = result["choices"][0]["message"]
return message.get("content") or "LFM3 returned an empty response."
class AppHandler(BaseHTTPRequestHandler):
static_files = {
"/": ("index.html", "text/html; charset=utf-8"),
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
"/style.css": ("style.css", "text/css; charset=utf-8")
}
def send_bytes(self, status, content_type, data):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
self.end_headers()
self.wfile.write(data)
def send_json(self, status, payload):
self.send_bytes(status, "application/json; charset=utf-8", json.dumps(payload, ensure_ascii=False).encode("utf-8"))
def do_GET(self):
static_file = self.static_files.get(self.path)
if not static_file:
self.send_json(404, {"error": "Not found"})
return
filename, content_type = static_file
self.send_bytes(200, content_type, (STATIC_DIR / filename).read_bytes())
def do_POST(self):
if self.path != "/api/chat":
self.send_json(404, {"error": "Not found"})
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self.send_json(400, {"error": "Invalid Content-Length"})
return
if content_length < 1 or content_length > 8192:
self.send_json(413, {"error": "Request body must be between 1 and 8192 bytes"})
return
try:
payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
prompt = str(payload.get("prompt", "")).strip()
except (UnicodeDecodeError, json.JSONDecodeError):
self.send_json(400, {"error": "Request body must be valid UTF-8 JSON"})
return
if not prompt or len(prompt) > 1000:
self.send_json(400, {"error": "Prompt must contain 1 to 1000 characters"})
return
try:
answer = call_lfm3(prompt)
except Exception as error:
self.send_json(502, {"error": f"The local model request failed: {error}"})
return
self.send_json(200, {"answer": answer})
if __name__ == "__main__":
server = ThreadingHTTPServer((APP_HOST, APP_PORT), AppHandler)
print(f"Laptop URL: http://127.0.0.1:{APP_PORT}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopping the assistant.")
finally:
server.server_close()
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Private LFM3 Tasks</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main class="app-shell">
<header>
<p class="eyebrow">Running on your laptop</p>
<h1>Private LFM3 Tasks</h1>
<p>Ask LFM3 to add, list, or complete a task.</p>
</header>
<section id="messages" class="messages" aria-live="polite">
<article class="message assistant">Try: Add test the mobile assistant to my tasks.</article>
</section>
<form id="chat-form" class="composer">
<label class="sr-only" for="prompt">Message</label>
<textarea id="prompt" rows="3" maxlength="1000" placeholder="Type a task request" required></textarea>
<button id="send-button" type="submit">Send</button>
</form>
</main>
<script src="/app.js"></script>
</body>
</html>
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #08111f;
color: #e8eef8;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: linear-gradient(145deg, #08111f, #102542); }
button, input, textarea { font: inherit; }
.app-shell { width: min(760px, 100%); min-height: 100vh; margin: 0 auto; padding: 24px; display: grid; grid-template-rows: auto auto 1fr auto; gap: 18px; }
header h1 { margin: 4px 0 8px; font-size: clamp(2rem, 8vw, 3.5rem); }
header p { margin: 0; color: #b9c8dc; }
.eyebrow { color: #6ee7b7; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.access-panel, .composer { display: grid; gap: 8px; padding: 14px; border: 1px solid #294461; border-radius: 16px; background: rgba(8, 17, 31, 0.78); }
.access-panel input, .composer textarea { width: 100%; border: 1px solid #3b5877; border-radius: 12px; padding: 12px; background: #091522; color: #ffffff; }
.messages { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding: 4px; }
.message { max-width: 88%; padding: 12px 14px; border-radius: 16px; line-height: 1.5; white-space: pre-wrap; }
.message.user { align-self: flex-end; background: #2563eb; }
.message.assistant { align-self: flex-start; background: #18304c; }
.message.error { align-self: flex-start; background: #7f1d1d; }
.composer button { border: 0; border-radius: 12px; padding: 12px 18px; background: #34d399; color: #052014; font-weight: 800; cursor: pointer; }
.composer button:disabled { cursor: wait; opacity: 0.55; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 560px) {
.app-shell { padding: 16px; }
.message { max-width: 96%; }
}
const form = document.querySelector("#chat-form");
const promptInput = document.querySelector("#prompt");
const messages = document.querySelector("#messages");
const sendButton = document.querySelector("#send-button");
function addMessage(role, text) {
const message = document.createElement("article");
message.className = `message ${role}`;
message.textContent = text;
messages.appendChild(message);
message.scrollIntoView({ behavior: "smooth", block: "end" });
return message;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const prompt = promptInput.value.trim();
addMessage("user", prompt);
promptInput.value = "";
sendButton.disabled = true;
const waiting = addMessage("assistant", "Thinking on your laptop...");
try {
const response = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ prompt })
});
const payload = await response.json();
waiting.remove();
if (!response.ok) {
addMessage("error", payload.error || `Request failed with status ${response.status}.`);
return;
}
addMessage("assistant", payload.answer);
} catch (error) {
waiting.remove();
addMessage("error", `Could not reach the laptop: ${error.message}`);
} finally {
sendButton.disabled = false;
promptInput.focus();
}
});
Run the laptop chat
The four files now form one request path. The browser calls the Python gateway. The gateway calls LFM3 through the existing loopback endpoint.
- Return to the terminal inside ~/Desktop/private-lfm3-tasks.
- Start the laptop chat server by running:
python3 app.py
You should see Laptop URL: http://127.0.0.1:8000 in the terminal. Keep this process running while you use the chat.
What is running now?
Two local servers are active. LFM3 listens on 127.0.0.1:9931 while the browser gateway listens on 127.0.0.1:8000.
Both addresses use loopback. Only the laptop can reach this version of the chat.
- Enter http://127.0.0.1:8000 in your laptop browser's address bar.
- Press Enter to load the page.
You should see the dark Private LFM3 Tasks page with a message area, prompt field, and Send button.
Before you send the test prompt, do you expect the answer to come from the browser code or from the LFM3 server behind the Python gateway?
- Enter Reply with the word local in the prompt field.
- Click Send.
You should briefly see Thinking on your laptop... in the chat. The assistant response should then contain the word local.
That is your first browser-to-model loop working. The page can now exchange prompts with a model that stays on your laptop.
Can't get a browser response?
- Check that the LFM3 server still runs in its original terminal on 127.0.0.1:9931.
- Check that the Python terminal shows requests after you load the page.
- Check that index.html, style.css, and app.js are inside static.
Ask for help: Help me trace why my local browser chat cannot reach LFM3.
Your responsive laptop chat now reaches LFM3 through a local Python gateway. Next, you will test whether its task claims survive an application restart.
Reveal the Memory Limitation
The responsive chat now carries prompts from your laptop to LFM3. You can see local answers in the browser.
A useful task assistant must preserve tasks after the Python application restarts. This step tests whether the current chat has real persistent storage.
In this step, get ready to:
- Test whether the chat can save a named task.
- Restart the Python application to clear its runtime state.
- Confirm that no persistent task record exists.
Test a task claim
A task request should create evidence outside the model's reply. The first test gives the current chat a specific task to save.
- Return to the browser chat at http://127.0.0.1:8000.
- Enter Save test the mobile assistant as a task. in the prompt field.
- Click Send.
You'll see a conversational reply that treats test the mobile assistant like a saved task. No application record appears outside the conversation.
- Enter What task did you save? in the prompt field.
- Click Send.
You'll receive another conversational answer. The application still provides no stored record that you can inspect.
What Did This Test Show?
LFM3 can generate a reply that sounds like a saved-task confirmation. Generated text alone does not prove that the application changed its state.
A real task action needs an application tool plus a storage layer. The current chat has neither capability.
Restart the Python application
A restart removes any temporary state held by the Python process. The next request tests whether the task exists independently of that process.
- Switch back to the terminal running app.py.
- Press Ctrl+C to stop the application.
You'll see the terminal return to its command prompt. Keep the separate llama.cpp model server running.
- Restart the laptop chat application by running:
python3 app.py
What Does This Command Do?
This launches the existing laptop chat gateway as a new Python process. The terminal stays occupied while the application listens on http://127.0.0.1:8000.
Before you send the next request, do you expect the restarted application to retrieve a real task? Make your prediction now.
- Return to the existing browser chat.
- Enter List the tasks I have saved. in the prompt field.
- Click Send.
You'll see that the chat cannot retrieve a real stored task. Its wording can vary because LFM3 generates the response.
That is the intended shortfall. The earlier save claim existed only as conversational text.
Can't Reach the Restarted Chat?
- Confirm that the terminal running app.py remains occupied by the server process.
- Confirm that the separate llama.cpp terminal still shows the model server running.
- Reload http://127.0.0.1:8000 after the Python application starts.
Ask for help: Help me check why my local chat stopped responding after I restarted app.py.
Inspect the project files
Persistent tasks need a file or another storage system that survives application restarts. The project directory provides the final piece of evidence.
- Switch back to the terminal running app.py.
- Press Ctrl+C to stop the application.
Before you inspect the directory, do you expect to find a task file created by the chat request? Make your prediction now.
- List the project directory contents by running:
ls
What Does This Listing Prove?
You should see app.py in the listing. You should also see the static directory.
You should not see a data directory. This means data/tasks.json does not exist.
- Restore the running laptop chat application by running:
python3 app.py
Your Test Environment Is Restored
The Python gateway is running again on http://127.0.0.1:8000. The llama.cpp endpoint remains isolated on http://127.0.0.1:9931/v1/chat/completions.
Seeing Unexpected Project Files?
- Check that the listing includes the expected app.py file.
- Check that the listing includes the expected static directory.
- Remove any experimental data/tasks.json file only if you created it outside this project walkthrough.
Ask for help: Help me verify whether I am inspecting the correct task assistant project directory.
You have proved the memory gap with both a restart test and a file check. Next, you'll give LFM3 validated task tools backed by local storage.
Add Persistent Task Tools
Your restart test exposed the chat's missing memory. LFM3 could describe a saved task, but the application had no record to retrieve.
This step adds tool calling to LFM3. Validated Python functions will store each task in a local JSON file.
In this step, get ready to:
- Describe three task actions that LFM3 can select.
- Validate each action before changing the local task file.
- Prove that completed tasks survive an application restart.
Describe the allowed task tools
A tool schema tells the model which actions exist. Python remains responsible for deciding whether the supplied arguments are safe to use.
- Stop the laptop chat process in its terminal.
- Open app.py with GNU nano.
- Replace the import section and task-storage constants with this code:
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.request import Request, urlopen
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
DATA_DIR = BASE_DIR / "data"
TASKS_FILE = DATA_DIR / "tasks.json"
MODEL_URL = "http://127.0.0.1:9931/v1/chat/completions"
MODEL_NAME = "lfm3-local"
TASK_LOCK = threading.Lock()
What Does This Set Up?
- The Path values locate the existing static directory. They also define the new data/tasks.json storage path.
- The TASK_LOCK protects each read-modify-write operation from overlapping requests.
- The model URL remains on 127.0.0.1:9931. The inference endpoint stays isolated from other Wi-Fi clients.
- Add the first tool schema below the constants:
TOOLS = [
{
"type": "function",
"function": {
"name": "add_task",
"description": "Add one task to the user's persistent local task list.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "A concise task title."},
"details": {"type": "string", "description": "Optional extra details."}
},
"required": ["title"]
}
}
},
]
How Does the Add Schema Help?
The add_task schema gives LFM3 a task title plus optional details. Requiring title keeps an empty tool request from looking complete.
- Save app.py.
- Confirm that the edited server still starts by running:
python3 app.py
You should see the same local startup output as before. This confirms that the first schema is valid Python.
- Stop the laptop chat process in its terminal.
- Return to app.py in GNU nano.
- Insert the list schema before the closing bracket of TOOLS:
{
"type": "function",
"function": {
"name": "list_tasks",
"description": "List tasks from the user's persistent local task list.",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["all", "open", "done"],
"description": "Which tasks to return."
}
}
}
}
},
How Does the List Schema Help?
The list_tasks schema limits its status filter to all, open, or done. That fixed set gives Python a predictable value to validate.
- Insert the completion schema below the list_tasks schema:
{
"type": "function",
"function": {
"name": "complete_task",
"description": "Mark one task as complete using its numeric task ID.",
"parameters": {
"type": "object",
"properties": {
"task_id": {"type": "integer", "description": "The numeric ID of the task to complete."}
},
"required": ["task_id"]
}
}
}
How Does the Completion Schema Help?
The complete_task schema requests one numeric task_id. Python will use that ID to update one matching record.
- Save app.py.
- Confirm that all three schemas parse by running:
python3 app.py
You should see the local chat server start normally. The application now has three valid tool descriptions.
Does the Server Stop at Startup?
- Check that each schema sits inside the outer TOOLS list.
- Check the comma after the add_task object.
- Check the comma after the list_tasks object.
Ask for help: Help me find the bracket or comma problem in my Python TOOLS list.
Persist and validate task changes
The schemas only describe possible actions. The application still needs allowlisted functions that create the task file and validate every requested change.
- Stop the laptop chat process in its terminal.
- Return to app.py in GNU nano.
- Add the storage functions below TOOLS:
def load_tasks():
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not TASKS_FILE.exists():
TASKS_FILE.write_text("[]\n", encoding="utf-8")
return json.loads(TASKS_FILE.read_text(encoding="utf-8"))
def save_tasks(tasks):
TASKS_FILE.write_text(json.dumps(tasks, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
What Do the Storage Functions Do?
- The load_tasks() function creates the data directory when needed. It initializes tasks.json with an empty array.
- The save_tasks() function writes the complete task array as readable UTF-8 JSON.
- Find the if __name__ == "__main__": block near the bottom of app.py.
- Add this line immediately below that block's opening line:
load_tasks()
Why Initialize Storage at Startup?
Calling load_tasks() during startup guarantees that data/tasks.json exists before the first browser request. An empty file starts with a valid JSON array.
- Save app.py.
- Create the initial task file by running:
python3 app.py
You should see the local server start. The application has now created data/tasks.json with an empty JSON array.
- Stop the laptop chat process in its terminal.
- Open data/tasks.json with GNU nano.
You should see []. This is the first real storage result that the conversational version could not produce.
- Return to app.py in GNU nano.
- Add the initial run_tool() implementation below save_tasks():
def run_tool(name, arguments):
if not isinstance(arguments, dict):
return {"ok": False, "message": "Tool arguments must be an object."}
with TASK_LOCK:
tasks = load_tasks()
if name == "add_task":
title = str(arguments.get("title", "")).strip()
details = str(arguments.get("details", "")).strip()
if not title or len(title) > 120:
return {"ok": False, "message": "Task title must contain 1 to 120 characters."}
if len(details) > 500:
return {"ok": False, "message": "Task details must be at most 500 characters."}
task_id = max((int(task["id"]) for task in tasks), default=0) + 1
task = {"id": task_id, "title": title, "details": details, "status": "open"}
tasks.append(task)
save_tasks(tasks)
return {"ok": True, "message": f"Added task #{task_id}: {title}"}
if name == "list_tasks":
status = str(arguments.get("status", "all")).lower()
if status not in {"all", "open", "done"}:
return {"ok": False, "message": "Status must be all, open, or done."}
selected = tasks if status == "all" else [task for task in tasks if task["status"] == status]
return {"ok": True, "tasks": selected, "status": status}
return {"ok": False, "message": f"Unknown tool: {name}"}
How Are Add and List Requests Controlled?
- The initial type check rejects tool arguments that are not a JSON object.
- The TASK_LOCK keeps the file operation inside one protected section.
- The add branch limits task titles to 120 characters. It limits details to 500 characters.
- The list branch accepts only the three statuses declared in the schema.
- Find the final unknown-tool return inside run_tool().
- Insert the completion branch immediately above that return:
if name == "complete_task":
try:
task_id = int(arguments.get("task_id"))
except (TypeError, ValueError):
return {"ok": False, "message": "Task ID must be an integer."}
for task in tasks:
if int(task["id"]) == task_id:
task["status"] = "done"
save_tasks(tasks)
return {"ok": True, "message": f"Completed task #{task_id}: {task['title']}"}
return {"ok": False, "message": f"Task #{task_id} was not found."}
How Is Completion Validated?
The completion branch converts the supplied ID to an integer before searching. It updates only a matching task from the local array.
An invalid ID returns a controlled result. No generated command or file path is executed.
- Add the result formatter below run_tool():
def format_tool_result(name, result):
if not result.get("ok"):
return result.get("message", "The tool could not complete the request.")
if name != "list_tasks":
return result["message"]
tasks = result.get("tasks", [])
if not tasks:
return f"No {result.get('status', 'matching')} tasks found."
lines = []
for task in tasks:
marker = "done" if task["status"] == "done" else "open"
details = f" | {task['details']}" if task.get("details") else ""
lines.append(f"#{task['id']} [{marker}] {task['title']}{details}")
return "\n".join(lines)
Why Format Results in Python?
The formatter turns validated tool results into the text shown in chat. Listing tasks directly from the stored array prevents the model from inventing records.
- Save app.py.
- Confirm that the validation functions parse by running:
python3 app.py
You should see the local chat server start without a Python syntax failure. The storage and dispatch layer is ready for LFM3's tool selections.
Does Startup Fail After Adding the Functions?
- Check that run_tool() begins at the left edge of the file.
- Check that each action branch stays inside the with TASK_LOCK: block.
- Check that format_tool_result() begins after run_tool() has ended.
Ask for help: Help me fix the indentation in my persistent task functions.
- Stop the laptop chat process in its terminal.
- Return to app.py in GNU nano.
- Find the payload assignment inside call_lfm3().
- Replace that assignment with this model request:
payload = {
"model": MODEL_NAME,
"messages": [
{
"role": "system",
"content": (
"You are a private local task assistant. Use add_task, list_tasks, or complete_task "
"whenever the user asks to change or inspect tasks. Never claim a task action succeeded "
"without calling the matching tool. For unrelated questions, answer briefly without a tool."
)
},
{"role": "user", "content": prompt}
],
"tools": TOOLS,
"parallel_tool_calls": False,
"temperature": 0.1,
"top_k": 50,
"repeat_penalty": 1.1,
"max_tokens": 512,
"chat_template_kwargs": {"enable_thinking": False}
}
What Changes in the Model Request?
- The system instruction tells LFM3 to select a task tool for task changes or inspections.
- The tools field sends the three schemas with every prompt.
- The request allows one tool-selection path. Python formats the validated result without a second model turn.
- Find the existing message = result["choices"][0]["message"] line inside call_lfm3().
- Replace that line and the function's old return logic with this code:
message = result["choices"][0]["message"]
tool_calls = message.get("tool_calls") or []
if not tool_calls:
return message.get("content") or "LFM3 returned an empty response."
outputs = []
for tool_call in tool_calls:
function = tool_call.get("function") or tool_call
name = function.get("name", "")
raw_arguments = function.get("arguments", {})
try:
arguments = json.loads(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments
except json.JSONDecodeError:
outputs.append("LFM3 returned invalid JSON tool arguments, so no action was taken.")
continue
outputs.append(format_tool_result(name, run_tool(name, arguments)))
return "\n".join(outputs)
How Are Tool Calls Executed?
- A normal answer returns its text without running a task action.
- A tool response supplies a function name plus JSON arguments.
- Invalid argument JSON produces a safe message without changing the task file.
- A valid request passes through run_tool() before its result reaches the browser.
- Save app.py.
- Start the tool-enabled chat by running:
python3 app.py
You should see the local server start on http://127.0.0.1:8000. The model endpoint remains on loopback at port 9931.
Does a Task Request Fail?
- Confirm that the separate LFM3 server is still running on port 9931.
- Confirm that the request payload includes "tools": TOOLS.
- Confirm that the tool-response block calls both run_tool() and format_tool_result().
Ask for help: Help me trace why my LFM3 task request is not reaching run_tool().
Run the persistent task sequence
The assistant now has a real storage path behind its task language. The final test checks each action against the file that Python controls.
Before sending the first request, do you expect the response to claim success or return a numbered record backed by data/tasks.json?
- Return to the laptop browser page at http://127.0.0.1:8000.
- Enter Add test the mobile assistant to my tasks. in the prompt field.
- Click Send.
You should see a response confirming a numbered task. The response comes from the validated add_task result.
- Record the numeric ID from the response as your task ID.
- Enter List my open tasks. in the prompt field.
- Click Send.
You should see the stored task with an open marker. Its numeric ID should match your task ID.
- Enter a prompt asking the assistant to complete task your task ID.
- Click Send.
You should see a completion confirmation for the same task. Python has changed its stored status to done.
- Open data/tasks.json with GNU nano.
You should see a task object with id, title, details, and status fields. The status for your test task should be done.
- Stop the laptop chat process in its terminal.
Before restarting the application, do you expect the completed task to remain available?
- Restart the laptop chat by running:
python3 app.py
- Return to the laptop browser page.
- Enter List all tasks. in the prompt field.
- Click Send.
You should see the same numbered task with a done marker. Restarting app.py no longer erases the record.
That closes the memory gap: your assistant now performs real task actions that survive application restarts.
Your local assistant can now add, list, and complete persistent tasks. Next, you'll connect the phone through a narrow gateway while keeping LFM3 on loopback.
Securely Connect Your Android Phone
Your assistant now performs validated task actions. Its records survive application restarts in data/tasks.json.
Phone access creates a network boundary problem. A LAN gateway can accept phone requests while the LFM3 endpoint stays on loopback and remains hidden from Wi-Fi clients.
In this step, get ready to:
- Protect every chat request with a temporary startup access key.
- Expose only the Python gateway to your trusted private Wi-Fi network.
- Add tasks from your Android phone and retrieve them from your laptop.
Protect the LAN gateway
An access key gives the gateway a shared secret for the current application session. The server rejects chat requests that do not supply an exact match.
- Switch back to app.py in your editor.
- Find def do_POST(self): inside AppHandler.
- Add this access-key check after the existing if self.path != "/api/chat": guard:
if self.headers.get("X-Access-Key", "") != ACCESS_KEY:
self.send_json(401, {"error": "Invalid access key"})
return
How does the key check work?
- The handler reads the X-Access-Key request header before it processes the prompt.
- A mismatched value produces an unauthorized response. The request never reaches LFM3 or the task tools.
- Find the if __name__ == "__main__": block at the bottom of app.py.
- Replace that block with this startup sequence:
if __name__ == "__main__":
load_tasks()
server = ThreadingHTTPServer((APP_HOST, APP_PORT), AppHandler)
print(f"Access key: {ACCESS_KEY}")
addresses = private_ipv4_addresses()
if addresses:
for address in addresses:
print(f"Phone URL: http://{address}:{APP_PORT}")
else:
print(f"Phone URL: http://<your-laptop-private-ip>:{APP_PORT}")
print(f"Laptop URL: http://127.0.0.1:{APP_PORT}")
print("Use only on a trusted private Wi-Fi network. Press Ctrl+C to stop.")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopping the assistant.")
finally:
server.server_close()
What does the startup sequence do?
- The server uses the configured host and port to start the gateway.
- The terminal prints the current access key. A new key is generated each time Python starts the application.
- The detected private IPv4 addresses become phone URLs. The loopback address remains available for laptop testing.
- Save app.py.
- Return to the terminal running the Python application from earlier.
- Press Ctrl+C to stop the current process.
Before you restart the application, which startup values do you expect the terminal to reveal?
- Restart the application by running:
python3 app.py
What should the terminal show?
You should see lines beginning with Access key:, Phone URL:, and Laptop URL:. These values identify the protected gateway session.
- Press Ctrl+C to stop the application before changing its network binding.
The Python gateway can now enforce the key. Its listening address still needs to accept connections from other devices on your private network.
- Find the APP_HOST constant near the top of app.py.
- Set it to the following line:
APP_HOST = "0.0.0.0"
Why bind the gateway to every interface?
The value 0.0.0.0 makes the Python gateway listen on the laptop's available network interfaces. MODEL_URL remains bound to http://127.0.0.1:9931/v1/chat/completions, so Wi-Fi clients cannot contact the model server directly.
- Find the send_bytes method inside AppHandler.
- Replace the method with this response-header implementation:
def send_bytes(self, status, content_type, data):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
self.end_headers()
self.wfile.write(data)
What do these headers protect?
- The cache policy prevents the browser from storing gateway responses.
- The content-type policy tells the browser to respect the declared response type.
- The content security policy limits scripts, styles, and network requests to the same gateway origin.
- Save app.py.
Before you start the final gateway, do you expect the LFM3 endpoint address to change with the Python listening address?
- Start the LAN-facing gateway by running:
python3 app.py
What remains private?
The Python gateway now listens on port 8000 across the laptop's network interfaces. The LFM3 endpoint remains on 127.0.0.1:9931 because its server command has not changed.
You should also see a newly generated key. Keep this terminal running because the browser needs the current value.
Gateway not starting?
- Check that an older app.py process is not still using port 8000.
- Confirm that each new line sits inside the correct method with matching Python indentation.
- Confirm that the LFM3 server is still running in its separate terminal.
Ask for help: Help me debug why app.py does not start after I added access-key checks and LAN startup output.
Add the browser access-key flow
The browser needs a password field for the current startup key. It also needs to attach that key to each chat request without placing it in the prompt body.
- Switch back to static/index.html in your editor.
- Find the closing </header> tag.
- Add this access panel immediately below that tag:
<section class="access-panel" aria-label="Access key">
<label for="access-key">Startup access key</label>
<input id="access-key" type="password" autocomplete="off" placeholder="Paste the key printed by app.py">
</section>
What does the access panel add?
The password input masks the current startup key on screen. Its access-key identifier gives JavaScript a precise way to read the value.
- Save static/index.html.
- Refresh http://127.0.0.1:8000 in the laptop browser.
You should see a masked Startup access key field between the page header and the chat messages.
- Switch to static/app.js.
- Replace the constants at the top of the file with this block:
const form = document.querySelector("#chat-form");
const promptInput = document.querySelector("#prompt");
const keyInput = document.querySelector("#access-key");
const messages = document.querySelector("#messages");
const sendButton = document.querySelector("#send-button");
keyInput.value = sessionStorage.getItem("lfm3-access-key") || "";
How is the key restored?
The browser reads lfm3-access-key from sessionStorage when the page loads. The value belongs to the current browser session.
- Find the start of the existing form.addEventListener handler.
- Replace its opening lines through addMessage("user", prompt); with this block:
form.addEventListener("submit", async (event) => {
event.preventDefault();
const prompt = promptInput.value.trim();
const accessKey = keyInput.value.trim();
if (!accessKey) {
addMessage("error", "Enter the startup access key printed by app.py.");
keyInput.focus();
return;
}
sessionStorage.setItem("lfm3-access-key", accessKey);
addMessage("user", prompt);
What happens before submission?
- The handler stops an empty key before it sends a network request.
- A supplied key is stored for the current tab session. The prompt then enters the chat as before.
- Save static/app.js.
- Refresh the laptop chat.
- Leave the Startup access key field empty.
- Enter List all tasks. in the prompt field.
- Click Send.
You should see Enter the startup access key printed by app.py. in the chat. The browser blocks the request before contacting the gateway.
- Find the headers property inside the existing fetch request.
- Replace that property with this line:
headers: {"Content-Type": "application/json", "X-Access-Key": accessKey},
How does the browser authenticate?
Each request keeps its JSON content type. It also sends the current access key in the exact header checked by app.py.
The access key grants control of your task assistant. The password field masks it while you verify the browser flow.
- Save static/app.js.
- Refresh the laptop chat.
- Copy the current access key from the app.py terminal.
- Paste the key into the Startup access key field.
- Enter List open tasks. in the prompt field.
- Click Send.
You should see your persistent open tasks or a message reporting that no open tasks were found. The authenticated browser flow now reaches the validated task tools.
Request rejected with your key?
- Copy the key from the latest app.py startup output. Restarting the application creates a different value.
- Check that the request header uses X-Access-Key with matching capitalization.
- Confirm that the field uses id="access-key" so keyInput can read it.
Ask for help: Help me find why my browser request is rejected even after I enter the current startup key.
✔️ Awesome, I've got everything!
Great. Save every file before testing the assistant from your Android phone.
ⓧ I'd like to double check the full code
Compare these complete files with your project. They represent the authenticated LAN gateway and browser interface.
import json
import secrets
import socket
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.request import Request, urlopen
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
DATA_DIR = BASE_DIR / "data"
TASKS_FILE = DATA_DIR / "tasks.json"
MODEL_URL = "http://127.0.0.1:9931/v1/chat/completions"
MODEL_NAME = "lfm3-local"
APP_HOST = "0.0.0.0"
APP_PORT = 8000
ACCESS_KEY = secrets.token_urlsafe(32)
TASK_LOCK = threading.Lock()
TOOLS = [
{
"type": "function",
"function": {
"name": "add_task",
"description": "Add one task to the user's persistent local task list.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "A concise task title."},
"details": {"type": "string", "description": "Optional extra details."}
},
"required": ["title"]
}
}
},
{
"type": "function",
"function": {
"name": "list_tasks",
"description": "List tasks from the user's persistent local task list.",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["all", "open", "done"],
"description": "Which tasks to return."
}
}
}
}
},
{
"type": "function",
"function": {
"name": "complete_task",
"description": "Mark one task as complete using its numeric task ID.",
"parameters": {
"type": "object",
"properties": {
"task_id": {"type": "integer", "description": "The numeric ID of the task to complete."}
},
"required": ["task_id"]
}
}
}
]
def load_tasks():
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not TASKS_FILE.exists():
TASKS_FILE.write_text("[]\n", encoding="utf-8")
return json.loads(TASKS_FILE.read_text(encoding="utf-8"))
def save_tasks(tasks):
TASKS_FILE.write_text(json.dumps(tasks, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def run_tool(name, arguments):
if not isinstance(arguments, dict):
return {"ok": False, "message": "Tool arguments must be an object."}
with TASK_LOCK:
tasks = load_tasks()
if name == "add_task":
title = str(arguments.get("title", "")).strip()
details = str(arguments.get("details", "")).strip()
if not title or len(title) > 120:
return {"ok": False, "message": "Task title must contain 1 to 120 characters."}
if len(details) > 500:
return {"ok": False, "message": "Task details must be at most 500 characters."}
task_id = max((int(task["id"]) for task in tasks), default=0) + 1
task = {"id": task_id, "title": title, "details": details, "status": "open"}
tasks.append(task)
save_tasks(tasks)
return {"ok": True, "message": f"Added task #{task_id}: {title}"}
if name == "list_tasks":
status = str(arguments.get("status", "all")).lower()
if status not in {"all", "open", "done"}:
return {"ok": False, "message": "Status must be all, open, or done."}
selected = tasks if status == "all" else [task for task in tasks if task["status"] == status]
return {"ok": True, "tasks": selected, "status": status}
if name == "complete_task":
try:
task_id = int(arguments.get("task_id"))
except (TypeError, ValueError):
return {"ok": False, "message": "Task ID must be an integer."}
for task in tasks:
if int(task["id"]) == task_id:
task["status"] = "done"
save_tasks(tasks)
return {"ok": True, "message": f"Completed task #{task_id}: {task['title']}"}
return {"ok": False, "message": f"Task #{task_id} was not found."}
return {"ok": False, "message": f"Unknown tool: {name}"}
def format_tool_result(name, result):
if not result.get("ok"):
return result.get("message", "The tool could not complete the request.")
if name != "list_tasks":
return result["message"]
tasks = result.get("tasks", [])
if not tasks:
return f"No {result.get('status', 'matching')} tasks found."
lines = []
for task in tasks:
marker = "done" if task["status"] == "done" else "open"
details = f" | {task['details']}" if task.get("details") else ""
lines.append(f"#{task['id']} [{marker}] {task['title']}{details}")
return "\n".join(lines)
def call_lfm3(prompt):
payload = {
"model": MODEL_NAME,
"messages": [
{
"role": "system",
"content": (
"You are a private local task assistant. Use add_task, list_tasks, or complete_task "
"whenever the user asks to change or inspect tasks. Never claim a task action succeeded "
"without calling the matching tool. For unrelated questions, answer briefly without a tool."
)
},
{"role": "user", "content": prompt}
],
"tools": TOOLS,
"parallel_tool_calls": False,
"temperature": 0.1,
"top_k": 50,
"repeat_penalty": 1.1,
"max_tokens": 512,
"chat_template_kwargs": {"enable_thinking": False}
}
request = Request(
MODEL_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST"
)
with urlopen(request, timeout=180) as response:
result = json.loads(response.read().decode("utf-8"))
message = result["choices"][0]["message"]
tool_calls = message.get("tool_calls") or []
if not tool_calls:
return message.get("content") or "LFM3 returned an empty response."
outputs = []
for tool_call in tool_calls:
function = tool_call.get("function") or tool_call
name = function.get("name", "")
raw_arguments = function.get("arguments", {})
try:
arguments = json.loads(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments
except json.JSONDecodeError:
outputs.append("LFM3 returned invalid JSON tool arguments, so no action was taken.")
continue
outputs.append(format_tool_result(name, run_tool(name, arguments)))
return "\n".join(outputs)
def private_ipv4_addresses():
addresses = set()
try:
for item in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
address = item[4][0]
if not address.startswith("127."):
addresses.add(address)
except socket.gaierror:
pass
return sorted(addresses)
class AppHandler(BaseHTTPRequestHandler):
static_files = {
"/": ("index.html", "text/html; charset=utf-8"),
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
"/style.css": ("style.css", "text/css; charset=utf-8")
}
def send_bytes(self, status, content_type, data):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
self.end_headers()
self.wfile.write(data)
def send_json(self, status, payload):
self.send_bytes(status, "application/json; charset=utf-8", json.dumps(payload, ensure_ascii=False).encode("utf-8"))
def do_GET(self):
static_file = self.static_files.get(self.path)
if not static_file:
self.send_json(404, {"error": "Not found"})
return
filename, content_type = static_file
self.send_bytes(200, content_type, (STATIC_DIR / filename).read_bytes())
def do_POST(self):
if self.path != "/api/chat":
self.send_json(404, {"error": "Not found"})
return
if self.headers.get("X-Access-Key", "") != ACCESS_KEY:
self.send_json(401, {"error": "Invalid access key"})
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self.send_json(400, {"error": "Invalid Content-Length"})
return
if content_length < 1 or content_length > 8192:
self.send_json(413, {"error": "Request body must be between 1 and 8192 bytes"})
return
try:
payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
prompt = str(payload.get("prompt", "")).strip()
except (UnicodeDecodeError, json.JSONDecodeError):
self.send_json(400, {"error": "Request body must be valid UTF-8 JSON"})
return
if not prompt or len(prompt) > 1000:
self.send_json(400, {"error": "Prompt must contain 1 to 1000 characters"})
return
try:
answer = call_lfm3(prompt)
except Exception as error:
self.send_json(502, {"error": f"The local model request failed: {error}"})
return
self.send_json(200, {"answer": answer})
if __name__ == "__main__":
load_tasks()
server = ThreadingHTTPServer((APP_HOST, APP_PORT), AppHandler)
print(f"Access key: {ACCESS_KEY}")
addresses = private_ipv4_addresses()
if addresses:
for address in addresses:
print(f"Phone URL: http://{address}:{APP_PORT}")
else:
print(f"Phone URL: http://<your-laptop-private-ip>:{APP_PORT}")
print(f"Laptop URL: http://127.0.0.1:{APP_PORT}")
print("Use only on a trusted private Wi-Fi network. Press Ctrl+C to stop.")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopping the assistant.")
finally:
server.server_close()
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Private LFM3 Tasks</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main class="app-shell">
<header>
<p class="eyebrow">Running on your laptop</p>
<h1>Private LFM3 Tasks</h1>
<p>Ask LFM3 to add, list, or complete a task.</p>
</header>
<section class="access-panel" aria-label="Access key">
<label for="access-key">Startup access key</label>
<input id="access-key" type="password" autocomplete="off" placeholder="Paste the key printed by app.py">
</section>
<section id="messages" class="messages" aria-live="polite">
<article class="message assistant">Try: Add test the mobile assistant to my tasks.</article>
</section>
<form id="chat-form" class="composer">
<label class="sr-only" for="prompt">Message</label>
<textarea id="prompt" rows="3" maxlength="1000" placeholder="Type a task request" required></textarea>
<button id="send-button" type="submit">Send</button>
</form>
</main>
<script src="/app.js"></script>
</body>
</html>
const form = document.querySelector("#chat-form");
const promptInput = document.querySelector("#prompt");
const keyInput = document.querySelector("#access-key");
const messages = document.querySelector("#messages");
const sendButton = document.querySelector("#send-button");
keyInput.value = sessionStorage.getItem("lfm3-access-key") || "";
function addMessage(role, text) {
const message = document.createElement("article");
message.className = `message ${role}`;
message.textContent = text;
messages.appendChild(message);
message.scrollIntoView({ behavior: "smooth", block: "end" });
return message;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const prompt = promptInput.value.trim();
const accessKey = keyInput.value.trim();
if (!accessKey) {
addMessage("error", "Enter the startup access key printed by app.py.");
keyInput.focus();
return;
}
sessionStorage.setItem("lfm3-access-key", accessKey);
addMessage("user", prompt);
promptInput.value = "";
sendButton.disabled = true;
const waiting = addMessage("assistant", "Thinking on your laptop...");
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: {"Content-Type": "application/json", "X-Access-Key": accessKey},
body: JSON.stringify({ prompt })
});
const payload = await response.json();
waiting.remove();
if (!response.ok) {
addMessage("error", payload.error || `Request failed with status ${response.status}.`);
return;
}
addMessage("assistant", payload.answer);
} catch (error) {
waiting.remove();
addMessage("error", `Could not reach the laptop: ${error.message}`);
} finally {
sendButton.disabled = false;
promptInput.focus();
}
});
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #08111f;
color: #e8eef8;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: linear-gradient(145deg, #08111f, #102542); }
button, input, textarea { font: inherit; }
.app-shell { width: min(760px, 100%); min-height: 100vh; margin: 0 auto; padding: 24px; display: grid; grid-template-rows: auto auto 1fr auto; gap: 18px; }
header h1 { margin: 4px 0 8px; font-size: clamp(2rem, 8vw, 3.5rem); }
header p { margin: 0; color: #b9c8dc; }
.eyebrow { color: #6ee7b7; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.access-panel, .composer { display: grid; gap: 8px; padding: 14px; border: 1px solid #294461; border-radius: 16px; background: rgba(8, 17, 31, 0.78); }
.access-panel input, .composer textarea { width: 100%; border: 1px solid #3b5877; border-radius: 12px; padding: 12px; background: #091522; color: #ffffff; }
.messages { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding: 4px; }
.message { max-width: 88%; padding: 12px 14px; border-radius: 16px; line-height: 1.5; white-space: pre-wrap; }
.message.user { align-self: flex-end; background: #2563eb; }
.message.assistant { align-self: flex-start; background: #18304c; }
.message.error { align-self: flex-start; background: #7f1d1d; }
.composer button { border: 0; border-radius: 12px; padding: 12px 18px; background: #34d399; color: #052014; font-weight: 800; cursor: pointer; }
.composer button:disabled { cursor: wait; opacity: 0.55; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 560px) {
.app-shell { padding: 16px; }
.message { max-width: 96%; }
}
Test the assistant from your phone
The final test crosses from your Android phone to the laptop gateway. A task created there should appear through the same persistent file when you list tasks from the laptop.
Keep this test on your private network
This learning server uses plain HTTP with Python's basic server components. Use it only on a trusted private Wi-Fi network.
- Keep port 8000 closed to internet forwarding.
- Keep the LFM3 server bound to 127.0.0.1:9931.
- Confirm that your laptop is connected to your trusted private Wi-Fi network.
- Connect your Android phone to the same trusted private Wi-Fi network.
- Record the printed phone address here: your printed Phone URL.
- Return to Google Chrome on your Android phone.
- Enter your printed Phone URL in the address bar.
You should see the responsive Private LFM3 Tasks page. The narrow layout should fit the phone without horizontal scrolling.
- Copy the current startup access key from the laptop terminal.
- Paste the key into the phone's Startup access key field.
- Enter Add check phone connection to my tasks. in the prompt field.
Before you send the request, do you expect the task to be stored only in the phone tab or in the laptop's persistent task file?
- Tap Send.
The local model may need a moment to choose the tool. You should then see confirmation that a numbered task named check phone connection was added.
- Switch back to the laptop browser chat from earlier.
- Enter the current startup access key if the field is empty.
- Enter List all tasks. in the prompt field.
- Click Send.
You should see check phone connection in the laptop's task list. That shared result proves the phone reached the authenticated gateway and changed data/tasks.json on the laptop.
You made the full connection work. Your phone can now control persistent task tools while the model endpoint stays isolated on the laptop.
Phone cannot reach the page?
- Confirm that both devices use the same trusted Wi-Fi network.
- Check that the Python terminal still shows the running gateway.
- Read the laptop's private IPv4 address from its Linux network settings if the application printed only the placeholder phone URL.
- Check whether the laptop firewall or the Wi-Fi router blocks communication between client devices.
Ask for help: Help me diagnose why my Android phone cannot reach the Python gateway on my trusted Wi-Fi network.
Secret mission
Prove the LAN Boundary
Your phone can reach the assistant. Now prove the gateway enforces its key. Restart the gateway to test the old browser credential without changing LFM3's loopback binding.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your local assistant available, pause its network services, or remove its files entirely. This project uses no paid cloud resources, so there are no ongoing charges.
The cached model occupies approximately 1.1 GB on your laptop. Keeping it avoids downloading the model again.
Resources you used:
- Local llama.cpp model server for LFM3-1.7B on 127.0.0.1:9931.
- LAN-facing Python gateway on 0.0.0.0:8000 while app.py is active.
- Cached model file LFM3-1.7B-Q4_K_M.gguf using approximately 1.1 GB of local storage.
- Project source files app.py, static/index.html, static/style.css, and static/app.js.
- Persistent JSON task data in data/tasks.json.
Keep everything running
Leave the current setup in place if you are still testing the assistant. Local storage and active processes do not create cloud charges.
- Keep the model server bound to 127.0.0.1:9931.
- Keep the gateway available only on your trusted private Wi-Fi network.
- Avoid forwarding port 8000 through your router.
- Retain the cached model to avoid another approximately 1.1 GB download.
- Retain data/tasks.json to preserve your saved tasks.
Pause - I'll come back to this later
Stop both local processes to remove the active network services. Your source files, saved tasks, and cached model remain available for later.
- Switch back to the terminal running the Python gateway.
- Press Ctrl+C to stop the gateway on port 8000.
- Switch back to the terminal running the model server.
- Press Ctrl+C to stop the model server on port 9931.
- Refresh the chat page on your Android phone.
You'll see that the phone can no longer reach the gateway. No local network service from this project remains active.
Delete - I don't want to use this again
This option removes the assistant's source code and saved tasks. Deleting data/tasks.json permanently erases your task history.
- Switch back to the terminal running the Python gateway.
- Press Ctrl+C to stop the gateway.
- Switch back to the terminal running the model server.
- Press Ctrl+C to stop the model server.
- Return to the gateway terminal from earlier.
- Delete the application files and persistent task data by running this command:
rm -rf app.py static data
What does this remove?
- Removing app.py deletes the Python gateway.
- Removing static deletes the responsive browser interface.
- Removing data deletes data/tasks.json and every saved task.
- Confirm that app.py, static, and data are no longer present in the project directory.
Still See the Application Files?
Make sure you returned to the gateway terminal before running the deletion command. A different terminal may be pointing at another directory.
If Linux prevents deletion, check that your user owns the project files.
Help me remove the local project files safely.
Finding the cached model can be fiddly because its cache location depends on how the download was stored. Removing this final file is optional if you want to reclaim disk space.
- Use your Linux file manager's search feature to locate LFM3-1.7B-Q4_K_M.gguf in the llama.cpp or Hugging Face cache.
- Delete the matching cached model file.
- Confirm that LFM3-1.7B-Q4_K_M.gguf no longer appears in the cache search results.
Your assistant files are now removed. The laptop no longer hosts either project service.
Nice Work!
Nice Work!
You did it! Your private LFM3 task assistant now accepts authenticated task requests from your Android phone.
You've learned how to:
- Run local inference with quantized LFM3-1.7B through llama.cpp on your Linux laptop.
- Use validated tool calling to limit model actions to add_task, list_tasks, or complete_task calls. Persist approved changes in data/tasks.json across application restarts.
- Serve an authenticated mobile chat through a LAN gateway on your laptop. Keep the model API isolated on loopback.
- Secret Mission: Prove the LAN security boundary with an authorized session. Reject incorrect access keys. Rotate the startup key by restarting app.py.
Ready to quiz yourself?