[ norboten ]
How it works

The whole system, part by part

A terminal interface that drives real virtual machines on your own computer, a runner inside each machine that breaks it and grades it, one server that holds everything a laptop should not, and a CI that refuses to ship a lab nobody can solve. This is the architecture, the data, the pipelines, and the measurements that settled the arguable decisions.

The system

Everything a learner needs runs on their own machine, and keeps working with no network once an image and a lab are downloaded. The server exists for what a client must not do alone: keep accounts and ratings, run the consultant on its own Ollama, carry live sessions to viewers, and serve the site. It calls no hosted model. It is one Docker Compose project on one machine — no cloud provider's services, no Kubernetes.

Your machine norboten — the TUI (Textual) eight sections · the lab screen · doctor the terminal player · the activity log engine → Lima 2.2.0 (pinned) → QEMU + HVF / KVM disk snapshots · serial console · QMP reset Lab VM — Rocky 10 · Ubuntu 26.04 · Alpine norboten-runner standard library only break/ → the faults check/ → pass + evidence your shell · norboten-grader (its own key and sudo) git in /var/lib/norboten/watch → a diff per command ~/.norboten: images · labs · sessions · recordings · token tutor + review: your Claude Code, API key or Ollama One server — docker compose up -d caddy — TLS · the site · proxy api — FastAPI auth · ratings · play · chat grafana · digest dashboards · a weekly timer postgres every table redis live · limits ollama a local model prometheus · exporters · backup and analytics jobs GitHub Actions tests · the gate · Claude Code jobs GHCR images · labs HTTPS pull images, labs rsync + deploy.sh
Without the server, labs, theory, journals, local recordings and the tutor all still work — the tutor runs on your own model — and the boards say they are offline.

A lab, from download to grade

A golden image is the distribution's own cloud image plus a baseline applied by Ansible: every package the track needs, a serial console, a persistent journal, shell history written after every command. Nothing is installed while you work, which is why a lab runs on a plane. The keys below are the lab screen's.

01u Pull by digest 02s Boot cloud-init, grader 03 Snapshot clean, VM stopped 04 Break the faults applied 05o k b h t Work shell · hints 06c Check twice, with reboot r — roll the disks back to 03, cold-boot, re-apply the faults (≈10 s)
The clean snapshot is taken before the faults, with the VM stopped, so a reset is a disk rollback and a cold boot rather than a rebuild.

Grading is a sequence, not a comparison

c copies the lab's check scripts into the guest, runs them as the grading account with a 30-second timeout each, deletes them, reboots the machine, waits for SSH, and runs them again. A check counts as passed only if it passed both times. If the machine never comes back, every check fails with the tail of the serial console as evidence — which is exactly what you want to read when a boot stops in emergency mode.

x is the same run without the reboot: feedback while you work, and it can never mark a lab passed. w repeats it every couple of seconds in a side panel. Persistence is the thing being taught.

Pass 1 copy check/ to the guest as norboten-grader, 30 s pass · message · evidence Reboot delete the scripts reboot, poll SSH ≤ 300 s fresh short connections Pass 2 the same checks again on the booted machine nothing carried over Report passed = pass 1 ∧ pass 2 score = weighted by objective sessions/<lab>.last.json no SSH after the reboot → every check fails, the serial console's tail as evidence c — the grade that counts then, if signed in: POST /attempts

The solvability gate

This runs in CI for every lab a change touches and every base image the lab supports, and for every lab when the runner, the engine, the image registry or the baseline role change. It is the reason the labs can be trusted, and the reason an upstream change that quietly breaks a lab shows up as a red build instead of a bad afternoon.

1 Clean VM from the golden image 2 Apply faults the lab's break scripts 3 All must fail or it tests nothing 4 Reference fix the lab's own solution 5 All must pass and after a reboot A check that passes on the broken machine fails the gate too. Labs that reboot into their fault run it again after that boot.
Measured on this repository: 22–77 seconds per VM lab and image on an M-series laptop (about 100 s for the two labs that reboot into their fault), which is why it runs on every pull request rather than nightly. Locally: make validate LAB=<id>.

Inside a lab VM

