Docs

Libraries

A library is source code shared as a signed package: how one is declared, what adm build packs into an .admlib, and how it travels through the registry to another program's use line.

Declaring a library

A library is a compilation unit without an entry point, like a plugin: library name { } at file scope with a use list and an export list. Its name is a module prefix it owns: a library called humanize may export humanize and humanize.*, nothing else, and a library called acme.imaging owns acme.imaging.*. Modules the exported ones use come along inside the package but stay invisible to consumers unless they are exported too.

library.adm
@manifest(version = "0.1.0")
library humanize {
	use (
		humanize
	)

	export (
		humanize
	)
}

The modules themselves are ordinary files next to it; a test suite beside them rides along in the repository but is left out of the package:

humanize/humanize.adm
// Numbers, byte counts, durations and instants formatted the way people read them.
partial module humanize {
	use std.time

	// Formats a byte count: `512 B`, `1.5 MB`; `binary` switches to 1024 and `MiB`.
	def bytes(count int64, binary bool = false) string { ... }

	// Formats a duration with its two largest units: `2h 5m`, `350ms`.
	def span(d duration) string { ... }

	// `3 minutes ago`, `yesterday`, `in 2 hours`.
	def ago(t time.Time, now time.Time = time.now()) string { ... }
}

@manifest(version = ...) is the library's version, semver; without it the version is 0.1. The compiler checks the export list the same way it does for plugins: every module named is one the library uses, it sits under the library's prefix, and no exported signature mentions a type that is not exported.

What ships, what a consumer sees

The package holds the exported modules and every module they use, recursively, and nothing else from the repository. The walk stops at three boundaries: the prelude and std.* are never shipped; a module that belongs to an installed package is recorded as a dependency (by package name, fetched by adm get) instead of being copied; and any other module outside the library's prefix is an error: module "x" is outside its prefix; make the package that owns it a dependency instead of shipping it. So a library never ships a module it does not own, and no module is ever inside two packages - installed modules would collide in a program that uses both.

For a consumer there are three levels of visibility. Exported modules are the API. Modules that shipped because an exported one needs them are present but refused: use acme.imaging.psd.layers fails with module "acme.imaging.psd.layers" is internal to library "acme.imaging"; it exports acme.imaging, ..., which is what lets the author reorganise them between versions. internal declarations stay invisible even inside an exported module. Within the library's own sources every module is visible as usual.

Several libraries in one repository is fine; adm publish --library <name> picks one, and each has its own versions. Code they share has three homes: a third library that both depend on (a sibling prefix such as acme.core, not a parent - a library named acme would own acme.imaging too), std when it is general plumbing, or a copy under each prefix when it is small. Naming it acme.imaging.common and using it from acme.audio makes audio depend on the whole imaging library.

The block name is the library's only identity: the prefix it owns, the package file name, the entry in the registry and in adm.lock, and what people write after use. @manifest(name = ...) is application identity and is ignored on a library, and the key under [deps] in adm.toml is that same name (qualified as publisher:name when two publishers share one), not an alias you choose.

The .admlib package

adm build in the library's directory (or adm build --libs in a tree that also holds applications) writes <name>-<version>.admlib, a zip archive of sources. Nothing is compiled ahead of time: a consumer compiles the library together with its own code, on its own platform, with its own compiler, so a library never has to be rebuilt for a new platform or a new compiler release.

humanize-0.1.0.admlib
manifest.json          name, version, the compiler it was checked with,
                       the exported modules, dependencies (namespace:name -> version),
                       the publisher's key and the signature
meta/checksums.json    SHA-256 of every file below; the signature covers this list
meta/exports.json      every exported symbol: kind, signature, doc comment (for the IDE)
src/humanize/humanize.adm   the exported modules and everything they import from the
                            package, as written; tests and unrelated files stay out
files/native/sqlite3.h      what the [package] files rules of adm.toml select:
files/native/sqlite3.c      headers and C sources a binding needs, assets

A library that binds C can ship what the binding needs, so a consumer does not have to have it installed. The [package] files rules of adm.toml, the same ones an application uses for its distribution archive, name the files; they land under files/ in the package and beside src/ when installed. A program that installs the library gets that folder on its include path and every .c under it compiled and linked, so @c("native/sqlite3.h") resolves the same way in the library's own tree and in a consumer's build when the rule keeps the path (from = "native", to = "native/"). The manifest lists them as files, and adm get --dry-run --json shows the list before anything is installed.

Files a library needs while the program runs, an image or a data table, are named with a package path: os.readFile("package://assets/logo.png") from the library's own code reads its own copy, and acme:imaging://assets/logo.png names a file another library shipped. Every std.os call and every Storage method accepts the form, and Application.files() gives the folder. During development the copy is the one in the package cache; adm package build copies every library's files into the application's archive under share/<namespace>/<name>/, and the same path finds them there once the program is installed. A bare logo.png resolves against the working directory and finds nothing.

adm.toml
[[package.files]]
from = "native"
to = "native/"

