i99dash docs
Guides

Capability detection

Use `client.has()` and `client.capabilities()` to render conditionally so an older host without a family doesn't crash your UI.

A mini-app uses the host's bridge families at runtime, but the host on a given car may not ship all of them. A 6-month-old head-unit firmware and a brand-new build of your app share the same catalog — yours added vehicle.environment last week, theirs hasn't shipped a handler for it. Calling client.vehicleEnvironment.getSnapshot() on that older host throws VehicleEnvironmentUnavailableError.

Capability detection is the pre-flight check that prevents that throw turning into a blank screen.

TL;DR

import { MiniAppClient } from 'i99dash';

const client = MiniAppClient.fromWindow();

// Once, near app startup — memoised internally, cheap to await again.
const caps = await client.capabilities();
const hasMedia = await client.has('media.read');

if (hasMedia) renderNowPlaying(); else renderPlaceholder();

client.has(scope) is a memoised predicate over the host's HostCapabilities. The first call hits the bridge; every later call is in-process. Cheap enough to await from a render path.

Two capability axes

Capability detection in the SDK spans two independent gates:

AxisAuthoritative sourceRead at runtime viaFailure mode
Host family — does the host implement the bridge handler?Host build (varies per car ROM)client.has(scope) / client.capabilities()<Family>UnavailableError at call time, or false from client.has(scope)
Vehicle hardware — does the physical car support this feature?Per-(DiLink, variant, sub-trim) profile + empirical probe overlaydisplay.list().vehicle.capabilitiespkg.launch returns not_supported_on_this_car; actuator calls fail at the daemon

has() only checks the first axis. For the vehicle-hardware axis, read the snapshot at runtime — see Vehicle-hardware capabilities below.

When to use it

Reach for capability detection in three situations:

  1. Optional features. Your app degrades gracefully without media.read — render a placeholder instead of crashing. Check at mount, decide once.
  2. Targeting older firmware. You ship an app to a fleet that spans multiple host versions. Newer scopes (vehicle.environment, system.read) need a has() gate; older ones (car.status) don't realistically since every shipping host has them.
  3. Conditional UI affordances. A "now playing" widget, a climate tile, a diagnostics panel — show the section if the family is available, hide it otherwise.

You don't need has() for:

  • The React hooks in i99dash/react — every hook stays on its fallback value when the family isn't available, no throw. useMedia({ fallback: silentMedia }) does the graceful-degradation work for you.
  • External fetch() to a declared manifest.network origin — reaching an external API doesn't depend on a host family capability (only on the CSP allow-list).

The pattern

import { useEffect, useState } from 'react';
import { MiniAppClient, type HostCapabilities } from 'i99dash';

export function AppShell({ client }: { client: MiniAppClient }) {
  const [caps, setCaps] = useState<HostCapabilities | null>(null);

  useEffect(() => {
    client.capabilities().then(setCaps);
  }, [client]);

  if (!caps) return <Splash />;

  return (
    <main>
      {caps.families.includes('media.read') && <NowPlayingTile />}
      {caps.families.includes('climate.read') && <ClimateTile />}
      {caps.families.includes('vehicle.environment') && <WeatherTile />}
    </main>
  );
}

capabilities() returns once per client instance. Calling it from inside each tile component is fine — the second-and-later calls return the cached value synchronously after the first promise settles.

Older hosts return bridgeVersion: 'unknown'

Hosts that pre-date the capability handshake (Phase-7-ish and earlier) don't expose the capabilities() bridge method. The SDK falls back to a structural sniff:

{ bridgeVersion: 'unknown', families: ['car.status'] }

car.status is included when the bridge implements the legacy car-status methods (getCarStatus + subscribeCarStatus); other families default to absent. So has('media.read') returns false on an old host even if the host happens to have a media handler the SDK can't see — better to render a fallback than to throw mid-render.

When you need the real version string for analytics (e.g. "what fraction of our user base is on the new bridge?"), branch on caps.bridgeVersion === 'unknown'.

Don't has() in a tight loop

The first call hits the bridge with a 5-second timeout. The cache is per-client-instance, so calling has() thousands of times across a session is fine — but constructing a fresh MiniAppClient per render defeats the cache. Hold the client in a context, a module, or a hook that uses useMemo.

// ❌ Wrong — new client per render means a new bridge round-trip
function Tile() {
  const client = MiniAppClient.fromWindow();
  // ...
}

// ✓ Right — one client, cached caps for the whole session
const client = MiniAppClient.fromWindow();
function Tile() { /* uses the singleton */ }

In React, the canonical home is <MiniAppProvider> + useClient.

SSR and no-host environments

MiniAppClient.fromWindow() throws NotInsideHostError outside the host. Use createClientOrSSR to get null instead:

import { createClientOrSSR } from 'i99dash';

const client = createClientOrSSR();   // null on SSR / Storybook / Node
const hasMedia = client ? await client.has('media.read') : false;

Treat "no host" the same as "host without the family" — render the fallback path. The two cases are indistinguishable to a user, and your code stays branch-light.

Vehicle-hardware capabilities

The host shipping your bridge family is necessary but not sufficient — a Leopard 5 has the pkg.launch family wired up, but its instrument-cluster panel is daemon-locked, so a pkg.launch to the cluster role can't actually paint pixels. Vehicle-hardware capabilities cover that gap.

The set is a closed enum of 16 strings — see VEHICLE_CAPABILITIES for the full list. Every entry maps to a fixed bit position so the host can fold a capability set into a single 32-bit integer for hot-path subset checks.

import {
  VEHICLE_CAPABILITIES,
  bitsFromCapabilities,
  hasAllCapabilities,
} from 'i99dash';

const need = bitsFromCapabilities(['surface.write.cluster', 'display.read']);
const have = bitsFromCapabilities(vehicle.capabilities);  // from display.list
const fits = hasAllCapabilities(have, need);  // one AND

Reading at runtime

The host emits the active vehicle's capabilities in the vehicle block of display.list:

const r = await bridge.callHandler('display.list');
// r.vehicle.capabilities    → ['display.read', 'pkg.read', ...]
// r.vehicle.capabilityBits  → 0b1011 — the same set, packed
// r.vehicle.variantId       → 'l5'
// r.vehicle.subTrim         → 'flagship'  (since SDK 1.8)
// r.vehicle.dilinkFamily    → 'di5.0'

Use the readable list for logs / UI labels; use the bitmask when you need a fast subset check inside a hot loop.

Profile fallback semantics

The host resolves a 4-tier fallback chain when reading the empirical-overlay row from the backend (VehicleCapabilitiesSnapshot):

  1. Precise — (dilinkFamily, variantId, subTrim, fingerprint) exact match.
  2. Sub-trim aggregate — same triple, fingerprint stripped.
  3. Trim aggregate — variant-only, sub-trim stripped.
  4. DiLink-default — generation-only floor.

When the resolver fell back, the snapshot carries isFallback: true and a fallbackReason string. Tap-to-launch always proceeds; the daemon stays the authority for the actual actuator call.

Runtime gating patterns

Read the active vehicle's bits at startup and branch your UI:

  1. Per-display feature gating. The pkg-launcher example hides its "Driver" card on cars where surface.write.cluster isn't in the active vehicle's bits.
  2. Conditional onboarding. "We noticed your car doesn't expose the passenger panel — here's what changes." Read the bits from the first display.list and branch.
  3. Hide-vs-disable. Following the error-model convention, omit controls whose required cap is missing — don't grey them out.

On this page