hal0 ships a deliberately small seed profile core. Everything else is an
addon: a single portable profile, published as one JSON file, installed
through the dashboard’s profile Import.
This page is the contract — the exact wire shapes, who verifies what, and the
compatibility rules a publisher must respect.
What an addon is
An addon is a portable profile envelope and nothing else:
- A profile, not an image. Profiles carry no image reference at all —
hardware and image are slot-owned, not profile-owned.
ProfileConfigis a complete tune —flags,ctx_size,enable_thinking,
vision,speculation,runner,intent,cloned_from(src/hal0/config/schema.py,
ProfileConfig). - No secrets, no host paths.
export_envelopeserializes the profile
template only (src/hal0/profiles/portable.py). - No models and no slots. Unlike a stack, a profile references neither, so
importing one resolves nothing and downloads nothing. runneris a registry KEY, never an image ref. When present it must be a
key ofRUNNER_IMAGES(src/hal0/runners/). Keys survive image and tag
updates; image strings rot, which is why profiles stopped carrying them.
The key is checked against the registry on the way in
(hal0.profiles.screen_profile_runner).
The .hal0profile.json envelope
Produced by hal0.profiles.portable.export_envelope, and by
POST /api/profiles/{name}/export (src/hal0/api/routes/profiles.py).
| Field | Type | Meaning |
|---|---|---|
kind |
string | Always hal0.profile (portable.ENVELOPE_KIND). Import rejects anything else. |
schema_version |
int | Envelope schema. Current: 2 (PROFILE_SCHEMA_VERSION_CURRENT). A schema-1 envelope is refused. |
hal0_version |
string | The release that produced the file. Provenance only — never gates import. |
exported_at |
string | Caller-stamped timestamp. export_envelope has no clock of its own. |
name |
string | Suggested profile name. The importer may override it. |
checksum |
string | sha256:<hex> over the profile body only. |
profile |
object | The ProfileConfig body, exclude_none — absent keys mean “unset”. |
The checksum covers profile and nothing else, so re-exporting the same
profile yields the same checksum no matter what exported_at says
(portable._checksum).
A real published addon, community/addons/dense.hal0profile.json, verbatim:
{
"checksum": "sha256:11944623612f84aaa4f2a4d13f6425f385df1bf5818160a53f78d8ddd2ac8916",
"exported_at": "2026-09-01T00:00:00Z",
"hal0_version": "1.4.0",
"kind": "hal0.profile",
"name": "dense",
"profile": {
"ctx_size": 131072,
"flags": "-fa auto -b 2048 -ub 512 --reasoning off --reasoning-budget -1 --no-context-shift --temp 0 --top-p 0.95 --top-k 20 --seed 123",
"intent": "Generic dense workload · tuned for 128K ctx",
"speculation": "auto",
"vision": "off"
},
"schema_version": 2
}
The index.json catalog
One index per addon directory. It exists so a catalog UI can render the whole
listing without fetching every envelope, and so a client can tell before
downloading whether it can install a row.
{
"kind": "hal0.addon-index",
"schema_version": 1,
"addons": [
{
"name": "dense",
"title": "Generic dense workload",
"description": "Generic dense workload · tuned for 128K ctx",
"runtime_family": "llama-server",
"runner": null,
"checksum": "sha256:11944623612f84aaa4f2a4d13f6425f385df1bf5818160a53f78d8ddd2ac8916",
"file": "dense.hal0profile.json",
"min_hal0_version": "1.5.0"
}
]
}
| Field | Meaning |
|---|---|
kind |
Always hal0.addon-index. |
schema_version |
Index schema. Current: 1. Independent of the envelope’s. |
addons[].name |
The profile name, and the index’s sort key — rows are sorted by it. |
addons[].title |
The intent headline: everything before the first · or (. |
addons[].description |
The full intent string. |
addons[].runtime_family |
From hal0.profiles._runtime_family — the same classification the API reports, derived from runner > name, never from an image string. |
addons[].runner |
The envelope’s runner key, or null when unset. |
addons[].checksum |
Copied from the envelope. Must match byte-for-byte. |
addons[].file |
Envelope filename, relative to the index. |
addons[].min_hal0_version |
Oldest hal0 release that can install this row. |
min_hal0_version is 1.5.0 for every row hal0 publishes
(MIN_HAL0_VERSION in scripts/export_addons.py). The envelopes are schema 2,
which an older release does not read.
Serving a catalog
An addon site is a static directory — no API, no database:
/addons/index.json
/addons/dense.hal0profile.json
/addons/thinking.hal0profile.json
…
Two rules make that safe to cache:
- Envelopes are immutable per checksum. Changing a profile’s body changes
its checksum, so it is a new envelope. Never edit a published file in
place and keep its old checksum — every importer that cached the checksum
will then report corruption. - The index is regenerated, never hand-patched. Adding, changing, or
withdrawing an addon means rewritingindex.jsonalongside the envelopes,
in the same publish.
For the catalog hal0 publishes, both rules are mechanical:
scripts/export_addons.py is the only author of community/addons/. It
derives every envelope from its ADDON_PROFILES table with a frozen exported_at
constant (so output is reproducible byte-for-byte), writes the index, and
deletes orphans. scripts/export_addons.py --check exits non-zero when the
committed catalog is stale, and tests/community/test_addon_envelopes.py
fails the build if the two ever disagree.
Installing one
In the dashboard: Profiles → Import, then paste the envelope JSON or upload
the .hal0profile.json file. The underlying call is
POST /api/profiles/import (src/hal0/api/routes/profiles.py):
{ "envelope": { }, "name": "dense", "dry_run": false, "force": false }
- Dry run (
"dry_run": true) validates the envelope and reports back
without writing anything —valid,checksum_ok,name,schema_version,
andcollides(whether the target name is already taken). Returns 200. - Commit creates the profile under
nameand returns the resolved profile
item with 201. A commit without anameis a 400profiles.import_no_name.
Compatibility rules
Envelope schema gate. A schema_version below 2 is refused whole — 400
profiles.envelope_schema_unsupported, with got and supported_min in the
error details, and a “re-export from hal0 1.5” message — rather than having
fields silently stripped (hal0.profiles.portable.parse_envelope). A
schema_version greater than the running hal0’s
PROFILE_SCHEMA_VERSION_CURRENT is rejected outright — 400
profiles.envelope_too_new, with got and supported in the error details
(hal0.profiles.portable.import_profile). Unknown envelope keys are ignored
(ProfileEnvelope is extra="ignore") so a newer producer’s additions never
break an older importer — the inner ProfileConfig still forbids unknown
fields, so a bogus profile key is a hard profiles.bad_envelope.
Unknown runner. A runner key the importing box does not have in
RUNNER_IMAGES does not fail the import. The key is stripped, the profile
imports with runner unset — i.e. Auto, letting the slot resolve its own
runtime — and the operator is told: the dry-run preview and the commit response
both report the stripped key. This is the deliberate ruling for a portable
artifact: a catalog is published once and installed on boxes with different
runtime sets, and a flag bundle is still useful without the runtime pin, so a
missing runner is a warning, never a blocker. Setting the runner
explicitly, by contrast, is a typed write and stays strict — POST/PUT
/api/profiles reject an unknown key with 422 profiles.unknown_runner,
listing the available keys in details.available
(hal0.profiles.screen_profile_runner).
Name collisions. Import never overwrites. An existing name is a 409
profiles.exists (ProfileCatalog.create); the dry run flags it as collides
first so the UI can offer a different name.
Flags. Screened unconditionally on every import, forced or not: a profile
may not carry hardware flags the slot owns (-ngl, -dev, -t), arguments
hal0 supplies itself (--model, --ctx-size, --host, --port, --alias),
model-fact flags (--jinja, --chat-template*, --mmproj, --no-mmproj),
host-owned flags (remote model fetches, host file reads and writes, listener
and auth/TLS settings, llama.cpp’s built-in preset switches), bare positional
arguments or --reasoning-format none. Violations are a 400 with
slot.hardware_flag_denied, slot.managed_arg_denied,
profiles.model_fact_flag_denied, slot.host_owned_flag_denied,
slot.positional_arg_denied or slot.reasoning_format_none_denied.
Publishing your own
- Author the profile on a box, or generate a draft with
POST /api/profiles/generate. - Export it:
POST /api/profiles/{name}/exportreturns the exact envelope
shape above. Save it as<name>.hal0profile.json. - Serve the file and an
index.jsonrow for it from any static host. - Keep the row’s
checksumequal to the envelope’s, and set
min_hal0_versionto the oldest release whose import semantics your
envelope actually relies on.