OneLinersCommand workbench
Guides
AI/ML / Software Engineering / Security

Build a fully local desktop chatbot with LM Studio

Create a small Linux desktop chat application that talks only to LM Studio on loopback, streams answers into a Tk interface, manages model loading explicitly, keeps conversation storage opt-in, and packages the result as a reproducible Python zip application.

150 min12 stepsChanges system stateRevision 1
Save or explore
Save to collectionCreate a collection in the sidebar first.
0 of 12 steps completed
Goal

Produce a usable on-device chatbot whose messages never need a cloud API after model download, whose local data lifecycle is visible and reversible, and whose server, storage, streaming, tests, launcher, backups, and teardown can be operated without trusting model output as executable instructions.

Supported environments
  • LM Studio 0.4.x
  • Python 3.12+
  • Linux desktop Ubuntu 24.04 LTS or equivalent
Prerequisites
  • Supported desktop Use a maintained Linux desktop with Python 3.12, Tk support, at least 16 GiB RAM for a modest quantized model, and enough free storage for both the model and local exports.python3 --version && python3 -m tkinter
  • LM Studio and model Install LM Studio from its official distribution, download a model appropriate for the hardware while online, and review the model license before any organizational use.lms --version
  • Local privacy decision Decide whether conversation persistence is permitted, who can access the operating-system account, how device encryption is handled, and where exported conversations may be copied.
  • Recovery space Keep a separate backup destination for explicitly saved history and retain the source archive or package recipe so the app can be rebuilt rather than repaired in place.
Operating boundary

OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.

Full guide

What you will build

System
  • A native Linux desktop window that discovers downloaded LM Studio LLMs through an authenticated loopback API, streams partial Chat Completions into a responsive transcript, and exposes an honest Stop state.
  • A privacy-first data layer where conversations are ephemeral by default, saving is a visible opt-in, exports are explicit owner-only files, and deletion has documented filesystem limitations.
  • A model lifecycle adapter that loads and unloads exact instances with bounded context, response, event, and timeout limits instead of leaving resource behavior implicit.
  • A reproducible single-file Python zip application, deterministic transport and storage tests, a desktop-launcher boundary, and operator procedures for backup, upgrade, rollback, and teardown.
Observable outcome
  • After model download, a user can disable external connectivity and still list a local model, stream a complete response, cancel generation, and unload the model without sending prompt content off-device.
  • A fresh installation stores no conversation rows until the user enables saving, while an opted-in synthetic conversation can be exported, restored, counted, and deleted.
  • The packaged artifact contains code only, has a recorded checksum, starts through an unprivileged user launcher, and contains no API token, model weight, database, transcript, or private export.
  • Operators can distinguish model load delay, prompt processing, generation, cancellation, storage, and failures through status and content-free evidence rather than guessing whether the app froze.

Architecture

How the parts fit together

The design keeps four trust zones small. The Tk desktop UI accepts human text and renders untrusted model text. A reviewed Python adapter is the only component allowed to call LM Studio, and it rejects non-loopback URLs before opening a connection. LM Studio owns model discovery, loading, inference, and unloading on the same workstation. SQLite stores only conversations the user explicitly elects to persist. Packaging copies code but never state or secrets, and operator procedures control the few consequential actions: saving, exporting, deleting, loading large models, changing a launcher, and revoking the token.

Tk desktop interfaceOwns user interaction, status, cancellation, model choice, explicit persistence consent, and text-only rendering without HTML, shell evaluation, or automatic clipboard behavior.
LM Studio client adapterValidates loopback configuration, authenticates, bounds requests and responses, parses SSE events, and exposes model list, load, chat, cancel, and unload operations.
LM Studio local serverManages downloaded models and local inference. It is configured independently, listens only on loopback, and requires a dedicated API token for this client.
Conversation storeCreates an owner-only SQLite database, saves only after UI opt-in, supports explicit JSON export, and provides logical deletion with accurately documented secure-erasure limits.
Package and launcherDistributes reviewed source as a checksum-addressed pyz while injecting runtime configuration without embedding the token or conversation state.
  1. The user chooses a model returned by the authenticated native model inventory rather than typing an arbitrary endpoint or executable string.
  2. The UI appends the bounded prompt to in-memory turns and starts a worker that sends at most twenty turns to the loopback Chat Completions endpoint.
  3. LM Studio performs inference locally and emits SSE deltas; the adapter validates and queues text fragments while the Tk event loop remains responsive.
  4. The UI renders fragments as inert text. Stop closes the connection cooperatively, and no generated content is interpreted as a command or tool call.
  5. If saving is disabled, turns disappear when the process ends. If enabled, sanitized bounded turns are inserted into SQLite and become eligible for explicit export or deletion.
  6. Model load and unload remain auditable lifecycle operations, while packaging and backup treat source, secrets, weights, and conversations as separate assets.

Assumptions

  • The workstation is operated by one trusted desktop account with maintained security updates and working full-disk encryption when confidentiality requires it.
  • A suitable model has been obtained through an approved channel, its license has been reviewed, and its resource requirements fit measured RAM, VRAM, and storage.
  • LM Studio 0.4.x is used because this guide relies on the native v1 model lifecycle endpoints documented for that generation.
  • The app is not a multi-user service, network server, agent, web browser, code runner, or privileged administration console.
  • Conversation exports are user data and inherit retention, access-control, incident-response, and deletion obligations from the prompts they contain.
  • Model output can be false, unsafe, biased, or incomplete; local execution changes transport and custody, not epistemic reliability.

Key concepts

Loopback
A host-local network interface such as 127.0.0.1 or ::1. Binding there avoids LAN exposure, although local processes and administrators may still reach the service.
OpenAI-compatible endpoint
An API surface implemented by LM Studio that accepts familiar request and streaming shapes. Compatibility is partial and must be verified against LM Studio documentation rather than assumed from an SDK name.
Server-sent events
A line-oriented HTTP streaming format where data events arrive before the full response finishes. The client must bound, parse, cancel, and recover from malformed or interrupted events.
Model instance
A loaded model process and configuration identified by instance_id. Loading consumes memory; unloading releases the instance but does not remove downloaded model files.
Context length
The maximum token window available to a loaded model instance. A larger window can increase memory use and processing time and does not automatically improve answer quality.
Ephemeral conversation
Turns retained only in process memory for the active window. Ephemeral mode reduces deliberate retention but cannot prevent screenshots, swap, crash artifacts, or a compromised device.
SQLite WAL
A write-ahead log used by SQLite for transaction durability and concurrency. Backups must checkpoint or include WAL state, and deletion procedures must not ignore it.
Application package
The reviewed Python code and launcher required to start the client. It deliberately excludes model weights, API tokens, conversation databases, exports, and machine-specific configuration.
Logical deletion
Removal of database records from the active application view. It is not a universal promise that every filesystem block, snapshot, SSD cell, or previous backup is erased.
Human review boundary
The rule that model output is displayed for judgment but cannot directly trigger shell commands, file writes, network tools, or other consequential actions.

Before you copy

Values used in this guide

{{lmModel}}

Exact downloaded model key returned by the LM Studio model inventory and selected after checking license, capability, and memory requirements.

Example: ibm/granite-4-micro
{{backupDirectory}}

Resolved owner-only directory on an approved encrypted backup destination used only for intentionally saved conversations and recovery metadata.

Example: /media/encrypted/local-chat-backup
{{LM_STUDIO_TOKEN}}secret

Dedicated API token created in LM Studio for this desktop client, injected at runtime and revoked during compromise, replacement, or teardown.

Example: stored in a desktop secret manager, never in the guide or launcher
{{LM_STUDIO_URL}}

HTTP loopback origin for the local LM Studio server. The client rejects LAN hosts, public hosts, URL credentials, and non-HTTP schemes.

Example: http://127.0.0.1:1234

