# SDK reference

## Environment backend boundary

Browser code calls `client.requestEnvironmentBackendGrant({ scopes, context })`
and sends only the returned short-lived token to the exact declared backend.
Server code uses `createEnvironmentBackendGrantVerifier` from
`@al-amr/backend`; live Registry introspection is the secure default. The
verifier checks issuer, audience, endpoint, the desktop-hosted bundle origin,
scopes, context and the active Runtime fence without exposing a raw account
subject.

Al-Amr keeps its core browser client framework-neutral and exposes explicit
framework adapters.

| Package             | Responsibility                                                                               |
| ------------------- | -------------------------------------------------------------------------------------------- |
| `@al-amr/contracts` | Zod schemas, types, generated JSON Schema, and OpenAPI                                       |
| `@al-amr/sdk`       | Runtime/App sessions, Host Services, notifications, presence, settings, and Grant primitives |
| `@al-amr/react`     | Provider, hooks, Compact/Workspace Widget surfaces, inbox, and DOM Portal                    |
| `@al-amr/r3f`       | React Three Fiber scene and Portal adapter                                                   |
| `@al-amr/backend`   | Plugin Grant verification and App-session introspection helpers                              |
| `@al-amr/cli`       | Create, add, validate, test, publish, and inspect workflows                                  |

React and Plugin implementations are not part of the core SDK. Wire schemas,
reviewed identifiers, and BackendComponent ceilings shared by the first-party
Multiplayer and Media browser/backend pairs live in `@al-amr/contracts`, so
both sides validate the same narrow-waist contract. The Plugin packages
re-export those protocol symbols for compatibility and continue to own their
clients and behavior. A framework-neutral Environment can use the core SDK
without React.

## Embedded App rule

An App creates `createEmbeddedAppClient({ appId })` inside its own bundle
document and calls `connect()`. The client receives its generation-bound
session only over the desktop AppHost's transferred `MessageChannel`, keeps the
App bearer token in memory, and exposes `authorizationHeader()` for calls to
the App's own backend. It does not expose the parent Runtime credential.

The desktop AppHost verifies the exact held revision, product window, and
`alamr-app://<appId>/` bundle origin before transferring the channel. Apps must
not import or mount `AlAmrWidget`; the platform owns Widget chrome and product
lifecycle.

The connected session exposes `displayMode` and the exact
`grantedCapabilities`. `compact` is mandatory for every App revision;
`workspace` is optional.

A mode change no longer destroys the App document: in-memory state survives
it, and the new mode arrives through `client.subscribePresentation(...)`. The
`displayMode` on the signed session is the mode the launch was _authorized_
under and never moves; `presentation.displayMode` is the current one. An App
must still expect a relaunch when the platform says so — a revision change or a
recovered fault — so durable state belongs on your backend.

`client.presentation` also reports `state: "presented" | "background"`. Running
and not on screen is a state an App can now be in, if it declared
`display.background: "persistent"`. Stop drawing, keep playing, and read a clock
rather than counting ticks: the platform promises the document survives, not
that your code runs on time. Call `client.setAudible(true)` while producing
sound — advisory, never a permission.

Sessions renew inside the SDK, so `session.id` changes every few minutes. Key
stored data on `session.appActorSub`.

For an optional capability that the connected session does not hold, a visible
user action may call
`client.requestOptionalCapability("platform.media.microphone@1")`. The SDK
does not grant the capability: Registry verifies that the immutable App
manifest offered it, and the desktop host opens the platform-owned consent
surface. The App cannot open or impersonate that surface. Because the host
requires a live user activation, call this from the gesture itself rather than
after other work. Returning to the Widget, or selecting a persistent App again,
reassesses authorization; the host recreates the product renderer only when its
browser Permissions Policy changed. Browser permission remains a separate
decision.

The generation-bound `MessagePort` remains the Host Services transport. Apps
use the typed methods on `EmbeddedAppClient`:

```ts
const context = await appClient.getSpatialContext();
const locations = await appClient.listSemanticLocations();
const captured = await appClient.captureCurrentLocation();
const assessment = await appClient.assessNavigation(captured);
const result = await appClient.commitNavigation(assessment.assessmentId);
```

