Skip to content

apps/

HTML apps the realm ships. A realm-bundled app is served at /apps/{realm-name}/{name}.html (the reference host, verified 2026-09-23: /apps/github-actions/ci-health.html; /apps/{name} and /api/v1/apps/{name} answer 404 for it). Vibe-coded and world-template apps share the /apps/{name} resolution below:

  1. <world>/data/apps/{name} — user-owned (vibe-coded), highest priority
  2. <world>/config/apps/{name} — world-template apps shipped with default-world
  3. <world>/config/realms/<realm>/apps/{name} — realm-bundled apps (this directory)

A realm app’s manifest entry takes a title — the name shown for the app. Without one a host may derive it from the file stem (ci-health → “Ci Health”), so an acronym needs it spelled: "title": "CI Health".

Ship the Embabel badge. An app is expected to carry the attribution block before </body>: a fixed-position <div id="embabel-badge"> linking to worlds.embabel.com. The host’s check is deliberately fuzzy — it looks for the well-known embabel-badge id, not for exact markup — and the vibe-coding path injects the block when it is missing. Nothing injects it into a realm-shipped app, so a realm ships its own, and the app harness (vibe-apps/browser-harness.md) asserts it is visible in the viewport rather than merely present.

A user can shadow a realm-bundled app by vibe-coding one with the same filename. Realm apps are read-only from the user’s perspective; they’re refreshed whenever the realm is updated.

apps/
├── github-dashboard.html
├── github-dashboard.svg # its icon (optional)
├── pr-review-board.html
└── shared/ # assets several apps load (optional)
├── board-core.js
└── board.css

Apps may share files. Every file under apps/ is served at its own path — /apps/<scope>/shared/board-core.js — with a content type from its extension (.js, .mjs, .css, .json, .svg and common images), so a realm that ships several apps (or editions of one) keeps their common script, styles and data in one place and each page loads them with an ordinary relative <script src> or <link href>. Subfolders are allowed. A host serves nothing outside the realm’s apps/ directory: no .., no absolute paths, no hidden (dot) files or folders, and no symlink that leads out. Only .html files directly in apps/ are listed as apps; everything else is an asset. When a user copies a realm app to customise it, a host should keep the copy pointing at the realm’s shared assets rather than copying them, so the fork still receives the realm’s updates.

An app may declare its own icon, by the means the web already has — a <link rel="icon" href="…"> in its <head>. The href must be a plain filename beside the app in this directory (no /, no .., no URL); a host serves it from the same /apps/{name} resolution as the app itself and draws it wherever the app is listed. An app that declares none is listed as before. This is per-app and separate from the realm’s own icon: a realm shipping three apps can give each its own.

A captured realm’s apps work differently. Each page ships with an apps/<name>.html.app.json declaration naming the handlers it may call, and the owner approves every app on its own. The page runs in a sandboxed frame with no network: it reaches the realm only through realm.call(handler, arguments), one call at a time, and everything it needs is inlined into the page. The rest of this section describes the conventional app runtime, which a captured page does not get. See captured browser apps for the declaration, the operator’s size limits (10 MiB by default) and the bridge.

Realm apps must use the same architecture as vibe-coded apps: tool-gateway calls via fetch('/api/v1/tools/{name}'), no direct external fetches. They have access to all the user’s tools (MCP, learned APIs, etc.) because they run in the user’s authenticated session.

Prefer invoking a named Lens over calling raw tools. An app that posts to /api/v1/lenses/{id}/invoke gets a result the realm has already shaped, scoped and caveated; an app that assembles raw tool calls duplicates that reasoning in a browser where it cannot be tested or reused. Keep the app to presentation: it should choose a Lens and render what comes back, never decide what to fetch. This also honours the rule below — an app must not accept or submit arbitrary Cypher or JavaScript from a browser.

What “no direct external fetches” does and does not cover. The prohibition is on the app reaching a third-party data API itself — that would bypass the gateway’s auth, scoping, quotas and provenance, and would leak keys into the browser. It does not prohibit ordinary outbound navigation: an <a href> deep link to an external site (a map, a source document, a public register entry) is how a surface cites its sources and should be encouraged. Embedding third-party runtime assets — a map SDK, tiles, remote fonts — is a different question again: it adds a network dependency and a privacy surface the host does not mediate, so treat it as a deliberate choice rather than a default, and never let a provider’s key reach the page. A realm that wants map rendering without that dependency can deep-link out instead, which also keeps provider terms about attribution and caching simple to honour.

An app link opens an app at a place inside it:

app://<scope>/<name>#<route>

<scope>/<name> is the app’s own address: the same one it is served at, /apps/<scope>/<name>. That address is unique in a world, so an app link cannot collide with another app’s, and nothing is registered or declared to make one work. The .html suffix may be left off.

app://google/mail#/thread/18f3a2c
app://google/mail.html#/thread/18f3a2c # the same app

The route is the app’s business. A host opens the app with #<route> and does not read it. The app reads location.hash when it loads and follows hashchange after that: when the app is already open, a host changes only the route, and does not reload the app.

apps/mail.html
function route() {
const m = location.hash.match(/^#\/thread\/([A-Za-z0-9_-]+)$/)
if (m) showThread(m[1])
}
addEventListener('hashchange', route)
route()

Treat the route as untrusted input. Anything that can produce a link can put anything after the #. An app matches the route against the shapes it expects, as above, and ignores the rest. It never passes route text to eval, innerHTML, a lens argument, or a query without validating it.

Where app links go. Anywhere a host shows a link it may carry an app link. A notification’s url is the common case:

ctx.gateway.notifications.createNotification({
event: 'Reply needed: contract renewal',
source: 'email',
url: 'app://google/mail#/thread/18f3a2c',
})

A realm should link to its OWN apps. Its producers and its apps ship together, so the link cannot point at an app that is not there.

The host’s own places. The scope host is reserved: no realm or vibe-coded app may occupy it, and app://host/<place>#<route> names a place in the host’s own interface rather than an app. What follows host/ is the host’s to define, and a place one host has another may not:

app://host/settings#keys # a console's Keys and connections, say
app://host/inbox

They exist mostly for notifications the host raises about itself, such as a key that stopped working. A realm should rarely use them — its links belong in its own apps — and must not rely on one existing. A host that does not recognise a place falls back as below.

When the app is not there. A user can uninstall a realm, or shadow its app. A host that cannot resolve an app link opens the nearest list of the thing instead, such as the notification inbox. It never shows an error for an unresolvable app link. External http(s) links are unchanged.