Security and production boundaries

  • Local inference is a data-routing choice, not a guarantee of correctness, safety, confidentiality against local compromise, or permission to process data outside its approved purpose.
  • Use a dedicated LM Studio API token and bind only to loopback. A token protects against accidental same-host access but does not replace operating-system isolation from an administrator or malware.
  • Keep history off by default. Explain consent beside the control, avoid dark patterns, and do not silently restore the previous opt-in state on a shared workstation.
  • Render model output as inert text. Never auto-run code, follow URLs, execute Markdown, invoke tools, or modify files because a local model asked.
  • Do not log prompts, responses, authorization headers, exports, or database content. Operational logs should contain timestamps, status classes, model identifiers, latency, and bounded error categories.
  • Use synthetic prompts for tests and screenshots. Real conversation data should not become a fixture, crash attachment, support paste, or source-controlled golden file.
  • Full-disk encryption, locked sessions, secure backups, and account separation remain necessary because SQLite mode 0600 cannot protect against every local threat.
  • Model downloads and updates require separate online supply-chain review. Record the model identifier, source, license, quantization, application version, and checksum when available.
  • No feature in this guide grants the model tools. Adding filesystem, browser, clipboard, network, or shell capabilities changes the product into an agent and requires a new threat model.
  • Deletion should enumerate the live database, WAL, exports, snapshots, and backups. Be explicit that secure erasure depends on storage technology and retention systems.

Stop before continuing if

  • Stop before release if any normal chat request can leave loopback, use an unreviewed proxy, or activate a cloud model without a separate explicit product mode.
  • Stop if the application saves conversation content before the user enables the visible persistence control.
  • Stop if the package, launcher, tests, screenshots, logs, or repository contains a live LM Studio token or real conversation text.
  • Stop if a generated response can trigger shell execution, open a URL, write a file, call a tool, or alter the clipboard without a separate reviewed human action.
  • Stop when a model load causes swapping, desktop instability, GPU driver faults, or sustained thermal behavior outside the workstation's operating limits.
  • Stop if deletion, export, backup, or rollback cannot identify exact resolved filesystem targets and preserve the only intended copy before a destructive change.
  • Stop if the selected model license, provenance, or organizational approval is uncertain.
  • Stop if marketing or UI copy claims encryption, guaranteed privacy, factual correctness, or secure deletion that the implementation and acceptance tests do not prove.
01

instruction

Define the local-only boundary before writing code

Write a one-page operating note that classifies prompts, records whether history may be stored, states that model answers are untrusted drafts, and identifies the only permitted inference destination as the loopback LM Studio server. Separate offline inference from the earlier online act of downloading software and model weights. The boundary should also say that this app has no tools, browser access, shell execution, telemetry, update mechanism, or automatic upload path. These absences are deliberate controls, not missing features.

Why this step matters

A local label is meaningful only when the application, server address, storage behavior, and operator procedure all enforce the same boundary.

What to understand

Loopback limits exposure to the current host, but another process under the same account may still reach the API; LM Studio API authentication therefore remains useful.

Local inference protects messages from cloud transit, yet plaintext history can still be read by malware, backups, support bundles, or another administrator with filesystem access.

A generated answer can contain dangerous commands even when generation is fully offline, so the UI must never interpret output or offer an unreviewed execution shortcut.

System changes

  • Creates an approved design note only; no package, service, model, or conversation data changes yet.
Example output / evidence
Approved boundary: inference origin http://127.0.0.1:1234; history disabled by default; no tools, shell, telemetry, cloud fallback, or automatic uploads; model output requires human review.

Checkpoint: Boundary review complete

Continue whenThe note names loopback, opt-in persistence, no tools, no automatic network egress, local deletion limits, and human review for consequential use.

Stop whenStop if stakeholders require hidden telemetry, automatic cloud fallback, shared LAN access, or silent persistence; those requirements need a different threat model.

Security notes

  • Do not market local storage as encrypted unless the application or full-disk platform actually supplies and tests encryption.
  • Do not paste secrets into prompts merely because the model is local; transcripts, screenshots, crash reports, and exports remain disclosure paths.

Alternatives

  • Use LM Studio's own chat interface when a custom storage policy, custom desktop workflow, or reproducible package is unnecessary.

Stop conditions

  • Do not proceed on a shared workstation until account isolation and local file permissions are understood.
02

command

Create a minimal Python desktop project

caution

Create a dedicated source directory, isolated virtual environment, application package, tests directory, and state-free build directory. This implementation intentionally uses Python standard-library Tkinter, HTTP, SQLite, JSON, and unittest modules so the runtime dependency chain stays small. The model weights remain managed by LM Studio and are not copied into the application package.

Why this step matters

An isolated, owner-only project makes dependencies, build output, and later deletion easier to inspect without mixing chat state into source control.

What to understand

The braces create source, tests, and distribution directories beneath one explicit project path; no system Python packages are overwritten.

The virtual environment is still useful for reproducible tooling even though the finished application uses only the standard library.

The application database belongs under the desktop user data directory at runtime, not under src, dist, Git, or a cloud-synchronized folder.

System changes

  • Creates ~/src/local-desk-chat with mode 0700, a Python virtual environment, and an initially empty dist directory.
  • Updates pip only inside the new virtual environment.

Syntax explained

install -d -m 0700
Creates directories with owner-only permissions from the first filesystem operation.
python3 -m venv .venv
Creates an isolated interpreter environment without modifying the operating-system Python installation.
. .venv/bin/activate
Selects the project interpreter for the current shell only.
Command
install -d -m 0700 ~/src/local-desk-chat/{src,tests,dist} && cd ~/src/local-desk-chat && python3 -m venv .venv && . .venv/bin/activate && python -m pip install --upgrade pip
Example output / evidence
Requirement already satisfied: pip in ./.venv/lib/python3.12/site-packages (25.1.1)

Checkpoint: Project boundary exists

cd ~/src/local-desk-chat && test -d src -a -d tests -a -x .venv/bin/python && stat -c '%a %n' .

Continue whenThe project directory reports mode 700 and the isolated Python interpreter is executable.

Stop whenStop if the resolved project path is shared, synchronized externally, or points outside the intended home directory.

Alternatives

  • Use a distribution package for python3-tk when python3 -m tkinter proves the Tk runtime is absent.

Stop conditions

  • Do not continue if the project directory is group-writable or located in a repository that automatically publishes artifacts.
03

command

Start LM Studio on loopback with API authentication

caution

In LM Studio's Developer settings, keep the server bound to localhost, enable API-token authentication, create a dedicated token for this desktop app, and start the server. Export the token only into the terminal or launcher process that starts the application. Then list available models through the current native v1 endpoint. Model download requires connectivity, but the server and already downloaded models can operate offline afterward.

Why this step matters

A loopback listener plus a per-application token reduces accidental exposure and gives the UI a current, inspectable inventory instead of guessing model identifiers.

What to understand

The v1 model endpoint can describe downloaded models and loaded instances; the application filters entries to type llm before presenting them.

A shell environment variable is preferable to embedding a live token in source, a .desktop file, screenshots, test fixtures, or version control.

The curl probe has an explicit five-second timeout and prints a bounded administrative response; avoid verbose mode because it can expose authorization headers.

System changes

  • Starts the LM Studio local API server through the Developer UI or lms CLI and enables its authentication setting.
  • Adds LM_STUDIO_URL and LM_STUDIO_TOKEN to the current process environment only.

Syntax explained

read -rsp
Reads the token without echoing it into the terminal display.
--max-time 5
Prevents a missing local service from hanging the setup indefinitely.
Authorization: Bearer
Supplies the token format documented for authenticated LM Studio API requests.
Command
export LM_STUDIO_URL='http://127.0.0.1:1234' && read -rsp 'LM Studio API token: ' LM_STUDIO_TOKEN && export LM_STUDIO_TOKEN && printf '\n' && curl --fail --silent --show-error --max-time 5 -H "Authorization: Bearer $LM_STUDIO_TOKEN" "$LM_STUDIO_URL/api/v1/models"
Example output / evidence
{"models":[{"type":"llm","publisher":"ibm","key":"ibm/granite-4-micro","display_name":"granite-4-micro","loaded_instances":[]}]}

Checkpoint: Authenticated loopback inventory works