Environment code implements the provider side with
`createSemanticEnvironmentHostAdapter()` and passes it to
`<AlAmrWidget hostAdapter={hostAdapter} />`. It calls
`refreshSpatialContext()` after its actual place changes. The Widget brokers
only methods declared by contracts and capability grants; it does not inspect
or synthesize Environment scene state.

Two options say where the visitor is, and both are optional (`ADR-0075`):

- `currentZone(): { zoneId, label } | undefined` — which region of the
  Environment. Omit it entirely when the Environment is one continuous place;
  an App renders the absence correctly rather than showing a Zone that had to
  be invented to fill the field.
- `currentLocation(): { locationId?, label } | undefined` — where in it.
  Include `locationId` only for a place declared in `spatial.semanticLocations`;
  a room or a bench the Environment merely wants to name comes back as a label
  alone, and cannot be captured without a `CapturedLocationResolver`.

Both labels are what a visitor reads, authored at runtime and in their
language. The Environment's own display name is not among them: it reaches an
App on the session token, attested by the Registry.

For arbitrary-place capture, provide a `CapturedLocationResolver` with all
three operations: `capture`, `assess`, and `navigate`. The SDK exports
`CapturedEnvironmentLocationRef` and `CapturedLocationAssessment` for this
provider boundary. `assess` receives the opaque locator as untrusted input and
must validate the exact Environment revision, resolver version, Zone, bounds,
access, and navigability. It runs both during assessment and again immediately
before navigation commit.

Capture and navigation assessment require a transient user activation.
Navigation uses an expiring caller-bound assess/commit pair and the Environment
keeps final movement authority. A captured locator is opaque and
revision-bound; only declared semantic locations may travel through a
cross-Environment desktop navigation. A successful same-Environment commit
does not close the Widget or reload the App document.

Use `setBeforeExitHandler()` for unsaved work. The host waits at most one second
and owns any confirmation:

```ts
appClient.setBeforeExitHandler(async ({ reason }) => {
  const saved = await saveDraft(reason);
  return saved ? { disposition: "allow" } : { disposition: "confirm" };
});
```

`requestClose()` asks the Widget to close. `clear()` drops the memory-only App
token, closes the port, cancels pending calls, and removes lifecycle listeners.

## Notification APIs

An active Environment Runtime reads only the current Environment's projection:

```ts
const page = await client.listNotifications({ cursor, limit: 50 });
const notification = await client.publishNotification(request);
const stop = client.subscribeNotificationInvalidation(() => reconcile());
```

`subscribeNotificationInvalidation` fires when something was raised for this
session and says no more than that — the presence frame behind it carries a
session id and no notice. Re-read with `listNotifications`; coalesce, because a
burst of messages is a burst of hints. A list, first snapshot, page, poll, or
reconnect is reconciliation and never reconstructs a transient Pulse.

V1 rows carry a platform-owned `projectionVersion`. Send the version the UI
actually rendered when marking seen or dismissing, so a stale action cannot
consume a newer Subject revision:

```ts
const item = page.notifications[0];
const versions =
  item.projectionVersion === undefined
    ? undefined
    : { [item.notificationId]: item.projectionVersion };

await client.markNotificationsRead([item.notificationId], undefined, versions);
await client.dismissNotifications([item.notificationId], undefined, versions);
```

The second argument remains the legacy `{ [notificationId]: createdAt }`
fallback for immutable readers. Registry returns its own `readAt`; do not stamp
one in the browser. `unseenCount` includes unseen `mute` rows,
`attentionCount` excludes them, and legacy `unreadCount` aliases
`attentionCount`.

An open App with reviewed event policy uses the occurrence API:

```ts
await appClient.signalNotificationOccurrence(occurrence);

await appClient.acknowledgeNotificationSubjects({
  acknowledgements: [{ eventType, subjectKey, throughRevision }],
});
```

