# App Host Services and spatial context

Host Services are the only supported way for a bundled App to read selected
host context or request an Environment action. An App never imports Environment
code, reads the scene, reaches into platform chrome, receives a Runtime
credential, or calls a Plugin Grant.

```text
App revision
  createEmbeddedAppClient()
          │  versioned App frames
          ▼
desktop AppHost session ─ capability grant / generation / deadline
          │  validated Main↔Environment relay
          ▼
EnvironmentHostAdapter ── Environment-owned scene semantics
```

The launch handshake creates one persistent logical channel. In the desktop
client, `window.alamr.appHost` carries it out of the App's sandboxed
`WebContentsView`; Main owns the `AppHostProtocolSession` and keeps the same
versioned frame protocol used by the SDK. The channel carries strict request, response, error,
cancellation, event, and lifecycle frames. Every frame belongs to one exact App
revision and host generation. Frames are limited to 64 KiB and an App may have
at most eight concurrent requests.

## Where this lives

- `packages/contracts/src/host-services.ts` — the capability ids, the frame
  shapes and the spatial context every side of this protocol validates against.
- `packages/sdk/src/embedded-app.ts` — the App's half: `createEmbeddedAppClient`,
  the request, response and lifecycle frames, and every host call an App may make.
- `packages/sdk/src/app-client-port.ts` — the channel adapter that selects a
  document `MessagePort` or the desktop `window.alamr.appHost` bridge.
- `packages/sdk/src/app-host-protocol-session.ts` — the framework-neutral
  logical host session: launch, renewal, generation fencing, bounded dispatch,
  presentation and `beforeExit`. Browser and Desktop feed authenticated ports
  to this same implementation.
- `packages/sdk/src/host-service-dispatcher.ts` and
  `packages/sdk/src/environment-host.ts` — the Environment's half, where an
  `EnvironmentHostAdapter` answers.
- `packages/contracts/src/environment-bridge.ts`, `packages/bridge`, and
  `packages/sdk/src/environment-host-service-relay.ts` — the versioned,
  64-KiB-bounded Desktop relay. Main sends only Environment-provided methods
  after policy admission; the Environment returns schema-checked results and
  complete spatial-context changes without sharing functions across processes.
- `packages/react/src/app-frame.tsx` — the browser admission adapter. It checks
  iframe source/origin and injects browser-only user activation, consent-tab and
  companion-picker ports; it owns no launch or renewal protocol.
- `platform/desktop/src/main/apps/app-view-manager.ts` — the receipt-bound
  desktop adapter that creates one shared `AppHostProtocolSession` for each
  independent App document and connects it to Main-owned host services.
- `platform/desktop/src/main/apps/environment-host-adapter-provider.ts` — the
  context-aware Main proxy for the currently mounted Environment. Home, a
  different Environment, a replaced document, and a released surface all fail
  closed instead of borrowing a stale scene adapter.

## Capabilities

An App requests closed, versioned capabilities in `al-amr.app.json`:

```json
{
  "capabilities": {
    "required": [
      {
        "id": "platform.environment.identity.read@1",
        "versionRange": "^1.0.0"
      },
      {
        "id": "host.spatial.context.read@1",
        "versionRange": "^1.0.0"
      }
    ],
    "optional": [
      {
        "id": "host.spatial.locations.read@1",
        "versionRange": "^1.0.0"
      }
    ]
  }
}
```

Required capabilities make the App unavailable when they cannot be granted.
Optional capabilities let the App provide a reduced experience. Dependencies
must be declared at the same or stronger requirement level. Registry checks the
published revision, capability catalog, principal policy, dependencies,
Runtime authority, and user consent before the App is launched. The App
must still check `client.snapshot.hostSession.grantedCapabilities`; a manifest
request is not a grant.