curl --fail --silent --max-time 5 -H "Authorization: Bearer $LM_STUDIO_TOKEN" "$LM_STUDIO_URL/api/v1/models" | python3 -m json.tool | head -40

Continue whenA valid JSON document lists downloaded model keys without an HTTP authentication or connection error.

Stop whenStop if LM Studio listens on 0.0.0.0, a LAN address, or a remote hostname, or if the token appears in shell history or a committed file.

Security notes

  • Do not enable serving on the local network for this guide; LAN exposure requires TLS, network access policy, client authentication, and a separate review.

Alternatives

  • Use a desktop secret manager to inject LM_STUDIO_TOKEN when the local environment and launcher support one.

Stop conditions

  • Do not disable authentication merely to clear a 401 response; fix the token and revoke any exposed value.
04

config

Implement bounded model lifecycle and streaming

Save this client as src/lm_api.py. It rejects non-loopback destinations during construction, adds the configured bearer token to every request, lists models through the native v1 API, loads and unloads explicit instances, and streams Chat Completions SSE events. Every administrative response, stream line, timeout, conversation window, and generated-token budget is bounded. Cancellation closes the local connection; it never executes text returned by the model.

Why this step matters

Model lifecycle and streaming belong behind one reviewed adapter so UI code cannot silently choose a remote host, omit limits, or parse arbitrary event shapes.

What to understand

The native /api/v1/models/load endpoint accepts a model identifier and context length, while /api/v1/models/unload uses the returned instance_id.

The OpenAI-compatible /v1/chat/completions stream emits SSE data records whose choice delta may contain text; unknown fields are ignored rather than trusted.

HTTP rather than HTTPS is acceptable only because the destination is a verified loopback address; the constructor rejects credentials in URLs and every non-loopback hostname.

System changes

  • Creates the local LM Studio transport module in the project source tree; it does not load a model until its method is invoked.

Syntax explained

messages[-20:]
Bounds the manually managed conversation window before serialization.
readline(65_537)
Detects an SSE line exceeding the 64 KiB application safety limit.
max_tokens: 1200
Caps generated output independently of the model's larger context capacity.
File ~/src/local-desk-chat/src/lm_api.py
Configuration
from __future__ import annotations

import http.client
import json
import os
import threading
from collections.abc import Iterator
from urllib.parse import urlsplit


class LmStudioError(RuntimeError):
    pass


class LmStudioClient:
    def __init__(self, base_url: str, token: str, timeout_seconds: float = 60.0) -> None:
        parsed = urlsplit(base_url)
        if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
            raise ValueError("LM Studio must use an HTTP loopback address.")
        if parsed.username or parsed.password:
            raise ValueError("Credentials may not appear in the server URL.")
        self.host = parsed.hostname or "127.0.0.1"
        self.port = parsed.port or 1234
        self.token = token
        self.timeout_seconds = timeout_seconds

    @classmethod
    def from_environment(cls) -> "LmStudioClient":
        return cls(
            os.getenv("LM_STUDIO_URL", "http://127.0.0.1:1234"),
            os.environ["LM_STUDIO_TOKEN"],
            float(os.getenv("LM_STUDIO_TIMEOUT_SECONDS", "60")),
        )

    def _headers(self) -> dict[str, str]:
        return {
            "Authorization": f"Bearer {self.token}",
            "Content-Type": "application/json",
            "Accept": "application/json",
        }

    def list_models(self) -> list[dict[str, object]]:
        connection = http.client.HTTPConnection(self.host, self.port, timeout=5)
        try:
            connection.request("GET", "/api/v1/models", headers=self._headers())
            response = connection.getresponse()
            body = response.read(1_048_577)
            if len(body) > 1_048_576:
                raise LmStudioError("LM Studio returned an oversized model list.")
            if response.status != 200:
                raise LmStudioError(f"Model listing failed with HTTP {response.status}.")
            document = json.loads(body)
            return list(document.get("models", []))
        except (OSError, ValueError, json.JSONDecodeError) as error:
            raise LmStudioError(f"Could not read the local model list: {error}") from error
        finally:
            connection.close()

    def load_model(self, model: str, context_length: int = 8192) -> str:
        if not model or len(model) > 240:
            raise ValueError("Model identifier is missing or too long.")
        body = json.dumps({
            "model": model,
            "context_length": context_length,
            "echo_load_config": True,
        })
        connection = http.client.HTTPConnection(self.host, self.port, timeout=180)
        try:
            connection.request("POST", "/api/v1/models/load", body, self._headers())
            response = connection.getresponse()
            payload = json.loads(response.read(1_048_576))
            if response.status not in {200, 201}:
                raise LmStudioError(f"Model load failed with HTTP {response.status}.")
            return str(payload["instance_id"])
        finally:
            connection.close()

    def unload_model(self, instance_id: str) -> None:
        body = json.dumps({"instance_id": instance_id})
        connection = http.client.HTTPConnection(self.host, self.port, timeout=30)
        try:
            connection.request("POST", "/api/v1/models/unload", body, self._headers())
            response = connection.getresponse()
            response.read(1_048_576)
            if response.status != 200:
                raise LmStudioError(f"Model unload failed with HTTP {response.status}.")
        finally:
            connection.close()

    def stream_chat(
        self,
        model: str,
        messages: list[dict[str, str]],
        cancelled: threading.Event,
    ) -> Iterator[str]:
        body = json.dumps({
            "model": model,
            "messages": messages[-20:],
            "stream": True,
            "temperature": 0.3,
            "max_tokens": 1200,
        })
        connection = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout_seconds)
        try:
            connection.request("POST", "/v1/chat/completions", body, self._headers())
            response = connection.getresponse()
            if response.status != 200:
                detail = response.read(16_384).decode("utf-8", "replace")
                raise LmStudioError(f"Generation failed with HTTP {response.status}: {detail[:400]}")
            while not cancelled.is_set():
                line = response.readline(65_537)
                if not line:
                    break
                if len(line) > 65_536:
                    raise LmStudioError("A streaming event exceeded the 64 KiB safety limit.")
                text = line.decode("utf-8", "strict").strip()
                if not text.startswith("data:"):
                    continue
                data = text[5:].strip()
                if data == "[DONE]":
                    break
                event = json.loads(data)
                token = event.get("choices", [{}])[0].get("delta", {}).get("content")
                if isinstance(token, str):
                    yield token
        except (OSError, UnicodeError, json.JSONDecodeError) as error:
            raise LmStudioError(f"Local streaming failed: {error}") from error
        finally:
            connection.close()
Example output / evidence
Loaded src/lm_api.py: loopback policy active; native model lifecycle and OpenAI-compatible streaming endpoints configured.

Checkpoint: Client imports and rejects remote URLs

cd ~/src/local-desk-chat && PYTHONPATH=src .venv/bin/python -c "from lm_api import LmStudioClient; LmStudioClient('http://127.0.0.1:1234','test'); print('loopback accepted')"

Continue whenThe import succeeds and prints loopback accepted; substituting a LAN hostname raises ValueError.

Stop whenStop if the implementation accepts arbitrary hosts, uses eval on streamed content, omits response limits, or logs the bearer token.

Security notes

  • A model identifier is data sent to LM Studio, not a filesystem path or shell fragment; never interpolate it into a command.
  • Cancellation is cooperative at the next local network read and should be presented honestly rather than claimed as instantaneous.

Alternatives

  • Use LM Studio's official Python SDK when SDK-managed model objects and richer native events are preferable to a small HTTP adapter.

Stop conditions

  • Do not add remote MCP integrations or web-search features to a build advertised as fully local.
05

config

Add explicit, local conversation storage

Save the storage layer as src/store.py. Conversations are written only when the caller invokes save_turns after the visible opt-in control is enabled. SQLite keeps records in one inspectable file, constrains transcript size, and supports export and deletion. Permissions are set to owner-only, but this is not application-level encryption; the guide therefore treats full-disk encryption and account security as separate prerequisites.

Why this step matters

Persistence must be a named, testable action with export and deletion semantics rather than an invisible side effect of rendering a conversation.

What to understand

The CHECK constraint and per-turn slicing prevent one accidental conversation from creating an unbounded local database record.

