parachute-surface — UI host module for custom UIs, MVP shape

Date: 2026-05-21 Status: Proposed. Targets v0.7. The Gitcoin Brain UI (shipped May 2026), Aaron's about-to-start Unforced Brain UI, and Notes itself (which migrates from own-module to app-hosted over 4 phases — see section 16) collectively name the audience this module serves. App is positioned as committed-core; Notes is the canonical first app installed under it.

Naming note: the module is parachute-surface (singular), mirroring the vault precedent — parachute-vault (singular module) hosts many vault instances; parachute-surface (singular module) hosts many surface instances. Originally shipped as parachute-app; renamed to parachute-surface on 2026-05-26 (filename changed from 2026-05-21-parachute-apps-design.md in the same shift). The artifact name is parachute-surface throughout.

Companions:

The decision

parachute-surface is a new module: a small Bun HTTP service that supervises a directory of pre-built static UI bundles. Each bundle is a self-contained SPA living under ~/.parachute/surface/uis/<name>/, with a dist/ (the bundle) and a meta.json (mount path, OAuth scopes, display props). app mounts each declared UI at its declared subpath, serves the bundle with SPA-routing fallback, and auto-registers each as an OAuth client of the hub on add.

The module name is parachute-surface; the binary is parachute-surface; the npm package is @openparachute/surface. CLI verbs:

The unit is the UI bundle (a directory of static files + meta.json). The module is the host. They are explicitly separate, and they stay separate.

Why we got here

Three observations pinned the shape:

1. The Gitcoin Brain UI established the use case. Aaron's May 2026 UI for the Gitcoin team's vault is a vanilla SPA — three files (index.html, main.js, style.css), hash-based routing, talks to vault REST via a pvt_* bearer the operator pastes on first visit. No build step; no framework. It runs today at https://unforced-dev.github.io/gitcoin-brain-ui/ and the connection model is "paste vault URL + token." The UX wants to evolve to OAuth-against-hub, and Aaron is about to build a second UI of the same shape (Unforced Brain). The pattern is real: small, operator-curated SPAs that read + write one vault and live as part of a Parachute deployment.

2. Each-UI-as-its-own-module is too much ceremony for this audience. A first-class module today means: own git repo, .parachute/module.json, port reservation, npm package + RC versioning chain, services.json self-registration, hub install path. That ~all-of-it for every small SPA puts a tax on the next custom UI Aaron writes. He'll do this twice in a month then resist building a third. The trust-gradient pattern is explicit about not paying complexity tax that doesn't earn its keep.

3. The runner-design pattern absorbs this cleanly. parachute-runner is the supervisor for "many job notes in vault" — the unit (job) is lightweight, the supervisor is the module. parachute-surface mirrors that exactly on the UI axis: many UI bundles in a directory, one supervisor module. Same audience (owner-operated, flat trust gradient), same shape (supervisor + discovered units), same module-protocol surface. The cost of a second supervisor is small; the reuse of the mental model is high.

What "app" means precisely

A UI is a directory under ~/.parachute/surface/uis/<name>/ containing:

The app daemon polls uis/ on startup and on reload. For each declared UI:

  1. Parse meta.json, validate against schema. Malformed → log, skip, surface as status: invalid in parachute-surface list.
  2. Mount the bundle at meta.path under the hub origin (via hub's reverse proxy, same as notes today).
  3. Serve index.html for any unmatched path under the mount (SPA fallback).
  4. On first add, register the UI as an OAuth client of the hub via DCR (RFC 7591) using meta.scopes_required and meta.path as the redirect-URI base. Persist the resulting client_id (no secret — public client, PKCE) in app's own state.
  5. Expose the OAuth client_id and discovery doc per-UI at /surface/<name>/oauth-client so the UI's JS can read it at boot.

There is no per-UI sandbox, no per-UI origin, no iframe. All UIs share the hub origin. The trust gradient is flat: the operator put the bundles in uis/, the operator owns the vault those UIs read, the operator runs the host. Isolation is a parachute-cloud (TBD) concern.

Framework freedom for hosted UIs. parachute-surface requires only a dist/ directory with an index.html. Your UI can be Vite + React (recommended for the JS bootstrap convenience), Vue, Svelte, vanilla JS, anything — app doesn't care. The Vite + React stack is what app's own admin SPA uses (see section 7), not what app requires of hosted UIs.

The 19 design landings

1. Naming — parachute-surface (singular)

Decision: the module is parachute-surface (singular), npm @openparachute/surface, binary parachute-surface. Each unit hosted by the module is "an app."

This mirrors vault precedent: parachute-vault (singular module) hosts many vault instances; parachute-surface (singular module) hosts many app instances. The relationship between module-name and hosted-units is the same shape across the ecosystem.

The patterns#74 issue preferred parachute-pages. Pushing back: the things hosted are apps, not pages. The Gitcoin Brain UI has hash-based routing, an OAuth dance, state in localStorage, a search input wired to vault full-text — it's not a page. "Pages" undersells what these are. The other rejected candidates (parachute-display, parachute-host, parachute-mount, parachute-surfaces, parachute-canvas, parachute-deck) were either too generic, overlapping with existing vocabulary, or undersold compared to "app."

The "app implies app-store/marketplace" concern is real but doesn't bite the owner-operated audience. App is the operator's directory of operator-curated UIs; there's no publishing model, no third-party submission, no marketplace UI. The connotation is harmless because the surface contradicts it.

2. Shape — B (UI host module supervises many UIs in a directory)

Decision: Option B. With Option A as a documented escape hatch when a hosted UI grows its own backend services.

Stress-testing against Aaron's actual two use cases (Gitcoin Brain + Unforced Brain):

Option A (each UI is its own module). Each becomes a .parachute/module.json-bearing npm package with its own port reservation, services.json row, install command, RC chain, hub install path. For two ~500-line vanilla-JS SPAs that talk to vault REST, this is the ceremony of three committed-core modules to ship two reading rooms. Aaron would do this twice and then resist building the third UI — the friction discourages the use case.

Option B (host module supervises many UIs). One module ships once. Adding a UI is parachute-surface add <path-to-dist> --name <name> --path <mount-path>. Each UI is a directory + meta.json — no npm, no port, no module.json, no RC chain. The ceremony is paid once (app itself); marginal cost per UI is the directory copy.

Option C (vault stores UIs as content as tag:ui notes). Clever but breaks: how does vault serve an SPA bundle? Either vault grows a static-server mode (a new vault responsibility that crosses the data-vs-presentation boundary) or each UI lives as a single HTML note (no multi-file bundle, no real SPA shell, no build tooling). The Gitcoin Brain UI is three files served from a directory — that doesn't fit as a single note. C is right for "tiny widgets stored as content" but wrong for "real SPA bundles."

B wins decisively. The escape-hatch matters: if a future UI grows server-side dependencies (not just vault reads), it graduates to its own module (A). App doesn't try to be a backend host. The continuum is clean: B for client-only-against-vault UIs (most things); A for UIs with real backend services.

This also resolves a question the issue left open: does Notes move into app? Yes — app replaces notes. Notes-as-module retires; Notes lives forward as the canonical first app installed under parachute-surface, distributed as the @openparachute/notes-ui bundle. The migration arc is captured in detail in section 16 (Notes migration to app). The short of it: app's existence collapses the per-module-ceremony tax that justified Notes-as-module today, and Notes' PWA-specific needs (offline-first, service worker) are absorbed by app's opt-in PWA mode (section 18). Notes' release cadence + team commitment carry forward — they're commitments to the bundle's quality, not to the module shape. The committed-core line ends up as vault + app + scribe + hub post-migration; Notes-as-app lives within app as the first canonical app installed.

3. Repository shape for UIs themselves — each UI is its own project

Decision: each UI is its own project / git repo. Operators clone, build, and either run parachute-surface add <dist> or symlink/copy the dist/ into uis/<name>/.

Alternatives: a single monorepo of UIs (one repo with app/gitcoin-brain/, app/unforced-brain/, etc.). Rejected because:

Trade-off accepted: discoverability — there's no canonical place to find "all UIs hostable by app." An optional convention "publish your UI as a git repo named <scope>/parachute-surface-<name>" is fine as a docs note; not enforced.

4. Distribution mechanism — CLI add for primary path; npm-fetch shorthand + manual cp supported

Decision: the primary surface is parachute-surface add <source> --name <name> --path <mount-path> where <source> is either (a) a local path to a built dist/ directory or (b) an npm package specifier (e.g. @openparachute/notes-ui). Manual cp -r dist/ ~/.parachute/surface/uis/<name>/ works equivalently as a fallback for the operator who wants to script around it. Git-clone-and-build is deferred to Phase 2; npm-publish-as-module is explicitly out of scope (that's option A).

The add flow:

  1. Resolve <source>:
  2. Copy the directory contents to ~/.parachute/surface/uis/<name>/dist/. Reject if <name> collides with an existing UI unless --force is passed.
  3. If a <source>/../parachute-surface.json (or <source>/meta.json, or the npm package's meta.json) is present, copy it as ~/.parachute/surface/uis/<name>/meta.json. Otherwise scaffold a minimal meta.json with the --name and --path flags + sensible defaults, and warn the operator to fill in the rest.
  4. Run the OAuth DCR registration against hub for this UI. Persist the resulting client_id.
  5. Touch the app daemon's reload signal (or POST /surface/<name>/reload) so the running daemon picks up the new mount without a restart.

The manual cp path skips step 1 + 2 + 3 (operator does it themselves) and triggers steps 4 + 5 on the next parachute-surface reload <name> call.

Why npm-fetch is MVP, not Phase 2: it's a thin wrapper — bun install <pkg> into a temp dir, then the existing copy-dist code path. No build sandbox, no language detection. The operator-natural way to install a UI an author published is parachute-surface add @openparachute/notes-ui, not npm pack && tar -xzf && parachute-surface add ./package/dist. Folding it into MVP keeps the primary path one-command for the common case.

Why not git-clone-and-build at MVP: app would need a build sandbox per UI, language detection, node/bun version handling, build-tool detection, network access for npm install of the UI's dev deps. That's a whole second product. Operators who want git-clone-and-build can shell-script it (git clone && cd <repo> && bun run build && parachute-surface add ./dist --name <n> --path <p>). Phase 2 can fold the convenience in.

Why not npm-publish: the whole point of app is to avoid per-UI npm ceremony. If a UI is npm-published with a module.json, it's option A, not option B — install it as its own module instead.

5. Per-UI metadata schema — meta.json (Draft-07)

Decision: each UI ships a meta.json. Required fields locked in early so they're the operator-facing API.

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["name", "displayName", "path"],
  "properties": {
    "name": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9-]*$",
      "description": "Stable identifier. Becomes the uis/<name>/ directory and OAuth client name."
    },
    "displayName": {
      "type": "string",
      "description": "Human label rendered on hub discovery."
    },
    "tagline": {
      "type": "string",
      "description": "One-line description; rendered under displayName on hub discovery."
    },
    "path": {
      "type": "string",
      "pattern": "^/surface/[a-z0-9-]+$",
      "description": "Mount path under the hub origin, always under /surface/ (e.g. '/surface/gitcoin-brain'). No trailing slash."
    },
    "version": {
      "type": "string",
      "description": "Bundle version. Free-form; rendered for diagnostics."
    },
    "iconUrl": {
      "type": "string",
      "description": "Path to icon (relative to the UI bundle, e.g. 'icon.svg'). Hub discovery + app admin SPA render it."
    },
    "scopes_required": {
      "type": "array",
      "items": { "type": "string" },
      "default": ["vault:*:read"],
      "description": "OAuth scopes the UI declares as required. Wildcards (vault:*:read) signal vault-agnostic; concrete names (vault:gitcoin:read) signal vault-specific."
    },
    "public": {
      "type": "boolean",
      "default": false,
      "description": "If true, hub does not enforce a session gate at /surface/<name>/* — the UI is reachable unauthenticated. Default false (gated)."
    }
  }
}

