AntOS Library Design
How AntOS's functionality is split into libraries — native cores, thin Luau bindings, where they live on disk, the command search path, and the rule that keeps the scripting API compatible with standard Lua. This is the canonical spec for the CM5 rewrite; the per-library API pages linked at the end document each library's calls.
The pre-CM5 libraries were, honestly, practice — one blob of bindings, an os that had drifted from stock Lua, and file handling that didn't behave the way a Lua programmer expects. The rewrite fixes that with a small number of firm rules. This page is those rules.
1. Two layers: native core + Luau binding
Every library is two units, built and versioned separately:
- Native core —
libantos_<name>(e.g.libantos_fs,libantos_net). Plain C/C++, a clean public header, and no dependency on Luau at all. This is the real functionality. Anything can link it — AntOS, a standalone CLI tool, a unit test, a future daemon — without pulling in the script VM. - Luau binding — a thin adapter (
luaopen_<name>) that marshals the core's API into Luau: argument checking, userdata/metatables, return-value shaping, and nothing else. It depends on the core plus the Luau headers. The core never depends on the binding.
The dependency arrow points one way only: binding → core, never back. If you find yourself wanting to put behaviour in a binding, it belongs in the core.
Why bindings are separate libraries too — one binding per core, independent of each other:
- the core stays language-agnostic and unit-testable without a VM;
require("net")loads only the net binding — a script that never touches the network never pulls it in;- a library can be added or replaced by dropping in one core + one binding, without relinking the rest;
- if AntOS ever exposes the same cores to something other than Luau (an RPC surface, a REPL, a remote console), that's a new binding layer over the same, unchanged cores.
2. Where the libraries live
Everything below lives inside the running OS image, not on a data partition — see §3 for why.
<os-image>/
├── antos # the executable
├── libs/ # native cores → libantos_*.so (linker rpath)
├── bindings/ # Luau C modules → fs.so, net.so … (package.cpath)
├── lua/ # pure-Luau modules (no native code) (package.path)
├── scripts/ # system commands (reached as SYS:/scripts)
└── doc/ # one .md per library
The AntOS executable links the cores it needs like any other program (-lantos_fs -lantos_term …, resolved from libs/ via rpath). At runtime:
require("term")resolvesbindings/term.so(onpackage.cpath), which in turn resolveslibantos_term.sofromlibs/;require("json")— a pure-Luau helper with no C behind it — resolveslua/json.lua(onpackage.path).
Keeping bindings/ (native modules) and lua/ (pure modules) as separate search roots means the native cpath and the pure path never get conflated — one of the things that was muddy in the practice version.
Note — this is not
PATH.package.path/package.cpathresolverequired modules. The command PATH (§4) resolves typed commands (speedtest→<dir>/speedtest.lua). Two different namespaces; don't conflate them.
3. The image slot and SYS:
The system scripts, libs, and bindings must always match the executable that links them, so they version, update, and roll back atomically with the OS. They therefore live in the A/B OS-image slot from the firmware design, never on a mutable data partition.
That slot physically flips on every OS update (slot A ↔ slot B), so nothing may hardcode the slot path. Instead a reserved prefix resolves it:
SYS:— "the currently-running OS image." It resolves to whichever A/B slot is live, soSYS:/scriptsis valid forever, across every update.
SYS: is read-only at runtime (it's the signed OS image) and is the anchor for libs/, bindings/, lua/, scripts/, and doc/. User code and user data live on DBFS (D:); code in the image, data in DBFS.
4. The command PATH
The old hardcoded D:/scripts becomes a real ordered search path, Windows-style — and Windows-style is not just familiar here, it's required: the separator must be ;, because : is already a drive letter (D:/scripts;C:/scripts is unambiguous; a colon-separated path would collide with D:).
Layered default + DBFS copy, the same robustness pattern used elsewhere in AntOS:
- a compiled-in default is the guaranteed baseline — the system commands are always reachable even with no
D:mounted (first boot, recovery), so the console never becomes unusable because DBFS is missing; - a DBFS copy (a
pathkey insys.dbconfig) is the persistent, user-editable version, seeded from the default at first boot.
Effective path = default (always) + DBFS value (when D: is present), first-match-wins.
Default order — SYS: is last:
D:/scripts ; C:/scripts ; SYS:/scripts
User scripts (D:) resolve first, then an inserted cartridge (C:), then the built-in system commands (SYS:). Because SYS: is last, a user can override any system command simply by dropping a script of the same name in D:/scripts — in keeping with the AntOS "let me do something weird with it" ethos. The order is a single editable string, so anyone who wants system-first can reorder it.
Supporting commands (all persisting to DBFS via the os.arguments convention):
| Command | Does |
|---|---|
path |
view / set the path · path +add D:/bin · path +remove … (the analogue of Windows path / set PATH) |
where (canonical) / which (alias) |
resolve a command name and show which path entry won; flags shadowed duplicates |
help |
scans every path entry in order (not just D:/scripts), so system, user, and cartridge commands all self-document; notes when one shadows another |
This is really the first instance of a general env/config concept, but it ships as a single named config key with the default+DBFS layering — the wider env system is deferred until a second variable actually needs it.
5. The standard-compatibility contract
The single most important rule, and the one the practice version broke:
Where an AntOS library overlaps a standard Lua/Luau library, it matches the reference contract exactly. AntOS only ever adds — new names and optional arguments, never redefined behaviour.
Concretely:
ostime functions are stock Luau, untouched.os.time,os.date,os.clock,os.difftimekeep reference semantics —os.clock()stays process-CPU seconds (the benchmarking delta), not wall time or uptime, which was the classic deviation. A Lua user "gets the time" exactly as they always have.- Files are pure Lua
io.io.open(path, mode)returns a normal Lua file handle with:read/:write/:lines/:seek/:flush/:close, the standard read formats, andnil, errmsgon failure. A Lua programmer's file code runs unchanged. The only AntOS-visible thing is thatpathis a drive-letter string (D:/scripts/foo.lua) — transparent, because it's just a string the VFS resolves underneath. - Genuinely-new functionality goes in its own namespace. Directory listing,
stat,mkdir, existence, DBFS tags/queries — Lua never had these, so they live in *`fs.**, not bolted ontoos/io. That separation is what prevents the old confusion:osandiomean exactly what a Lua user expects;fs` is "the AntOS extras."
Per-function tagging. Every function in every library doc is tagged so the boundary is explicit per call:
[standard]— behaves as reference Lua/Luau; a portable Lua program can rely on it.[antos]— an AntOS addition; no portability guarantee, this is ours.
A small compat test runs the reference os/io expectations (host-independent cases) against the AntOS implementations, so drift is caught mechanically, not by review.
6. Extending an API call
Extensions attach as an optional trailing argument that stock code never passes, so the reference contract stays byte-for-byte intact:
io.open("D:/notes.txt", "w") -- pure standard, unchanged
io.open("D:/notes.txt", "w", { tags = {"note"}, -- AntOS extension (3rd arg)
meta = {author="anthony"} })
Two rules keep this honest:
- the extension argument is always last and always optional;
- it is only meaningful on DBFS (
D:) — a plain FAT volume (A:/B:/E:) has nowhere to store tags. Policy: the optionalio.opentable is silently ignored off-D:(so casual cross-volume code doesn't break), while an explicitfs.tag()call off-D:returnsnil, err(so intent-to-tag fails loudly). (Open — see §12.)
7. DBFS auto-cataloguing (below the binding)
Automatic cataloguing lives in the DBFS / VFS layer, not in any Luau binding. If it lived in the binding, a file written from C, from the debug server, or over the network wouldn't be catalogued — two classes of file. In the VFS it can't be bypassed:
- any create / update / delete / rename on
D:— fromio.open, from C, from acopycommand, from the network — writes/updates the catalogue row (path, size, mtime, checksum, kind) in the same SQLite transaction as the data BLOB. It's another statement in a commit you're already making, so it's transactional and nearly free; - the explicit
tags/metafrom the extended API (§6) are extra rows/columns layered on top of that automatic baseline — user intent added to system-maintained facts; fs.query{ tag = "draft", modified_after = … }then works uniformly regardless of who created the file.
Division of labour: DBFS auto-catalogues everything structurally (can't be skipped); the binding surface adds optional user semantics (tags, meta).
8. The binding macro layer — libantos_bind
The ESP32-P4 bindings already show where the boilerplate is. Counting the idioms across the shipped lib_*.cpp files:
| Idiom | Uses | What it is |
|---|---|---|
luau_setfunc(L, "name", fn, …) |
~510 | function registration (already a helper) |
lua_setfield + lua_newtable |
~500 + ~160 | building result tables a field at a time |
luaL_check* / luaL_opt* |
~360 | typed argument extraction |
pushnil/pushboolean(0) + message + return 2 |
~220 | the nil, err error return, hand-written each time |
luaL_checkudata |
~11 | fetching a handle userdata |
So the rewrite gives every binding one shared header, libantos_bind.h — used by every binding and by no core — that turns each of those into a one-liner. It is marshalling only; no behaviour ever hides in a macro.
Registration. The existing luau_setfunc(L, "name", fn, "lib.name") primitive stays; ANTOS_MODULE(net, …) wraps a module's whole list of them plus the luaopen_<lib> and the package.preload registration, so adding a call is one line.
Argument extraction. ARG_STR(1, path), ARG_INT(2, count), ARG_OPT_INT(3, limit, 100), ARG_OPT_STR, ARG_NUM, ARG_BOOL — each expands to the luaL_check*/opt* call plus a named local, with a uniform lib.fn: bad argument N message. This also replaces the ad-hoc optional idiom seen in the source (lua_isnumber(L,n) ? … : def).
Result tables — the biggest single win, since ~660 lua_newtable/lua_setfield calls are just building tables field by field. A tiny table DSL:
TBL_BEGIN(L);
TBL_STR (L, "server", host);
TBL_INT (L, "unix", ts);
TBL_BOOL(L, "valid", ok);
TBL_END(); // table left on the stack — return 1
with TBL_NUM, TBL_NIL, and TBL_SUB (a nested table). A status() / info() / stat() that was fifteen stack calls becomes five readable lines.
The return contract. The nil, err shape appears ~220 times, written out by hand each time — from the source, lua_pushboolean(L,0); lua_pushstring(L,msg); return 2;. Macros make it one line and uniform:
| Macro | Pushes / returns |
|---|---|
RET_OK(L) |
true — return 1 |
RET_BOOL(L, c) · RET_INT(L, n) · RET_STR(L, s) · RET_NIL(L) |
the value — return 1 |
RET_ERR(L, "msg") |
nil, "lib.fn: msg" — return 2 |
RET_ERRF(L, fmt, …) |
formatted error — return 2 |
RET_OR_ERR(L, cond, "msg") |
the value, or nil, msg — the one-liner |
This is what makes the §5 contract un-skippable: a binding returns the reference value / nil, msg shape by construction, and can't accidentally throw, luaL_error, or return 0 where Lua expects nil, err.
Userdata handles. UD_NEW(L, Type) / UD_CHECK(L, 1, Type) wrap the allocation + metatable and the checked fetch for the handle types the source already uses this way (HTTP easy/multi, sockets, WebSocket, prepared statements, file handles), paired with a standard __gc.
One rule, unchanged: macros marshal, they never carry behaviour — the moment logic wants to live in a macro, it belongs in the core.
9. Dynamic vs static
Default is dynamic — .so cores + .so bindings, required on demand (§2). That is what "proper separate libraries" implies, and it lets a third party ship one library as a pair of files.
A single self-contained binary is still supported: statically register the bindings into antos and link the cores in. The two-layer split and the docs are unchanged; only runtime modularity is lost. Reasonable path: start dynamic, statically fold in the stable libraries later.
10. The library manifest
A single manifest — SYS:/libs/manifest.json (mirrored into sys.db when D: is present) — lists every library: name, version, core file, binding file, one-line summary, doc page. require, help, where, and the doc build all read the manifest rather than hard-coding the list, so adding a library is a manifest entry plus the two .so files.
11. Documentation convention
- One
.mdper library, indoc/. - Each page serves two audiences: the script-facing surface (
require, functions, args, examples — what luau promises authors) and, in a final section, the native C API for contributors. One file with both, or<lib>.md+<lib>-native.mdfor large libraries. - Every function is tagged
[standard]or[antos](§5). - Signature, one-line description, arguments, return values (including the
nil, errform), and at least one example per function.
11. Naming conventions
API names are snake_case throughout — send_text, on_click, set_texture, buzzer_clear — for the native core and the Luau binding. Two reasons:
- Core ↔ binding symmetry. The native cores are C/C++ (snake_case by convention), so
sys_send_textmaps tosend_textwith no case translation — the binding name is the core name minus the prefix. - Standard-Lua fit. The stdlib AntOS extends is lowercase (
io.read,os.time); snake_case is its natural multi-word extension and never collides with a reference-Lua name (§5).
Rules: functions and fields are snake_case; a namespace is the library/table name (os.settings.get); acronyms lower-case inside a name (set_rgbw, node_id — not setRGBW/nodeID); event handlers are on_<event> (on_click, on_draw, on_change); booleans read as predicates where natural (is_breakpoint, ready). The P4 sources carried a few camelCase holdovers (buzzerClear, unpackFile, invalidateAll, dbInit, addBreakpoint, …) — all normalized to snake_case in the rewrite.
11a. Retired and reconceived from the ESP32-P4 libraries
The CM5 rewrite drops or folds several P4 libraries. Recorded here so the absence is intentional, not an oversight:
| P4 library | Disposition |
|---|---|
rtos |
Folded → sys. The task view survives (sys.tasks/spawn/wait/kill); the FreeRTOS primitives (queues, semaphores, mutexes, event groups, software timers, tick math) are dropped — kernel-specific and misconceived on Linux, where Lua coroutines and the OS serve instead. |
mem |
Folded → sys as sys.mem.* (bounds-checked peek/poke). |
rtc |
Folded → os as os.rtc.* (the hardware clock the OS reads). |
settings |
Folded → os as os.settings.*; the settings window is a UI registry (subsystem), not a scripting library. |
curl, internet, udp, icmp |
Merged → net — one networking library (HTTP/WS/TCP/UDP/DNS/ICMP/NTP/mail). |
data.crypto, data.ca |
Split → crypto (Ed25519, HMAC, TLS CA store); plain hashing stays in data. |
buzzer |
Folded → audio (system tones; the home for wider audio). |
screenshot |
Folded → image (image.screenshot() — a screenshot is "encode the framebuffer"). |
sqlite |
Merged → db (one raw-SQL surface over DBFS). |
mipi |
Dropped — an ESP32-P4 display bring-up crutch; on the CM5, MIPI is Pulse / the keyboard TFT / the cartridge port (hardware), not a script library. |
ppa |
Dropped — the ESP32-P4 Pixel Processing Accelerator; its work (sprites, scaling) moves to FireStorm / VideoCore. See sprite. |
phreak |
Skipped — empty in the source (the C5 wireless bring-up is elsewhere). |
The rule throughout: nothing is kept for compatibility's sake. Where a P4 concept still makes sense on the CM5 it's reconceived onto the new hardware; where it was kernel- or chip-specific, it's dropped.
12. Open policy questions
Settle these in this doc rather than rediscovering them mid-rewrite:
- Tags off-
D:— silently ignore theio.opentable but hard-fail explicitfs.tag()? (§6 assumes yes.) - Tag lifetime on rewrite — when
io.open("D:/x","w")truncates an existing tagged file, keep the user tags or clear them? (Proposed: system columns always refreshed; user tags sticky across content rewrites, cleared on delete.) - PATH precedence — ship
D: → C: → SYS:(user-first, decided) and leave reordering to the user; no system-first lock. - Dynamic vs static default — ship dynamic; fold stable libraries into the binary later? (§9.)
- Cartridge scripts (
C:) in the default path — include by default (convenient) or opt-in (a cartridge shouldn't silently add commands)?
The standard libraries
| Library | require |
Overlaps standard? | Page |
|---|---|---|---|
os |
built-in / require("os") |
Yes — time/process is stock; host bits added | antos_os |
io |
built-in / require("io") |
Yes — stock file handles; drive paths + optional tags | antos_io |
fs |
require("fs") |
No — AntOS filesystem extras (dir, stat, tags, query) | antos_fs |
terminal |
require("terminal") (alias term) |
No | antos_terminal |
net |
require("net") |
No | antos_net |
data |
require("data") |
No | antos_data |
sys |
require("sys") |
No | antos_sys |
(A hw library — I²C / SPI / GPIO / LED / cartridge access over the CM's Linux interfaces — is planned but not yet specified; it will follow the same two-layer model and land as antos_hw when its surface is settled.)
Related documents
- Luau scripting on AntOS — the scripting hub and the help-header /
os.argumentsconventions - Standard built-in libraries — the libraries that ship inside the Luau VM itself
- The Luau language — how Luau differs from standard Lua
- Filesystem & drive letters — DBFS, the drive-letter scheme,
SYS:and image slots - Firmware & flashing — the A/B OS-image slots that
SYS:resolves to