Adopting azphalt as a companion-app host
How an app becomes a conforming host for companion-app packages (kind: "app") — Android apps and PWAs a host launches to perform a function and hand a result back. ADOPTION.md covers a code host (runs sandboxed extensions); ADOPTION_ASSET_HOST.md covers an asset host (consumes data). This profile is different again: the host runs none of the companion's code — it hands off to a separate OS-level app and ingests a validated result. The normative contract is in spec/companion-app.md; this is the implementer's how-to.
Why this profile exists
A code extension is powerless by design — no camera, no sensors, no network (the moat). But some functions want a full app: an AR-capture tool that returns a stencil to Graffux, an audio-mastering PWA that returns a track to Guillotine. Those are whole OS-level applications with their own permissions — the opposite of a sandbox. A kind: "app" package doesn't put them in the sandbox; it standardizes the handoff so any host invokes any companion the same way, and the moat still holds:
- azphalt grants the companion nothing — the OS governs its permissions, granted by the user to that app. Nothing crosses into the host.
- The host never runs the companion's code; it hands off a declared input over an OS transport and gets back a declared output it validates before use.
- The user gives OS-style consent before each handoff.
What a companion host does
- Open and verify the
.azp. It's a normal signed package (manifest.json+LICENSE, no/code). RunverifyAzp; where provenance matters, check the signer against a trust store withverifyTrust. Reject anything that fails. Readmanifest.app—platforms+handoffs. - Match the platform + decide you can honor the handoff. Read the
platformsentry for your OS (androidorpwa). For eachhandoffyou want to surface, confirm you can produce itsinputassets and ingest itsoutputassets/params. If the target app isn't installed, deep-link toplatforms.*.install(Android) or prompt to install the PWA. - Surface the handoff under its
action.capture→ a "Capture from…" entry,edit-image→ an "Edit in…" affordance, etc. (open vocabulary; seespec/companion-app.md§ Blessed actions). - Consent, then launch via the transport. Show an OS-style prompt naming the app and exactly what input leaves the host, then invoke it (below).
- Validate the return, then ingest. Treat the returned assets/params as untrusted input: verify each asset's declared wire format + a size cap, validate params against the declared shape, then apply. Never execute a returned asset.
Transports
Android
- Build an
Intentfortransport.android.intentAction; attach input assets as content URIs and input params as typed extras; setIntent.FLAG_GRANT_READ_URI_PERMISSION(or the companion can't read them); launch for result (Activity Result API). - The companion returns via
setResult(RESULT_OK, …)— output assets as content URIs (MIME types inresultMimeTypes), params as result extras — likewise settingFLAG_GRANT_READ_URI_PERMISSION. - Read the result, verify each asset against
output, ingest.RESULT_CANCELED/timeout is a clean no-op.
PWA
- Open the companion as a popup you hold a handle to and
POSTthe input totransport.pwa.shareTargetUrl(declared in the manifest, so you never fetch its.webmanifestcross-origin) asmultipart/form-data. - The companion returns via
window.opener.postMessagewith structured clone (transfersBlob/ArrayBufferby value — a companion-originblob:URL would be unreadable to you under the Same-Origin Policy). - Accept the message only from the popup you opened, only with the
nonceyou issued, only from the companion's expectedevent.origin; then validate againstoutputand ingest.
Conformance checklist (companion host)
- [ ] Opens the
.azp, runsverifyAzp(andverifyTrustwhere provenance matters), and refuses failures. - [ ] Reads
manifest.app; only surfaces a handoff whoseinput/outputit can honor on its platform. - [ ] Shows OS-style consent — the app being opened and exactly what input leaves the host — before launch.
- [ ] Sends only the handoff's declared
input(no ambient data channel). - [ ] Grants the OS-level read permission the transport needs (Android URI flags; PWA nonce+origin binding).
- [ ] Validates the return — each output asset's wire format + size cap, params against the declared shape — and refuses/ignores anything that doesn't match, before ingesting.
- [ ] Runs no code from the package and never executes a returned asset.
@azphalt/conformance certifies these lines for you: runCompanionConformance(host) drives a fixture handoff against your host and asserts each — it verifies the header, refuses a non-kind:"app" package (you run no code), surfaces the handoff on a platform you support, shows consent, sends only the declared input, and validates the return against output. It reports { ok, checks }, and a conforming host declares a "companion" profile so a registry can match package↔host.
import { runCompanionConformance } from "@azphalt/conformance";
const report = await runCompanionConformance(myHost);
report.ok; // true ⇒ conformingMeet these and any conforming kind: "app" package plugs a full external tool into your editor — safely, uniformly, and with the moat intact.