> ## Documentation Index
> Fetch the complete documentation index at: https://docs.go.gbgplc.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Capability Handling

> Declaring, registering, and managing dynamic capability state on the host page.

This guide explains how the GBGBridge Web SDK handles capabilities — the host-side features that web journeys can request. It covers the two-step declare-then-register pattern, the one-call alternative via `registerCustomCapability`, permission state, capability negotiation, and graceful-degradation patterns.

## What is a capability?

A **capability** is a host-side feature the host page can fulfil on the journey's behalf. Examples:

* `camera.document` — Document photo capture handled by the host page.
* `camera.selfie` — Selfie / liveness capture handled by the host page.
* `analytics.forward` — Forward analytics events from the journey to your telemetry pipeline.

Capability IDs are dot-separated string keys. Standard IDs are exported as constants in `CAPABILITY_IDS` — use them to avoid typos:

```typescript theme={null}
import { CAPABILITY_IDS } from "@gbg/go-bridge-web";
// CAPABILITY_IDS.CAMERA_DOCUMENT === "camera.document"
```

## Two ways to declare and register

Unlike iOS / Android — which use typed slots that combine declaration and handler registration in a single property assignment — the Web SDK keeps declaration and registration as separate concerns by default. There are two patterns:

| Pattern      | When to use                                                                                                                           | API                                                                      |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| **Two-step** | Capabilities you support at all times — declare them statically with the provider, register handlers near where the relevant UI lives | `capabilities` prop on `BridgeHostProvider` + `useBridgeCapability(...)` |
| **One-call** | Capabilities you add dynamically at runtime, or capabilities tied to a single coordinator object                                      | `host.registerCustomCapability(id, version, handler)`                    |

Both can be used together. `registerCustomCapability` entries are merged with the constructor's `capabilities` map when building `capability.query` responses.

## Two-step: declare via prop, register via hook

This is the default React pattern. The provider's `capabilities` prop declares what the host supports; `useBridgeCapability` attaches the handler that actually fulfils requests.

### Declaring capabilities

Pass a `CapabilityMap` to the provider:

```tsx theme={null}
import { BridgeHostProvider } from "@gbg/go-bridge-web-react";
import { CAPABILITY_IDS } from "@gbg/go-bridge-web";

<BridgeHostProvider
  iframe={iframe}
  allowedOrigins={[...]}
  hostVersion="1.0.0"
  capabilities={{
    [CAPABILITY_IDS.CAMERA_DOCUMENT]: { supported: true, version: "1.0" },
    [CAPABILITY_IDS.CAMERA_SELFIE]: { supported: true, version: "1.0" },
  }}
>
  {/* ... */}
</BridgeHostProvider>
```

Each `CapabilityInfo` entry can include:

| Field             | Type                      | Description                                                                                      |
| ----------------- | ------------------------- | ------------------------------------------------------------------------------------------------ |
| `supported`       | `boolean`                 | Whether the host can fulfil this capability.                                                     |
| `version`         | `string`                  | Optional version. Emitted as `null` in query responses when unset.                               |
| `constraints`     | `Record<string, unknown>` | Optional. E.g., `{ maxResolution: 4096, formats: ["jpeg", "png"] }`.                             |
| `permissionState` | `string`                  | Optional. E.g., `"granted"`, `"prompt"`, `"denied"`. Surfaced to the journey in query responses. |

### Registering handlers

Use `useBridgeCapability` inside any descendant of the provider:

```tsx theme={null}
import { useBridgeCapability } from "@gbg/go-bridge-web-react";

function DocumentCaptureHandler() {
  useBridgeCapability("camera.document.capture", {
    handle: async (request, responder) => {
      const data = (request.payload as { data: Record<string, unknown> }).data;
      const side = data.side as string;
      const image = await captureDocument(side);
      responder.success({ image });
    },
  });
  return null;
}
```

The hook wraps the handler in a stable proxy — inline `{ handle: ... }` objects do not re-register on every render. On unmount the hook unregisters only if its proxy is still the active handler.

### Capability ID vs. action ID

```
capability ID  →  appears in capabilities prop, capability.query response
                  e.g. "camera.document"

action ID      →  appears in useBridgeCapability(...), payload.action
                  e.g. "camera.document.capture"
```

These are related but distinct strings. Declaring `camera.document` does not implicitly register a handler for `camera.document.capture`. You need both.

## One-call: registerCustomCapability

For dynamic capabilities — added after mount, conditional on runtime state, or tied to a non-React coordinator — call `host.registerCustomCapability(id, version, handler)` directly. It declares the capability AND registers the handler in one step.