Parameter binding keeps conversation text out of SQL syntax, while a generated UUID avoids turning titles or user text into database keys.

VACUUM can reclaim logical database pages but cannot promise forensic erasure on SSDs, snapshots, copy-on-write filesystems, or external backups.

System changes

  • Creates a SQLite database only after the application opens the store, and creates conversation rows only after opt-in.
  • Exports create an owner-readable JSON file at an explicit operator-selected path.

Syntax explained

PRAGMA journal_mode=WAL
Improves local concurrency while creating WAL state that backup and deletion procedures must include.
CHECK(length(turns_json) <= 1048576)
Applies a database-side one-megabyte ceiling to each stored conversation document.
path.chmod(0o600)
Restricts ordinary filesystem access to the owning desktop account.
File ~/src/local-desk-chat/src/store.py
Configuration
from __future__ import annotations

import json
import sqlite3
import time
import uuid
from pathlib import Path


class ConversationStore:
    def __init__(self, path: Path) -> None:
        self.path = path
        self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
        self.database = sqlite3.connect(path, check_same_thread=False)
        self.database.execute("PRAGMA journal_mode=WAL")
        self.database.execute("PRAGMA foreign_keys=ON")
        self.database.execute("""
          CREATE TABLE IF NOT EXISTS conversations (
            id TEXT PRIMARY KEY,
            created_at INTEGER NOT NULL,
            turns_json TEXT NOT NULL CHECK(length(turns_json) <= 1048576)
          )
        """)
        self.database.commit()
        path.chmod(0o600)

    def save_turns(self, turns: list[dict[str, str]]) -> str:
        cleaned = [
            {"role": item["role"], "content": item["content"][:32_768]}
            for item in turns[-40:]
            if item.get("role") in {"user", "assistant"} and isinstance(item.get("content"), str)
        ]
        identifier = str(uuid.uuid4())
        self.database.execute(
            "INSERT INTO conversations (id, created_at, turns_json) VALUES (?, ?, ?)",
            (identifier, int(time.time()), json.dumps(cleaned, ensure_ascii=False)),
        )
        self.database.commit()
        return identifier

    def export_json(self, destination: Path) -> int:
        rows = self.database.execute(
            "SELECT id, created_at, turns_json FROM conversations ORDER BY created_at"
        ).fetchall()
        document = [
            {"id": row[0], "created_at": row[1], "turns": json.loads(row[2])}
            for row in rows
        ]
        destination.write_text(json.dumps(document, indent=2, ensure_ascii=False), encoding="utf-8")
        destination.chmod(0o600)
        return len(document)

    def delete_all(self) -> int:
        count = self.database.execute("SELECT count(*) FROM conversations").fetchone()[0]
        self.database.execute("DELETE FROM conversations")
        self.database.commit()
        self.database.execute("VACUUM")
        return int(count)
Example output / evidence
Created ~/.local/share/local-desk-chat/conversations.sqlite3 with mode 600 and WAL journaling.

Checkpoint: Persistence lifecycle is reversible

cd ~/src/local-desk-chat && PYTHONPATH=src .venv/bin/python -c "from pathlib import Path; from tempfile import TemporaryDirectory; from store import ConversationStore; d=TemporaryDirectory(); s=ConversationStore(Path(d.name)/'c.db'); s.save_turns([{'role':'user','content':'test'}]); print(s.delete_all())"

Continue whenThe isolated lifecycle prints 1, proving one saved record was deleted without touching the real user database.

Stop whenStop if saving happens by default, export destinations are uploaded automatically, or local deletion is described as guaranteed secure erasure.

Security notes

  • Do not store passwords, API keys, personal records, or regulated data merely because storage is local.

Alternatives

  • Remove the storage module entirely for a kiosk-style ephemeral build.
  • Use an operating-system keyring and audited encrypted database design when policy requires field-level encryption.

Stop conditions

  • Do not place the database in Dropbox, OneDrive, a Git working tree, or another automatically synchronized location without explicit approval.
06

config

Build the responsive desktop chat interface

Save the application as src/app.py. The Tk event loop remains responsive because model listing and generation run in daemon worker threads. Tokens cross a queue and are rendered on the UI thread. The screen exposes model selection, server status, Stop, an opt-in history checkbox, and a destructive deletion confirmation. Text is inserted into a Text widget as text, not HTML, Markdown, a command, or code to execute.

Why this step matters

A desktop client needs visible privacy and lifecycle controls as much as it needs a prompt box; responsive streaming cannot come at the cost of unsafe cross-thread UI access.

What to understand

The queue contains only event kind and payload; Tk widgets are modified exclusively inside _drain_events on the main event loop.

The selected model comes from the authenticated local inventory rather than a free-form field, reducing typos and accidental model alias confusion.

The input byte limit, twenty-turn context window, output token limit, and Stop action keep normal mistakes bounded even when the model is slow.

System changes

  • Creates the desktop UI source file and, on first launch, an owner-only application data directory and empty database schema.
  • A conversation row is created only after the history checkbox is explicitly enabled for that running application.

Syntax explained

queue.Queue
Transfers bounded UI events safely from the worker thread to the Tk event loop.
root.after(40, ...)
Schedules short, recurring queue drains without blocking redraw and input handling.
BooleanVar(value=False)
Makes local conversation persistence visibly opt-in for every fresh launch.
File ~/src/local-desk-chat/src/app.py
Configuration
from __future__ import annotations

import json
import queue
import threading
import tkinter as tk
from dataclasses import dataclass
from pathlib import Path
from tkinter import messagebox, ttk
from typing import Callable

from lm_api import LmStudioClient, LmStudioError
from store import ConversationStore


@dataclass(frozen=True)
class PrivacySettings:
    save_history: bool = False
    include_previous_turns: bool = True
    max_saved_conversations: int = 50


