The .azp package format
Normative. The on-disk container an extension ships as: assets, code, manifest, signature. Format version 0.1 — pre-stable, may break.
Container
- A
.azpfile MUST be a ZIP archive (Deflate or Store) — same shape as.vsix/.crx/.brushset. No container-level encryption. - Entries MUST use forward-slash paths, UTF-8 names, no absolute paths, no
..traversal. A host MUST reject a package with unsafe paths. - For reproducible signing, entries SHOULD be written in sorted path order with fixed timestamps.
Media type
application/vnd.azphalt.package, file extension .azp. This is the only normative statement of the type in this specification; every other document cross-references it rather than restating it. A sender — a repository serving a download (repository-api.md § Download Package), a store app handing bytes to a host (store-app.md § The result), a browser saving a file (web-handoff.md) — MUST use it.
It is an unregistered vendor-tree type. RFC 6838 § 3.2 opens the vnd. tree to anyone interchanging files associated with a product, without requiring registration; registration with IANA is a later, additive step that would not change the string.
application/x-azphalt is a deprecated alias. A client SHOULD accept it, because servers predating this section emit it; a server MUST NOT send it. The x- prefix was deprecated for new types by RFC 6648, which RFC 6838 § 3.4 defers to — it was never a way to say "unregistered", only a convention that stopped meaning anything once two parties had to agree on it.
A receiver MUST NOT validate on the media type. It is a routing hint for the OS, the browser and the share sheet — it decides which application opens the bytes, never whether the bytes are trustworthy. That question is answered by the digests in manifest.files and the signature (§ Signing), both of which are carried inside the package and are unforgeable by whoever transported it. A sender that lies about the type causes the wrong app to open; a receiver that trusts the type causes something worse.
Required tree
manifest.json REQUIRED — the manifest (see extension-manifest.md)
signature.json REQUIRED once signed — detached signature (§ Signing)
/assets/ asset payload: brush tips, grain maps, .cube LUTs, patterns, stamps
/code/ code payload: entry module(s) — .js and/or .wasm
/ui/ optional — UI schema files, if not inlined in the manifest
LICENSE REQUIRED — SPDX-identified license text (author's choice)
README.md optionalEvery package MUST contain manifest.json and LICENSE. assets/ and/or code/ are present per kind.
Package kind
Declared in the manifest as kind: asset | code | mixed | app | mcp | pack | skill | script.
asset— data only (brushes, LUTs, patterns). No executable code; a host MAY load it with no runtime. This is what importers produce.code— one or more extensions on the sandbox (JS on QuickJS-in-WASM, or raw WASM).mixed— both (e.g. a filter shipping its own LUTs).app— an external OS-level app: a companion (an Android app or PWA the host launches via a declared handoff) and/or a host (an app that runs extensions, listed so a storefront can point users at it). Carries no/codepayload and nocapabilities— just anappheader declaringplatforms,roles, and per-rolehostId/handoffs(see extension-manifest.md § app, companion-app.md, and web-handoff.md § Host directory). The tree ismanifest.json+LICENSE(+ optionalpreview).mcp— an MCP server a host's MCP client connects to. A header (anmcpblock) that declares how to reach the server; no/code, nocapabilities(see extension-manifest.md § mcp and mcp-server.md).pack— an extension pack: a curated set that references other packages by id (a recommended bundle or an app's base set). A header (apackblock); no/code, nocapabilities, no assets. Each member is resolved and free/paid-gated individually (see extension-manifest.md § pack and pack.md).skill— a skill bundle: one or more Agent Skills an AI-agent host loads. Askillblock declaring each bundled skill'sid(plus advisoryname/description); nocapabilities, no/codeentry/runtime— but unlikeapp/mcp/packit DOES carry a real payload, theSKILL.mdfiles themselves atskills/<id>/(see extension-manifest.md § skill and skill.md).script— a native script (bash, Python, PowerShell, …) a host installs and runs like a package manager's own package: ascriptblock declaringinterpreter,entry, an optionalcommand/args, and optionaldependencies(system packages, namespaced by package manager, resolved before the script runs). Nocapabilities, no/codeentry/runtime— but likeskillit DOES carry a real payload, the script file itself (see extension-manifest.md § script and script.md).
Assets
- Assets are normalized, host-neutral data — never a host-proprietary blob. Per-asset parameters (and a shader's or transition's declared inputs) live in the manifest.
- Importers MUST emit this normalized form — an imported
.abrbecomesassets/*.pngplus manifest params, not a repackaged.abr.
Wire formats
Each asset type (defined in extension-manifest.md § assets) pins the byte format its path (or remoteUrl) delivers, so any conforming host consumes identical bytes identically. Where a row lists several formats, a host MUST support the bold one (the interoperability floor) and MAY support the others; an importer SHOULD emit the bold one unless an asset requires otherwise.
type | Wire format |
|---|---|
brush, pattern, stamp | PNG (8-bit gray or RGBA) |
lut | .cube (Adobe Cube LUT) |
shader | GLSL or ISF source (UTF-8 text); params.format = glsl | isf |
transition | GLSL source (UTF-8 text); params.format = gl-transition |
image | PNG, JPEG, or WebP |
vector | SVG — the static SVG 1.1 / SVG Tiny 1.2 profile (see below) |
font | SFNT (.otf / .ttf), or WOFF2 |
mesh, material | glTF 2.0 — .glb (binary) or .gltf with its referenced resources bundled under /assets; a material travels as a glTF material |
hdri | Radiance .hdr, or OpenEXR .exr |
audio | WAV / PCM (the uncompressed floor), or Ogg/Opus, FLAC, MP3 |
video | MP4 (H.264 + AAC), or WebM (VP9 + Opus) |
motion | JSON keyframe/animation preset — host-neutral, keys per extension-manifest.md params conventions |
palette | JSON — see § Palette format |
overlay | PNG (or APNG / animated WebP) |
template | JSON layout referencing bundled image/vector assets, with fillable slots under params |
tflite, litert, onnx, sherpa-bundle | the model's native file(s) (.tflite / .onnx / …); large or multi-part models use the remote-asset / multi-file patterns in extension-manifest.md § assets |
Static SVG profile (vector). A vector asset is a self-contained SVG document: no <script>, no active/interactive elements (<foreignObject>, <iframe>, <embed>, <object> — <foreignObject> in particular can embed arbitrary HTML/XHTML that slips past a naive <script> filter), no event-handler attributes (onload, on*), no external or remote references (<image href="http…">, external <use>, CSS @import, web-font URLs) — any raster it embeds MUST be a data: URI. A host MUST render it as inert graphics and MUST NOT execute script or fetch anything it references.
Self-containment (the moat). No asset's bytes may make a host fetch from the network or read outside the package. The only sanctioned remote path is the manifest's explicit remoteUrl large-file pattern, which is checksum-gated (extension-manifest.md § assets). A host MUST ignore — never fetch — any external reference embedded inside asset bytes.
Palette format
A palette asset (type: "palette") is a UTF-8 JSON file under /assets. It is an ordered list of named sRGB colors — order is significant (swatch order in a picker). Shape:
{
"colors": [
{ "name": "Signal White", "color": "#F2F2F0" },
{ "name": "Wildfire Red", "color": "#C41E1E", "brand": "MTN 94", "sku": "RV-3020", "finish": "matte" }
]
}colors— REQUIRED, an ordered array. Each entry:name— REQUIRED, human-readable swatch name.color— REQUIRED, sRGB as#RRGGBB(strictly 7 characters, e.g.#F2F2F0; shorthand#RGBis not permitted; lowercase or uppercase hex; no alpha — a palette entry is an opaque swatch).brand,sku,finish— OPTIONAL colorant metadata mapping the swatch to a physical product (e.g. a spray-paint can).finishis an open vocabulary; blessed values:matte·satin·gloss·metallic·fluorescent. A host ignores keys it doesn't understand.
This lets the ecosystem distribute brand-accurate color sets (Montana, MTN, …) as portable .azp packages a host can match against real colorants.
LUT application
A .cube file carries only a table plus DOMAIN_MIN/DOMAIN_MAX; it says nothing about how to sample it, so two hosts can produce a visibly different grade from identical bytes. To make a LUT a portable look, a host applying a lut asset MUST:
- Interpolate at least trilinearly. Trilinear is the floor every host matches. A host MAY offer tetrahedral (or another higher-quality) interpolation as an upgrade only if its result stays within a small, stated tolerance of trilinear — never a different look by default.
- Assume sRGB input for a bare
.cube(the pragmatic default). An author MAY declare the LUT's expected input transfer withparams.inputTransfer—srgb(default) ·linear·log-c— and a host MUST convert the pixel into that transfer domain before sampling, then back afterward. (A LUT authored against log input but applied to sRGB-gamma pixels is simply wrong.) - Clamp out-of-domain. Coordinates outside
[DOMAIN_MIN, DOMAIN_MAX]clamp to the domain edge (no extrapolation). - Leave alpha untouched. A LUT transforms RGB only; the alpha channel passes through unchanged.
- Sample at ≥ 8-bit precision (higher is fine); coarse LUTs (
LUT_3D_SIZE ≤ 17) especially depend on the interpolation floor to agree across hosts.
strength blend. After sampling, a host applies the lut asset's params.strength (number 0..1, default 1) as a per-channel dry/wet blend with the original RGB — out = lerp(original, graded, strength) — blended in the same encoded domain the LUT is sampled in (sRGB-gamma by default, or the declared inputTransfer domain), not the host's own working/compositor space. Pinning the blend domain is what makes a fractional strength reproduce the same partial-grade on every host (a lerp in linear space gives a visibly different result). See extension-manifest.md § Per-type params. A host renders the control from the asset's canonical ui panel (a single strength slider) when present.
Code
- Entry module(s) declared in the manifest (
entry,runtime).runtime: js→ an ES module on QuickJS-in-WASM;runtime: wasm→ a WASM module against the host ABI. - Code has NO ambient authority (see capability-model.md). It reaches the editor only through host functions, for capabilities it declared and was granted.
Signing
- Integrity:
manifest.jsonMUST carry a digest of every payload file underfiles(path → digest). The digest issha256-<hex>— the literal prefixsha256-followed by the lowercase-hex SHA-256 of the file's exact bytes (e.g.sha256-2c26b46b68ffc6…). Only SHA-256 is defined for0.1. A host MUST recompute each file's digest and reject any mismatch before use. (This is not SRI base64 or multihash — it is the fixedsha256-+lowercase-hex form@azphalt/azp'sdigest()emits.) - Authenticity:
signature.jsonis a detached Ed25519 signature over the exactmanifest.jsonbyte sequence stored in the archive, plus the signer's public key / key id. The bytes are signed and verified verbatim — there is no re-canonicalization (no JCS/RFC 8785, no whitespace normalization, no key reordering): a verifier MUST readmanifest.jsonfrom the container as-is and check the signature against those bytes. Signing the manifest transitively signs the payload through thefilesdigests. The public key is base64 SPKI,algised25519. A worked test vector — private key, public key, manifest bytes, and expected signature — lives atpackages/azp/test/vectors/signature-vector.jsonfor cross-implementation checks. Implemented in@azphalt/azp(signAzp/generateSigningKey);verifyAzpvalidates it when present. - Trust model. A signature alone is tamper-evidence, not identity —
verifyAzponly confirms it is internally consistent. Identity is decided by an explicit trusted-key set. A host holds a trust store of Ed25519 public keys; a package is trusted when either its signer key is directly in the store, or its signer key was counter-signed by a registry key in the store. A counter-signature is an Ed25519 signature over the vouched-for key's SPKI public-key bytes (the author at the base of the chain, or the previous counter-signer), stored insignature.jsonundercountersignature(publicKey,signature, optionalkeyId, optional nestedcountersignature); it does not disturb the author's manifest signature, so integrity still verifies. This lets a host trust one registry instead of every author. Counter-signatures nest into a chain — a registry key can itself be counter-signed by a higher authority (countersignature.countersignature, each hop signing the key below it) — and a package is trusted when any key along the chain is in the store and every hop down to it verifies. Key distribution (how a host learns which keys/registries to trust) is out-of-band by design. Implemented in@azphalt/azp(verifyTrust(azp, store),countersign).
Signing status
Signing is implemented: @azphalt/azp produces signature.json (signAzp / generateSigningKey), verifyAzp validates it when present, and verifyTrust(azp, store) decides trust. Signing is still optional — the SDK, importers, and CLI emit unsigned packages by default. A host consuming .azp files MUST:
- Verify file-level integrity via the
manifest.filesdigests (enforced byverifyAzp()), and reject any failure. - For a signed package, verify the signature (
verifyAzpfolds this intook), and — where provenance matters — check the signer against a trust store viaverifyTrust. - Treat an unsigned or untrusted-but-valid package as having integrity but no established provenance, and warn accordingly (e.g., "not from a trusted signer; install only from sources you trust").
- For any remote asset — an
assets[]entry (orfiles[]member) delivered byremoteUrlrather than bundled — verify the downloaded bytes against itschecksum(sha256-<hex>) before use, and reject on mismatch. These bytes aren't in thefilesmap, soverifyAzpcan't cover them; the check happens lazily at fetch time (see extension-manifest.md § assets). The signed manifest makes thechecksumvalue itself trustworthy.
Publisher continuity (updates)
Integrity and a valid signature say a package is internally consistent and names its signer; they do not, on their own, say an update comes from the same party as the version already installed — a package id is just a string, and anyone can put com.acme.azphalt.thing in a manifest. To stop a third party from replacing an installed extension via a same-id package, a host that supports updating an installed extension MUST enforce publisher continuity:
- On first install of an
id, record ("pin") the signer's SPKI public key (trust-on-first-use). An unsigned first install pins nothing. - On any later install/update of the same
id, the new package's signer MUST equal the pinned key. A different key — or a signed→unsigned regression — MUST be rejected as a publisher change rather than silently applied. The rejection is distinct from the generic "untrusted signer" warning: it means "not the party you installed from." - A host MAY allow the user to approve a publisher change (a legitimate key rotation) explicitly, after which it re-pins to the new key. A registry
countersignaturechaining the new key to the old (or to a trusted registry) MAY be accepted automatically in place of a manual prompt.
Because only the holder of the pinned private key can produce a package whose signature verifies against the pinned public key, this makes "only the original publisher can push an accepted update" enforceable offline, without a central authority. Publishers SHOULD therefore sign at package-creation time (keep one signing key per publisher; store the PKCS8 PEM private key as a CI secret and sign the built .azp with signAzp before publishing) so the very first release a user installs establishes the pin. Reference host implementation: Guillotine's AzpPublisherPins + AzpModelInstall.
Versioning
azphalt(format version) gates compatibility. The package's ownversionis semver.