| Capability                                         | Disclosure and activation                                                                                                 |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `platform.identity.profile.read@1`                 | Account display name and avatar; account only                                                                             |
| `platform.environment.identity.read@1`             | Signed identity of the active Environment                                                                                 |
| `host.spatial.context.read@1`                      | Current Zone and semantic location                                                                                        |
| `host.spatial.locations.read@1`                    | Reviewed stable locations declared by the Environment                                                                     |
| `host.spatial.location.capture@1`                  | Opaque reference to the current place                                                                                     |
| `host.spatial.navigation.request@1`                | Ask the Environment to move the user                                                                                      |
| `platform.notifications.publish.self@1`            | Signal within the current App principal's reviewed notification declaration                                               |
| `platform.notifications.connect.background.self@1` | Delegate delivery to this App's backend; account only                                                                     |
| `platform.companions.list@1`                       | Continuing reads of each companion's name, face, and App-pairwise pseudonym until revoked; never location; account only   |
| `platform.companions.offer@1`                      | Offer something to a companion the App never learns of                                                                    |
| `platform.notify.companion@1`                      | Raise one platform-authored, content-free companion notice                                                                |
| `platform.call@1`                                  | Place a call to a companion and receive that companion's calls; account only; ringing arrives as a live account CallAlert |
| `platform.media.microphone@1`                      | Let the granted App frame request microphone input                                                                        |
| `platform.presence.roster.read@1`                  | How many people are in this world; never who                                                                              |

User activation is a property of the **method**, not of the capability, and the
table above deliberately no longer claims otherwise.
`HOST_SERVICE_METHOD_DEFINITIONS` carries `requiresUserActivation` per method,
which is the only grain that can express the shipped rule:
`host.spatial.navigation.request@1` requires a gesture for `assessNavigation`
and does not for `commitNavigation`, because the gesture belongs to the moment
the person asked, not to the moment the host acts on it.

### Companion reach at an App-owned backend

`platform.companions.list@1` discloses only App-pairwise `aact_` subjects and
resolved presentation; it carries no account subject, whereabouts, Environment,
or raw companion-graph key. An `aact_` is an address inside one App, not proof
that the relationship is still live.

Before a backend accepts a direct cross-person write, the App presents one
subject from that consented list to `POST /v1/apps/companions/reach`. Registry
re-checks the live edge, refusals and suspension and, when allowed, returns a
Registry-signed proof valid for 60 seconds. Every target-side refusal otherwise
collapses to `{ "status": "unavailable" }`. The App-owned backend verifies the
issuer, App audience, caller and exact companion with
`createAppCompanionReachVerifier`; it never accepts a subject by shape alone.

This is a platform primitive for every App, not a messaging API. Registry sees
no product content or conversation identifier, and product data remains in the
App-owned backend under ADR-0068.

## Environment declaration

An Environment that provides spatial services declares a stable vocabulary and
the matching services together:

```json
{
  "spatial": {
    "semanticLocations": [
      {
        "locationId": "garden-gate",
        "label": "Garden gate",
        "navigationPolicy": "confirm"
      }
    ]
  },
  "hostServices": {
    "protocolVersion": "1.0.0",
    "provides": [
      "host.spatial.context@1",
      "host.spatial.locations@1",
      "host.spatial.capture@1",
      "host.spatial.navigation@1"
    ]
  }
}
```

Location IDs are Environment-owned semantic identifiers, not routes or
coordinates. Keep them stable when App data may refer to them. Publication
creates an immutable vocabulary snapshot.

Zones are absent from this declaration on purpose. An Environment reports its
current Zone at runtime on `EnvironmentSpatialContext`, with the label its own
visitors read, and an Environment that is one continuous place reports none —
so the Registry never holds a Zone list, and `ADR-0075` is where that is
argued.

Create the adapter from actual Environment state and pass it to the Widget:

```ts
import { createSemanticEnvironmentHostAdapter } from "@al-amr/sdk";

const hostAdapter = createSemanticEnvironmentHostAdapter({
  environmentId: manifest.environmentId,
  environmentRevisionId: () => currentRevisionId,
  spatial: manifest.spatial!,
  currentZoneId: () => currentZoneId,
  currentSemanticLocationId: () => currentLocationId,
  capturedLocationResolver: {
    capture: () => ({
      kind: "captured",
      environmentId: manifest.environmentId,
      environmentRevisionId: currentRevisionId,
      zoneId: currentZoneId,
      resolverId: "example.player-position",
      resolverVersion: "1.0.0",
      locator: worldNavigation.captureOpaqueLocator(),
    }),
    assess: (target) =>
      worldNavigation.canResolveCapturedPlace(target.locator)
        ? {
            presentation: { title: "Saved place" },
            minimumPolicy: "direct",
          }
        : undefined,
    navigate: (target) => worldNavigation.goToCapturedPlace(target.locator),
  },
  navigate: async (locationId) => {
    return await worldNavigation.goToSemanticLocation(locationId);
  },
  onWidgetStateChange: ({ capturesInput, occupiedRect }) => {
    controls.setWidgetCapture(capturesInput);
    camera.setWidgetSafeArea(occupiedRect);
  },
});
```

```tsx
<AlAmrWidget hostAdapter={hostAdapter} />
```

`AlAmrWidget` automatically binds that adapter to `window.alamr.hostServices`
when the Desktop preload or development host exposes the relay, and releases
both request and spatial-context subscriptions on unmount. A framework-neutral
Environment can make the same explicit connection with
`bindEnvironmentHostServices(window.alamr.hostServices, hostAdapter)`.

This extra hop does not move authority into the Environment renderer. Main
still checks the Registry-granted capability, current App generation, current
Environment context, user activation, request budget, navigation assessment,
and deadline before it relays an Environment-owned method. Every response must
match the exact request id, generation, and method. A reload settles in-flight
work as stale; an absent or released Environment is retryably unavailable.

Call `hostAdapter.refreshSpatialContext()` after a Zone or semantic-location
change. The adapter preserves one visit ID, increments its sequence, and emits
the complete latest context. Do not report a place until the Environment has
actually entered it.

To support arbitrary places that are not authored semantic locations, provide
the complete `capturedLocationResolver`: `capture`, `assess`, and `navigate`.
Its result is a bounded opaque locator with a resolver ID/version and the exact
Environment revision. Treat the locator as untrusted input in every `assess`,
checking its revision, Zone, shape, bounds, access, and walkability. The SDK
calls `assess` again at commit before `navigate`, so capture and restore cannot
become disconnected security paths. The App stores the reference but does not
interpret its locator.

## App usage

Connect once per App document generation:

```ts
import { createEmbeddedAppClient } from "@al-amr/sdk";

const client = createEmbeddedAppClient({
  appId: manifest.appId,
  registryBaseUrl,
});

const session = await client.connect();
const mode = session.displayMode;
const context = await client.getSpatialContext();
const locations = await client.listSemanticLocations();
```

Subscribe when the App needs live spatial affordances:

```ts
const unsubscribe = client.subscribeSpatialContext((nextContext) => {
  notes.showCurrentPlace(session.environmentId, nextContext.zoneId);
});
```

Capture and navigation must start from a user action:

```ts
saveButton.addEventListener("click", async () => {
  const location = await client.captureCurrentLocation();
  await notes.save({ location });
});

goButton.addEventListener("click", async () => {
  const assessment = await client.assessNavigation(note.location);
  showAssessment(assessment); // The App may explain, but the host owns confirmation.
  const result = await client.commitNavigation(assessment.assessmentId);
  if (result.status === "unavailable") showUnavailable(result.reason);
});
```