class LocalChatApp:
    def __init__(self, root: tk.Tk, data_dir: Path) -> None:
        self.root = root
        self.root.title("Local Desk Chat")
        self.root.geometry("940x700")
        self.client = LmStudioClient.from_environment()
        self.store = ConversationStore(data_dir / "conversations.sqlite3")
        self.events: queue.Queue[tuple[str, str]] = queue.Queue()
        self.cancel_event = threading.Event()
        self.turns: list[dict[str, str]] = []
        self.save_history = tk.BooleanVar(value=False)
        self.model = tk.StringVar()
        self.status = tk.StringVar(value="LM Studio has not been checked")
        self._build()
        self.root.after(40, self._drain_events)

    def _build(self) -> None:
        toolbar = ttk.Frame(self.root, padding=12)
        toolbar.pack(fill=tk.X)
        ttk.Label(toolbar, text="Model").pack(side=tk.LEFT)
        self.models = ttk.Combobox(toolbar, textvariable=self.model, state="readonly", width=45)
        self.models.pack(side=tk.LEFT, padx=(8, 16))
        ttk.Button(toolbar, text="Refresh", command=self.refresh_models).pack(side=tk.LEFT)
        ttk.Checkbutton(
            toolbar,
            text="Save conversations on this device",
            variable=self.save_history,
        ).pack(side=tk.RIGHT)

        self.transcript = tk.Text(self.root, wrap=tk.WORD, state=tk.DISABLED, padx=14, pady=14)
        self.transcript.pack(fill=tk.BOTH, expand=True, padx=12)
        self.transcript.tag_configure("user", foreground="#4FC1FF")
        self.transcript.tag_configure("assistant", foreground="#DCDCAA")
        self.transcript.tag_configure("notice", foreground="#CE9178")

        composer = ttk.Frame(self.root, padding=12)
        composer.pack(fill=tk.X)
        self.input = tk.Text(composer, height=5, wrap=tk.WORD)
        self.input.pack(side=tk.LEFT, fill=tk.X, expand=True)
        actions = ttk.Frame(composer)
        actions.pack(side=tk.RIGHT, padx=(12, 0))
        ttk.Button(actions, text="Send", command=self.send).pack(fill=tk.X)
        ttk.Button(actions, text="Stop", command=self.stop).pack(fill=tk.X, pady=6)
        ttk.Button(actions, text="Delete local history", command=self.delete_history).pack(fill=tk.X)
        ttk.Label(self.root, textvariable=self.status, padding=(12, 0, 12, 12)).pack(anchor=tk.W)

    def refresh_models(self) -> None:
        def task() -> None:
            try:
                models = self.client.list_models()
                identifiers = [
                    item["key"] for item in models
                    if item.get("type") == "llm"
                ]
                self.events.put(("models", json.dumps(identifiers)))
            except LmStudioError as error:
                self.events.put(("error", str(error)))
        threading.Thread(target=task, daemon=True).start()

    def send(self) -> None:
        prompt = self.input.get("1.0", tk.END).strip()
        model = self.model.get().strip()
        if not prompt or not model:
            messagebox.showwarning("Missing input", "Choose a loaded model and enter a message.")
            return
        if len(prompt.encode("utf-8")) > 32_768:
            messagebox.showwarning("Input too large", "The message is limited to 32 KiB.")
            return
        self.input.delete("1.0", tk.END)
        self.turns.append({"role": "user", "content": prompt})
        self._append("You", prompt, "user")
        self._append("Assistant", "", "assistant")
        self.cancel_event.clear()
        self.status.set("Generating locally…")

        def task() -> None:
            collected: list[str] = []
            try:
                for token in self.client.stream_chat(model, self.turns, self.cancel_event):
                    collected.append(token)
                    self.events.put(("token", token))
                answer = "".join(collected)
                if answer:
                    self.turns.append({"role": "assistant", "content": answer})
                    if self.save_history.get():
                        self.store.save_turns(self.turns)
                self.events.put(("done", "Stopped" if self.cancel_event.is_set() else "Ready"))
            except LmStudioError as error:
                self.events.put(("error", str(error)))
        threading.Thread(target=task, daemon=True).start()

    def stop(self) -> None:
        self.cancel_event.set()
        self.status.set("Stopping after the current network read…")

    def delete_history(self) -> None:
        if messagebox.askyesno("Delete history", "Delete every conversation saved by this app?"):
            deleted = self.store.delete_all()
            self.status.set(f"Deleted {deleted} saved conversations")

    def _append(self, label: str, text: str, tag: str) -> None:
        self.transcript.configure(state=tk.NORMAL)
        self.transcript.insert(tk.END, f"\n{label}\n", tag)
        self.transcript.insert(tk.END, text, tag)
        self.transcript.see(tk.END)
        self.transcript.configure(state=tk.DISABLED)

    def _drain_events(self) -> None:
        try:
            while True:
                kind, payload = self.events.get_nowait()
                if kind == "models":
                    values = json.loads(payload)
                    self.models.configure(values=values)
                    if values:
                        self.model.set(values[0])
                    self.status.set(f"{len(values)} local model(s) available")
                elif kind == "token":
                    self.transcript.configure(state=tk.NORMAL)
                    self.transcript.insert(tk.END, payload, "assistant")
                    self.transcript.see(tk.END)
                    self.transcript.configure(state=tk.DISABLED)
                elif kind == "done":
                    self.status.set(payload)
                elif kind == "error":
                    self.status.set("Request failed")
                    self._append("Local error", payload, "notice")
        except queue.Empty:
            pass
        self.root.after(40, self._drain_events)


if __name__ == "__main__":
    home = Path.home() / ".local" / "share" / "local-desk-chat"
    home.mkdir(parents=True, exist_ok=True, mode=0o700)
    root = tk.Tk()
    LocalChatApp(root, home).refresh_models()
    root.mainloop()
Example output / evidence
Local Desk Chat opened; 1 local model(s) available; Save conversations on this device is unchecked.

Checkpoint: Ephemeral launch succeeds

cd ~/src/local-desk-chat && . .venv/bin/activate && LM_STUDIO_URL=http://127.0.0.1:1234 LM_STUDIO_TOKEN="$LM_STUDIO_TOKEN" PYTHONPATH=src python src/app.py

Continue whenThe window opens, lists local models, streams a short answer, and leaves the save-history control unchecked.

Stop whenStop if the interface freezes during generation, silently saves a prompt, renders model text as active markup, or exposes an execution action.

Security notes

  • Model text is untrusted display content. Do not convert it into clickable commands or privileged desktop actions.
  • A confirmation dialog reduces accidental deletion but does not replace backup policy for intentionally saved records.

Alternatives

  • Use a local browser UI bound to loopback when accessibility testing and richer layout outweigh the simplicity of a native standard-library widget set.

Stop conditions

  • Do not add automatic clipboard writes; generated secrets, commands, or misleading text could overwrite user data without an explicit action.
07

command

Exercise model load, chat, stop, and unload

caution

Choose a model from the downloaded inventory, load it with an initial context length that fits measured memory, send a harmless prompt, stop a deliberately long response, and unload the exact returned instance when testing is complete. Keep lifecycle operations separate from the UI's normal chat flow so a user can distinguish a slow load from a generation problem and can reclaim memory intentionally.

Why this step matters

Explicit lifecycle rehearsal establishes the expected model identifier, memory envelope, load delay, cancellation behavior, and cleanup procedure before packaging hides development context.

What to understand

The model placeholder must exactly match a key returned by the model inventory; it is JSON data and is never evaluated by a shell.

An 8192-token context is an initial operational choice, not a universal maximum; increase it only after measuring memory and latency.

The load response's instance_id is the value accepted by the unload endpoint, which may differ from a human-friendly display name.

System changes

  • Loads the selected model weights into local CPU or GPU memory and writes a temporary JSON result under /tmp for this rehearsal.
  • A later unload request releases the selected model instance without deleting downloaded weights.

Syntax explained

context_length: 8192
Sets the initial maximum context considered by the loaded model instance.
echo_load_config: true
Returns the effective load configuration for evidence and troubleshooting.
tee /tmp/lm-load-result.json
Keeps a short-lived local lifecycle result so the instance identifier can be inspected before unload.
Command
Fill variables0/1 ready

Values stay on this page and are never sent or saved.

curl --fail --silent --show-error --max-time 180 -H "Authorization: Bearer $LM_STUDIO_TOKEN" -H 'Content-Type: application/json' -d '{"model":"{{lmModel}}","context_length":8192,"echo_load_config":true}' "$LM_STUDIO_URL/api/v1/models/load" | tee /tmp/lm-load-result.json
Example output / evidence
{"type":"llm","instance_id":"ibm/granite-4-micro","load_time_seconds":4.62,"status":"loaded","load_config":{"context_length":8192}}

Checkpoint: Lifecycle is visible and reversible

python3 -c "import json; d=json.load(open('/tmp/lm-load-result.json')); print(d['status'], d['instance_id'])"

Continue whenThe command prints loaded followed by the exact local instance identifier.

Stop whenStop if memory pressure threatens desktop stability, the identifier was not obtained from inventory, or the server is not bound to loopback.

Security notes

  • Review /tmp permissions on multi-user systems and remove the result after the instance identifier is no longer needed.

Alternatives

  • Use the LM Studio Developer UI to load and unload models when operators should not handle lifecycle HTTP requests.

Stop conditions

  • Do not repeatedly retry a failed load while the desktop is swapping or the GPU driver reports allocation errors.
08

config

Add deterministic tests without a live model

Save the test suite as tests/test_local_chat.py. The fake HTTP connection emits known SSE frames, so the test proves token reassembly without requiring LM Studio, a model download, network access, or nondeterministic text. A temporary SQLite database proves the save, export, and delete lifecycle. Add separate manual acceptance checks for actual model load and UI responsiveness because mocks cannot validate GPU behavior or desktop rendering.

Why this step matters

Deterministic transport and storage tests catch framing and lifecycle regressions while keeping model quality, hardware variance, and private prompts out of the automated test suite.

What to understand

The fake produces two data events followed by [DONE], matching the stream contract the adapter actually parses.

TemporaryDirectory guarantees the storage test cannot read, export, vacuum, or delete the user's real conversation database.

