A privacy-first AI learning environment for classrooms
Setun is a self-hosted environment for teaching AI and Teknologiforståelse. Students use pseudonymous access cards instead of accounts, email addresses, or real names. Educators control which models, skills, tools, schedules, and spending limits each classroom receives.
The application combines streaming AI chat with a code artifact workspace, while keeping generated code on a separate, tightly restricted origin. Model credentials stay behind an internal gateway and never reach the browser or the Setun database.
The name comes from the 1958 Setun computer, built around balanced ternary. The mark stacks its three states: minus, zero, and plus.
- Pseudonymous student access — issue printable access cards without collecting names, email addresses, or third-party identities.
- Classroom controls — manage rosters, weekly schedules, temporary locks, model allowlists, instructions, retention, and daily or per-student budgets.
- Streaming AI chat — run classroom-scoped conversations with attachments, search, cancellation, and model aliases chosen by the educator.
- Live artifacts beside the conversation — preview, edit, rerun, and version HTML, SVG, JSX,
TSX, and Svelte creations in a pane that never covers the composer, so a student can look at what
the model made while asking for the next change. One control moves the workspace between the
conversation, both, and the build. Artifacts are built up over several turns: the model writes an
id on the fence (
```html id=home-page) and reuses it to revise the same thing, and a run's outcome travels back so the next answer can fix the error the student saw. - Curated capabilities — publish reusable skills and expose only approved MCP tools, with per-classroom controls for tools that require confirmation.
- Image workflows — support image attachments and model-generated images without exposing the underlying storage directory.
- English and Danish — complete localized interfaces, with a classroom default and per-student override.
- Light and dark — the interface follows the device by default, with an explicit choice stored in the browser and never against the student.
- Self-hosted operations — SQLite persistence, automatic migrations, retention jobs, nightly snapshots, and a Compose deployment behind Caddy.
flowchart LR
Browser[Student and educator browsers] -->|app.example.org| Caddy
Browser -->|artifacts.example.org| Caddy
Caddy --> App[Setun app]
Caddy --> Sandbox[Static artifact sandbox]
App --> SQLite[(SQLite)]
App --> Storage[(Private file storage)]
App --> CPA[CLIProxyAPI]
CPA --> Providers[AI providers]
App --> MCP[Approved MCP servers]
The application and artifact sandbox must use different hostnames. That origin boundary is part of the security model: Caddy serves the sandbox as static files with a restrictive content security policy, and generated code has no route back into the authenticated application. CLIProxyAPI is reachable only from the app's internal network and has no published host port.
| Layer | Technology |
|---|---|
| Runtime and package manager | Bun 1.4+ |
| Application | SvelteKit 3 with the official Bun adapter, Svelte 5, TypeScript |
| UI | UnoCSS, shadcn-svelte, bits-ui |
| Data | SQLite with Drizzle ORM |
| Validation and forms | Valibot and sveltekit-superforms |
| Localization | Paraglide JS |
| Model gateway | CLIProxyAPI |
| Production edge | Caddy and Docker Compose |
| Quality gates | svelte-check, Biome, Bun test, Vitest, Playwright, Python, repository hygiene |
For local development, install:
- Bun 1.4 or newer
- Node.js 22.17 or newer (CI uses
.node-version) forsvelte-kit sync, svelte-check, Vitest and Playwright - Python 3.14 for the development suite
- prek for repository hooks
Then run:
bun install
prek install
./scripts/devsuite startThe suite starts the application, sandbox, and a persistent development database, then prints the
student and educator URLs and credentials. Development secrets are generated per instance when they
are not present in the environment or .env; the suite never writes them to .env.
The default application is at http://localhost:5173, with educator login at
http://localhost:5173/educator/login. The sandbox runs separately on port 5174. Pressing Ctrl-C
stops an attached stack while preserving its data.
Useful commands:
./scripts/devsuite status # show services, ports, and health
./scripts/devsuite logs app # follow one service
./scripts/devsuite stop # stop and preserve the default instance
./scripts/devsuite start --ephemeral # use a disposable database
./scripts/devsuite start --with-cpa # include the model gateway; needs Docker
./scripts/devsuite start --production # reproduce the Caddy deployment locally
./scripts/devsuite --help # all commands and optionsProduction mode serves http://setun.localhost:8080 and
http://sandbox.setun.localhost:8080. It builds both applications, runs bun ./server.js
behind the repository's Caddy configuration, and uses Caddy's static file server for artifacts. TLS
is the only production behavior omitted.
To run the two development servers manually instead:
bun --bun run dev # application on :5173
bun run dev:sandbox # sandbox on :5174, in a second terminalYou need Docker with Compose, Bun 1.4+, and two DNS names pointing to the host: one for Setun and one for the artifact sandbox.
bun install --frozen-lockfile
cp .env.example .env
cp cpa/config.example.yaml cpa/config.yaml
cp mcp.example.json mcp.jsonFill in .env, then configure the provider account in cpa/config.yaml. Two secrets deserve special
care:
SETUN_STUDENT_CODE_PEPPERmust be high-entropy and permanent. Changing it invalidates every existing student access code.SETUN_CPA_LISTENER_KEYmust exactly match the value underapi-keysincpa/config.yaml.
SETUN_APP_ORIGIN and SETUN_SANDBOX_ORIGIN must be full public URLs on different hosts. Their
hostname-only counterparts configure Caddy. Leave both educator seed variables blank to use the
guided first-run setup. SETUN_APP_ORIGIN is also the app's public origin, which Compose passes to
it as ORIGIN (see "Public origin" below).
MCP is optional. If the installation offers no tools, replace the example entry in mcp.json with
an empty servers object. Credentials are referenced there by environment-variable name; do not put
secret values in the JSON file.
bun run build:sandbox
docker compose run --rm --no-deps app touch /data/db/setun.sqlite # new installation only
docker compose up -d --build
docker compose logs appThe sandbox build is explicit because Caddy serves build-sandbox/ directly from the host. The app
itself is built into its container image.
A new installation creates its database file deliberately, once. In production Setun refuses to
start on a missing database, because that usually means the volume is not mounted, and a fresh
database would reopen first-run setup on a configured installation. touch never changes the
contents of an existing database.
On an unconfigured database, Setun writes a one-time setup token to the app log and redirects every
route to /setup. Open the application URL, enter the token, and follow the wizard to:
- Create the educator account.
- Verify the model gateway.
- Add the first model alias.
- Create a classroom.
- Issue the first student access cards.
The token expires after 15 minutes; restarting the app issues a new one. If
SETUN_EDUCATOR_SEED_USERNAME and SETUN_EDUCATOR_SEED_PASSWORD are both set, Setun seeds that
account at boot and skips the wizard. Updating the seeded credential and restarting remains a
supported recovery path for forgotten educator credentials.
The educator login page shows a generic command that an operator with shell access can run against the live database. The browser never performs the reset, and recovery needs neither manual SQL nor an application restart.
From the deployment directory, use the interactive mode to enter a new educator username and a new password. Both password prompts hide terminal input:
docker compose exec app bun /app/recover-educator.jsTo have Setun generate a 256-bit password instead, run:
docker compose exec app bun /app/recover-educator.js --generateThe generated password is printed exactly once, on its own terminal line after the database commit. Setun does not copy it to the clipboard or write it to a log or configuration file. Save it before closing the terminal; if it is lost, run recovery again. The new username is always collected by the prompt, so neither credential belongs in shell history or a process listing.
For a source-based installation or manual development server, run the matching package scripts from the repository root:
bun run recover:educator
bun run recover:educator -- --generateThe application must already be running on an existing, migrated database. Compose supplies the
container with the same database path and seed environment automatically. For a direct invocation,
run from the same configured environment as the application: SETUN_DATABASE_PATH must identify the
live database, and the command must see the same SETUN_EDUCATOR_SEED_* values when they are used.
Bun loads the repository's .env automatically. The command refuses a missing database, a partial
seed pair, a non-interactive terminal, and any password supplied as an argument.
Recovery updates the one educator row and invalidates every live educator session in one immediate
SQLite transaction. Pupil sessions and all classroom data, conversations, artifacts, files, and
backups are untouched. Exit status 0 means success, 1 an operational failure with no committed
change, 2 invalid usage or input, and 130 cancellation.
Boot-time seeds remain backward compatible. A CLI recovery stores only an Argon2id hash identifying
the seed pair that it superseded, so restarting with that unchanged pair cannot silently restore the
old credentials. Changing either seed value and restarting is treated as an intentional seed-based
reset, updates the same educator row, and invalidates educator sessions. Leaving both variables blank
keeps the recovered credential under CLI control; the recovery command never edits .env itself.
The roster can replace one pupil's access code or every active pupil's code and produce A4 access slips for printing or direct PDF download. Reissuing a slip immediately invalidates the previous code and that pupil's signed-in sessions. Plaintext codes exist only in the issuing action response; Setun does not store them, and reloading or leaving the page makes them unrecoverable.
Each QR code contains the configured application origin followed by /login#code=…. The access code
is in the URL fragment, so browsers do not send it in HTTP requests or referrers, and the login page
removes the fragment from browser history before submitting through the normal sign-in action. PDF
files are generated entirely in the browser; no credential-bearing PDF request reaches the server.
An access slip is still a bearer credential: anyone with a copy can sign in as that pseudonymous pupil until the code is rotated, the account is disabled or removed, or normal access policy blocks the sign-in. Store and distribute printed and downloaded copies accordingly.
- Provider credentials belong to CLIProxyAPI, not Setun. Its management API, control panel, plugins, and public port are disabled by the supplied configuration.
- MCP servers are defined in the read-only
mcp.json; the educator panel may enable configured servers and tools but cannot add endpoints. - Persistent data lives in separate Compose volumes for the database, private storage, backups, provider authentication, and Caddy state.
- TLS is handled automatically by Caddy for publicly reachable hostnames. For a closed network,
use Caddy's internal CA as described in the comments in
Caddyfile. - Backups contain a consistent SQLite snapshot plus private storage. Fourteen daily snapshots are retained by default; copy the backup volume off-host for disaster recovery.
- Public origin. Set
ORIGINto the address people open in the browser: scheme, host and port only, for exampleORIGIN=http://192.168.1.10:3000.SETUN_APP_ORIGINmeans the same, and one of the two is enough; when both are set,ORIGINwins. Compose takes it fromSETUN_APP_ORIGINin.env. Setun needs it in every deployment:- Over plain HTTP, set the
http://address. SvelteKit 3 has no runtime origin setting of its own, so the production entry,bun ./server.js, runs the app on a private socket and tells it this origin on every request. - Behind an HTTPS reverse proxy that passes the original
Host(the Compose deployment's Caddy), set thehttps://address. Setun prints it on access slips, QR codes and the first-run banner. - With neither
ORIGINnorSETUN_APP_ORIGINset, the server stops at startup, before it listens, withSetun needs its public address: set ORIGIN to the address users open, for example ORIGIN=http://192.168.1.10:3000.Onlybun run devfalls back tohttp://localhost:5173. - A value with a path, query, fragment or credentials stops the server at startup with
ORIGIN must be a bare http(s) origin such as http://192.168.1.10:3000 (no path, query, fragment or credentials).The origin is never taken from the request'sHostheader. - Start production with
bun ./server.js, neverbun ./build: that skips the origin handling and the process guard.
- Over plain HTTP, set the
- Writes from other origins. Setun refuses any
POST,PUT,PATCHorDELETEwhoseOriginheader is not the public origin above, whatever its content type. Browsers send it on their own; a script that calls Setun's API must sendOrigin: <the public origin>itself. - Behind a proxy. Set
ADDRESS_HEADER=x-forwarded-forandXFF_DEPTH(the number of proxies in front of Setun, default 1) only when every request passes through them, so the per-address login and rate limits see each pupil's address rather than the proxy's. Compose sets both for its Caddy.PROTOCOL_HEADERandHOST_HEADERare for deployments withoutORIGIN; Setun always has an origin, and its front replaces them. - Shutdown and request size.
SHUTDOWN_TIMEOUTis how many seconds a stopping server lets open requests, such as a pupil's streaming answer, finish: 30 by default, and 7 in the container image, so that it fits inside the 10 secondsdocker stopwaits. When it has passed, the server exits even if the app still has work in flight, such as a turn waiting on the model. Raise it only together withdocker stop -tor Compose'sstop_grace_period.BODY_SIZE_LIMITcaps a request body, 2M by default (Compose readsSETUN_BODY_SIZE_LIMIT); restoring an artifact posts the whole project, which may be up to 1 MB. - Missing compressed files. The build ships a
.brand a.gzcopy of every static file, and the server sends one when the browser accepts it. If one of those copies goes missing on disk while the server runs (for example, a new build copied over a running one), the front asks the app for the plain file instead, so the page keeps working.
Every build has two outputs:
build/— the application, built by@sveltejs/adapter-bunand started bybun ./server.jsbuild-sandbox/— the static artifact host, runtimes, and compilers
Always use bun run build; running only the SvelteKit build leaves the artifact panel without the
files it needs. The development suite keeps each named instance under .devsuite/instances/ with
its own database, logs, and production build outputs.
Run the application checks from the package scripts and Python checks with
bash scripts/check-python.sh. Run repository hygiene checks locally with prek;
prek run --all-files --hook-stage manual is the set CI runs. CI also runs the component and
Playwright suites below, bun run build && bun run smoke, a cold production start, and
bun run smoke:container, which starts the Compose deployment with dummy settings and checks it
through Caddy (it needs Docker and the build).
bun run check # Svelte and TypeScript correctness
bunx biome ci # formatting and linting
bun test # server logic and rune modules
bun run test:component # Chromium components and Vite server tests
bunx playwright test # end-to-end flows
bun run check:python # locked Ruff, format, strict types, syntax, dev-suite testsInstall Chromium once with bunx playwright install chromium if it is not already available.
Test filenames select their runner:
| Scope | Filename | Runner |
|---|---|---|
Server logic and .svelte.ts rune modules |
*.test.ts |
Bun |
| Svelte components | *.svelte.spec.ts |
Vitest Browser Mode |
| Other tests needing Vite resolution | *.spec.ts |
Vitest server project |
| Real-server flows | *.e2e.ts |
Playwright |
- Add every user-facing message to both
messages/en.jsonandmessages/da.json. - Styling uses UnoCSS, not Tailwind. Add shadcn-svelte components with
bunx shadcn-svelte add <component> --skip-preflight; never runinit. - Use
bun run checkas the type authority for Svelte templates. Biome uses experimental markup support with the compatibility overrides documented below; never apply blanket unsafe fixes. - The design baseline is tweakcn's clean-slate theme, ported to bare oklch components in
uno.config.ts. A<style>block reading one of those variables must wrap it —oklch(var(--muted))— because the preset's utilities supply the wrapper and a barevar()silently resolves to transparent. Dark mode is<html class="dark">; check both themes. - Install
prekbefore committing. Hooks enforce Conventional Commits, protectmain, and scan for secrets.
Setun is licensed under the GNU Affero General Public License v3.0.
biome.json uses the pinned Biome version, Git ignore rules, recommended lint and
import-organizing rules, and the existing two-space, 100-column, double-quote style.
Its maintained scope remains src/, sandbox/, and root JavaScript, TypeScript and
JSON files. Generated Paraglide files and build output are excluded. Run
bun run lint to check or bun run format to apply formatting and safe fixes.
Experimental HTML support enables Svelte markup checks and formatting; every component is formatted. Narrow lint compatibility overrides preserve:
- Custom tree, radio-button, separator and grouped controls with intentional ARIA roles.
- The elicitation label whose native control is inside a Svelte conditional.
- Component
scopeprops, which are unrelated to HTML table-headerscope, and the language placeholder insrc/app.html.
Keep bun run check as the framework/type check. Do not apply blanket unsafe
fixes to components; re-evaluate these overrides when upgrading Biome.