Skip to content

azphalt — Architecture & Reasoning ​

azphalt is an open, portable extension standard for creative apps — write an extension once, run it in any app that adopts it — plus a consignment marketplace built on top. This document is the architecture and the reasoning behind each decision.


Current version ​

  • The apps (store, Android, desktop) share one version, held only in version.properties. Print it with node tools/version.mjs print. It is never written down anywhere else, including here. See RELEASING.md § Versioning the apps.
  • The npm packages (@azphalt/*, create-azphalt) each carry their own version in their package.json, managed by Changesets.

Invariants ​

Each is enforced in code; the reference is where.

  1. Extensions have no ambient authority. A host function exists only for a capability the manifest declares and the host grants. The never-list (a host's engine, camera and sensors, the filesystem outside the package, the network) has no host function at all. spec/capability-model.md; enforced in packages/runtime-reference/src/host.ts (createHost adds each sub-API only when granted.has(…)).
  2. Nothing is installed unverified. Every file's digest must match the signed manifest, and an update is accepted only from the publisher key pinned at first install. spec/package-format.md § Signing; enforced in packages/azp/src/index.ts (verifyAzp) and packages/azp/src/trust.ts (verifyTrust).
  3. Assets are self-contained. No asset makes a host fetch from the network; the only remote path is a remoteUrl checked against its checksum before use. spec/package-format.md, spec/extension-manifest.md § assets.
  4. Header kinds carry no code and no capabilities. app, mcp, pack and composable packages declare how to reach or list something; they ship no /code and grant nothing. Enforced by the per-kind validators in packages/azp/src/ (for example app.ts, mcp.ts, composable.ts).
  5. Packages never contain secrets. A credential-keyed value must reference an input the host prompts for. Enforced in packages/azp/src/mcp.ts (CREDENTIAL_KEY_RE).
  6. azphalt depends on no host. Hosts depend on the published packages; nothing here imports a host's code. Enforced by the repository boundary (this repo contains no host engine).
  7. One version, one file. App versions come only from version.properties, written only by tools/version.mjs (and the shared release workflow it matches). Enforced in tools/version.mjs, tested in tools/version.test.mjs.
  8. Dependencies are on their latest stable releases. CLAUDE.md § Dependencies; kept current by .github/dependabot.yml.

What lives here — two things, kept separable ​

  1. The standard (open, MIT) — the .azp format spec, the SDK extensions are written against, the asset importers, the reference runtime, and the registry. This is the thing other apps adopt. It must be, and look, vendor-neutral.
  2. The marketplace (a business, on top) — a consignment storefront: a commercial venue sitting on the open standard, the way a store sits on Open VSX. Its code can be MIT or private — your call — but it is architecturally downstream of the standard, never fused to it. The format is azphalt; the store can carry its own name.

Keeping these separable is the core constraint: anyone can implement azphalt and even run their own store; yours is just the flagship.

Why azphalt is a separate repo from its hosts ​

  • Credible neutrality. A standard trapped in an app's repo reads as that app's private thing, and no competitor adopts it. Separation is the first structural signal that it's shared infrastructure.
  • A physical moat boundary. A separate MIT repo literally cannot see a host's proprietary engine source. "The extension surface never exposes a host's tracking engine" stops being a discipline you maintain and becomes a line you can't cross by accident.
  • Clean dependency direction. A conforming host (Graffux the first, Guillotine — a video/audio editor — the second) depends on azphalt as a published library and calls across the boundary. azphalt never depends on any host.

The stack, and why ​

TypeScript everywhere in this repo. It's the language every ecosystem you're recruiting from already writes plugins in — VS Code, Figma, and Canva extensions are all TS — so authors get types and autocomplete for free, and "the language you already know" is part of the pitch.

React is one surface, not the whole thing. Split it by where a browser actually is:

ComponentStackReact?
Production marketplace storefrontReact + Vite static export behind a Cloudflare WorkerYes
The registry (host/version/search/serve packages)Node/TS service + APINo — its browse UI can reuse the storefront's React
The SDK (what authors build against)TSNo — it's a library, not a UI
Extension UIDeclarative schema, host-renderedNo — see below
Extension executionWASM substrate (QuickJS-in-WASM for JS), on-deviceNo — decided, see below

Two decisions fall out, and neither is React — these are the real architecture:

  • Execution substrate. Extensions don't run in a browser; they run inside a native host — Graffux, for instance, is a Kotlin/Compose Android app. Decided: a WASM substrate — not React Native (which drags a whole runtime and renderer into a Compose app to do what a lean embedded runtime does lighter), and not a bare JS engine. In short, WASM is the substrate and JS runs on it via QuickJS-in-WASM, so the single TS SDK survives and behavior is deterministic across hosts.
  • Extension UI. A declarative UI schema the host renders as native controls — not React-in-a-webview (the Figma model). This is what keeps azphalt genuinely cross-app: if a plugin's UI is a control schema, a Kotlin app renders it in Compose and a SwiftUI app renders it natively; if it's React/HTML, every adopting app is forced to ship a webview, which quietly breaks "any app can implement." Portability argues against React here.

The native host that embeds the engine and renders the schema is each app's own conforming implementation (Graffux writes the Kotlin one). This repo ships the language-neutral spec plus the TS/JS reference so any host can be checked against it.

Repo layout (monorepo — pnpm workspaces) ​

/spec/                      normative, language-neutral (mirrored under /docs/specs for the site)
  package-format.md         the .azp package (assets + code, manifest, signing, trust)
  extension-manifest.md     entry points, permissions, declared capabilities
  capability-model.md       what an extension may touch: layers, bitmaps, canvas — and nothing else
  ui-schema.md              the declarative UI controls hosts render natively
  repository-api.md         the HTTP interface a discovery/distribution repository exposes
  workflow.md               declarative workflow/orchestration packages
  role.md                   declarative role/persona packages
  companion-app.md          external companion/host apps (kind: app)
  mcp-server.md             MCP servers (kind: mcp)
  pack.md                   curated packs that reference other packages
  skill.md                  Agent Skill bundles
  script.md                 native scripts a host installs like a package manager
  composable.md             UI element descriptions a host's own renderer interprets
  llm.md                    off-device language models (kind: llm) — proposed; verifier + first-party packages
  store-app.md              delegating browse/install to a store app
  web-handoff.md            the web → host azphalt://install handoff
  state-reporting.md        install/usage state a host reports back
  marketplace-integrity.md  keeping the open registry safe
/packages/
  sdk/                      TS SDK authors build against (typed editor extension points), published as @azphalt/azdk
  sdk-compat/               @azphalt/sdk, a compatibility alias re-exporting @azphalt/azdk
  azp/                      read/write/verify/sign .azp containers (Ed25519 + trust store)
  importers/                .abr, .cube, ISF, gl-transitions, glTF, ML models … -> normalized into .azp
  runtime-reference/        in-process reference host that proves the capability contract
  runtime-wasm/             the real sandbox — QuickJS-in-WASM (js) + raw WebAssembly (wasm)
  conformance/              an executable pass/fail battery for code hosts and asset hosts
  registry/                 verify · index · version · serve · search, plus the consignment overlay
  repository-client/        client SDK for the Repository API
  llm-host/                 host side of kind: llm (sandbox install, runner, delimiters)
  mcp/                      an MCP server exposing azp verify/inspect/extract to any MCP host
  create-azphalt/           scaffolder for a new extension package
  web-handoff/              the web → host install handoff (spec/web-handoff.md)
  submit-check/             validator for submissions/ PRs (CI + local); not published
/apps/
  storefront-react/         production marketplace UI; statically exports the git-backed catalog
  storefront-worker/        Cloudflare Worker serving the production storefront, docs + Repository API
  storefront/               the git-pinned catalog (registry/) and the scripts that build it
  repository-server/        a reference Repository API backend over @azphalt/registry
  storefront-cmp/           native Compose Multiplatform store client (Android, desktop, web)
  marketplace/              demo marketplace client
  mock-backend/             template repository server paired with the demo client
/examples/                  sample extensions; double as reference templates
/docs/                      the docs site (VitePress) + these design/adoption guides
  ARCHITECTURE.md           this document
  RATIONALE.md              the research it's built on
  ADOPTION.md               how another app implements a conforming (code) host
  ADOPTION_ASSET_HOST.md    the lighter, data-only host profile
  ADOPTION_COMPANION_HOST.md  hosts that launch companion apps
  OPERATIONS.md             running the production storefront
  creators/                 getting started as a package creator
  hosts/                    getting started as a host consuming a repository
  GOVERNANCE.md             neutrality + how decisions get made
LICENSE                     MIT
README.md

The importers normalize into .azp, which makes them the seeding mechanism: every .abr a user imports becomes a portable package, so the catalog fills as a byproduct of people bringing brushes they already own. Define the format first (even minimally) and point the importers at it.

The marketplace — consignment model ​

Two lanes, and the fee lives in only one. The standard/storefront split, made concrete about money:

  • The registry = free, open distribution. No payment rails, no fee. A creator who just wants reach publishes here for nothing and monetizes externally if they choose — the VS Code / Open VSX answer, where the platform touches no money. This lane is what keeps the standard adoptable.
  • The marketplace storefront = the consignment lane. The only place the fee exists, because it's the only place you've taken on selling. A creator who'd rather you handle the money lists here — the Clip Studio answer.

Keeping the fee out of the registry is what lets the standard stay neutral while the storefront is a business.

Storefront-lane details:

  • Non-exclusive recruitment. Creators are free agents; the incumbent platforms rent their work, they don't own it, so the marketplace can court the whole existing base at once. Caveat: some platforms offer opt-in exclusive tiers for a higher cut (Adobe Stock, historically Envato), so a slice lock in voluntarily for the rate — your terms have to beat what exclusivity pays them. Thin fee + portability across every adopting app is a strong hand for it.
  • Payment is outsourced to a payment provider / merchant-of-record — the right call, and what dissolves most of the "running the rails" burden: tax/VAT, chargebacks, and compliance become the provider's problem. One sharpening: a marketplace is a different payment shape than single-vendor selling. You take money from a buyer, split it, and pay out a third-party creator — that's split-payment / marketplace-payout territory (Stripe Connect and the like, or a marketplace-capable MoR), not a plain single-vendor MoR built for solo SaaS. Provider choice hinges on split-payout + seller-onboarding support — verify against current docs before picking one.
  • What doesn't outsource: curation / moderation of what's listed (malware, IP), and being the marketplace facilitator of record for creator tax forms. The provider does the mechanics; you own the policy.
  • "Very small" has a floor — and outsourcing raises it. The fee must clear the processor (~3% + a fixed cost per transaction) plus the provider's cut on top plus overhead, or you lose money per sale, and it only nets at volume. Outsourcing trades margin for not running the rails — the right trade for a solo dev, but the fee has to absorb the stacked cuts. Build the importers and the free catalog first; the paid marketplace follows users.

Licensing & boundary ​

  • Everything in azphalt: MIT (LICENSE at repo root). The marketplace storefront can be MIT or private.
  • A host's engine is not here and cannot be referenced. The SDK exposes only editor extension points — layers, bitmaps, canvas. A host's proprietary engine (GraffitiXR's PolyForm-licensed relocalization engine, the case azphalt was split out to protect) stays in that host, behind the boundary.
  • This is the MIT/PolyForm line from GraffitiXR's LICENSING.md, where azphalt started, now enforced as a repo boundary instead of a module boundary.

Governance / neutrality ​

Cross-app adoption needs credible neutrality: an open spec, an open decision process, a neutral name. The honest tension for a solo project — you seed it and benefit from it, but control caps adoption. Minimum viable: public spec + open issue/RFC process now; a neutral-foundation path later if it gains real traction.

Self-hostability is the adoption driver — and it is not in tension with consignment. Adopting a standard is a control decision, not a convenience one: no serious app, least of all a competitor, will build its product on infrastructure a rival meters, controls, and can revoke. Requiring another app to route its users' extensions and its ecosystem's money through your consignment store — where you take a cut and hold the rails — asks to be its landlord and tax collector; that is an anti-adoption force, not a business model. This is exactly the Open VSX story: Microsoft's marketplace is legally locked to Microsoft's own products, so every VS Code fork (VSCodium, Cursor, Theia, Gitpod) couldn't use it and the community built a vendor-neutral, self-hostable registry instead. Self-hostability is what made adoption possible.

The two-lane design already gives both:

  • The standard + registry stay open and self-hostable — the layer other apps adopt, free and neutral, nobody forced through your store. This is what wins adoption.
  • The consignment marketplace is a hosted service on top — one venue, not the toll booth. Never a precondition for adopting the standard.

This is the npm / Docker Hub / Maven Central pattern: the format is open and self-hostable, yet a dominant hosted registry still captures most usage because it's the best default. Openness drives adoption; the hosted default captures convenience.

Which answers the obvious worry — "if it's self-hostable, can't anyone run a competing store and cost me the revenue?" In practice, no: open registries consolidate around a canonical hosted default, because running and moderating a registry is real work and being the best-curated, best-discovery venue is a genuine moat of convenience and network effect. You win the marketplace by being the best host, not by locking the standard — and the openness is what created the audience the marketplace monetizes. The sequence runs one way only: open standard → adoption → audience → your store monetizes that audience. Consignment-first strangles the adoption that would make consignment worth anything.

So: keep the standard open and self-hostable, require nobody to touch the store to adopt it, and offer hosted consignment as the turnkey default. Self-host for competitors, enterprises, and the privacy-conscious who need independence; hosted for indies and small apps who'd rather not run infrastructure or handle payments. Offer both, require neither.

Sequencing ​

  1. Spec skeleton — package format, extension manifest, capability model, UI schema. The seed. (built)
  2. SDK + first importers — .abr and .cube first; now a wide importer family targeting .azp. (built)
  3. Reference runtime + real sandbox — runtime-reference proves the contract; runtime-wasm runs it under QuickJS-in-WASM and raw WebAssembly. (built)
  4. Native hosts — embed the engine, render the UI schema natively. Graffux (paint/AR) is the first real consumer; Guillotine (video/audio) is the second, adopting the same .azp loader + Ed25519 trust on-device. These live outside this repo, behind their own engine boundaries.
  5. Registry + marketplace — registry (open lane), its consignment overlay, and repository-server are built; production azphalt.store deploys the React static storefront plus Cloudflare Worker from the git-backed registry and verifies live app-scoped catalog freshness after deploy.
  6. Declarative agentic package lanes — kind:"workflow" and kind:"role" are first-class signed data packages. They reuse repository discovery, signatures, entitlements, and targetApps scoping without inheriting the code-extension sandbox or granting host authority merely by installation.

Execution engine — decided ​

A WASM substrate — one substrate, not three engines. QuickJS and Hermes are JS engines; WASM is the compilation target they'd sit inside. So rather than cloning engines, compile to WASM:

  • Default (JS/TS) on-ramp: authors write TypeScript → JS → run on QuickJS-compiled-to-WASM (the Javy pattern, in production at Shopify). Keeps the TS audience and the single SDK; QuickJS is ~1/40th of V8's footprint.
  • Performance on-ramp: hot filters ship as raw WASM (Rust / AssemblyScript), and JS can drop to WASM for tight pixel loops. One host API, one runtime, two on-ramps.
  • Behavior, not performance, is what's identical. WASM's deterministic execution gives identical results across platforms for free — target behavioral conformance (a test suite), not the impossible-and-pointless goal of equal performance.

Remaining sub-decision: on Android, run WASM via Chicory (pure-JVM WASM runtime — no native binaries, no JNI; simpler, slower) vs a native runtime (Wasmtime / WAMR via JNI — faster, but per-ABI binary cost). Off the shelf, QuickJs4j already does QuickJS → WASM → Chicory → pure JVM bytecode. Likely path: mainstream JS extensions on Chicory, performance-critical raw-WASM filters on a native runtime — or start all-Chicory and optimize. Design the image-buffer ABI (shared linear memory) deliberately; it's what bites a pixel app.

Open questions ​

  1. UI schema expressiveness: rich enough for real tool UIs, constrained enough to render natively on every host.
  2. Importer distribution: shared TS/WASM modules any host runs, vs per-host native reimplementation.
  3. Governance model — see Governance; moves to a neutral foundation if traction warrants.

Resolved since: the registry was built from scratch (packages/registry) rather than forking Open VSX, with a reference HTTP backend (apps/repository-server); the image-buffer ABI is pinned (straight alpha, 8-bit default with 16-bit opt-in).