adm lib verify humanize-0.1.0.admlib checks the checksums and the signature and prints what the package is: name, version, publisher, hash, modules, dependencies. adm doc --plugin humanize-0.1.0.admlib lists the exports.

Signing

Packages are signed with an ed25519 key that belongs to the publisher. adm app init in the library's directory creates it: the public key goes into adm.toml, which is committed, and the private key is written outside the repository (the command prints where) and must never be committed. Every build signs the package with it; a consumer refuses a package whose checksums or signature do not verify, and the registry records which key published each version so a later version signed by a different key is refused unless the consumer opts in.

adm.toml
schema = 1

[publisher]
pubkey = "ed25519:base64:oW+JniOg9fdkW/Ccs9jsT2OQXNDGJj9dm6sytU/TlZs="
repo_salt = "base64:LZU1Nt+IhCAlqAtmKigUEvkpoUP2Goc2VAgkBqFspCg="

In CI, ADM_SIGNING_KEY holds the base64 private key instead of a file.

Publishing

The registry is a git repository of pointers, not a file host: every library lives in its author's own GitHub repository, and the registry's index says where. Publishing needs a clean working tree, an origin on GitHub, and the gh CLI logged in as the account that owns the repository:

terminal
$ adm publish --description "Human-readable bytes, durations, relative times, ordinals and thousands separators."
✓ built dist/humanize-0.1.0.admlib
✓ pushed dist/humanize-0.1.0.admlib and tag v0.1.0 to git@github.com:cthackers/humanize.git
✓ pull request opened: https://github.com/admlang/registry/pull/1
the registry's pipeline verifies the package and merges on green

What happened: the package was built and signed into dist/, committed and tagged v0.1.0, the tag was pushed, and a pull request added one line to the registry's index for your account. The registry's pipeline then fetches the package from that tag, checks its hash, checksums, signature, name, version and publisher, checks that the pull request comes from the namespace it claims and that the version is newer than the last one listed, and merges on its own. No human review, no upload, no token: your SSH key and gh login are all that is involved. The namespace is your GitHub account (or organisation); it is not part of the library's name, so two accounts may both publish a utils.

A version is immutable once published. Bump @manifest(version) and publish again; --dry-run shows the index line without touching git.

Finding and using one

terminal
$ adm search humanize
LIBRARY             VERSION  PUBLISHED   DESCRIPTION
cthackers:humanize  0.1.0    2026-09-03  Human-readable bytes, durations, relative times, ...

$ adm get cthackers:humanize
fetching cthackers:humanize 0.1.0 from https://github.com/cthackers/humanize...
✓ cthackers:humanize 0.1.0 (publisher admpub_zp7f...) -> ~/.adm/pkg/cthackers/humanize/0.1.0
lock: ./adm.lock

adm get resolves the highest version that is not yanked, downloads the package from the library's repository (a shallow git fetch of the tag, so a private repository works with the same SSH key that reads it), verifies it exactly as the registry did, installs it into the package cache and records it in adm.lock. Dependencies the library declares are fetched the same way. A bare name that several accounts publish is refused with the list; say which one. Ranges: humanize@0.1.0, @^0.1, @~0.1.0, @>=0.1.

In code the namespace disappears; the module is just its name:

main.adm
use humanize

application Report {
	def new(args string[]) int {
		println(humanize.bytes(1536000), humanize.span(2h + 5m))
		return 0
	}
}

adm lib lists what the project depends on, whether each package is installed, and the newer version the registry offers; adm lib verify inspects a package file. The IDE reads the same lock, so completion and navigation work into the package's sources.

adm.lock and vendoring

adm.lock pins every package to a version, a hash and a publisher, one line each. Commit it: a fresh checkout runs adm install with no arguments to fetch exactly what it names.

adm.lock
# adm.lock - namespace:name:hash:version [source=... publisher=...]
cthackers:humanize:59b7bb9a...b0efd3ac:0.1.0 source=registry:public publisher=admpub_zp7f...

For a build that must not touch the network or the cache, adm vendor copies the locked packages into vendor/ beside the lock; when that directory exists the compiler and the IDE read dependencies from it instead of ~/.adm/pkg. A project can also list ranges under [deps] in adm.toml; adm get with no arguments fetches the ones the lock does not cover yet.

Registries

The public registry is github.com/admlang/registry: per-letter index files, one JSON line per published version. The compiler keeps a shallow clone under ~/.adm/catalog and pulls it before searching or resolving, so both are local operations. A private registry is a clone of the same repository shape kept anywhere git can reach, with the same pipeline; a project names its registries in adm.toml, first one wins on a conflict:

adm.toml
[registries]
order = ["corp", "public"]

[registries.corp]
url = "git@git.corp.example:adm/registry.git"

[registries.public]
url = "https://github.com/admlang/registry"

Tokens never appear in that file: catalogs and packages travel over git, so the remote's own authentication applies, and the one HTTPS step, opening the publish pull request, goes through gh.