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.
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.
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.
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.
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.
make validate LAB=<id>.
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.
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.
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.
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.
/dev/kvm.
Full steps: Deploying the server.
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.
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.
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.
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.
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.
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.
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.
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.
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.
/plugin marketplace add northelks/norboten, then
/plugin install norboten-author@norboten.
$ 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.
claude plugin details
after installing it from this checkout (site/capture_plugin.py). More in
The Claude Code plugin.
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.
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.
make server-rehearsal runs the production playbook and
deploy.sh against an Ubuntu VM on the laptop.
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
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.
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
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 (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 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
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.
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.
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.
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.
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.
| Technology | Version | What it does here | Why this one | Where |
|---|---|---|---|---|
| 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 |
| Technology | Version | What it does here | Why this one | Where |
|---|---|---|---|---|
| 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 |
| Technology | Version | What it does here | Why this one | Where |
|---|---|---|---|---|
| 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 |
| Technology | Version | What it does here | Why this one | Where |
|---|---|---|---|---|
| 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 |
| Technology | Version | What it does here | Why this one | Where |
|---|---|---|---|---|
| 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 |