```tsx theme={null}
import { useBridgeHost } from "@gbg/go-bridge-web-react";
import { useEffect } from "react";

function NFCSupport() {
  const host = useBridgeHost();
  useEffect(() => {
    if (!host) return;
    if (!("NDEFReader" in window)) return; // browser doesn't support NFC

    host.registerCustomCapability("nfc.read", "1.0", {
      handle: async (_request, responder) => {
        try {
          const reader = new (window as any).NDEFReader();
          const chipData = await reader.scan();
          responder.success({ mrz: chipData.mrz });
        } catch (err) {
          responder.error({
            code: "INTERNAL_ERROR",
            message: String(err),
            recoverable: true,
          });
        }
      },
    });
  }, [host]);
  return null;
}
```

The capability appears in the next `capability.query` response. There is no API to explicitly remove a custom capability — once registered, it lives for the lifetime of the host instance.

### Capability collision rules

When the same capability ID appears in both the constructor `capabilities` map AND a `registerCustomCapability` call, **the constructor wins**. The query handler builds responses by merging in this order:

```
constructorCapabilities ∪ (customCapabilities \ keys(constructorCapabilities))
```

— i.e., custom entries fill in IDs that the constructor didn't declare, but never override one that did. This matches Android's "configuration is authoritative" pattern and lets you safely use `registerCustomCapability` for runtime additions without worrying about clobbering your declared baseline.

The action-handler registration is separate: whoever called `registerHandler` (or `registerCustomCapability` with a handler) most recently for a given action ID wins. If you need to verify ownership before re-registering, use `host.getHandler(action)` to inspect the current registration.

## Handler lifecycle and exceptions

Capability handlers have a single lifecycle hook — `handle(request, responder)` — but several distinct exit paths affect what the journey sees.

### Successful terminal response

```typescript theme={null}
useBridgeCapability("camera.document.capture", {
  handle: async (_request, responder) => {
    const image = await captureDocument();
    responder.success({ imageBase64: image, mimeType: "image/jpeg" });
  },
});
```

Terminal methods (`success`, `error`, `cancelled`, `unsupported`) flip the responder's internal `hasResponded` flag. Any subsequent terminal call on the same responder is silently a no-op — exactly-once delivery is enforced at the responder level.

### `acknowledge()` for long-running operations

```typescript theme={null}
handle: async (_request, responder) => {
  responder.acknowledge();
  const result = await reallyLongRunningCapture(); // 10+ seconds
  responder.success({ image: result });
}
```

`acknowledge()` does NOT flip `hasResponded` and is chainable (returns `this`). Use it for handlers that exceed the journey-side default timeout (\~30s). A second `acknowledge()` is also a no-op — gated by a separate `hasAcknowledged` flag so a buggy handler can't spam duplicate ack envelopes.

### Handler throws or rejects before responding

If the handler throws synchronously or its returned Promise rejects, and the responder has not yet been called, the SDK auto-sends a `HANDLER_FAILURE` response and routes the error to `delegate.onError`:

```typescript theme={null}
handle: async (_request, responder) => {
  await iThrowSometimes(); // throws → SDK responds with HANDLER_FAILURE
  responder.success({}); // never reached
}
```

The auto-response payload:

```json theme={null}
{
  "status": "error",
  "error": {
    "code": "HANDLER_FAILURE",
    "message": "...",
    "recoverable": false
  }
}
```

Without this, the journey would sit waiting until its own request timeout (\~30s) — the user would see a spinner instead of an error.

