Skip to content

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 .azp file 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         optional

Every 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 /code payload and no capabilities — just an app header declaring platforms, roles, and per-role hostId / handoffs (see extension-manifest.md § app, companion-app.md, and web-handoff.md § Host directory). The tree is manifest.json + LICENSE (+ optional preview).
  • mcp — an MCP server a host's MCP client connects to. A header (an mcp block) that declares how to reach the server; no /code, no capabilities (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 (a pack block); no /code, no capabilities, 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. A skill block declaring each bundled skill's id (plus advisory name/description); no capabilities, no /code entry/runtime — but unlike app/mcp/pack it DOES carry a real payload, the SKILL.md files themselves at skills/<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: a script block declaring interpreter, entry, an optional command/args, and optional dependencies (system packages, namespaced by package manager, resolved before the script runs). No capabilities, no /code entry/runtime — but like skill it 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 .abr becomes assets/*.png plus 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.

typeWire format
brush, pattern, stampPNG (8-bit gray or RGBA)
lut.cube (Adobe Cube LUT)
shaderGLSL or ISF source (UTF-8 text); params.format = glsl | isf
transitionGLSL source (UTF-8 text); params.format = gl-transition
imagePNG, JPEG, or WebP
vectorSVG — the static SVG 1.1 / SVG Tiny 1.2 profile (see below)
fontSFNT (.otf / .ttf), or WOFF2
mesh, materialglTF 2.0.glb (binary) or .gltf with its referenced resources bundled under /assets; a material travels as a glTF material
hdriRadiance .hdr, or OpenEXR .exr
audioWAV / PCM (the uncompressed floor), or Ogg/Opus, FLAC, MP3
videoMP4 (H.264 + AAC), or WebM (VP9 + Opus)
motionJSON keyframe/animation preset — host-neutral, keys per extension-manifest.md params conventions
paletteJSON — see § Palette format
overlayPNG (or APNG / animated WebP)
templateJSON layout referencing bundled image/vector assets, with fillable slots under params
tflite, litert, onnx, sherpa-bundlethe 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:

json
{
  "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 #RGB is 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). finish is 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 with params.inputTransfersrgb (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.json MUST carry a digest of every payload file under files (path → digest). The digest is sha256-<hex> — the literal prefix sha256- followed by the lowercase-hex SHA-256 of the file's exact bytes (e.g. sha256-2c26b46b68ffc6…). Only SHA-256 is defined for 0.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 fixed sha256-+lowercase-hex form @azphalt/azp's digest() emits.)
  • Authenticity: signature.json is a detached Ed25519 signature over the exact manifest.json byte 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 read manifest.json from the container as-is and check the signature against those bytes. Signing the manifest transitively signs the payload through the files digests. The public key is base64 SPKI, alg is ed25519. A worked test vector — private key, public key, manifest bytes, and expected signature — lives at packages/azp/test/vectors/signature-vector.json for cross-implementation checks. Implemented in @azphalt/azp (signAzp / generateSigningKey); verifyAzp validates it when present.
  • Trust model. A signature alone is tamper-evidence, not identity — verifyAzp only 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 in signature.json under countersignature (publicKey, signature, optional keyId, optional nested countersignature); 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:

  1. Verify file-level integrity via the manifest.files digests (enforced by verifyAzp()), and reject any failure.
  2. For a signed package, verify the signature (verifyAzp folds this into ok), and — where provenance matters — check the signer against a trust store via verifyTrust.
  3. 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").
  4. For any remote asset — an assets[] entry (or files[] member) delivered by remoteUrl rather than bundled — verify the downloaded bytes against its checksum (sha256-<hex>) before use, and reject on mismatch. These bytes aren't in the files map, so verifyAzp can't cover them; the check happens lazily at fetch time (see extension-manifest.md § assets). The signed manifest makes the checksum value 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 countersignature chaining 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 own version is semver.