The tests never assert a particular natural-language model answer; they assert application behavior that engineering controls can reliably guarantee.

System changes

  • Creates a unittest module under the project tests directory; the test itself writes only inside a temporary directory.

Syntax explained

unittest.mock.patch
Replaces only the HTTP connection constructor during the streaming test.
TemporaryDirectory
Creates automatically removed state isolated from the production data path.
discover -s tests -v
Finds the explicit test directory and prints each named test result.
File ~/src/local-desk-chat/tests/test_local_chat.py
Configuration
import json
import sqlite3
import tempfile
import unittest
from pathlib import Path
from threading import Event
from unittest.mock import patch

from lm_api import LmStudioClient
from store import ConversationStore


class FakeResponse:
    status = 200
    def __init__(self) -> None:
        self.lines = iter([
            b'data: {"choices":[{"delta":{"content":"local"}}]}\n',
            b'data: {"choices":[{"delta":{"content":" answer"}}]}\n',
            b'data: [DONE]\n',
        ])
    def readline(self, _limit: int) -> bytes:
        return next(self.lines, b"")
    def read(self, _limit: int = -1) -> bytes:
        return json.dumps({"models": [{"key": "test/model", "type": "llm"}]}).encode()


class FakeConnection:
    def __init__(self, *_args, **_kwargs) -> None:
        self.response = FakeResponse()
    def request(self, *_args, **_kwargs) -> None:
        return None
    def getresponse(self) -> FakeResponse:
        return self.response
    def close(self) -> None:
        return None


class LocalDeskChatTests(unittest.TestCase):
    @patch("lm_api.http.client.HTTPConnection", FakeConnection)
    def test_stream_reassembles_sse_tokens(self) -> None:
        client = LmStudioClient("http://127.0.0.1:1234", "test-token")
        result = "".join(client.stream_chat(
            "test/model",
            [{"role": "user", "content": "hello"}],
            Event(),
        ))
        self.assertEqual(result, "local answer")

    def test_store_is_opt_in_and_exportable(self) -> None:
        with tempfile.TemporaryDirectory() as directory:
            store = ConversationStore(Path(directory) / "chat.sqlite3")
            identifier = store.save_turns([
                {"role": "user", "content": "private test"},
                {"role": "assistant", "content": "local reply"},
            ])
            output = Path(directory) / "export.json"
            self.assertEqual(store.export_json(output), 1)
            self.assertIn(identifier, output.read_text(encoding="utf-8"))
            self.assertEqual(store.delete_all(), 1)


if __name__ == "__main__":
    unittest.main()
Example output / evidence
Ran 2 tests in 0.014s

OK

Checkpoint: Local deterministic tests pass

cd ~/src/local-desk-chat && PYTHONPATH=src .venv/bin/python -m unittest discover -s tests -v

Continue whenBoth stream and store tests pass, and no LM Studio request or real conversation database is opened.

Stop whenStop if tests require a real secret, a cloud API, a downloaded model, or the production data directory.

Alternatives

  • Add a separately labeled local integration test that runs only when LM_STUDIO_INTEGRATION=1 and uses a harmless fixture prompt.

Stop conditions

  • Do not record real prompts or model outputs as golden fixtures unless they are synthetic and approved for source control.
09

command

Package the application and create a desktop launcher

caution

Copy the three source modules into a package directory with app.py renamed to __main__.py, build a Python zip application, calculate its checksum, and install it under the current user's local application tree. Create a desktop entry that launches a small wrapper responsible for obtaining LM_STUDIO_TOKEN from an approved local secret mechanism. Do not place the token directly in the .desktop file because desktop entries and process arguments are routinely inspectable.

Why this step matters

A deterministic package and checksum make the desktop artifact inspectable and replaceable while keeping model weights, API tokens, conversations, and machine-specific settings outside it.

What to understand

zipapp packages Python source and an interpreter declaration; it does not bundle Python, Tk, LM Studio, native GPU runtimes, or model files.

The build directory is recreated from reviewed source so stale modules do not remain hidden in a new package.

A launcher should inject secrets through the environment and execute the package directly, never concatenate user input into a shell command.

System changes

  • Recreates build/local_desk_chat, writes dist/local-desk-chat.pyz, and prints a SHA-256 digest.
  • A later explicit install may copy the artifact and launcher into ~/.local without modifying system-wide application directories.

Syntax explained

rm -rf build/local_desk_chat
Removes only the explicit project build staging directory before repopulating it.
python3 -m zipapp
Builds a standard-library Python executable archive from the staged modules.
sha256sum
Creates a reproducible integrity value for release notes and local verification.
Command
cd ~/src/local-desk-chat && rm -rf build/local_desk_chat && install -d -m 0700 build/local_desk_chat dist && cp src/lm_api.py src/store.py build/local_desk_chat/ && cp src/app.py build/local_desk_chat/__main__.py && python3 -m zipapp build/local_desk_chat -o dist/local-desk-chat.pyz -p '/usr/bin/env python3' && sha256sum dist/local-desk-chat.pyz
Example output / evidence
8f1601f39fdc7b3fbac907eeaec772395e2266d22dc0ed69178928307032f744  dist/local-desk-chat.pyz

Checkpoint: Packaged application matches source behavior

cd ~/src/local-desk-chat && test -s dist/local-desk-chat.pyz && unzip -l dist/local-desk-chat.pyz | sed -n '1,12p'

Continue whenThe archive lists __main__.py, lm_api.py, and store.py and contains no database, token, export, model, or test fixture.

Stop whenStop if the archive contains .env files, conversation data, model weights, build caches, private exports, or a hard-coded token.

Security notes

  • The recursive removal is intentionally limited to a literal project subdirectory; verify the working directory before running it.
  • A checksum detects change but does not authenticate the publisher unless it is distributed through a trusted signed channel.

Alternatives

  • Distribute the reviewed source tree and launcher without packaging when transparency is more important than a single-file artifact.

Stop conditions

  • Do not package the application from a directory containing secrets or saved conversations.
10

verification

Measure privacy, responsiveness, and lifecycle acceptance

read-only

Run a documented acceptance session with networking disabled after model download. Verify the app can list the already available model, load it, stream a synthetic prompt, stop a long response, remain responsive, keep ephemeral mode empty, save only after opt-in, export one synthetic conversation, delete it, and unload the model. Record timings and identifiers, never transcript content. Treat an unexpected outbound connection as a release blocker.

Why this step matters

Release acceptance must prove the advertised local boundary and data lifecycle on the actual desktop instead of inferring them from unit tests or interface copy.

What to understand

The listener check verifies the current socket address, not merely the configured URL, and should show no wildcard or LAN binding.

File listing records only permissions, names, and sizes; it avoids copying database content into a support log.

Use synthetic prompts that contain no secrets so screenshots, timing notes, or crash diagnostics remain safe to share internally.

System changes

  • Reads current listener and application data metadata; the acceptance flow may create and then remove one synthetic conversation and model instance.

Syntax explained

ss -lntp
Reports local listening TCP sockets and owning processes without sending network traffic.
find -maxdepth 1
Limits metadata inspection to the application data directory itself.
-printf '%m %f %s bytes'
Shows permissions, file name, and size without printing conversation content.
Command
ss -lntp | grep -E '127\.0\.0\.1:1234|\[::1\]:1234' && find ~/.local/share/local-desk-chat -maxdepth 1 -type f -printf '%m %f %s bytes\n'
Example output / evidence
LISTEN 0 4096 127.0.0.1:1234 0.0.0.0:* users:(("lms",pid=24870,fd=43))
600 conversations.sqlite3 12288 bytes

Checkpoint: Acceptance evidence is complete

Continue whenThe bot works offline with a loopback-only listener, UI remains responsive, persistence is demonstrably opt-in, export and deletion work, and the model unloads.

Stop whenStop release for any wildcard listener, hidden persistence, unbounded response, token exposure, UI execution path, or unexpected external connection.

Security notes

  • Offline acceptance should start only after approved model weights are present; model search and download are intentionally excluded.

Alternatives

  • Use an operating-system firewall test harness when policy requires enforceable egress denial rather than observational verification.