The first call returns only `{ accepted: true }` and exact retries reuse the
same `eventId`. The second advances the source watermark only after the App has
actually presented authoritative content through `throughRevision`; opening the
App or clicking Shell chrome is not acknowledgement. Both source and recipient
come from the App session. The App must hold
`platform.notifications.publish.self@1` to signal.

When a person presses an App notification, Shell opens or focuses that App and
then sends its source-owned semantic target over the established AppHost
channel:

```ts
const stopActivation = appClient.subscribeActivation(({ source, target }) => {
  if (source === "notification" && target.kind === "notification_subject") {
    openAuthoritativeSubject(target.subjectKey);
  }
});
```

`subjectKey` is opaque to Shell and scoped to the recipient. It is not a raw
conversation, document, or account identifier. If the Host event wins the
mount race, the SDK retains only its latest target until the first
`subscribeActivation()` and consumes it once; a later effect subscription does
not replay it. A cold App must then retain that activation until its
authoritative list is ready, resolve it only against its own current data and
permissions, and acknowledge only after that content is actually presented. A
failed launch or an unknown/stale target therefore leaves platform Attention
intact.

For App background delivery, the connected account App creates or revokes one
opaque delegation:

```ts
const delegation = await appClient.createNotificationDelegation();
await appClient.revokeNotificationDelegation();
```

The App backend combines the one-time delegation token with an expiring Project
token scoped to `notifications:publish`:

```ts
import { createAppNotificationWorkloadClient } from "@al-amr/backend";

const workload = createAppNotificationWorkloadClient({
  issuer: registryIssuer,
  projectToken,
});
await workload.signal(delegationToken, occurrence);
```

Once caller-owned workload facts are admitted, recipient-dependent outcomes
are accepted-only. A durable source outbox retries the same `eventId`; the
response exposes no count, treatment, or recipient state.

`appClient.publishNotification(request)` and
`workload.publish(delegationToken, request)` remain additive legacy methods for
immutable callers. Their caller-supplied `category` gains no family, sound, or
Pulse authority.

Environment backends do not use App delegations. They reuse their short-lived,
exact-endpoint Environment Backend Grant containing
`notifications.publish@1.0.0` and the existing legacy publish path.

`navigateToEnvironment({ environmentId, focusLocationId? })` sends a typed
navigation request to the desktop host and resolves with `{ ok: true }` or
`{ ok: false, refusal }`. The host resolves the destination's exact active held
bundle, while Registry validates an optional semantic location against that
revision. No publisher URL is constructed or exposed.

The promise settles when the journey has been decided, not when the request
left: resolving the destination may fetch a bundle this machine does not hold,
so a caller shows a waiting state and gives it no ceiling. On success the asking
document is torn down as the destination is raised, so `{ ok: true }` means the
document is over rather than that a waiting state should be cleared. The
`refusal` is the host's own sentence, meant to be shown rather than branched on.

The host refuses a journey to the Environment the caller is already in, a second
journey while one is in flight, and one asked less than two seconds after a
granted one. These are enforced host-side and only host-side: a guest's
self-reported `navigator.userActivation` is not a defence, because a guest
stamps it itself.

`consumeEnvironmentLaunchIntent()` is the arriving half and **no host delivers
it yet** — nothing constructs a client with a `launchIntent`, so it returns
`undefined` in every shipped build. The Widget still owns confirmation and
spatial commit.

## Runtime rule

The desktop Shell selects the exact held Environment bundle and injects its
Runtime bridge. Configure the SDK with the Registry origin and registered
Environment identity; product code must not initiate browser OAuth or declare
authorization/post-logout callback URIs. Account sign-in belongs to the Shell.

The SDK stores the injected Runtime credential only in memory and never
downgrades an account after a network or authorization error. It reports
`connected` only after the host session is accepted, Runtime activation, and
the first successful presence heartbeat.

`client.snapshot` and every value delivered to `client.subscribe()` are
deeply read-only observations. The SDK clones and freezes the complete Runtime
snapshot, including nested session, activity, and detail values. Consumers
must request transitions through client methods rather than mutate an observed
snapshot; an older asynchronous completion is fenced and cannot restore state
after release, sign-out, supersession, or shutdown.