The required-vs-optional split:

Validation runs at add time and on every daemon poll. Invalid meta.json → UI marked status: invalid, mount skipped, error surfaced in parachute-surface list. Validation failures don't bring down the daemon or affect other UIs.

Scope shape — wildcard vs concrete. The scopes_required array declares the shape of scopes a UI needs, not a binding to a specific vault. Two idioms:

This generalizes the Notes pattern. Today Notes' VaultPopover lets users pick which vault to work against, doing an OAuth flow per vault and caching the token per vault. Same shape applies to any multi-vault app hosted under parachute-surface.

Why name is constrained to ^[a-z][a-z0-9-]*$: it becomes the directory name, the OAuth client identifier, and the URL-safe lookup key in parachute-surface list. Same constraint as module.json's name field for symmetry.

Why path is constrained to ^/surface/[a-z0-9-]+$: all hosted UIs live under the /surface/ mount that parachute-surface owns. Single-segment after /surface/ keeps routing simple at MVP; multi-segment can be a Phase 2 relaxation if real UIs need it. Mount-path conflicts (two UIs at /surface/brain) need a deterministic rejection rule (see section 8).

6. Auth model — same-hub auto-trust + multi-vault install-once

Decision: each UI is its own OAuth client of the hub, registered via DCR (RFC 7591) at parachute-surface add time. The UI does its own OAuth dance against hub using the registered client_id. app does NOT pre-issue tokens or proxy them. But the consent ceremony is silent for same-hub apps.

Same-hub auto-trust (the major simplification)

Quoting Aaron: "we shouldn't need to say that it's okay for an app that's on here to refer to a Vault that's also on here. The user is still authenticating with the vault."

When a user visits a hosted UI and the UI initiates an OAuth flow against hub:

This generalizes hub#270's "auto-approve the first OAuth client after wizard" to "auto-approve same-hub apps for non-admin scopes." The trust gradient is: install-time gating (operator chose this app) replaces grant-time gating (user clicks "Allow"). Admin-scope is the one exception that keeps consent in the loop.

Implementation note — how hub distinguishes same-hub DCR clients from external ones. Auto-trust must not apply to arbitrary callers of hub's public /oauth/register endpoint, or the trust gate collapses. parachute-surface authenticates to hub's /oauth/register using its operator bearer (per same-hub trust — app holds an operator-scoped token because the operator installed it). Hub marks the resulting client with same_hub: true in the client registry. Auto-trust rules (silent consent for vault:*:read|write scopes) apply only to same_hub: true clients. External DCR clients — anything registered without the operator bearer — get same_hub: false and require explicit user consent on every scope regardless of shape. This keeps the public DCR endpoint open (RFC 7591 conformance) while the auto-trust shortcut stays gated by operator-install.

Per-UI client_id and DCR auto-registration are still load-bearing — they're how revocation, per-UI audit, and scope-grant tracking continue to work. The user-visible change is the consent screen disappears for the common case.

Install-once + multi-vault pattern

A UI's meta.json declares scope shape, not vault binding:

// Single-vault (bound at meta.json time)
{
  "name": "gitcoin-brain",
  "displayName": "Gitcoin Brain",
  "path": "/surface/gitcoin-brain",
  "scopes_required": ["vault:gitcoin:read", "vault:gitcoin:write"]
}

// Multi-vault (vault chosen at use time)
{
  "name": "notes",
  "displayName": "Notes",
  "path": "/surface/notes",
  "scopes_required": ["vault:*:read", "vault:*:write"]
}

The pattern:

This is what Notes does today. parachute-surface generalizes it — any multi-vault app inherits the same flow.

Why each UI is its own OAuth client (not one shared client)

The per-UI OAuth client lookup endpoint app exposes — GET /surface/<name>/oauth-client returning { client_id, scopes, discovery_url } — lets the UI read its own client_id at boot without baking it into the build (so the same dist bundle can be deployed against different hubs / different client_ids).

7. App's own admin SPA — Vite + React, bundled with the package

Decision: parachute-surface ships with its own admin SPA, built with Vite + React (matching the notes stack), bundled inside the @openparachute/surface npm package. Mounted at /surface/admin/ — a path the app module reserves for itself, separate from user-added apps under /surface/<name>/.

The admin SPA is not dogfooded as a hosted-UI of itself. Aaron's explicit call: "too messy." Mounting it as a regular UI through app's own discovery surface would mean the admin needs an OAuth client_id, would be subject to the same gating as user UIs, would appear in parachute-surface list as a UI you can remove, etc. — a recursive special case for marginal elegance. The cleaner shape: app's admin SPA is a known fixed path served directly by the app daemon, distinct from the directory-driven UIs.

Capability surface of the admin SPA:

The parachute-surface/admin/ path is reserved at the app daemon level: meta.json files attempting to claim path: "/surface/admin" are rejected at add time.

The admin SPA inherits the caching defaults from section 18: no service worker, smart cache headers on index.html (no-cache) vs. hashed Vite output (immutable). Hot-reload during admin development uses the same parachute-surface dev admin plumbing as user-added UIs, with one wrinkle — the admin path is reserved, so the dev-mode CLI accepts admin as a special case that targets the bundled admin SPA's source directory inside the npm package's source tree (only meaningful in dev installs).

8. Discovery + supervision

Decision: app scans uis/ at daemon startup and on explicit reload. No file watcher in MVP — operators run parachute-surface reload <name> or restart the daemon to pick up changes. Phase 2 can add a watcher if friction is real.

Why no watcher at MVP: file watching is fiddly across OSes (FSEvents vs. inotify vs. ReadDirectoryChangesW), and the operator action ("I just added a UI") is exactly when they're already at the CLI. The cost-of-cron isn't worth it for MVP.

What happens to broken UIs:

Registration via HTTP API: app exposes POST /surface/add for programmatic registration (the CLI calls this internally; third-party tooling can call it too). Auth: app:admin scope on a hub-issued bearer.

9. Routing + mount + hub-level auth gate

Decision: hub's reverse proxy routes /surface/* requests to the app daemon's port (1946). The app daemon owns the entire /surface/ namespace. Within that namespace, the daemon serves each hosted UI under /surface/<name>/ and the admin SPA under /surface/admin/.