`HANDLER_FAILURE` is NOT a member of the `BridgeErrorCode` enum (it's a raw string emitted on the wire). Journey-side consumers that pipe `response.error.code` through `toBridgeErrorCode(...)` see `INTERNAL_ERROR` (the documented fallback). If you need to distinguish handler failures from genuine `INTERNAL_ERROR`, match against the raw `error.code` string before normalising.

### Handler throws AFTER responding

If a handler calls `responder.success(...)` and THEN throws (e.g., from stray cleanup code), the SDK logs a `console.warn` and does NOT flip the wire response. The journey sees the successful response; only the host's `delegate.onError` is notified about the post-terminal throw. This preserves wire truthfulness.

### Cleanup on unmount

`useBridgeCapability` registers on mount and unregisters on unmount, **only if its own proxy is still the active handler**. Two components racing for the same action don't accidentally remove each other's installations. For non-React hosts using `registerHandler` directly, you own the unregistration lifecycle.

If a handler is mid-flight when the component unmounts (e.g., still awaiting a capture), the responder reference may outlive the component. Either capture the responder in a ref and call `responder.cancelled("...")` from an unmount effect, or let the handler complete with its existing reference — the SDK doesn't care, but the journey-side journey may time out if the responder never resolves.

## Permission state

Browser-mediated permissions (camera, microphone, geolocation, etc.) are not automatically detected by the SDK — unlike iOS, which ships `CameraDetector`. Query the browser yourself via `navigator.permissions.query()` and pass the result through `CapabilityInfo.permissionState`.

```typescript theme={null}
async function getCameraPermissionState(): Promise<string> {
  if (!navigator.permissions) return "prompt"; // older browsers
  try {
    const status = await navigator.permissions.query({ name: "camera" as PermissionName });
    return status.state; // "granted" | "denied" | "prompt"
  } catch {
    return "prompt";
  }
}
```

### Surfacing it to the journey

```tsx theme={null}
function JourneyView({ journeyUrl }: { journeyUrl: string }) {
  const [iframe, setIframe] = useState<HTMLIFrameElement | null>(null);
  const [permissionState, setPermissionState] = useState("prompt");

  useEffect(() => {
    getCameraPermissionState().then(setPermissionState);
  }, []);

  return (
    <BridgeHostProvider
      key={permissionState}  // remount on permission change to apply
      iframe={iframe}
      allowedOrigins={[new URL(journeyUrl).origin]}
      hostVersion="1.0.0"
      capabilities={{
        "camera.document": {
          supported: true,
          version: "1.0",
          permissionState,
        },
      }}
    >
      <DocumentCaptureHandler />
      <iframe ref={setIframe} src={journeyUrl} allow="camera" />
    </BridgeHostProvider>
  );
}
```

The `key={permissionState}` trick remounts the provider when the state changes — necessary because `capabilities` is snapshotted at attach time. If you'd rather keep the provider stable, switch to `registerCustomCapability` for the affected entries (it reads from the live capability map on every query).

### Listening for permission changes

`PermissionStatus` exposes an `onchange` event for browsers that support it:

```typescript theme={null}
const status = await navigator.permissions.query({ name: "camera" as PermissionName });
status.onchange = () => {
  setPermissionState(status.state);
};
```

## Capability negotiation

The journey iframe sends a `capability.query` request as it loads. The SDK's built-in `CapabilityQueryHandler` responds automatically — you don't register this yourself.

```mermaid theme={null}
sequenceDiagram
    participant Web as Journey iframe
    participant Host as BridgeHost
    participant QH as CapabilityQueryHandler

    Web->>Host: request: capability.query
    Host->>QH: route (built-in handler)
    QH->>Host: respond with capabilities + permissionState
    Host->>Web: response: { environment, hostVersion, capabilities }
    Note over Web: Adapt UI based on<br/>capabilities and permissions
```

The response combines `capabilities` from `BridgeHostOptions` with any `registerCustomCapability` entries. Typed-slot-style auto-declaration does not exist on web — explicit declaration is required for every capability you want to advertise.

### Response shape

```json theme={null}
{
  "environment": "web",
  "hostVersion": "1.0.0",
  "capabilities": {
    "camera.document": { "supported": true, "version": "1.0", "permissionState": "granted" },
    "camera.selfie": { "supported": true, "version": "1.0", "permissionState": "prompt" },
    "nfc.read": { "supported": false, "version": null }
  }
}
```

`version` is `null` when unset. `permissionState` is omitted when unset. `environment` is always `"web"` from this SDK.

### Declaring "not supported" explicitly

Declaring `supported: false` is meaningful — it tells the journey "I know about this capability and I don't fulfil it" rather than leaving the journey to infer that from the absence of an entry. Use this for capabilities the journey might expect on iOS/Android but that web hosts genuinely cannot provide:

```typescript theme={null}
capabilities: {
  "camera.document": { supported: true, version: "1.0" },
  "nfc.read": { supported: false, version: null },
  "biometric.auth": { supported: false, version: null },
}
```

## Environment-specific behavior

Not all capabilities make sense on web. The journey can detect the environment from `capability.query` response and adapt its flow.

| Capability          | iOS / Android                    | Web (this SDK)                                                                                           |
| ------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `camera.document`   | Native SmartCapture SDK          | Host page implements via `getUserMedia` or a third-party SDK; journey can fall back to in-iframe capture |
| `camera.selfie`     | Native SmartCapture SDK          | Same as above                                                                                            |
| `nfc.read`          | iPhone 7+ / Android with NFC     | Limited; only the experimental Web NFC API on a small set of browsers                                    |
| `biometric.auth`    | Face ID / Touch ID / Fingerprint | WebAuthn (not exposed via this SDK)                                                                      |
| `analytics.forward` | Forward to host telemetry        | Forward to host telemetry — fully web-supported                                                          |

Declare the capabilities you actually fulfil. Don't declare `supported: true` for `nfc.read` unless you've actually wired up Web NFC.

## Graceful degradation patterns

When a capability isn't available, the journey has two options: fall back to an in-iframe equivalent, or skip the step. The host doesn't need to do anything special — the journey handles the routing.

### Pattern 1: Fall back to in-iframe capture

The journey checks capabilities and adapts:

```
If host declares camera.document.supported = true:
    Journey delegates capture to host via bridge
Else:
    Journey runs in-iframe capture using getUserMedia
```

If you don't host a richer capture UI than the iframe can provide, simply don't declare `camera.document` — the journey handles it natively.

### Pattern 2: Permission-aware UX

The journey can read `permissionState` from the query response and surface a permission prompt before triggering the request:

```
If camera.document.permissionState == "denied":
    Show "Camera access is blocked — enable it in browser settings"
    before starting the capture step
```

### Pattern 3: Explicit unsupported response

If the journey sends a request for a capability you've declared but cannot currently fulfil (e.g., camera unavailable on the device), respond with `unsupported`:

```typescript theme={null}
useBridgeCapability("camera.document.capture", {
  handle: async (_request, responder) => {
    if (!navigator.mediaDevices) {
      responder.unsupported("Camera API not available in this browser");
      return;
    }
    // ... proceed with capture
  },
});
```

`unsupported` carries `code: "UNSUPPORTED"` and `recoverable: false` — the journey treats this as a final outcome, not retryable, and is expected to fall back or skip.

## Busy guards on web

Unlike iOS and Android typed slots — which automatically reject concurrent requests with a `BUSY` error — the Web SDK's handler registration does **not** enforce single-flight semantics. If the journey sends a second `camera.document.capture` request while the first is still in flight, your handler runs twice unless you guard against it explicitly.

The recommended pattern uses a `useRef` for the active responder:

```tsx theme={null}
import { useBridgeCapability } from "@gbg/go-bridge-web-react";
import { useRef, useState } from "react";
import type { BridgeResponder } from "@gbg/go-bridge-web";

function DocumentCaptureHandler() {
  const responderRef = useRef<BridgeResponder | null>(null);
  const [active, setActive] = useState(false);

  useBridgeCapability("camera.document.capture", {
    handle: async (_request, responder) => {
      if (responderRef.current) {
        responder.error({
          code: "BUSY",
          message: "A document capture is already in progress",
          recoverable: true,
        });
        return;
      }
      responderRef.current = responder;
      setActive(true);
    },
  });

  function complete(image: string) {
    responderRef.current?.success({ imageBase64: image });
    responderRef.current = null;
    setActive(false);
  }

  // ... render capture UI when active
}
```

A few rules to keep this guard correct:

* **Always clear `responderRef.current = null` AFTER calling a terminal method.** The responder enforces single-terminal at its own level, but your guard needs to know the slot is free again.
* **Cancel the in-flight responder on unmount** — see "Cleanup on unmount" above. Leaving the journey waiting is the worst failure mode.
* **`recoverable: true` on the BUSY error** signals to the journey that it can retry once the current capture completes. Use `recoverable: false` only if you're certain the journey shouldn't try again.

For multi-capability hosts, give each capability its own responder ref — don't share state across `camera.document.capture` and `camera.selfie.capture`.

## Dynamic capability updates

Capability state is dynamic by nature. Two situations to handle:

### Capabilities added after mount

Use `host.registerCustomCapability` via `useBridgeHost()`. The journey can re-query at any time and will see the new entry.

### Capabilities changing at mount-time props

`capabilities` from `BridgeHostProviderProps` is snapshotted when the host is built. To apply a change:

* **Recommended:** force a remount via a changing `key` prop.
* **Alternative:** use `registerCustomCapability` for the affected entries — they bypass the snapshot.

### Re-querying capabilities

The journey is expected to re-query capabilities after significant state changes (returning from background, permission grants, etc.). If you've updated capability state, you can also `host.sendEvent("host.capabilities.changed", {})` so the journey re-queries proactively — though the journey-side SDK does this implicitly on visibility change.

## Next steps

* [Messaging](/docs/go-v2/developer-integration/sdks/web/messaging) — Request/response patterns and the bridge event catalogue.
* [Security](/docs/go-v2/developer-integration/sdks/web/security) — Origin allowlist, CSP, and permission delegation.
* [Troubleshooting](/docs/go-v2/developer-integration/sdks/web/troubleshooting) — Diagnosing capability-related issues.
* [API Reference](/docs/go-v2/developer-integration/sdks/web/api-reference) — Detailed signatures for `BridgeHostProvider`, hooks, and `BridgeHost`.
