Guidance for Claude Code (and human contributors) working in this repository.
ArozOS is a self-hosted, web-based cloud desktop / NAS operating system written
in Go. It runs as a single binary on everything from a Raspberry Pi to a desktop
server. The Go module lives in src/ (module path imuslab.com/arozos);
the repository root holds docs, the installer and release tooling.
License: GPLv3 (see LICENSE).
In this codebase AGI stands for ArOZ Online JavaScript Gateway Interface
— not "Artificial General Intelligence". It is the server-side JavaScript
runtime that powers ArozOS web apps: module scripts with a .agi (or .js)
extension are executed inside a sandboxed Otto
JavaScript VM, one fresh VM per request/script, with permission-checked
access to ArozOS functions (file system, database, sharing, IoT, image/zip/
ffmpeg helpers, SQLite, WebSockets, an LLM/aimodel chat library, and more).
src/mod/agi/ (agi*.go); the runtime
version is the AgiVersion constant in src/mod/agi/agi.go.
The gateway is constructed in src/agi.go (AGIInit, run from
src/startup.go) via agi.NewGateway(agi.AgiSysInfo{…}).USERNAME, USERICON,
HTTP_RESP, LOADED_MODULES, …) and functions (sendResp, sendJSONResp,
registerModule, requirelib, includes, execd, the …DBItem DB helpers,
…) are injected per VM by injectStandardLibs / injectUserFunctions
(src/mod/agi/agi.user.go).requirelib("name");
registered in LoadAllFunctionalModules
(src/mod/agi/moduleManager.go): filelib,
imagelib, http, share, iot, appdata, sysinfo, ziplib (incl. 7z),
sqlite, aimodel, sharedspace (multi-user collaboration spaces with
texts/images/files, revision-synced documents, open/public/private ACLs and
optional persistence, src/mod/sharedspace/; web
clients reach the same spaces via /system/sharedspace/* + WebSocket),
meetroom (MeetRoom room creation/control, gated by MeetRoom module
permission), git (version control over folders in the user's file system —
clone/status/stage/commit/branch/diff/fetch/pull/push via go-git with no git
binary on the host, plus encrypted per-user HTTPS credentials,
src/mod/git/; the GitApp WebApp is its front end), and
ffmpeg (only when ffmpeg is on the host), plus
websocket and scheduler which are injected only in an HTTP request
context.init.agi — a web app's startup/registration script, scanned at boot
from ./web/*/init.agi (InitiateAllWebAppModules) and run with system
scope only (no user functions); used to registerModule(…)./system/ajgi/interface (logged-in users) and
/api/ajgi/interface (token auth; this is the -rpt callback subservices
receive). Both run scripts scoped to the invoking user's permissions.src/mod/agi/README.md. When
you change AGI functions or signatures, also update the in-app help data file
src/web/Terminal/docs/api.json to match
(one object per library section; the README's maintainer note explains how).A WebApp (a.k.a. a module) is a user-facing application that shows up on
the ArozOS desktop. Each one is a folder under src/web/ — e.g.
Photo/, Music/, NotepadA/, Calendar/, Code Studio/ — holding the
front-end assets (HTML/JS/CSS) plus an optional init.agi and any backend
.agi scripts it calls.
init.agi by calling
registerModule(JSON.stringify(moduleLaunchInfo)). The launch-info object
maps field-for-field to the ModuleInfo struct in
src/mod/modules/module.go: Name, Desc,
Group, IconPath, Version, StartDir, SupportFW/LaunchFWDir,
SupportEmb/LaunchEmb, InitFWSize, InitEmbSize, SupportedExt. See
src/web/Photo/init.agi for a minimal example.StartDir), floatWindow (SupportFW +
LaunchFWDir, sized by InitFWSize), and embedded (SupportEmb +
LaunchEmb, sized by InitEmbSize) — embedded is what loads when a file is
opened with the module. SupportedExt registers the file-type associations
that let the module become a default opener.ModuleHandler
(src/mod/modules/module.go) holds the
loaded-module list; RegisterModuleFromAGI is the hook init.agi drives, and
module visibility is filtered per user by GetModuleListJSONForUser (users
only see modules they have permission for).init.agi runs with system scope (registration /
system functions only — don't call user or file functions there); backend
.agi scripts invoked from the front end run with the invoking user's
scope. Subservices (below) also register through ModuleInfo, so they appear
on the desktop just like WebApps.A SubService lets ArozOS launch an independent binary web server (written in any language) as a child process and reverse-proxy it under the main server, so it appears as a normal ArozOS module. Reach for it when a feature needs a real native binary rather than a sandboxed AGI script — heavy compute, an existing Go/Rust/… server, or third-party software such as Syncthing.
src/mod/subservice/
(SubService, SubServiceRouter); wiring + boot-time scan in
src/subservice.go (SubserviceInit, run from
src/startup.go). Disable it all with the
-disable_subservice flag; reverse-proxy ports start at subserviceBasePort
(12810, src/flags.go)../subservice/<name>/, named for
its platform — <name>.exe (Windows) or <name>_<GOOS>_<GOARCH> (e.g.
demo_linux_amd64); on Linux an apt-installed binary on PATH is preferred.<bin> -info (it must print its
ModuleInfo as JSON — or ship a moduleInfo.json instead), then launches it
as <bin> -port <port> -rpt http://localhost:<parentPort>/api/ajgi/interface
(the -rpt URL is the AGI callback so the service can call ArozOS APIs back).
The reverse-proxy URL prefix is the directory part of StartDir and must not
collide with a reserved path (web, system, ws, …,
src/subservice.go). A failed proxy is auto-restarted.aouser, aotoken and X-Forwarded-Host headers injected; routing happens
in the authenticated branch of src/main.router.go..disabled (skip at boot; toggle in
the admin UI), .noproxy (run but don't proxy — compatibility mode),
.startscript (launch start.sh/start.bat instead of the binary),
.intport (pass the port without a leading :), moduleInfo.json (static
info, skips the -info probe)./system/subservice/{list,kill,start} (admin only).
Full guide: the "Subservice Logics and Configuration" section of
src/README.md.A subservice is a separate program — usually a small Go web server, but it can be any binary — that ArozOS launches as a child process and stitches into the desktop through an authenticated reverse proxy. Subservices are how you extend ArozOS in the language/runtime of your choice, or wrap an existing third-party web app (e.g. Syncthing), without touching the core binary. Contrast with AGI, which runs JavaScript inside the core: a subservice runs outside it as its own OS process and only talks back through the gateway.
A complete, buildable example is the "demo" service at
aroz-online/ArozOS-Subservice-Example.
The canonical reference is the "Subservice Logics and Configuration" section of
src/README.md.
Key points:
src/mod/subservice/; the wiring (scan directory,
admin endpoints, graceful shutdown) is in
src/subservice.go. Disable the whole subsystem with the
-disable_subservice flag../subservice/<name>/ at the ArozOS root. The executable must be named after
the folder with a platform suffix — <name>_<GOOS>_<GOARCH> (e.g.
demo_linux_amd64) or <name>.exe on Windows. (On Linux, a system-installed
binary found via which <name> is used if present.)moduleInfo.json in the folder, or by running <binary> -info and parsing the
JSON it prints — then relaunches the binary as a long-running web server with
-port :<port> (the next free port from base 12810) and
-rpt "http://localhost:<arozosPort>/api/ajgi/interface" (the AGI gateway the
subservice calls back into for filesystem/user access).StartDir, so StartDir: "demo/home.html" proxies /demo/* to the service.
The metadata is registered as a normal module, so the service appears on the
desktop like a built-in app, gated by per-module permission. The endpoint must
not collide with reserved paths (web, system, SystemAO, img, ws, …).
If the proxied process stops responding, the core kills and restarts it..disabled (skip at boot — an admin can re-enable it in System Settings),
.noproxy (compatibility mode: just run the binary, no port/proxy injection),
.startscript (run start.sh/start.bat instead of the binary, e.g. to wrap
Syncthing), .intport (pass the port as 12810 instead of :12810)./system/subservice/{list,kill,start} and the UI in
src/web/SystemAO/modules/subservices.html
let an admin start and stop services without restarting ArozOS.Minimal example — a ./subservice/demo/ folder with a binary and its metadata:
subservice/demo/
├── demo_linux_amd64 # binary, named <folder>_<GOOS>_<GOARCH>
├── demo.exe # a Windows build (optional, one per target)
└── moduleInfo.json # metadata — OR print the same JSON on `-info`
{
"Name": "Demo Subservice",
"Desc": "A simple subservice showing how subservices work in ArozOS",
"Group": "Development",
"IconPath": "demo/icon.png",
"Version": "0.0.1",
"StartDir": "demo/home.html",
"SupportFW": true,
"LaunchFWDir": "demo/home.html",
"SupportEmb": true,
"LaunchEmb": "demo/embedded.html",
"InitFWSize": [720, 480],
"InitEmbSize": [720, 480],
"SupportedExt": [".txt", ".md"]
}
// The binary answers -info (and exits), then serves its web UI on -port.
func main() {
info := flag.Bool("info", false, "Print module info as JSON and exit")
port := flag.String("port", ":8000", "Listen address assigned by ArozOS")
flag.String("rpt", "", "ArozOS AGI gateway endpoint for callbacks")
flag.Parse()
if *info {
// Same JSON as moduleInfo.json above; StartDir's dir ("demo") is the proxy endpoint.
fmt.Println(`{"Name":"Demo Subservice","Group":"Development","StartDir":"demo/home.html","Version":"0.0.1"}`)
return
}
http.Handle("/demo/", http.StripPrefix("/demo/", http.FileServer(http.Dir("./web"))))
http.ListenAndServe(*port, nil) // ArozOS reverse-proxies /demo/* here
}
Both a webapp and a subservice register the same ModuleInfo and, once
loaded, look identical on the desktop. The difference is what runs the code and
where it lives:
| Webapp | Subservice | |
|---|---|---|
| What it is | Static front-end (HTML/CSS/JS) plus optional server-side AGI scripts | A standalone compiled binary (any language) |
| Lives in | src/web/<AppName>/, served by the core's static file server |
./subservice/<name>/, run as its own executable |
| Process model | No process of its own — backend logic runs as JavaScript inside the core's Otto VM (one fresh VM per request) | Its own OS process on its own port, reached through a reverse proxy |
| How it registers | An init.agi startup script calls registerModule(...) from inside the VM |
The core reads -info / moduleInfo.json when it launches the binary |
| Talks to the host via | AGI globals/libraries in-VM (requirelib("filelib"), …) |
HTTP calls back to the -rpt AGI gateway endpoint |
| Reach for it when | A standard ArozOS app whose logic fits the AGI/JS sandbox | You need native code, heavy/long-running work, a non-Go runtime, or to wrap an existing third-party server |
In short: a webapp is front-end assets + JavaScript executed inside ArozOS through AGI, while a subservice is an external program ArozOS launches, supervises and reverse-proxies. Use a webapp by default; reach for a subservice when the work doesn't fit the in-core JavaScript sandbox.
Container Apps publish a web server running on another port (usually a Docker container's published port) through a built-in reverse proxy, so it is reachable on the ArozOS port itself (and therefore through Cloudflare or a single forwarded port) and can be pinned to the desktop by every user.
src/mod/appproxy/ (routing,
rewriting, detection, subdomain login hand-off); wiring and endpoints in
src/appproxy.go. mrouter calls
appProxyManager.HandleRequest before any other branch
(src/main.router.go); app is a reserved
subservice path. Records live in the appproxy table of the system DB./app/<slug>/, same origin as the desktop, so it
requires the admin's "I trust this container" flag) and subdomain (its own
hostname; /app/<slug>/ on the ArozOS host issues a one-time ticket and
redirects to https://<hostname>/__appproxy/claim, which sets an HttpOnly
ao_appsess cookie). /app/<slug>/ is always the canonical entry point./app/<slug>/ (so they never hit
the ArozOS web root); "rewrite root paths" rewrites HTML/CSS and injects
/app/<slug>/__appproxy/shim.js (patches fetch/XHR/WebSocket/history/DOM
setters). The redirect alone loses to the browser cache and pushState, so
most apps need the rewrite. ArozOS cookies (ao_*) are never forwarded.ContainerApps web app (all users see the apps they may open and
pin them; admins publish/edit and get a detection verdict from
/system/appproxy/admin/probe), a "Publish as app" action in Docker Manager,
and the app desktop shortcut type, which asks /system/appproxy/launch
how to open on every launch so admin changes apply to existing shortcuts.Several independent ArozOS installations can form a cluster that exposes one
logical computer (namespace, identity, compute) while each node keeps its own
hardware, OS and storage and keeps working standalone when the cluster is away.
The runtime lives in src/mod/cluster/ and is documented in
src/mod/cluster/README.md — read that first.
All nine phases are built and verified: membership, identity, metadata store,
the cluster:/ drive, replication, the AGI library and event bus, jobs,
map/reduce and scheduling.
src/mod/cluster/TASKS.md is kept as the design
record, with every deviation from the original plan listed at the top; read it
before changing how a phase works, but start new work from the packages below.
src/mod/cluster/acn/) is the node-to-node
protocol: Ed25519 node keys, signed requests with replay protection, and a
transport that routes direct (advertised URL, Cloudflare-friendly),
over a tunnel (a NAT-only node keeps one WebSocket open to a reachable
member) or by relay through the tunnel host. It is mounted at
/cluster/acn/* in src/main.router.go before the
user-session check; cluster is a reserved subservice path. Register new
signed endpoints with acn.Server.HandleFunc and call peers with
acn.Transport.DoJSON — never talk to node addresses directly.src/mod/cluster/membership/)
is the cluster agent: create / join (pasteable join tokens) / leave, records
merged last-writer-wins by gossip, heartbeats, computed node states, health
and capability manifests (src/mod/cluster/capability/).
Admin API /system/cluster/* and the System Settings tabs Cluster
Settings (cluster.html), Cluster
Info (clusterinfo.html) and
Cluster Jobs; all three are localized through
src/web/SystemAO/locale/cluster.json
(server messages included, via CL.tr), so add new user-facing strings and
server messages there. Wiring in src/cluster.go;
-disable_cluster turns the whole feature off.src/mod/cluster/identity/):
one member is the identity origin; other members forward logins to it
through the auth agent's ForwardAuth hook (password hash only), mirror the
account locally, pull the origin's account directory as a fallback when the
origin is unreachable, and write password changes back. Cross-node requests
carry signed user assertions (X-Aroz-User). Password changes in core code
must call clusterNotifyPasswordChanged after writing the hash.src/mod/cluster/metadata/):
the replicated namespace index (file records with their copies, volumes,
folder replica policies). Records merge last-writer-wins by version; a
quorum-free leader lease (first joiner) serialises decisions and drives a
replicated log with catch-up and snapshots. Write through
Submit(kind, record), read through Stat / ListDir / Volumes /
PolicyFor; new cluster tables go through membership.RegisterClusterTable.
The disk copy is written behind (persist.go, batched through
database.WriteBatch): never add a synced disk write inside the store
lock, or a write burst starves lease renewal and the leader loses its lease.cluster:/ drive
(src/mod/cluster/storage/,
src/mod/filesystem/abstractions/clusterfs/):
admins contribute folders (volumes); files stay whole files inside them.
The drive is only mounted while the node is in a cluster that has at least
one volume (clusterSyncDrive), so otherwise nothing sees or scans it.
Nightly maintenance of a drive shared by the cluster belongs to the master
node (the metadata leader) alone, through
nightly.TaskOption{MasterNodeOnly: true} and nightlyShouldMaintainFsh,
so expired trash and old version history are not swept once per member.
The abstraction also answers arozfs.StorageInfoProvider, which is what
puts the copies of a file, their nodes, volumes and states in the File
Manager properties dialog (src/cluster.fsinfo.go).
Writes spool and hash locally, get placed by the leader, are copied
(locally or by the 4 MiB chunked, SHA-256 verified store/* protocol) and
only then published. The core mounts the drive into the base storage pool
whenever the node is in a cluster (clusterMountDrive in src/cluster.go),
so every app, WebDAV and the AGI filelib see cluster:/ unchanged.src/mod/cluster/replication/):
a planner on the metadata leader keeps every file at its folder policy's
copy count (pull tasks to nodes without a copy, in-place repair of stale
copies, drop of extras, evacuation of retiring volumes, stale marking for
nodes offline over 10 minutes); workers pull with storage.Service.PullCopy
and report back under a task lease. A nightly pass re-checksums local copies.src/mod/cluster/jobs/): AGI job scripts
defining run(job) are captured at submit time, scheduled by the metadata
leader on capability match and input locality, executed per node through
agi.Gateway.ExecuteJobScript (core adapter src/cluster.jobs.go) with
progress, logs, leases, timeouts and cancellation. AGI: cluster.jobs.*.src/mod/cluster/scheduling/):
one weighted scorer answers "which node?" for jobs, write placement and
replication. Weights (locality, free CPU/RAM/disk, queue depth, network
distance, health, wanted features) are a replicated cluster setting edited
on the Cluster Settings page; every score carries its factors, so
/system/cluster/sched/explain?job=<id> can show why a node won. Heartbeat
round trips feed NodeView.LatencyMs, and a volume under 5 % free goes
read only with a node.diskfull event. For a second copy the planner also
scores site diversity, using a pairwise latency matrix collected on demand
from the members (GET /cluster/acn/latency, cached two minutes) instead of
gossiping latency vectors.system/cluster.db (never ao.db)
and the node key in system/cluster/node.key..agi scripts because nodes differ in architecture; keep everything portable
(build-tagged files for syscalls, see capability/diskusage_*.go).All Go commands run from src/:
cd src
go mod tidy
go build # produces ./arozos
./arozos -port 8080 # run (sudo only needed for hardware/WiFi features)
go test ./... # run the test suite
go vet ./... # static checks
gofmt -l . # list unformatted files (should be empty)
make binary # cross-compile every supported OS/arch (see Makefile)
These six rules are enforced on new and changed code by a Claude Code
PostToolUse hook (during editing) and by CI (on every pull request). Both call
scripts/check-conventions.sh. Existing legacy
code is grandfathered — CI only inspects the lines a change adds — but do not add
new violations, and prefer fixing nearby ones when you touch them.
log packageNew code must send log output through the system logger so it lands in the managed, rotated system log instead of bare stdout.
import "imuslab.com/arozos/mod/info/logger"
// Good — title, message, and the originating error (nil if none):
logger.PrintAndLog("ModuleName", "could not open config", err)
// Bad — bypasses the system log:
log.Println("could not open config", err) // and log.Printf/Fatal/Panic
The package-level logger.PrintAndLog delegates to the system-wide logger wired
up in src/main.go; you do not need your own *logger.Logger
instance. The only file allowed to wrap the standard log package is the logger
implementation itself (src/mod/info/logger/).
Enforced as an ERROR (blocks CI) on added log.Print* / log.Fatal* /
log.Panic* calls.
Every package under src/mod/ is expected to carry a *_test.go file, and new
functions must come with table-driven Go tests (see
src/mod/info/logger/logger_test.go for
the house style: t.TempDir(), t.Fatalf/t.Errorf, one Test… per behaviour).
cd src && go test ./mod/yourpackage/ # must pass before you push
Enforced by the CI go test ./... gate; the convention checker additionally
warns when a touched mod/ package has no test file at all.
Any module added to src/go.mod must be licensed MIT, BSD-2/3,
Apache-2.0, MPL-2.0, or ISC — permissive, GPL-compatible, and fine for
commercial redistribution. Do not add GPL/AGPL/LGPL, source-available
(BSL/SSPL), or unknown-licensed modules. When unsure, state the dependency's
license in your summary so it can be reviewed before merge.
go install github.com/google/go-licenses@latest # optional audit helper
go-licenses report imuslab.com/arozos
Enforced by a CI reminder whenever go.mod/go.sum changes — verify the
license of each new dependency before merging.
Register authenticated endpoints through the permission router, not raw
http.HandleFunc, so they inherit login, per-module permission and (optionally)
admin/LAN/CSRF checks:
import prout "imuslab.com/arozos/mod/prouter"
router := prout.NewModuleRouter(prout.RouterOption{
ModuleName: "System Setting",
AdminOnly: true, // gate admin-only actions
UserHandler: userHandler,
DeniedHandler: func(w http.ResponseWriter, r *http.Request) {
utils.SendErrorResponse(w, "Permission Denied")
},
})
router.HandleFunc("/system/yourmodule/action", yourHandler)
Use raw http.HandleFunc only for deliberately public endpoints (e.g. the
/public/... registration pages) and treat all request input as untrusted —
validate parameters with mod/utils helpers and never interpolate user input
into shell commands or file paths. See
src/main.router.go and
src/register.go for the patterns.
Enforced by a CI/hook warning on every added raw http.HandleFunc, prompting
a deliberate "authenticated vs. intentionally public" decision.
ArozOS ships as one self-contained binary that must build and run across the
targets in the Makefile (Linux amd64/386/arm/arm64/mipsle/
riscv64, macOS, Windows). Therefore:
filepath.Join, and resolve
locations via os.TempDir(), os.UserHomeDir(), or paths relative to the
binary — never literal "/usr/...", "/etc/..." or "C:\\...".exec.Command,
syscall) is unavoidable, isolate it in a build-tagged file — foo_linux.go,
foo_windows.go, foo_darwin.go, or behind a //go:build constraint — and
provide a fallback for other platforms. See
src/mod/network/wifi/ for the pattern.cd src && GOOS=windows GOARCH=amd64 go build ./....Enforced as an ERROR on added hardcoded OS path literals, and as a warning
when exec.Command/syscall appears in a non-build-tagged file.
Literal Unicode emoji (😀 🎉 📁 ✅ …) must never appear in the program — not in front-end source (HTML/JS/CSS), not in Go source, not in log/UI strings. Emoji render inconsistently across platforms, fonts and themes, break the visual language of the desktop, and are not searchable/styleable. Instead, either draw the glyph yourself as an inline/local SVG, or reuse one of the icon libraries the existing web apps already ship (below). This rule is about emoji glyphs; ordinary typographic characters (✓ check marks, → arrows, curly quotes, em-dashes) are fine.
Icon resolution order — pick the first that fits:
src/web/script/semantic/semantic.min.css;
write <i class="download icon"></i>, <i class="folder open icon"></i>,
<i class="trash icon"></i>, etc. Reach for this first for standard UI
glyphs (the full set covers
most needs). See src/web/Photo/ and the SystemAO pages
for examples.img/ folder (e.g. src/web/SystemAO/desktop/img/icons/,
src/web/Musicify/img/) and reference them with
<img src="img/youricon.svg" ...> or inline <svg>…</svg>.src/web/OnScreenKeyboard/ or Codicon in
Code Studio (Monaco). Use these only inside the app that already ships
them; don't add a new icon-font dependency (especially a remote CDN one)
for a new app — prefer option 1 or 2.Never substitute a raw emoji character for any of the above.
<!-- Good — Semantic UI icon -->
<button class="ui button"><i class="save icon"></i> Save</button>
<!-- Good — local SVG you drew -->
<img src="img/artist.svg" style="width:22px;height:22px;">
<!-- Bad — literal emoji -->
<button class="ui button">💾 Save</button>
Enforced as an ERROR on added lines (Go and front-end HTML/JS/CSS) that contain a Unicode emoji.
| Mechanism | When it runs | What it does |
|---|---|---|
PostToolUse hook (.claude/settings.json) |
After Claude edits a Go or front-end (HTML/JS/CSS) file | Runs the checker on that file and feeds any finding back so Claude self-corrects |
GitHub Actions (.github/workflows/ci.yml) |
On every push / PR | gofmt, go build, go test ./..., and the diff-scoped convention checker (blocking; scans Go for rules 1–5 and Go + front-end for the emoji rule 6); plus module-wide go vet (advisory — never fails CI on grandfathered legacy code) |
scripts/check-conventions.sh |
Manually or from the above | Single source of truth for the rules above |
Run it yourself before pushing:
sh scripts/check-conventions.sh src/path/to/file.go # check specific files
sh scripts/check-conventions.sh --diff origin/master # check everything you changed
Escape hatch: in the rare, justified case where a line must keep a raw
log/path literal, append the marker arozos-lint-ignore to that line with a
short comment explaining why. Use it sparingly — it is reviewed.
src/ — Go module root; main*.go boot the server, *.go are feature handlers.src/mod/ — self-contained library packages (each with its own tests).src/mod/info/logger/ — the system logger (rule 1).src/mod/prouter/ — permission/auth router (rule 4).src/mod/agi/ — the AGI JavaScript gateway runtime (see "What AGI is"); API reference in src/mod/agi/README.md.src/mod/modules/ — module registry and the ModuleInfo struct shared by WebApps and SubServices (see "What a WebApp is").src/mod/subservice/ — reverse-proxied binary subservices (see "What a SubService is"); wired up in src/subservice.go.src/mod/appproxy/ — Container Apps reverse proxy (see "What Container Apps are").src/web/ — front-end assets and WebApps (one folder per module; see "What a WebApp is").src/system/ — runtime data and config (not shipped in release).