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:
<world>/data/apps/{name}— user-owned (vibe-coded), highest priority<world>/config/apps/{name}— world-template apps shipped with default-world<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.cssApps 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.
Linking into an app
Section titled “Linking into an app”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/18f3a2capp://google/mail.html#/thread/18f3a2c # the same appThe 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.
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, sayapp://host/inboxThey 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.