Plugin settings snapshots returned by client settings methods or read from the
local cache are also deeply read-only and frozen. This includes resolved
values, every source layer, nested provenance, revision markers, and the
settings schema. Write through `setPluginSettings()` or
`resetPluginSettings()` so validation, ETag concurrency, and reactive
publication remain authoritative.

If a concurrent mutation, sign-out, or Runtime reset invalidates an in-flight
settings read, that read rejects with `plugin_settings_load_stale` instead of
returning a snapshot from the older generation. Retry the read only after the
current Runtime state is ready. Catch the exported
`PluginSettingsStaleLoadError` when the caller needs to distinguish this
retryable concurrency outcome.

Wrap interactive Environment content in `AlAmrRuntimeGate`. Only one page or
device for a principal may hold the active Runtime lease. A superseded page
unmounts its Plugin content and stays inactive until the user explicitly
chooses **Activate this page**; focus alone does not reclaim it. Plugin cleanup
handlers must stop media, sockets, timers, and device access when activity is
lost.

While inactive, the SDK rotates the active Presence socket to a read-only
control channel. This keeps browser-wide sign-out immediate without heartbeat,
Presence, Plugin execution, or automatic ownership reclaim. Returning to a
visible tab also reconciles authoritative Runtime state to recover from browser
background suspension or a missed WebSocket frame.

## Environment boot rule

Environment readiness is a client-local truth, separate from the Registry
Runtime snapshot. A fresh client starts in `booting`; the Environment calls
`client.signalEnvironmentReady()` once its critical scene has mounted, and
optionally `client.reportEnvironmentBootProgress(0..1)` for real progress.
`AlAmrRuntimeGate` holds the branded boot surface until the signal arrives:
while the Runtime is active but the Environment is not ready, children stay
mounted under the surface (scenes keep loading); superseded, signed-out, and
failed states unmount Plugin content as before. The ordinary boot surface is a
non-interactive status and leaves on readiness, holding only long enough that a
fast boot cannot flash a loading screen for a single frame; only exceptional
states are dialogs. Environments must not render their own loading screens, and
a ready signal is idempotent.

`client.signalEnvironmentPainting()` marks the earlier moment when the world's
first frame reached the screen, and `EnvironmentBootSnapshot.painting` carries
it. The two facts answer different questions: readiness is "the critical scene
is built and the visitor may have it", while painting is "there are real pixels
of this world underneath you", typically several seconds sooner. Platform
chrome needs both because the halves of a boot cost differently — before the
first frame no world renderer exists and platform chrome may use the GPU
freely, and after it that same device is compiling this world's pipelines. The
signal is idempotent and ignored after readiness, exactly as progress reports
are, and readiness implies painting so no snapshot claims a ready world never
drew. Environments built on `EnvironmentCanvas` never call it: the canvas knows
when it presented a frame and signals it there.

## Environment chrome

`EnvironmentHostAdapter.chrome` is an optional, in-process bridge for behaviour
that sits behind a platform-owned mark. It is **not** a Host Service: nothing on
it crosses the App broker, is serialized, or is gated by a capability grant, so
it carries no contracts entry and no protocol version.

```ts
interface EnvironmentChromeState {
  readonly suppressed: boolean;
  readonly settingsAvailable: boolean;
}

interface EnvironmentChromeAdapter {
  getState(): EnvironmentChromeState;
  subscribe(listener: () => void): () => void;
  openSettings(): void;
}
```

The Widget draws its settings mark only while `settingsAvailable` is true and
hides every platform mark while `suppressed` is true — an Environment that has
opened its own modal or is mid Zone transition owns the screen, and the Widget
sits above its entire stacking context. `openSettings()` is called only while
the mark is drawn; everything after that call is the Environment's. The Widget
never renders, inspects, or persists Environment settings. Pass the bridge
through `createSemanticEnvironmentHostAdapter({ chrome })`, or omit it entirely
and the Environment simply gets fewer marks. See ADR-0062.