Hub-level auth gate at /surface/<name>/* (default = gated). Before forwarding any request under a hosted UI's mount to the app daemon, hub enforces session auth: unauthenticated requests are redirected to /login with next=/surface/<name>/.... The gate is on by default. Per-UI opt-out via meta.json: "public": true makes the UI reachable unauthenticated (use case: a public landing page hosted under app). The admin SPA at /surface/admin/ is always gated regardless of meta.json — it's app's own surface, not a hosted UI.

The gate is implemented hub-side (in the reverse-proxy layer), not app-side. App doesn't need to know who the user is to serve static assets; the gate just protects the bundle from anonymous reads when the operator wants gating.

Note (2026-05-23): The Vite base: meta.path + "/" build-time-mount guidance above is superseded by the runtime-tenancy-contract pattern for mount-agnostic apps. Current canonical guidance:

The build-time-baked-mount pattern still works for daemon installs (notes-daemon path) and for PWA installs that need a fixed scope, but it can't support parachute-surface add --name <custom> arbitrary mounts.

Canonical references:

The design doc body remains unchanged as a historical record of the 2026-05-21 design call. The pattern docs are the living spec.

10. Trust + sandboxing — none in MVP, documented explicitly

Decision: MVP ships no per-UI sandboxing. All UIs share the hub origin and trust each other through the operator's trust.

This is correct for the v0.6 owner-operated audience per trust-gradient-isolation.md:

Same-origin means UIs can in theory fetch('/surface/other-ui/...') and read each other's localStorage. That's acceptable because the operator chose the UIs. If a UI is untrusted, don't install it — don't reach for sandboxing in a flat gradient.

What we explicitly do NOT do at MVP:

For multi-tenant cloud (v0.8+), per-UI isolation becomes load-bearing — that's parachute-cloud's lane. App in cloud-mode would either run per-tenant (one app instance per tenant, separate origins) or grow real per-UI sandboxing. The cloud design is the right home for that decision, not app MVP. Same pattern as runner's "owner-operated only, no per-job sandbox; multi-tenant is parachute-cloud's lane."

11. Module-protocol compliance — no kind, hub infers from paths + health

App ships the standard module surface.

.parachute/module.json:

{
  "name": "app",
  "manifestName": "parachute-surface",
  "displayName": "App",
  "tagline": "Host module for custom Parachute UIs — drop a built bundle in and serve it under one origin.",
  "port": 1946,
  "paths": ["/surface", "/.parachute"],
  "stripPrefix": false,
  "health": "/surface/healthz",
  "uiUrl": "/surface/admin/",
  "managementUrl": "/surface/admin/",
  "startCmd": ["parachute-surface", "serve"],
  "scopes": {
    "defines": ["app:read", "app:admin"]
  }
}

No kind field. Per hub#301, the kind ∈ {"api" | "frontend" | "tool"} trichotomy is the wrong frame for app: app is both frontend (serves UI bundles) and backend (admin endpoints + supervision). Dropping kind entirely — letting hub infer routing behavior from paths + health + the static: boolean field hub#301 phase B introduces — is cleaner than picking the wrong-fit value. App ships without kind from day one and is one of the first modules to test the post-kind world hub#301 is migrating toward.

static: boolean is also the wrong shape for app and is not included. App serves static bundles AND dynamic admin endpoints; a single boolean doesn't capture that. Hub's per-path routing (which it already does via the paths array) is sufficient — paths matching mounted-UI prefixes route to static-asset behavior; paths matching /surface/api/* or /surface/admin/api/* route as backend. The shape is per-request, derived from the path, not per-module.

A few notes on other shape choices:

.parachute/config/schema (Draft-07):

Field Type Default Notes
hub_url string (uri) — required Where the hub lives — app uses this for OAuth DCR registration on add.
auto_dcr_register boolean true Whether add triggers an automatic DCR registration. Operators who want manual OAuth setup can flip this to false.
default_scopes array of string ["vault:*:read"] Default scopes_required for UIs that don't declare their own. Wildcard default mirrors the multi-vault-by-default expectation.
disabled boolean false Global kill switch — daemon stays running but unmounts all UIs and returns 404 under their paths.

Self-registration: app follows the module-self-registration.md pattern. On serve startup, after HTTP listen, app reads its own .parachute/module.json, computes installDir, and atomically upserts services.json. On subsequent UI add/remove/reload, app re-runs the upsert with the updated uis map (see section 12).

12. services.json shape — hierarchical, mirror vault

Decision: app registers in services.json as a hierarchical entry with a nested uis map, one entry per hosted UI. Hub discovery surfaces "App" as the module-level row; clicking through reveals each hosted UI (Gitcoin Brain, Unforced Brain) as a sub-unit — exactly the surface vault gives for multiple vault instances.

Proposed shape:

{
  "parachute-surface": {
    "name": "app",
    "manifestName": "parachute-surface",
    "displayName": "App",
    "port": 1946,
    "paths": ["/surface"],
    "health": "/surface/healthz",
    "version": "0.1.0",
    "installDir": "/Users/parachute/ParachuteComputer/parachute-surface",
    "uis": {
      "gitcoin-brain": {
        "displayName": "Gitcoin Brain",
        "tagline": "Reading room for the Gitcoin team's vault.",
        "path": "/surface/gitcoin-brain",
        "iconUrl": "/surface/gitcoin-brain/icon.svg",
        "scopes_required": ["vault:gitcoin:read", "vault:gitcoin:write"],
        "oauthClientId": "client_abc123",
        "status": "active"
      },
      "unforced-brain": {
        "displayName": "Unforced Brain",
        "tagline": "Reading room for the unforced.org vault.",
        "path": "/surface/unforced-brain",
        "iconUrl": "/surface/unforced-brain/icon.svg",
        "scopes_required": ["vault:unforced:read", "vault:unforced:write"],
        "oauthClientId": "client_def456",
        "status": "active"
      }
    }
  }
}

Why hierarchical, not flat. Hub today reads vault's flat paths: ["/vault/default", "/vault/gitcoin", ...] array and renders each path as a discoverable sub-unit. That works but loses per-unit display metadata (displayName, tagline, icon) — each sub-vault renders as a path, not a named thing. The hierarchical shape carries display metadata per sub-unit, which is exactly what app's discovery UX needs (and what vault should grow to as well).

This requires hub-side schema work. The ServiceEntry type in parachute-hub/src/services-manifest.ts today has flat fields only (name, port, paths, health, version, displayName, tagline, installDir, stripPrefix). Adding a nested uis: Record<string, UiEntry> field requires:

  1. Schema extension in services-manifest.ts (UiEntry interface + validation).
  2. Discovery rendering: hub's / HTML + /.well-known/parachute.json surface the uis sub-units as their own tiles, linking to entry.uis[k].path.
  3. Per-UI access logging hooks (the entry carries oauthClientId; hub-side request logging can attribute by it).

Dynamic-reload is already a property of hub. Hub reads services.json per-request (verified by reading parachute-hub/src/services-manifest.ts post-#292); there is no in-memory cache to bust. App's runtime writes to services.json — on every UI add/remove/reload — will be visible to hub on the next request without a restart. The "hub must learn to reload services.json" framing (formerly tracked at hub#311) is moot; hub#311 was closed as resolved-by-#292 on 2026-05-21. PR #292 (merged 2026-05-21) is the last piece of the puzzle — it stamps installDir on services.json rows in the API install path, which is what fixed the install-doesn't-show-in-discovery symptom.

The schema extension is the remaining hub-side work. The hierarchical uis field is a real schema-extension question separate from the dynamic-reload concern. Hub's current ServiceEntry interface accepts the flat shape; the uis map needs an interface addition + validation. Track this as its own hub issue; it's not blocked by anything else.

Migration plan for vault. Once app's hierarchical shape lands, vault can adopt the same shape in a follow-up (paths: ["/vault"] + uis: { default: ..., gitcoin: ..., ... }). This isn't blocking for app; vault's flat shape continues to work via hub's existing path-prefix routing. The point of the hierarchical shape is forward-consistent ecosystem discovery.

13. Admin endpoints

App's HTTP surface:

Endpoint Auth Returns
GET /surface/list app:read Array of { name, displayName, path, status, version, oauthClientId, scopes }
GET /surface/<name>/info app:read The UI's parsed meta.json + hub-derived fields (oauthClientId, status, mount timestamp)
GET /surface/<name>/oauth-client none (the UI reads this at boot) { client_id, scopes, discovery_url } — the UI's OAuth identity, public-client-shaped
POST /surface/add app:admin Register a new UI. Body: { name, path, source: { kind: "filesystem", path: "..." } | { kind: "upload", base64: "..." }, meta?: object }. Returns { ok, oauthClientId, status }.
DELETE /surface/<name> app:admin Remove the UI; revoke its OAuth client at hub.
POST /surface/<name>/reload app:admin Re-read meta.json + dist contents without daemon restart.
GET /surface/healthz none { ok: true, ui_count: N, daemon_active: bool }
GET /surface/admin/* session (hub-gated) The admin SPA (Vite + React bundle).

Plus the standard .parachute/info, .parachute/icon.svg, .parachute/config, .parachute/config/schema endpoints.

Why oauth-client is unauthenticated: the UI's client_id is public information (OAuth public clients with PKCE don't have a secret). The UI's JS needs to read it at boot before the user is signed in. Same shape as any public OAuth client discovery endpoint.

Scopes app defines: app:read (read the UI catalog), app:admin (add/remove/reload UIs, modify global config). The app:admin scope is what gates the admin SPA's writes.

14. Gitcoin Brain migration walkthrough

End-to-end concrete steps. From "I have a UI at ~/Gitcoin/gitcoin-brain-ui/" to "I see it at https://parachute.tailnet.example.com/surface/gitcoin-brain/."

Gitcoin Brain is the second app installed. Notes (via @openparachute/notes-ui, see section 16) is the first — either bundled with parachute-surface's installer as the canonical first app or one parachute-surface add @openparachute/notes-ui away. This walkthrough assumes Notes is already installed; the steps below are general and apply to any custom UI Aaron adds after Notes.

  1. Install surface. parachute install surface (calls the standard install path; ships canonical module.json + port 1946 + self-registers services.json row). At first install, Notes is the canonical first app to bootstrap.
  2. Configure app. Hub's admin SPA shows the app module-config form (from its /.parachute/config/schema). Set hub_url to the local hub URL (typically http://127.0.0.1:1939 for loopback, or the operator's tailnet URL). Defaults for auto_dcr_register: true and default_scopes: ["vault:*:read"] are fine.
  3. Start app. parachute start app — hub-supervised. Verify parachute status shows it healthy on 1946.
  4. Author meta.json for Gitcoin Brain. In ~/Gitcoin/gitcoin-brain-ui/, create (or update) meta.json:
    {
      "name": "gitcoin-brain",
      "displayName": "Gitcoin Brain",
      "tagline": "Reading room for the Gitcoin team's vault.",
      "path": "/surface/gitcoin-brain",
      "scopes_required": ["vault:gitcoin:read", "vault:gitcoin:write"],
      "iconUrl": "icon.svg"
    }
    
  5. Add the UI to app. parachute-surface add ~/Gitcoin/gitcoin-brain-ui --name gitcoin-brain --path /surface/gitcoin-brain. This:
  6. Update the UI's OAuth bootstrap to read its client_id from app. Replace the hardcoded vault-URL paste flow in index.html / oauth.js:
    const res = await fetch("/surface/gitcoin-brain/oauth-client");
    const { client_id, scopes, discovery_url } = await res.json();
    // …use client_id + discovery_url to drive the OAuth dance against hub
    
    The same pattern Notes uses. Token-paste flow stays as a fallback for dev.
  7. Re-build (if there's a build) and re-add. For Gitcoin Brain's vanilla three-file bundle there's no build step — edit and re-add. For Vite UIs, bun run build && parachute-surface reload gitcoin-brain (the reload path picks up changes to dist/ without re-running OAuth registration).
  8. Verify. Open https://parachute.tailnet.example.com/surface/gitcoin-brain/. Sign in via hub (if you weren't already). The OAuth flow runs silently — same-hub auto-trust skips the consent screen for vault:gitcoin:read + vault:gitcoin:write. UI loads with vault data.
  9. Discovery. Hub's discovery page shows "App" as a module; clicking through reveals "Gitcoin Brain" as a sub-tile linking to /surface/gitcoin-brain/.

Migration time estimate: ~15 min for a UI that already has working OAuth-against-hub JS; ~30 min for a UI that needs the token-paste-to-OAuth swap. Cheap enough that Aaron will do it for both UIs without it feeling like a chore.

Dev iteration. For iterative work on Gitcoin Brain after it's installed:

1. parachute-surface dev gitcoin-brain    # disables caching, opens SSE live-reload
2. Edit ~/Gitcoin/gitcoin-brain-ui/ source
3. bun run build                       # (skip if no build step — for the vanilla three-file bundle, edit dist/ directly)
4. The browser tab auto-reloads via SSE; new code is visible
5. parachute-surface dev --off gitcoin-brain   # restore production caching when done

Per section 18, dev mode disables all caching for the named UI and pushes a reload signal to connected browser tabs every time the bundle directory changes. Phase 2 will fold step 3 into the dev mode itself via dev_build_cmd in meta.json — for MVP, the operator runs the build manually after editing source.

15. Comparison table — Module-A vs App-hosted-B

Aspect Own module (A) App-hosted UI (B)
Ship as Published npm package (@openparachute/<name>) Built static bundle, dropped in uis/<name>/dist/
Versioning RC chain, semver, npm publish gates Whatever the operator wants — bundle version is opaque to app
Install parachute install <name> parachute-surface add <path>
Port Own port Shares app's port (1946)
Services.json entry Own row Sub-entry under app's uis map
OAuth client One client_id per install, DCR-registered by hub One client_id per UI, DCR-registered by app on add
Module-protocol surface Own .parachute/info, /config, /config/schema, /healthz Inherits app's surface for module-level; per-UI info at /surface/<name>/info
Hub admin SPA config form Yes (module-specific) App's config form covers global settings; per-UI config is meta.json + app's add/remove flow
Update cadence Released by Parachute team or third-party Released by UI's operator/author
Restart on update parachute restart <name> parachute-surface reload <name> (no daemon restart)
Reviewer + release gate Parachute governance (RC chain, reviewer dispatch, Aaron clicks merge) Operator's own (app doesn't gate adds)

When (A) wins over (B):

Notes-specifically. At the time of this design's first revision, Notes was placed in the A column as the canonical first-party UI. That framing reversed during this revision: Notes' shape (vanilla SPA + service worker + OAuth-against-hub + per-vault picker) is exactly the shape app was built for, and app's existence removes the per-module-ceremony tax that justified Notes-as-module. Notes migrates to B — see section 16 for the full migration arc. Post-migration, the A column's exemplars are scribe and vault (backend-shaped modules); the B column's exemplars are Notes (multi-vault), Gitcoin Brain (single-vault), Unforced Brain, and the long tail of future custom UIs.

16. Notes migration to app

Notes is the first app. Its migration from own-module to app-hosted is the proof-of-pattern for the whole architecture — if Notes can be an app, the bar for graduating any other UI to its own module gets higher.

Phase 1 — parachute-surface v0.7 MVP ships (this design).

Phase 2 — parachute-notes v0.4: Notes-as-module deprecated.

Phase 3 — parachute-notes v0.5: full retirement.

Phase 4 — cleanup (post-1.0).

Cross-references:

What carries forward unchanged:

What changes:

17. Phasing

MVP (v0.7 target):

Phase 2 (v0.8+):

Phase 3 (deferred indefinitely):

18. Caching + reload strategy

A recurring frustration tracked at parachute-notes#151: edit notes source, run bun run build, the browser shows old code. The culprit is the service worker caching the previous bundle. Solving this at the platform level — so future apps inherit a clean default — is the design intent here. App's HTTP serving makes the right choice the default, and PWA is opt-in for the apps that genuinely need offline-first behavior.

Default (MVP): no service worker.

App serves UI bundles with smart cache headers that match the bundle's content-hashed asset convention:

This pattern (Vite, Webpack, Parcel, esbuild, Rollup, Rspack — all major build tools default to content-hashed asset filenames) means: rebuild produces new hashed assets; next page load fetches the fresh index.html; index.html references the new hashed assets; the old hashes evict from cache eventually but never get served fresh. No service worker = no service worker caching problem. Operators don't have to think about it.

Opt-in per UI: PWA mode via meta.json.

For UIs that genuinely need offline-first behavior (Notes is the canonical example — the whole point of Notes is "edit your vault offline on your phone"), meta.json grows an opt-in PWA mode:

{
  "name": "notes",
  "displayName": "Notes",
  "path": "/surface/notes",
  "pwa": true,
  "pwa_service_worker": "sw.js"
}

When pwa: true:

Default-off is the load-bearing choice: every other app — Gitcoin Brain, Unforced Brain, any future custom UI that doesn't need offline — inherits the no-SW path. The caching-frustration default-on disappears.

Dev mode: parachute-surface dev <name>.

For iterative development, a built-in dev mode that explicitly disables caching for the named UI and broadcasts a refresh signal when the bundle changes.

The MVP operator flow:

1. parachute-surface dev notes        # puts /surface/notes into dev mode
2. Open browser; hard-reload once to clear the prior bundle
3. Edit notes source
4. cd ~/parachute-notes && bun run build
5. The browser tab auto-reloads via SSE; new code is visible

In Phase 2, step 4 disappears — dev_build_cmd in meta.json runs the build automatically on file change.

Why SSE + manual build at MVP, not a full HMR setup. Hot module replacement is per-build-tool (Vite's HMR ≠ Webpack's ≠ Parcel's), needs deep integration with each, and most usefully runs inside the UI's own dev server (Vite's bun run dev). App's job is to host the production bundle; matching that with a full HMR shim is out of scope. The SSE + manual build pattern is the lowest-common-denominator solution that works for every build tool, vanilla JS included.

meta.json extensions for dev mode:

{
  "dev_watch_dir": "../src",          // path relative to dist/, watched for changes in dev mode
  "dev_build_cmd": ["bun", "run", "build"]  // Phase 2: ran in the UI's source dir on file change
}

Both optional. If not declared, dev mode falls back to watching the dist directory itself (still useful — operator builds manually, browser still reloads).

Cross-reference: parachute-notes#151 is the dev-rebuild ergonomics issue this section closes at the platform level. Once Notes migrates to app (section 16), notes-ui inherits this dev mode and the original frustration is gone.

19. Open questions (resolution status)

Captured from the original design pass; updated with what's been resolved during the review.

  1. App's services.json paths array growing dynamically — does hub's longest-prefix routing handle this cleanly? ✓ Resolved — hub already reads services.json per-request. Verified by reading parachute-hub/src/services-manifest.ts (post-PR #292, merged 2026-05-21): there is no in-memory cache, no mtime watcher needed; every hub request that touches the manifest re-reads it from disk. App's runtime writes to services.json on UI add/remove/reload will be visible on the next hub request without a restart. (Hub#311, formerly tracked as a prerequisite, was closed as resolved-by-#292 — #292 stamped installDir on services.json rows in the API install path, which fixed the install-doesn't-show-in-discovery symptom that motivated #311.) The hierarchical uis schema extension (section 12) is the remaining hub-side work, tracked separately.

  2. Per-UI uiUrl propagation into hub's discovery page. ✓ Resolved via hierarchical services.json (section 12). Each hosted UI surfaces as a sub-unit under app's row, carrying its own displayName, tagline, iconUrl, and path. Hub renders sub-units as their own discovery tiles. This mirrors what vault should grow into; for now, vault keeps its flat shape and app pioneers the hierarchical surface.

  3. Should app own the OAuth DCR registration, or should hub own it on app's behalf? ✓ Resolved: app-direct. App calls hub's standard /oauth/register endpoint (RFC 7591 DCR) on add. Hub already has DCR machinery for Notes' install path; app reuses it. The trust boundary is fine — DCR is a public endpoint by design, and same-hub auto-trust (section 6) makes the registration step low-friction. Hub-mediated DCR would be a Phase 2 refactor if the trust boundary later feels uncomfortable; not needed at MVP.

  4. Per-UI scope inheritance / operator override. ✓ Partially resolved. meta.json declares scope shape (wildcard vault:*:read vs concrete vault:gitcoin:read). For multi-vault UIs, the UI handles vault selection in-app via a picker — the OAuth flow per vault-pick narrows the wildcard to a concrete name. Operators don't need to override meta.scopes_required for the vault-binding case (UI handles it). What remains open: operators wanting to widen or narrow the declared shape at install (e.g., "this Notes install only gets vault:gitcoin:*, never the rest"). MVP: no operator override of declared shape; if real need surfaces, add --scopes CLI flag at Phase 2.

  5. Storage of OAuth client secrets. Open. Public OAuth clients with PKCE don't have a secret, so MVP is moot — all app-hosted UIs are public clients. If a future UI registers as a confidential client (for some server-side OAuth callback flow), app would need to store the secret encrypted. MVP: public clients only.

  6. Per-UI access logging. Open. Each UI is a static bundle with no server-side; logging happens in the browser. App logs request-level (which UI was hit, which path, response code) to its own stdout at MVP. Per-user attribution requires reading the hub-issued bearer's sub claim, which app doesn't do today. Phase 2 addition if operators want it — naturally fits with the richer admin SPA work in Phase 2.

  7. App's own admin SPA — is it shipped with app, or is it a hosted-UI of itself? ✓ Resolved: NOT dogfood. Per section 7, app's admin SPA is a Vite + React bundle shipped inside @openparachute/surface, mounted at /surface/admin/ as a known fixed path. Aaron explicitly rejected the dogfood approach as "too messy" — recursive special-casing for marginal elegance. The admin SPA is distinct from user-added apps under /surface/<name>/.

  8. Notes-as-app vs notes-module — does Notes stay as its own module or migrate to app? ✓ Resolved: Notes migrates to app over 4 phases. Section 16 captures the full arc. Phase 1 ships @openparachute/notes-ui alongside the existing @openparachute/notes module; Phase 2 deprecates the module form; Phase 3 retires it; Phase 4 cleans up. The committed-core line becomes vault + app + scribe + hub; Notes is the canonical first app installed under parachute-surface. Aaron's framing: "app is replacing notes."

  9. Dev-rebuild ergonomics — how does the platform absorb the SW-caches-stale-code frustration that's recurred during Notes dev? ✓ Resolved at the platform level (section 18). Three pieces: (a) no-SW default — apps ship without a service worker, smart cache headers on index.html (no-cache) vs hashed assets (immutable); (b) opt-in PWA via meta.json "pwa": true — only apps that genuinely need offline-first carry the SW caching burden, and they own their SW lifecycle (skipWaiting, controllerchange); (c) parachute-surface dev <name> dev mode — disables caching for the named UI, SSE live-reload signals connected browser tabs on file change. Phase 2 folds auto-rebuild via dev_build_cmd. Closes parachute-notes#151 at the platform level.

What's new vs the Gitcoin Brain UI today

For readers familiar with the current Gitcoin Brain UI (deployed at unforced-dev.github.io):

Aspect Today App-hosted
Hosting GitHub Pages (external) Parachute hub origin
Vault discovery Operator pastes vault URL on first visit Service catalog from hub-issued token
Auth Operator pastes pvt_* token OAuth-against-hub with PKCE + same-hub auto-trust (no consent screen for non-admin scopes)
Bundle update Push to GitHub, Pages rebuilds parachute-surface reload gitcoin-brain after rebuild
Discovery URL passed person-to-person Hub discovery tile, alongside Notes / Vault / etc.
Multiple installs Each user runs against own vault Each operator has their own app install with their own UIs
Token storage localStorage in browser OAuth refresh tokens managed by hub; UI holds short-lived access tokens

The current UI works; app makes it ecosystem-native. Same shape, same UX intent, ecosystem-shaped integration.

Why the architecture is right

Three equivalences make this small primitive load-bearing:

The runner equivalence. parachute-runner is the supervisor for vault job-notes; parachute-surface is the supervisor for UI bundles. Same shape (one daemon, N discovered units, lightweight per-unit registration), same audience (owner-operated, flat trust gradient), same module-protocol surface. Two supervisors on the same pattern means future supervisors (parachute-feeds for RSS sources? parachute-bots for chat agents?) inherit the conceptual model for free.

The vault equivalence. parachute-vault (singular module) hosts many vault instances; parachute-surface (singular module) hosts many app instances. Same hierarchical discovery shape — module-level row in services.json, per-instance sub-units. App pioneers the hierarchical uis shape in services.json; vault adopts it in a follow-up. The ecosystem ends up with a consistent module-hosts-many-instances story.

The trust-gradient equivalence. App is explicitly flat-gradient. The operator chose every UI in uis/; the operator owns the vault; there's nothing to sandbox from. Same-hub auto-trust generalizes this — installed apps are trusted apps. The consent screen disappears for the common case because the install IS the consent. When multi-tenant cloud arrives, app in cloud-mode is one of the things that needs to change shape (per-tenant origin, per-UI sandboxing, consent screens back) — and that's a parachute-cloud problem, not an app problem.

The Notes-as-first-app proof. Notes migrating to app (section 16) is the proof-of-pattern for the whole architecture. If the canonical first-party UI — the one with the deepest cross-cutting integration, the longest history as own-module, the highest bar to clear — can be an app, the bar for graduating any other UI to its own module gets meaningfully higher. The committed-core line shifts from vault + notes + scribe + hub to vault + app + scribe + hub. App absorbs Notes; everything downstream inherits the absorption.

App is small on purpose. The supervisor + meta.json + DCR-on-add machinery is maybe ~500 lines of TypeScript plus the standard module-protocol scaffolding. The complexity it absorbs (per-UI hosting ceremony, OAuth boilerplate, discovery integration, dev-rebuild ergonomics, PWA-mode opt-in) is real; the complexity it adds is small. Keeping it small is the design.