Stop conditions

  • Do not explain an unexpected connection away as harmless until its process, destination, purpose, and disablement are verified.
11

command

Export and back up only intentional local state

caution

Close the application, checkpoint the database, create an explicit JSON export for human portability, and copy the database plus export to an encrypted, access-controlled destination. Keep the model library outside routine conversation backups because weights can usually be re-downloaded or re-sideloaded and consume substantial space. Record the app checksum, LM Studio version, model identifier, and review date beside the backup so recovery does not silently change runtime behavior.

Why this step matters

A deliberate backup preserves opted-in history without sweeping unrelated model files, temporary prompts, tokens, or uncontrolled application directories into an archive.

What to understand

Closing the app and checkpointing WAL state produce a simpler standalone database copy; copying a live database without its WAL can omit committed records.

The integrity check validates SQLite structure, not semantic completeness, privacy approval, encryption, or restore success.

A backup is proven only after restoring into an isolated directory, opening it with the same application revision, and comparing expected synthetic records.

System changes

  • Creates the approved backup directory, checkpoints the local database, copies the database with metadata, and calculates a digest.
  • Does not copy LM_STUDIO_TOKEN, process environment, application logs, model weights, or unrelated home-directory files.

Syntax explained

wal_checkpoint(FULL)
Moves committed WAL content into the main database before a standalone copy.
cp --preserve=mode,timestamps
Retains restrictive permissions and useful recovery timestamps.
sha256sum
Records a content digest that can detect corruption after transfer.
Command
Fill variables0/1 ready

Values stay on this page and are never sent or saved.

install -d -m 0700 "{{backupDirectory}}" && sqlite3 ~/.local/share/local-desk-chat/conversations.sqlite3 'PRAGMA wal_checkpoint(FULL); PRAGMA integrity_check;' && cp --preserve=mode,timestamps ~/.local/share/local-desk-chat/conversations.sqlite3 "{{backupDirectory}}/conversations.sqlite3" && sha256sum "{{backupDirectory}}/conversations.sqlite3"
Example output / evidence
0|12|12
ok
9b662ad5fd4f85434da1a929540a6672808ab7184673d2e81fd1bb24f3fde63e  /media/encrypted/local-chat-backup/conversations.sqlite3

Checkpoint: Backup can be restored in isolation

sqlite3 "{{backupDirectory}}/conversations.sqlite3" 'PRAGMA integrity_check; SELECT count(*) FROM conversations;'

Continue whenThe restored copy reports ok and the expected number of intentionally saved conversations.

Stop whenStop if the destination is unencrypted, broadly readable, automatically shared, or lacks capacity for the explicit files.

Security notes

  • A backup extends retention. Obtain the same consent and apply at least the same access controls as the live database.

Alternatives

  • Export only selected synthetic or approved conversations and omit the database when a full history backup is unnecessary.

Stop conditions

  • Do not back up a live WAL database by copying only the main .sqlite3 file.
12

warning

Upgrade, roll back, and remove the local chatbot safely

For an upgrade, keep the previous pyz and checksum, export approved history, run deterministic tests, then launch the new package against a copied database before replacing the desktop launcher target. If behavior regresses, restore the earlier artifact and database copy. For removal, unload models, stop the LM Studio server if it is no longer used, revoke the app token, remove the launcher and package, and ask separately whether the user wants to delete saved conversations and model weights.

Why this step matters

Application code, conversation history, LM Studio settings, authentication tokens, and model weights have different owners and recovery costs, so one broad deletion command would be unsafe.

What to understand

Published package revisions should be immutable; upgrade by installing a new checksum-addressed artifact and changing one launcher pointer after acceptance.

Database migrations should be additive and tested against a copy; this initial schema has no destructive migration requirement.

Model weight deletion is not required to remove the desktop client and may disrupt other local applications, so it always needs a separate decision.

System changes

  • May replace the launcher target after validation, revoke an LM Studio token, unload a model instance, or delete explicitly selected application files.
  • Does not authorize deletion of the whole LM Studio model directory or unrelated user data.
Example output / evidence
Rollback record: previous artifact local-desk-chat-1.pyz retained; database copy verified; LM Studio token rotated; model library unchanged pending separate owner decision.

Checkpoint: Rollback and teardown boundaries are recorded

Continue whenThe prior artifact and tested database copy remain available until the new revision passes, and every destructive removal target is listed separately.

Stop whenStop if the only copy of intentionally saved history is about to be removed or a cleanup path has not been resolved to the expected application directory.

Security notes

  • Revoking the token is necessary even for a local-only application because old launchers, logs, or backups may retain references.
  • Secure erasure behavior depends on filesystem, snapshots, SSD firmware, backups, and full-disk encryption; ordinary unlink is not a universal purge.

Alternatives

  • Disable the launcher and revoke the token while retaining application state for a defined quarantine period before final deletion.

Stop conditions

  • Never use a recursive removal command against an unresolved variable, the home directory, or the LM Studio library root.

Finish line

Verification checklist

Loopback and authenticationcurl --fail --silent --max-time 5 -H "Authorization: Bearer $LM_STUDIO_TOKEN" http://127.0.0.1:1234/api/v1/models | python3 -m json.tool | head -30An authenticated model inventory returns locally while the same request without the token is rejected when authentication is enabled.
Deterministic testscd ~/src/local-desk-chat && PYTHONPATH=src .venv/bin/python -m unittest discover -s tests -vStreaming framing and opt-in storage lifecycle tests pass without a real model, remote network, or production database.
Artifact inspectioncd ~/src/local-desk-chat && sha256sum dist/local-desk-chat.pyz && unzip -l dist/local-desk-chat.pyzThe package checksum is recorded and the archive contains only reviewed application modules.
Persistence defaultsqlite3 ~/.local/share/local-desk-chat/conversations.sqlite3 'SELECT count(*) FROM conversations;'After an ephemeral acceptance chat the count remains zero; it increases only after the visible save control is enabled.

Recovery guidance

Common problems and safe checks

The model selector remains empty after Refresh.

Likely causeThe LM Studio server is stopped, authentication is enabled with a different token, or no local LLM has been downloaded.

Safe checks
  • Confirm the Developer server shows a loopback address rather than a LAN address.
  • Run the bounded model-list curl request and inspect only its HTTP status and JSON model keys.

ResolutionStart the local server, create or copy the API token into the process environment, download one suitable model while online, then repeat Refresh.

Refresh returns HTTP 401 even though the server responds.

Likely causeLM Studio API authentication is enabled and LM_STUDIO_TOKEN does not match the token configured in the Developer server.

Safe checks
  • Inspect whether the environment variable exists without printing its value.
  • Create a fresh token in LM Studio and compare a SHA-256 digest rather than exposing either token.

ResolutionReplace the desktop launcher secret reference, fully restart the application process, and revoke the previous token after the new token succeeds.

Loading a model fails because memory is insufficient.

Likely causeThe selected quantization, context length, or GPU offload plan exceeds available RAM or VRAM.

Safe checks
  • Use the LM Studio estimate or model information view before changing load settings.
  • Close other GPU-heavy applications and record current memory pressure without deleting any model files.

ResolutionChoose a smaller quantization or model, reduce context_length, load only one model instance, and retest a short prompt before increasing limits.

The first token takes much longer than later responses.

Likely causeThe model is being loaded on demand, the prompt is long, or the runtime is compiling kernels and warming caches.

Safe checks
  • List models and confirm the selected instance reports loaded before measuring.
  • Compare a five-word prompt with the same model and context settings while recording elapsed time locally.

ResolutionLoad the model explicitly at application start, keep a bounded idle lifetime, and present separate load and generation status instead of implying the UI froze.

Streaming stops in the middle of a sentence.

Likely causeThe user pressed Stop, the HTTP read timed out, LM Studio reached a token limit, or a malformed SSE frame was rejected.

Safe checks
  • Check the local status line and application stderr for the specific bounded error.
  • Repeat with a short prompt and max_tokens well below the model context limit.

ResolutionDistinguish cancellation from failure, increase timeout only after measuring local latency, and keep the 64 KiB event limit rather than accepting unbounded frames.