Two accounts, one machine, and a line between them. The learner's account is what the lab says it is — an exam simulation gives it no sudo. The grading account is created before the clean snapshot, has its own key and its own sudo, and is the only one the engine uses. No host directory is mounted, no port is forwarded, no agent is forwarded.

lab VM you o — a shell over SSH, in a PTY k — the serial console b — reset into the bootloader sudo: as the lab decides history after every command never sees check/ or solution/ norboten-grader its own key, its own sudo /run/norboten/runner — per run break/ → faults, ctx.state saved check/ → pass, message, evidence facts → what the tutor reads deleted after every run the recording's witness /var/lib/norboten/watch a git dir, work tree = /etc commit after each command diff → the command before it only while p or P records

Grading Claude Code without a token

The claude track runs in a container with the real Claude Code, and nothing in it talks to Anthropic. The launcher on the PATH points Claude Code at a scripted Messages API on loopback — the same stand-in the CI rehearsal uses. A check writes the model's side of the conversation: delete this directory, read that token, call this MCP tool, never stop. Claude Code does everything else for real, and the check grades what it did.

ubuntu-26.04-claude — a container, no network needed check/02_….py claude_lab.run(steps, command=the learner's job) steps: Bash rm -rf drafts, Read .env, Write README… fake_anthropic.py 127.0.0.1, a random port one step per model turn records every request: tools, model, messages Claude Code 2.1.270 — the real binary, as learner settings levels · CLAUDE.md · hooks · subagents · .mcp.json permission mode and rules decide each tool call runs what is allowed, refuses the rest, sends tool results back as the next request ask step what the check grades the machine: are the drafts still there, did origin receive a push, is the file re-indented the requests: did the token reach the model, which tools and model did the subagent get, how many turns
Six labs, gated in 5–8 seconds each. The same stand-in rehearses Norboten's own Claude Code jobs in CI, which is where the next figure starts.

Claude Code in the project's own CI

Four jobs use a model and four do not. Each Claude job is started by an event, runs headless with a turn cap and an explicit tool list, and has its output applied by a script rather than by the model. Measured on real runs: a triage label costs under half a cent, release notes two cents.

event claude -p applied by the script issue opened triage · Haiku · no tools · a JSON schema one label · $0.003 Mondays 08:00 stuck points · Haiku · no tools Discord summary · a hints issue a v* tag release notes · Haiku · no tools · no thinking notes + the full commit list · $0.021 issue labelled lab-request lab author · Sonnet · drafts only · $1 cap a draft pull request · $0.33
Costs are Claude Code's list-price estimates from real runs on 2026-09-14; the jobs run on a subscription token. Lab health, announcements, the learner digest and duplicate detection use no model. Details: How Norboten uses Claude.

The server

One Ubuntu 26.04 machine, bootstrapped, hardened and configured by Ansible, running one Docker Compose project. Only Caddy publishes ports. The API is stateless — everything it keeps is in PostgreSQL or Redis — so a restart, a second worker or a rolled-back image loses nothing.

internet ufw: 22 · 80 443 tcp + udp SSH: keys only no root login /opt/norboten — docker compose caddy 2.10 Let's Encrypt, renews h1 · h2 · h3 norboten.org → /srv/site api. → api:8000 status. → grafana:3000 /metrics → 404 api · uvicorn × 2 /readyz: database + redis hourly purge of old sessions digest · analytics one-shot jobs on host timers a template, no model postgres 17 norboten · volume pgdata redis 7.4 no persistence · 256 MB · LRU ollama qwen2.5:0.5b, pulled on start prometheus 3.5 → grafana 12.1 scrapes every 30 s: api /metrics · node-exporter · postgres-exporter requests and latency by route template, host, database dashboards provisioned from deploy/grafana, behind a login systemd timers 03:20 backup.sh dump → restore check → restic 04:10 analytics → /srv/site Sun 18:00 digest → Discord DMs deploy.sh <tag> pull the image → up -d api → wait for /readyz (120 s) → record .deployed not ready → back to .deployed.previous, exit non-zero, the CI job fails
Why one machine: learners' VMs run on learners' machines, so the server carries accounts, text and a small model. A cloud VPS cannot run lab VMs anyway — there is no /dev/kvm. Full steps: Deploying the server.

Rated and unrated labs

Anything that runs on a learner's machine can be read by that learner, so an answer cannot be shipped and then hidden. The catalogue is split instead. Unrated labs and banks live in this repository — faults, checks and solutions in the open — and run offline. Rated ones live in a private repository attached as the rated/ submodule: GitHub shows the folder and the pointer, not a byte of content, and a plain clone simply leaves it empty.

A rated attempt is issued by the API with a nonce and a short-lived key. The guest receives what to break and which facts to collect, never the criteria those facts are compared against; checking signs the facts from both sides of the reboot and sends them back, and the server decides. The answers never enter the published API image either — they reach the server beside the container, mounted read-only.

The honest limit: whatever is delivered into a VM you control can, with effort, be read out of it. This raises the cost of cheating a long way; it does not make it impossible. Today every lab in the catalogue is unrated. The full write-up.

API issues an attempt nonce · per-attempt key faults · fact manifest you fix the machine no criteria on it nothing in ~/.norboten facts, signed before and after reboot accepted once the server decides pass/fail per check rating change
A rated attempt, as built: the API issues it, the guest collects and signs, the server judges and rates. The machinery works end to end in the tests; the first rated labs are still being written.

The data, and where it lives

Four places, and each holds only what belongs there. PostgreSQL keeps everything that must survive; Redis keeps only what may be lost — a lost key costs a reconnect or a cold cache. The schema is plain SQL applied on every start under an advisory lock, and a column holding data is never renamed or dropped, which is what lets an older API image run against a newer database during a rollback.

PostgreSQL · database norboten credentials user_id PK github_id unique github_login discord_id · digest no password, no email tokens token_hash PK sha256 user_id → credentials kind web | cli | mcp 90 d, or 12 h if not label · expires_at users user_id PK nick unique country · seed attempts kind lab | quiz lab_id · passed duration · score difficulty, topics from the manifest rating_delta jsonb ratings (user_id, topic) PK r · rd · sigma Glicko-2, 19 topics play_sessions session_id PK lab · nick · size expires_at +7 d play_batches (session_id, seq) PK events · commands changes (diffs) pending_sign_ins id PK · 15 min · once events append-only jsonb Redis · nothing persisted play:<session> pub/sub rate:<bucket>:<who>:<window> a counter per minute board:<topic>:<n> 30 s readyz 10 s memory fallback: one worker only ~/.norboten · the learner's lima/2.2.0/ vms/ images/<id>/<arch>/ labs/<id>/<version>/ sessions/<lab>.json .last.json plays/*.cast *.log.json progress.json credentials.json 0600
Not drawn: rated_attempts and rated_quiz_sessions, a rated attempt while it is open. Every column, index and key: Data model. The stores have a memory and a PostgreSQL implementation each, and the same tests run against both.

Signing in

GitHub, and nothing else — no password to steal, no email to keep, no registration. GitHub is asked who someone is; the answer kept is GitHub's numeric user id and the login beside it, and the GitHub token that carried it is revoked at once and stored nowhere. A terminal uses GitHub's device flow through the API, so no GitHub token ever reaches the learner's machine and the API never has to trust a token another app obtained. The site uses the web flow and gets back only a one-time code, in the URL fragment, after checking its own state. The server keeps the SHA-256 of each of its tokens, never the token; revoking one deletes a row.

TUI API PostgreSQL GitHub POST /auth/github/device /login/device/code · no scope INSERT pending · device_code poll_id · WDJB-MJHT · github.com/login/device the person types the code on any device, approves POST /auth/github/poll (every interval) authorization_pending 428 · or 429 slow down, 403 denied, 410 expired POST /auth/github/poll · remember token → GET /user DELETE the token github_id: find or INSERT credentials Norboten token · 90 days remembered, 12 hours not remembered: credentials.json 0600 not: in memory only no GitHub token is stored, and none reaches the TUI
The site's web flow ends the same way: GitHub returns the browser to the API, which sends it back to the page with a one-time code and the page's own state in the fragment; the page checks the state and exchanges the code, which lives sixty seconds and works once. Flows in progress are in PostgreSQL, so a restart strands nobody. Discord is separate and optional: linked from the account page for the weekly digest, a direct message from the bot.

A live session, from keystroke to viewer

P on a lab records the shell and publishes it. The recorder flushes a batch every couple of seconds; the API stores it and publishes it; every viewer's server-sent-events stream forwards it. A viewer subscribes before reading what is stored, so nothing published in between is lost, and a late viewer replays from the first frame.

PTY recorder bytes both ways asciicast v2 events commands from input guest git diff per command changes[] every ~2 s POST …/frames {seq, at, events, commands, changes} retried seq → ignored play_batches PostgreSQL · 7 days PUBLISH play:<id> Redis, any worker GET …/stream 1 subscribe 2 replay stored 3 forward new : heartbeat 15 s closes idle 120 s → the site's player read-only terminal Caddy: flush_interval -1 — never buffer a live terminal live = not ended and a frame in the last 25 s p records to ~/.norboten/plays only; o records nothing

What each agent may see

Four agents, separated by what they are allowed to know. The separation is enforced in code, not in a prompt: the tutor's request model has no field for the reference solution, and the guard that filters every reply is a different component from the model that wrote it. Nothing a model says is executed.

Tutor · t, during a lab objectives, check results the guest's fact bundle the hint level asked for no solution field cannot write to the guest reply guarded on the way out on your machine, your model no model → the hint ladder Review · m, after a lab recorded commands + history the final machine state the reference solution explains what was missed only on pass or surrender never mid-attempt Consultant · the site docs, briefings journals minus walkthroughs question explanations no solution file indexed no level 3–4 hint indexed the tutor's guard, again 20 questions a minute Drafting · off the server writes theory questions drafts labs as pull requests never solves its own its labs face the same gate a person reads every draft

An MCP server, with a door that reads the headers

The API is also a Model Context Protocol server, at /mcp: the docs search, the catalogue, a lab's briefing and first two hints, journals without their walkthroughs, the boards, a quiz that asks the user rather than the model — and, with a token, the account's own progress. It speaks the 2026-07-28 revision statelessly, so any worker answers any request. The API is its OAuth 2.1 authorization server, and an mcp token opens this server and nothing else.

MCP client Claude Code, Desktop, a claude.ai connector one POST per request the gate Mcp-Method · Mcp-Name headers rate limit · audit event no token for my_* → 401 + metadata tools · resources · prompts quiz_me → input_required → you hints to level 2, never a solution every text: the tutor's guard OAuth 2.1, this API PKCE S256 · client metadata URL consent on the site · iss back mcp token: this server only /mcp asks for a token only when a personal tool is called; /mcp/account asks on every request, so a client signs in as it connects. Results say they are data, not instructions: a journal or a briefing contains commands a model should read, not run.

The consultant: retrieval, then a model, then a guard

The consultant answers questions about Norboten from Norboten's own text. Retrieval is BM25, not embeddings: the useful terms in this corpus are exact ones — lvextend, fstab, restorecon — which lexical ranking finds better, for free, with no vector database and a score anyone can explain. The model is the server's own Ollama, and the call runs off the event loop.

corpus docs/*.md briefings journals − walkthrough explanations ≤ 180 words each BM25 k1 1.5 · b 0.75 built at API start top 5 passages model, first usable ollama/qwen2.5:0.5b Ollama models only JSON schema reply asyncio.to_thread guard solution text? command leak? blocked → logged answer + sources /chat or /chat/stream API unreachable the same corpus → site/dist/ask-index.json at build → ask-engine.js ranks it in the browser with the same tokenizer and BM25 → quotes the best three passages. A node test checks the two tokenizers agree.

How a drafted question earns its place

Every executable question in the repository's banks runs in CI. New ones are drafted on a maintainer's machine, on a Claude subscription: no draft goes into a bank until other models have solved it blind, a critic has failed to find a second defensible answer, a container has printed what it claims a program prints — and a person has read it. A learner can draft practice questions of their own the same way, with g on Theory.

1 Generate one model writes it 2 Schema the spec, checked rejects malformed 3 Solve blind two other models rejects a mismatch 4 Critic a second answer? rejects ambiguity 5 Execute no network rejects wrong keys 6 Dedup against the banks rejects repeats A surviving draft keeps its provenance: the writer, each verifier's blind answer, the critic's verdict, and what the snippet printed. Solvers never include the writer; without two usable models besides it — Claude Code, or exported keys — the pipeline refuses to start.

From an idea to a lab: the Claude Code plugin

Content is written with norboten-author, a Claude Code plugin this repository publishes as its own marketplace. /norboten-author:idea takes an idea, decides whether it is a lab, theory questions or a journal, and asks only what the specifications need and the idea leaves open, with a default for each. After a yes to its plan, the work runs in the same session, where the author steers it, or in a subagent that returns a draft — the questions always come first, because a subagent cannot ask them. Nothing is done until the repository's own checks say so: a hook lints every edited lab, and the gate, the question pipeline or the journal linter has the last word.

idea one sentence /norboten-author:idea questions only what the spec needs and what your idea left open plan the files, the proof, the cost waits for a yes in this session, or in a subagent new-lab · new-questions · new-journal lab-author · question-author · journal-author draft + lint a hook lints every edited lab norboten dev lint proof the gate, on real VMs or the question pipeline
One pull request per lab or bank, after the proof. In this checkout the plugin loads from the working tree; elsewhere /plugin marketplace add northelks/norboten, then /plugin install norboten-author@norboten.
claude — 2.1.273 (Claude Code)
$ claude plugin details norboten-author@norboten
Norboten author (norboten-author) 0.2.0
  Description: Turn an idea into a Norboten lab, theory questions or a journal: it asks what the specs need, shows a plan, then drafts, lints and proves it — in your session or in a subagent.
  Source: norboten-author@norboten

Component inventory
  Skills (5)  captures, idea, new-journal, new-lab, new-questions
  Agents (3)  question-author, journal-author, lab-author
  Hooks (1)  PostToolUse  (harness-only — no model context cost)
  MCP servers (1)  norboten  (tool schemas resolved at runtime; not counted)
  LSP servers (0)

Projected token cost
  Always-on:   ~536 tok   added to every session

Per-component (rounded)
  component        always-on  on-invoke
  captures               ~60       ~450
  new-journal            ~80       ~860
  new-lab                ~60      ~1.4k
  idea                   ~90      ~1.2k
  new-questions          ~70       ~890
  question-author        ~60       ~400
  journal-author         ~70       ~460
  lab-author             ~50       ~840

  On-invoke cost is paid each time a skill or agent fires.
  Token counts are estimates and may differ from actual usage.
What Claude Code reports about the plugin — a real run of claude plugin details after installing it from this checkout (site/capture_plugin.py). More in The Claude Code plugin.

Analytics and relations

The Analytics page is drawn from the attempts table: nightly on the server, from the sample population when the site is built. How labs, topics, banks and journals relate is computed rather than curated — deterministic, explainable, and needing nothing a relational database and scikit-learn do not already do.

source PostgreSQL users, attempts (04:10) or seed/accounts.json pandas frames pass rate by lab, difficulty time to solve, over the clock monthly cohorts (≥ 5 users) ratings via accounts.rate models k-means on topic ratings TF-IDF + cosine similarity networkx relation graph output 11 SVG charts, site palette facts.json → the sentences /analytics/ matplotlib renders to SVG with the site's colours and fonts; the page's numbers are read from facts.json, so text and charts never disagree. A vector store would add nothing yet; pgvector in the same PostgreSQL is the next step if semantic similarity is ever needed.

CI/CD

GitHub Actions does all of it. A pull request proves the code, the specs and every touched lab; a push to main tests against real PostgreSQL and Redis, builds the API image and the site, and rolls both out over SSH. A rollout that does not become ready rolls itself back.

pull request cli-test ruff · specs · pytest labs — the gate touched labs × images, /dev/kvm quiz-verify executable questions, no network push to main test postgres + redis services node checks image buildx amd64 + arm64 → GHCR :sha site build.py against api.<domain> rollout · environment production rsync deploy/ and site/dist ssh: sudo deploy.sh <sha> curl /readyz and the site deploy.sh on the server ready in 120 s → .deployed = sha not ready → previous tag, job fails manual deploy with api_tag = an older commit builds skipped; that commit's deploy/ and image, still in GHCR tag v* · manual lab-publish labs as OCI artifacts, signed with cosign image-publish golden image on KVM → oras push → PR with the digest
Every workflow and its secrets: CI/CD. Locally, make server-rehearsal runs the production playbook and deploy.sh against an Ubuntu VM on the laptop.

Decisions worth defending

Disk snapshots, not memory snapshots

QEMU can save a running machine's memory, which would make a reset instant. On Apple Silicon it also breaks the next reboot: after savevm the guest hangs in UEFI at 100% CPU, and starting QEMU with -loadvm aborts outright. Norboten snapshots the disk and cold-boots instead, and the golden images are tuned to make that fast.

Measured: reset 9.8–9.9 s on all three images

A grading account, separate from yours

The exam simulation takes your sudo away — that is the point of it. Everything that grades you therefore runs as its own account with its own key, so a lab can remove your privileges, and you can break your own sudo, without breaking the grader.

Boot speed is a feature

Every second of boot is paid on every reset. The baseline removes what blocks a lab VM's boot: cloud-init datasource probing, a chrony step that waits for NTP, dhcpcd's ARP probe, and a ten-second bootloader menu.

Measured: Alpine boot 22 s → 7 s

Poll SSH instead of waiting for Lima

Lima makes one SSH attempt as QEMU starts, and slirp then retransmits its SYN with exponential backoff while the guest boots — so it reports ready long after the machine is. Norboten polls with fresh short connections instead.

Measured: Rocky reset 19.7 s → 9.9 s

The quick check can never pass a lab

The quick check (x) is feedback while you work. It reports every check and refuses to mark the lab passed, because the reboot is the part that proves the fix.

The tutor cannot leak what it never had

The solution is not in the tutor's request model, so no prompt injection can extract it. A separate guard then compares the reply against the solution and the hint level, and logs every block. The consultant's replies go through the same guard.

Measured: 31 guard tests, adversarial replies included

One server, no Kubernetes

Hosted lab VMs would need /dev/kvm, which a cloud VPS does not offer, and without them the server carries accounts, text and a small model. One machine and a compose file cost a few euros a month, can be rehearsed on a laptop, and fail in ways one person can read.

BM25, not a vector database

The consultant's corpus is a few hundred passages whose useful words are exact commands and file names. Lexical ranking finds those better than embeddings, needs no model to index, runs identically in the browser as a fallback, and every score can be explained.

GitHub is the only way in

No password column, no email and no registration: the first sign-in with a GitHub account makes the Norboten account, keyed by GitHub's user id. The terminal's device flow goes through the API, so no GitHub token reaches a learner's machine and none is stored anywhere. The cost is plain — no GitHub account, no sign-in — and only ratings need one.

A column that holds data is never dropped

The schema is idempotent SQL applied on start, and no migration renames a column or drops one that holds data. An older API image therefore runs against a newer database, which is what makes deploy.sh's automatic rollback safe to do without a person. The one exception is before any account existed: the password and email columns of the earlier sign-ins went on 2026-09-16.

The stack

Every row does a job nothing else in the stack does, at the version this repository pins. If a row could be deleted without breaking something, it would be.

The client

TechnologyVersionWhat it does hereWhy this oneWhere
Python ≥ 3.12 The TUI, the engine, the recorder, the lab tooling. What administrators already have; the runner can share its models. cli/
Textual 8.2 The whole interface: sections, the lab screen, modals, the activity log. A real layout engine in a terminal, testable headless with its pilot. cli/src/norboten/tui
pyte 0.8 Emulates a terminal screen for the in-TUI player and the captures. A VT100 screen buffer in pure Python. tui/widgets.py
Typer 0.27 The hidden dev and image commands for authors and CI. Commands from type hints; the learner never needs it. cli/src/norboten/cli.py
Pydantic 2.13 Lab manifests, question banks, reports — shared with the API. One schema, validated on both sides and exported as JSON Schema. cli/src/norboten/models.py
httpx 0.28 Calls to the API, signing in, streaming uploads. Timeouts and streaming without ceremony. tui/data.py, auth.py

The machines

TechnologyVersionWhat it does hereWhy this oneWhere
Lima 2.2.0 Renders and runs one VM per lab; downloaded and checksummed by Norboten. QEMU with cloud-init and SSH solved, on macOS and Linux alike. cli/src/norboten/lima
QEMU HVF / KVM Real kernels, real boots, serial consoles, QMP reset, qemu-img snapshots. A container cannot fail to boot, and failing to boot is a lab. lima/snapshot.py
Rocky 10 · Ubuntu 26.04 (+ automation, + devops) · Alpine · containers by digest The golden base images the labs run on. The distributions the exams and jobs use, from their own cloud images. images/registry.yaml
Ansible core The baseline role that turns a cloud image into a golden image. The same tool configures the server; readable by the people it teaches. ansible/roles/lab_baseline
The runner stdlib only Applies faults, runs checks, collects facts inside the guest. Nothing may be installed in a lab VM. runner/norboten_runner

The server

TechnologyVersionWhat it does hereWhy this oneWhere
FastAPI + uvicorn 0.141 · 0.52 Auth, attempts, ratings, the consultant, live sessions, metrics. Async for SSE, Pydantic models shared with the client, OpenAPI for free. api/src/norboten_api
PostgreSQL 17 Every durable table. One database that does arrays, JSON and advisory locks; pgvector if ever needed. api/…/db.py
SQLAlchemy + asyncpg 2.0 · 0.31 The async connection pool; queries are plain SQL. A pool and a driver without an ORM between the SQL and the reader. api/…/db.py
Redis 7.4 · redis-py 8.1 Pub/sub for live frames, rate limits, a 30 s cache. Crosses worker processes; nothing in it needs to survive. api/…/live.py
GitHub OAuth · Discord REST device and web flows · API v10 Who someone is, once; and the weekly digest as a Discord direct message. No password or email to keep; a bot message needs no gateway connection. api/…/github.py, discord.py
Ollama 0.34 · qwen2.5:0.5b The server's only model, the consultant's; the tutor's last choice on a learner's machine; and four automation labs' subject. No key, no bill, nothing leaves the server. norboten/questions/providers.py
Claude Code · OpenAI · Gemini local, optional keys Drafting theory questions and their blind verification, off the server. A subscription serves its owner; other vendors make blind solving stronger. norboten/questions/
Caddy 2.10 TLS, the static site, the reverse proxy, unbuffered SSE. Certificates with no cron and no certbot; a config that fits on a screen. deploy/Caddyfile
Docker Compose v2 The whole server as one project, locally and in production. The same file on a laptop and a server; nothing to operate. deploy/compose.yaml
Claude Code 2.1.270 Issue triage, stuck-point summaries, release notes, lab drafts — and the claude track's subject. Headless runs with turn caps and tool lists, rehearsed against a scripted API. automation/jobs
Prometheus + Grafana 3.5 · 12.1 Request rate and latency by route, host and database health, dashboards. The standard pair; exporters for the host and PostgreSQL exist already. deploy/prometheus.yml

Data and the site

TechnologyVersionWhat it does hereWhy this oneWhere
pandas 3.0 Frames from the attempts table for every chart. Group-bys and cohorts in a few lines. api/…/analytics
scikit-learn 1.9 TF-IDF relations between labs, banks, journals; k-means on ratings. Deterministic and explainable, no service to run. analytics/relations.py
networkx · matplotlib 3.6 · 3.11 The relation graph; every chart, rendered to SVG in the site's palette. Static SVG needs no JavaScript and prints. analytics/figures.py
Jinja · markdown-it · Pygments 3.1 · 4.2 · 2.21 The static site: pages, docs, journals, highlighted code. A site is files; nothing to run but Caddy. site/build.py
JavaScript, no framework The terminal player, live viewer, profile, consultant fallback. A screen buffer and a clock are one file each. site/static

Delivery

TechnologyVersionWhat it does hereWhy this oneWhere
GitHub Actions Tests, the solvability gate, question verification, publish, deploy. Where the pull requests are. .github/workflows
GHCR · OCI · cosign The API image, golden images, labs as signed artifacts. Content-addressed: a client gets exactly what CI published, or nothing. images/Dockerfile.lab
Ansible core Hardening, Docker, the compose project, timers, secrets from the vault. Idempotent, agentless, and rehearsable against a local VM. ansible/playbooks/server.yml
restic optional Off-box copies of the nightly, restore-checked dumps. Encrypted, deduplicated, any S3-compatible target. deploy/backup.sh
uv · ruff · pytest The workspace, the lockfile, lint and format, about 480 tests. One fast tool per job. pyproject.toml