← Back to chat

How I was created

I'm a small language model running on Carlo's desktop. Here's the whole stack — the model, the retrieval pipeline, the path your message travels, what gets logged, and the sandbox that keeps me from doing any damage.

The two halves

This site is split deliberately into two pieces that live in completely different places:

The website is the front door; the chatbot is the brain. They live in separate repositories on purpose — if the model code and the prompt-engineering files were in the website repo, GitHub would happily serve them as plain-text downloads to anyone curious enough. Keeping them apart means the only thing the public can reach is one narrow API.

Nothing about me runs in the cloud. There's no OpenAI key, no hosted inference. The model weights and every generated token come off a single consumer GPU in a desktop.

The model

The brain is Qwen3-4B — a 4-billion-parameter open-source model from Alibaba's Qwen team — loaded in 4-bit quantisation (the pre-quantised unsloth/Qwen3-4B-bnb-4bit build). Storing the weights pre-quantised to 4-bit nf4 means loading skips the full-precision init buffer that would otherwise blow past the memory of a small (8 GB) card; the compute itself runs in bfloat16. The whole thing fits comfortably in a couple of gigabytes of GPU memory.

Qwen3 is a hybrid reasoning model: it can either answer immediately or first write out a private chain of thought inside a <think>…</think> block and then answer. The server decides which mode to use per message:

The retrieval pipeline

On its own, a 4B model knows nothing specific about Carlo. That's what the RAG (Retrieval-Augmented Generation) layer is for. Carlo keeps his CV and project notes as a set of markdown files in an Obsidian vault, and the system prompt is assembled from them in two tiers:

That two-tier split keeps the prompt small on simple turns and only spends context on deep notes when a question actually calls for them.

The path your message travels

Everything is glued together by a Python server (server.py) built on FastAPI and Uvicorn, with Pydantic validating incoming JSON and slowapi enforcing rate limits. When you hit "Send", here's the round trip:

  1. The page's JavaScript packages your message plus the recent conversation history into a JSON POST request.
  2. That request crosses the public internet to a Tailscale Funnel tunnel, which terminates TLS and forwards it to the desktop. The server itself only listens on 127.0.0.1:8000 — the loopback interface — so the tunnel is the only way in.
  3. The server validates the input, checks the rate limit and the daily cap, retrieves relevant notes, and builds the system prompt.
  4. It decides fast-vs-thinking, then Qwen3 generates a reply on the GPU. A lock makes sure only one generation runs at a time, so two visitors can't collide on the single GPU.
  5. The hidden reasoning is stripped off, the turn is written to a local log (see below), and FastAPI returns JSON — {"reply": "…"} — back through the tunnel, where the JavaScript drops the text into the chat window.

A second endpoint, GET /health, just returns {"ok": true} so the tunnel and any monitoring can check whether I'm awake.

What I remember — and what gets logged

Two different things, worth keeping straight:

That log stays on the desktop, inside the chatbot's sandboxed workspace — it isn't sent to any third party. But it's a real log of real conversations, so: don't tell me anything you wouldn't want written down.

The security model

The model itself is harmless — Qwen can't read a filesystem. The risk is the surrounding software: FastAPI, Uvicorn, PyTorch and a couple hundred transitive dependencies all become internet-reachable the moment the tunnel is on. One remote-code-execution bug in any of them, and an attacker would be running code on a daily-driver desktop. So defence happens at two layers.

Layer one — guards inside the server

These don't stop a machine compromise; they stop cost runaway and obvious abuse.

GuardWhat it prevents
CORS allowlist — only camofu.github.ioOther sites embedding the bot in their pages
Per-IP rate limit — 10 requests/minOne client hammering the endpoint
Daily cap — 500 requests/dayBurning a whole day's compute budget in one afternoon
Input length cap — 800 charactersMegabyte-long prompts sent to OOM the GPU
Server-side history trim — last 6 turnsClients inflating context by lying about history length
Generation lockTwo requests colliding on the single GPU at once
Bound to 127.0.0.1Any direct internet access that bypasses the tunnel

Layer two — an OS-level sandbox

The server runs as a dedicated, locked-down system user (chatbot) with no login shell, no home directory, and ownership of nothing except one scratch folder. It's launched and supervised by systemd, which applies a long list of kernel-enforced restrictions before any Python runs:

The upshot: even a complete RCE-class compromise of the Python process lands the attacker in a read-only box, as a user that owns nothing but a scratch directory, with no credentials and no other processes in sight.

What's left as residual risk

Two things this design doesn't fully eliminate. Prompt injection — clever messages trying to make me say something off-script — is partly handled by the persona decision tree and partly an irreducible fact of any public LLM. And a kernel exploit could in principle escape the sandbox; the airtight fix (a VM with GPU passthrough) wasn't viable because the desktop's single GPU shares an IOMMU group with the boot SSD, so it can't be isolated to a VM without faking guarantees the hardware doesn't make. Running on a dedicated GPU VPS is the available "zero-risk" upgrade if it's ever worth it.