The UI becomes sluggish during generation.

Likely causeNetwork reading or JSON parsing is running on the Tk event thread, or token events are inserted too frequently.

Safe checks
  • Confirm generation starts a daemon worker and the event queue drains through root.after.
  • Profile event frequency with message counts only; do not log prompt or response content.

ResolutionKeep all blocking I/O in the worker, batch very small tokens for display, and cap transcript size while preserving the complete answer only when history is explicitly enabled.

Conversations are stored even though the user expected ephemeral mode.

Likely causeThe save checkbox default changed, an older build retained a preference, or another component writes transcripts independently.

Safe checks
  • Start with a fresh data directory and verify no conversation row appears after a test turn.
  • Search the application code for every call to save_turns and confirm each is guarded by explicit opt-in.

ResolutionRestore save_history to false by default, remove implicit persistence paths, explain the setting beside the checkbox, and provide a visible delete action.

Deleting history reports success but disk space does not change immediately.

Likely causeSQLite WAL pages remain until checkpointing or the operating system retains deleted blocks; secure erasure is not guaranteed on SSDs.

Safe checks
  • Count database rows after deletion and inspect WAL file size without opening transcript contents.
  • Close the application and check whether a fresh database contains zero rows.

ResolutionCheckpoint and vacuum after deletion, document the limits of secure deletion, and recommend encrypted storage for devices with stronger confidentiality requirements.

The packaged pyz starts in a terminal but no desktop window appears.

Likely causeTkinter is not installed, DISPLAY or Wayland bridging is unavailable, or the desktop launcher uses the wrong Python path.

Safe checks
  • Run python3 -m tkinter to test the GUI runtime independently.
  • Launch the pyz from a terminal and inspect the exact local exception without sharing secrets.

ResolutionInstall the distribution Tk package, correct the launcher Exec path, and keep terminal diagnostics available for support rather than suppressing every startup error.

The application unexpectedly reaches an external network.

Likely causeLM_STUDIO_URL points to a non-loopback host, a model download is running, or another desktop feature such as update checking is active.

Safe checks
  • Inspect the resolved configuration; the client must reject any hostname other than loopback.
  • Test offline after models are already downloaded and observe connections with a local OS network monitor.

ResolutionRestore the loopback URL, finish downloads before the offline session, disable optional online features, and use host firewall policy when the threat model requires enforcement.

A conversation export contains text the user intended to keep ephemeral.

Likely causeThe user enabled saving before the conversation, exported a previous database, or the export destination was reused.

Safe checks
  • Open the export locally and identify the conversation IDs and timestamps involved.
  • Verify the source database path and current save toggle before creating another export.

ResolutionDelete the unwanted export, disable saving, create a new clean destination with restrictive permissions, and never upload exports automatically.

The model gives a plausible but incorrect answer.

Likely causeLocal generation remains probabilistic and the selected model lacks the knowledge, context, or capability needed for the task.

Safe checks
  • Rephrase the task with verifiable facts and request uncertainty rather than accepting confident prose.
  • Compare the answer with authoritative local documents or official documentation without granting the model tools.

ResolutionTreat output as a draft, add application-level validation for structured tasks, choose a more capable local model when resources permit, and require human review for consequential decisions.

After the procedure

Alternatives and next steps

Consider these alternatives

  • Use LM Studio's built-in chat interface when its history, privacy, and model controls already satisfy the need; less custom code means a smaller maintenance surface.
  • Use LM Studio's official Python SDK when the application needs richer native model objects, progress events, or API evolution support and the added dependency is acceptable.
  • Build a local loopback web application when accessibility tooling, responsive layout, or multi-window interaction matters more than a small native standard-library UI.
  • Remove history support completely for high-sensitivity environments; an export-only workflow can still let the user save an explicitly selected response.
  • Use an organization-managed inference workstation rather than each laptop when hardware governance is more important than keeping data on the exact end-user device.

Operate it safely

  • Add a local, synthetic evaluation set for the specific tasks users actually perform, measuring helpfulness and unsafe-command frequency without storing private production prompts.
  • Add accessible keyboard shortcuts, screen-reader labels, font scaling, and a high-contrast theme, then test them on the packaged build rather than only the source launcher.
  • Implement a bounded local retrieval index only after defining document consent, path allowlists, deletion, citations, and protection against instructions embedded in retrieved text.
  • Add content-free latency and failure metrics stored locally with a retention limit; never use observability as a back door for transcript collection.
  • Review LM Studio API changes and model lifecycle semantics every ninety days, rerun offline acceptance, and publish a new immutable application revision when behavior changes.

Reference

Frequently asked questions

Does fully local mean the computer never needs internet access?

No. LM Studio installation, model discovery, model download, application updates, and operating-system updates normally require connectivity. After an approved model is present, the inference server and this loopback client can work offline. Prove that claim in an offline acceptance session and distinguish it clearly from the earlier acquisition process.

Why use an API token for a loopback server?

Loopback prevents ordinary remote hosts from connecting, but other local processes may still reach the port. A dedicated token adds a useful application boundary and can be revoked independently. It does not protect against an administrator, debugger, compromised account, or malware that can read the process environment.

Is the SQLite conversation database encrypted?

No. This implementation uses owner-only permissions and relies on the workstation's storage controls. It deliberately does not call plaintext SQLite encrypted. If field-level encryption is required, choose and review an audited encryption design, key custody, backup, recovery, rotation, and deletion procedure before enabling persistence.

Why is saving disabled by default?

A chatbot can accumulate credentials, personal data, incident details, and intellectual property without users noticing the retention boundary. Opt-in storage makes the decision visible at the moment it matters. Teams may enforce a stronger policy by removing the storage feature entirely rather than preselecting consent.

Can the app execute a command suggested by the model?

Not in this design. Output is inert text and must be independently reviewed. Adding an Execute button would turn untrusted probabilistic text into a consequential action path, requiring strict allowlists, structured arguments, approvals, isolation, audit, and a substantially different guide.

Why use both native and OpenAI-compatible LM Studio endpoints?

The native v1 endpoints expose current model inventory and explicit load or unload operations. The OpenAI-compatible Chat Completions endpoint provides a familiar SSE stream for chat. The adapter keeps those contracts separate and cites LM Studio documentation for each instead of assuming every OpenAI feature behaves identically.

Can I expose this chatbot to other devices?

That is outside this local desktop design. LAN service introduces TLS, network firewalling, client identity, authorization, cross-user storage, rate limits, denial-of-service controls, audit, and incident response. Build and review it as a private service rather than changing the loopback validation constant.

How often should this guide be reviewed?

Review it every ninety days and whenever LM Studio changes its v1 lifecycle endpoints, the selected model changes, Python or Tk changes materially, the storage schema changes, or the product adds tools, retrieval, networking, telemetry, cloud fallback, or shared-user features.

Recovery

Rollback

Rollback is artifact-based: stop the new app, restore the previous checksum-verified pyz and launcher target, and restore a database copy only when the new revision changed or damaged local state. LM Studio model weights and server settings remain independent.

  1. Close every Local Desk Chat process and confirm no writer holds the conversation database.
  2. Move the failed artifact aside for diagnosis and restore the previous pyz whose checksum matches the release record.
  3. Restore the pre-upgrade database copy only after preserving the failed copy and confirming the intended conversation count.
  4. Point the desktop launcher back to the previous artifact without embedding the API token.
  5. Start the earlier revision, run one synthetic offline prompt, verify persistence remains opt-in, and record the rollback result.
  6. If retiring the app, revoke its LM Studio token and separately decide whether to retain or delete conversations, exports, packages, and downloaded models.

Evidence

Sources and review

Verified 2026-07-29Review due 2026-10-27
LM Studio REST API overviewofficialLM Studio REST API quickstart and authenticationofficialLM Studio model load endpointofficialLM Studio model unload endpointofficialLM Studio OpenAI compatibility endpointsofficialLM Studio offline operationofficialPython tkinter documentationofficialPython sqlite3 documentationofficialPython zipapp documentationofficial