A project is a source tree rooted by atoll.toml. The manifest names the module
root; the directory layout does the rest.
ledger/
atoll.toml module = "example.dev/ledger"
main.at module example.dev/ledger
money/
amount.at module example.dev/ledger/money
tax.at module example.dev/ledger/money
report/
format.at module example.dev/ledger/report
audit/
format.at module example.dev/ledger/audit# ledger/atoll.toml
module = "example.dev/ledger"
name = "ledger"
version = "0.1.0"module is the only required field, and it is the import prefix for every file
in the tree. A file’s module path is module plus its directory relative to the
manifest, so money/amount.at is in example.dev/ledger/money and main.at is
in example.dev/ledger itself.
| Field | Purpose |
|---|---|
module |
Required canonical import root |
name |
Human-readable project name |
description |
Project description |
version |
Validated semantic version |
package |
JVM package metadata |
Omitting module is a load error, not a default, and version must be a full
semantic version:
$ atoll check ledger
atoll check: atoll.toml: missing required `module` field
$ atoll check ledger # with version = "1.0"
atoll check: atoll.toml: `version = "1.0"` is not valid semver:
unexpected end of input while parsing minor version numberTOML is the supported format; historical YAML manifests are not part of the current toolchain.
atoll new demo scaffolds atoll.toml, src/main.at, tests/, docs/ and
build/. Be aware that at HEAD its generated @test fn smoke calls an
unresolved assert, so the fresh scaffold does not check until you delete that
function:
error[ATOLL1009]: unresolved name `assert` at src/main.at:10:5Entry points and units
A unit is an entry point plus everything it transitively calls. An entry
point is fn main or any fn entry_*. One file may declare several, and each
one becomes an independently linkable wasm module:
fn tax_cents(cents: int): int {
return cents * 825 / 10000
}
fn main(): int {
due := 1999 + tax_cents(1999)
println("due cents: " + due.to_string())
return due
}
fn entry_audit(): int {
tax := tax_cents(1999)
println("tax cents: " + tax.to_string())
return tax
}$ atoll build ledger -o out
Wrote 2 unit(s):
- main (out/atoll-unit-main)
- entry_audit (out/atoll-unit-entry-audit)
$ find out -name '*.wasm'
out/atoll-unit-main/main.wasm
out/atoll-unit-entry-audit/entry_audit.wasmtax_cents is reachable from both entries, so it is linked into both bundles.
The unit boundary is what makes per-unit incremental rebuilds possible: editing
one entry’s body re-emits one bundle, editing a shared helper re-emits every
bundle that reaches it.
Only reachable code is lowered
The consequence people trip over: a function no entry point reaches is
type-checked but never lowered to wasm. Here probe calls a prelude method that
has no lowering path at HEAD, and the build still succeeds:
fn probe(): void {
body := Http.get("https://example.com")
println("${body}")
}
fn main(): void {
println("probe is never called, so it is never lowered")
}Call it from main and the same program type-checks but fails to build:
fn probe(): void {
body := Http.get("https://example.com")
println("${body}")
}
fn main(): void {
probe()
}$ atoll check main.at
OK (2 diagnostics)
$ atoll build main.at -o out
error[ATOLL2004]: builtin method `to_string` has no lowering path — declared in
the prelude but never implemented (no body, host import, or intrinsic), so
this call cannot be compiled to wasm
1 error(s); build abortedatoll check passing is not the same as atoll build passing. Gate on the
build for anything you intend to ship.
Build, check, run
$ atoll check ledger # whole-project type-check
OK (0 diagnostics)
$ atoll run ledger/main.at # default unit
amount due: $156
atoll run: unit `main` returned 156
$ atoll run ledger/main.at --unit entry_audit
ada changed invoice to $21
atoll run: unit `entry_audit` returned 21
$ atoll build ledger -o out # one wasm per unit
Wrote 2 unit(s):
- main (out/atoll-unit-main)
- entry_audit (out/atoll-unit-entry-audit)atoll check and atoll build accept the project directory. atoll run
needs a file, and refuses a directory outright:
$ atoll run ledger
atoll run: read ledger: Is a directory (os error 21)Pass a path with a directory component (ledger/main.at, ./main.at). A bare
main.at from inside the project directory falls back to single-file
compilation and will not see the cross-module imports.
Depending on another project
Everything in this section is one worked example. A library project:
common/
atoll.toml module = "example.dev/common"
money.atstruct Money {
cents: int
}
fn dollars(m: Money): int {
return m.cents / 100
}An application that depends on it by path:
# app/atoll.toml
module = "example.dev/app"
name = "app"
version = "0.1.0"
[dependencies]
"example.dev/common" = { path = "../common" }import { Money, dollars } from example.dev/common
fn main(): int {
m := Money { cents: 12345 }
println("dollars: " + dollars(m).to_string())
return dollars(m)
}atoll walks both trees, resolves the import, and emits one unit:
$ atoll check app
OK (0 diagnostics)
$ atoll run app/main.at
dollars: 123
atoll run: unit `main` returned 123
$ atoll build app/main.at -o out
Wrote 1 unit(s):
- main (out/atoll-unit-main)Note the dependency key: it is the dependency’s module path, not its
directory name, and it must match the module value in that project’s own
manifest.
Dependency forms
[dependencies]
"example.dev/common" = { path = "../common" }
"example.dev/http" = { git = "https://example.com/http.git", rev = "7a6c7f0" }
"example.dev/forms" = "^1.2"| Form | Current behavior |
|---|---|
{ path = "…" } |
Walked locally and compiled with the project |
{ git = "…", rev = "…" } |
CLI fetches the pinned checkout, then walks it locally |
| version string | Constraint is validated; there is no registry fetch or version solver |
A dependency carrying its own atoll.toml uses that manifest’s module as its
root; a bare local directory uses the dependency key as its root. Path entries
are relative to the owning project unless absolute, and a missing directory is a
load error. A git dependency must supply both git and rev — no branch or
default revision is inferred, so builds stay reproducible.
Contexts that cannot fetch, such as some editor and test harnesses, reject an unresolved git dependency and defer to the CLI pipeline. Once fetched, the checkout is walked exactly like a path dependency.
Because a version-string dependency supplies no source, an import from it can never be fully type-checked. Prefer path dependencies locally and pinned git dependencies for shared builds.
Extra source roots
[libs] adds directories whose contents join the current project’s module
namespace — no dependency entry, no separate manifest:
# libs/atoll.toml
module = "example.dev/libs"
name = "libs"
[libs]
paths = ["lib", "../shared"]Each library path is resolved relative to the project root, and its own
subdirectories are appended to the configured module root. With the manifest
above, lib/util/clamp.at lands in example.dev/libs/util — the lib/ segment
itself disappears:
fn clamp(v: int, lo: int, hi: int): int {
if v < lo { return lo }
if v > hi { return hi }
return v
}import { clamp } from example.dev/libs/util
fn main(): int {
v := clamp(12, 0, 10)
println("clamped: " + v.to_string())
return v
}$ atoll run libs/main.at
clamped: 10
atoll run: unit `main` returned 10Because those files share one namespace with the main root, duplicate
declarations across the two roots collide. Use [libs] for vendored source you
own; use [dependencies] when the other tree has its own module identity.
Source discovery
The loader recursively collects .at files under every root, skipping
dot-prefixed directories, target, node_modules, and build. Files are
sorted before loading so project input and diagnostics are deterministic; other
extensions do not participate in module scope.
atoll check path/to/tree also works on a directory with no manifest. The
directory name becomes a synthetic root module and the same exclusions apply,
but there are no dependencies, datasources, or host allow-list. A lone .at
file compiles as a one-shot target when no project contains it. Create
atoll.toml as soon as module identity or reproducible behavior matters.
Host capabilities
[host] controls which @host namespaces the project may call.
| Configuration | Behavior |
|---|---|
No [host] section |
Allow all namespaces |
allow = [] |
Deny every host namespace |
Non-empty allow |
Permit exactly the listed namespaces |
Denial is a compile-time diagnostic, not a runtime surprise. This program is ordinary Atoll:
fn main(): int {
println("hello")
return 0
}With allow = ["atoll_runtime::io"] it checks and runs. With allow = [] it is
rejected before anything is emitted:
error[ATOLL4001]: host call `println` requires the `atoll_runtime::io`
capability, which this project does not grant
1 error(s) foundNote that println needs atoll_runtime::io — an empty allow-list really does
deny everything, including printing. Write an explicit allow-list for anything
you deploy; omitting the section is convenient but expresses no least privilege.
Datasources
Each [datasources.NAME] entry requires schema, engine, and url:
[datasources.PRIMARY]
schema = "ledger"
engine = "PostgreSQL"
url = "${DATABASE_URL}"
readOnly = false
pool = { min = 2, max = 16 }Engine names are case-sensitive, and a typo fails the manifest load with the full set spelled out:
atoll check: atoll.toml [datasources.PRIMARY]: unknown engine `Postgres`
(expected one of: PostgreSQL, MySQL, SQLite, Turso, DataFusion, DuckDB)${VAR} references in a datasource URL are substituted while parsing, so an
unset variable is a configuration error rather than a runtime one:
atoll check: atoll.toml: environment variable `${DATABASE_URL}` referenced by
`datasources.PRIMARY.url` is not setDatasource names label configuration entries; query routing is driven by the
declared logical schema. readOnly = true rejects compiled writes routed to
that datasource. See Datasources.
Locking
atoll lock records each local and git dependency with a hash of its .at
source tree:
# atoll.lock — generated by `atoll lock`. Commit this file.
# Records resolved dependencies for reproducible builds.
version = "1"
project = "example.dev/app"
locked_at_unix = 1785316334
compiler = "0.1.0"
[deps."example.dev/common"]
kind = "local"
source = "../common"
content_hash = "8fd19a88fae7d39b"atoll lock --check is the CI gate. Editing a path dependency without
re-locking is intentionally visible:
$ atoll lock --check
atoll.lock is up to date
$ atoll lock --check # after editing common/money.at
atoll lock: atoll.lock is out of date for: example.dev/common (changed)
(run `atoll lock`)atoll build and atoll run may fetch a pinned git dependency without
rewriting the lockfile; use the explicit command when you mean to update
recorded state. Registry resolution does not exist yet, so a lockfile is not a
version-solver output.
The lock covers dependency source and nothing else:
| Input | Lock coverage |
|---|---|
Project .at files |
not locked — use your VCS revision |
| Path dependency source | source-tree hash |
| Pinned git dependency | revision and source-tree hash |
| Version constraint | no resolved package |
| Datasource engine, schema, URL | not locked |
| Host allow-list | not locked |
Reproducible deployment therefore needs the lockfile and an external build record naming the compiler version, source revision, manifest, and required environment variables.
Other sections
The manifest also parses [identity], [embed], [messages], [dev], and
package. These configure compilers and runtimes; none of them introduce names
into Atoll source. [messages] requires a non-empty locale list whose default
appears in it, and [dev] ports must fit the platform port range. A section
being parsed does not mean every historical framework feature behind it has been
implemented — see Feature Status.
Keeping failures distinct
| Boundary | Example failure |
|---|---|
| Manifest parsing | invalid engine name or pool range |
| Environment substitution | missing ${DATABASE_URL} |
| Type checking | denied host namespace; unresolved import |
| Lowering | reachable call with no lowering path (ATOLL2004) |
| Runtime startup | database host unreachable |
| Runtime operation | credentials lack table permission |
Inlining a secret into atoll.toml removes a substitution error and creates a
source-control exposure. It is not a fix for deployment configuration.
For CI: choose a stable module, pin every git dependency, run atoll lock
after intentional dependency changes, run atoll lock --check in verification
jobs, gate on atoll build rather than atoll check, and declare host
namespaces and datasource variables explicitly.