Notes does not require these spatial capabilities to create or edit ordinary
notes. Its library and folders are keyed by the App-pairwise actor. Capture is
an explicit create-time choice that adds immutable provenance; it never becomes
the note's folder or corpus scope. A note attached to another Environment still
renders as context, but Notes must not offer `assessNavigation` until
`session.environmentId` equals the attachment's Environment.

Notes acknowledges an offline edit only after its per-actor outbox is durable.
Same-origin App windows serialize short cache transactions with an actor-scoped
Web Lock, reload after lock acquisition, and converge through storage events. A
second actor-scoped lock elects one remote sync worker; fetches never hold the
cache lock, so a stalled network cannot stall local durability in another
window. If durable storage or Web Locks are unavailable, Notes refuses to claim
a local save. Network work has a deadline and bounded jittered backoff; teardown
closes new mutation admission, drains mutations already admitted to local
storage, aborts transport, and fences late responses before they can mutate the
cache.

Assessment and commit are deliberately separate. The assessment is short-lived,
caller-bound, and revalidated on commit. The App never changes player, camera,
route, or Zone state directly. A successful same-Environment spatial commit
keeps the App document open; it is movement within the current experience, not
an App lifecycle transition.

## Location reference rules

- A `semantic` reference contains an Environment ID and stable location ID. It
  can participate in a Registry-validated cross-Environment deep link.
- A `captured` reference is opaque and revision-bound. It stays within the
  Environment revision/resolver contract and cannot be put in a launch URL.
- Coordinates, player poses, camera transforms, private route names, and raw
  scene node IDs are not portable spatial identity.
- Spatial context contains no raw account subject. App data is keyed by the
  App-pairwise actor from the verified App session.
- Spatial context contains no Environment identity either. The Environment
  fills the context from its own manifest, so a copy there would be the
  Environment's word for who it is — and the context rides
  `host.spatial.context.read@1`, which would have disclosed the identity to an
  App whose user refused `platform.environment.identity.read@1`. Environment
  identity comes only from `session.environmentId`, which the Registry signs
  from its own record and gates on that capability.

## Lifecycle

Register a bounded exit handler only when the App may have unsaved work:

```ts
client.setBeforeExitHandler(async ({ reason }) => {
  if (!draft.dirty) return { disposition: "allow" };
  const saved = await draft.saveBeforeDeadline();
  return saved
    ? { disposition: "allow" }
    : { disposition: "confirm", message: exitMessage(reason) };
});
```

The host allows at most one second. A timeout or failure cannot trap the user.
Call `client.clear()` during App cleanup.

`beforeExit` now fires strictly less often than it used to. Compact/Workspace
changes do not create a new document or App session and do not invoke it, and
closing the _panel_ no longer invokes it for an App declaring
`display.background: "persistent"` — that App is parked, not exited. It still
fires for a real close, a replace when the third running App must make room, and
a lost Runtime.

Two host events and two host methods exist for parked Apps:

| Frame                       | Direction  | Meaning                                                                                                        |
| --------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------- |
| `host.presentation.changed` | host → App | Presented or background, current mode, page visibility, whether the host can answer, and the host's wall clock |
| `host.session.renewal.due`  | host → App | The five-minute session needs re-minting                                                                       |
| `host.presentation.get`     | App → host | The current presentation, for an App that subscribed late                                                      |
| `host.presentation.audible` | App → host | Advisory: this App is producing sound                                                                          |
| `host.session.renew`        | App → host | Mint a fresh launch code against a new challenge and nonce                                                     |

None of the four takes a capability, and none requires a user gesture: a gesture
would make renewal impossible for an App behind a closed panel, which is the
only kind that needs it. Renewal repeats the launch ceremony in full, so every
Registry check that runs on a first launch runs again — the Registry is never
told a call is a renewal, and that ignorance is the property.

See [Apps](/docs/product/apps), [Manifest reference](/reference/manifests),
[SDK reference](/reference/sdk), and
[ADR-0046](../decisions/ADR-0046-host-services-and-spatial-context.md).
