i99dash docs
Recipes

Real-time car status widget

End-to-end recipe — scaffold, wire the streaming car-status bridge, render door / battery / lock state, ship it. ~30 minutes.

End-to-end: scaffold a mini-app, wire it to the host's car-status stream, render door / lock / battery state with live updates, ship it. ~30 minutes if you've never used the SDK.

The finished widget:

┌─────────────────────────────┐
│  Car: ****4567        🔒    │
│  Battery: 88%               │
│  Doors: 🚪 driver open      │
│  Last seen: 3 s ago         │
└─────────────────────────────┘

The widget shows live state and degrades cleanly when the host has no signal. It uses every relevant SDK feature: getContext, client.car.getStatus, onStatusChange, onConnectionChange, proper cleanup, and error handling for the offline / no-host case.

Prerequisites

  • Node 20+
  • pnpm (or npm; the recipe uses pnpm)
  • 10 min of patience for the dev-server's first launch

1. Scaffold

npx @i99dash/sdk-cli init car-status-widget
cd car-status-widget
pnpm install

The CLI creates a vanilla TS template (src/main.ts, src/index.html, manifest.json). If you want React / Vue, --framework react or --framework vue. Recipe stays vanilla so the SDK calls are visible.

2. Bump minHostVersion for the streaming feature

The car-status stream lives in host versions ≥ 0.0.2. Edit your generated manifest.json:

{
  "id": "car_status_widget",
  "name": { "en": "Car Status", "ar": "حالة السيارة" },
  "minHostVersion": "0.0.2",
  "permissions": ["car.status.read"],
  "safeWhileDriving": true
}

permissions defaults to ['car.status.read'] if omitted (v1 backward compat) but make it explicit so future you can grep for the contract. safeWhileDriving: true lets the host show the widget while moving — appropriate for a read-only status display.

3. Wire the SDK

Edit src/main.ts:

import {
  MiniAppClient,
  SDKError,
  type CarStatus,
  type CarConnectionState,
} from '@i99dash/sdk';

const client = MiniAppClient.fromWindow();

const root = document.querySelector('#root')!;
let status: CarStatus | null = null;
let conn: CarConnectionState = 'disconnected';

function render() {
  if (conn === 'disconnected') {
    root.innerHTML = `<p class="banner">No car signal</p>`;
    return;
  }
  if (status === null) {
    root.innerHTML = `<p>Loading…</p>`;
    return;
  }
  const vinTail = `****${status.vin.slice(-4)}`;
  const lock = status.doorsLocked ? '🔒' : '🔓';
  const battery = status.batteryPct ?? '—';
  const driver = status.doors?.driver ?? 'unknown';
  const stale = status.staleness === 'fresh' ? '' : ` (${status.staleness})`;
  root.innerHTML = `
    <div class="widget">
      <div class="row">Car: ${vinTail} ${lock}</div>
      <div class="row">Battery: ${battery}%</div>
      <div class="row">Doors: 🚪 driver ${driver}</div>
      <div class="row last-seen">Last seen: ${ageLabel(status.at)}${stale}</div>
    </div>
  `;
}

function ageLabel(iso: string): string {
  const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
  if (seconds < 5) return 'just now';
  if (seconds < 60) return `${seconds} s ago`;
  return `${Math.round(seconds / 60)} min ago`;
}

// One-shot first paint — no waiting for the first delta.
async function bootstrap() {
  try {
    status = await client.car.getStatus();
  } catch (e) {
    if (e instanceof SDKError && e.code === 'CAR_STATUS_UNAVAILABLE') {
      // Older host or test bridge — render a fallback.
      root.innerHTML = `<p>This car doesn't support live status.</p>`;
      return;
    }
    throw e;
  }
  render();

  // Subscribe to deltas — store the unsub fn for cleanup.
  const offStatus = client.car.onStatusChange((next) => {
    status = next;
    render();
  });
  const offConn = client.car.onConnectionChange((next) => {
    conn = next;
    render();
  });

  // Tear down on tab close (the host doesn't kill us automatically
  // when the user navigates away inside our shell).
  window.addEventListener('beforeunload', () => {
    offStatus();
    offConn();
  });
}

bootstrap();

A quick read-through of what's here:

  • MiniAppClient.fromWindow() — finds the host bridge. In dev, the bridge comes from sdk-i99dash dev.
  • client.car.getStatus() — one-shot read for the first paint. Avoid showing a loading spinner forever waiting for the first delta event.
  • SDKError switch on code — catches the "older host doesn't support streaming" path. Renders a fallback instead of crashing.
  • onStatusChange / onConnectionChange — return an unsubscribe fn each. Both are stored so beforeunload can clean them up.
  • status.staleness — gates the "live or stale" label separately from the connection-state banner. See Subscriptions — Connection state vs. staleness.
  • VIN masking — only the last 4 digits render. See Best practices — Privacy.

4. Run it locally

pnpm dev

The CLI launches sdk-dev-server and opens your default browser at http://localhost:5173 with the dev-server's bridge shim pre-installed. You'll see a fake "parked, doors locked, 88% battery" status because the dev-server's default CarStatus fixture says so.

To exercise live updates, open the dev-server's UI at http://localhost:5174/_dev and toggle the door / lock / moving state. Each change drives an onStatusChange callback in your widget within ~100 ms.

5. Test the cleanup path

Add a Vitest test:

// src/main.test.ts
import { describe, expect, it, vi } from 'vitest';
import { MiniAppClient, type CarStatusBridge } from '@i99dash/sdk';

describe('car-status widget', () => {
  it('cleans up subscriptions on beforeunload', async () => {
    const subscribed = vi.fn(async (notify) => {
      void notify;
      return { id: 'sub-1' };
    });
    const unsubscribed = vi.fn(async () => {});

    const bridge: CarStatusBridge = {
      getContext: async () => ({
        userId: 'u', activeCarId: 'V', locale: 'en',
        isDark: false, appVersion: '1.0.0', appId: 'car_status_widget',
      }),
      callApi: async () => ({ success: true, data: null }),
      getCarStatus: async () => ({
        vin: 'V', at: '2026-04-27T12:00:00Z', staleness: 'fresh',
      }),
      subscribeCarStatus: subscribed,
      unsubscribeCarStatus: unsubscribed,
      subscribeCarConnectionState: async () => ({ id: 'conn-1' }),
      unsubscribeCarConnectionState: async () => {},
    };

    const client = MiniAppClient.withBridge(bridge);
    const off = client.car.onStatusChange(() => {});
    await Promise.resolve(); // drain the lazy subscribe microtask

    expect(subscribed).toHaveBeenCalledOnce();
    off();
    await Promise.resolve();
    expect(unsubscribed).toHaveBeenCalledOnce();
  });
});
pnpm test

If unsubscribed is never called, your code has a leak — the same class of bug that throws CarStatusQuotaExceededError in production. See Subscriptions.

6. Validate the manifest + build

pnpm sdk-i99dash validate   # zod-checks manifest.json
pnpm sdk-i99dash build      # produces dist/bundle.tar.gz

The validate step catches missing minHostVersion, malformed permissions, and (in this case) reminds you that safeWhileDriving: true requires explicit opt-in justification in your PR.

7. Publish

pnpm sdk-i99dash login    # device-code flow if not already logged in
pnpm sdk-i99dash publish

The catalog picks up the new version on next refresh; users with host ≥ 0.0.2 see your widget tile in the store.

What you didn't have to write

  • Throttling. The host token-buckets pushes at ~10/sec; you don't need to debounce.
  • Page Visibility handling. The SDK suppresses callbacks while hidden and fires one catch-up on resume. Your render() doesn't thrash when the user backgrounds the tab.
  • Schema validation. Every event the host pushes is zod-validated by the SDK. A malformed payload silently drops with a console.warn in dev.
  • Reconnect logic. onConnectionChange reflects the host's polling-loop health; banner updates automatically when the signal comes back.

Troubleshooting

SymptomLikely cause + fix
"This car doesn't support live status."You're on an older host. Bump minHostVersion in the manifest so the catalog hides your app from incompatible cars.
Widget never updatesCheck pnpm dev's console for console.warn from the SDK — the host may be pushing a malformed payload. Otherwise, check that onStatusChange is wired before bootstrap returns.
CarStatusQuotaExceededError in devYou're calling onStatusChange on every render (likely React strict mode). Wrap in useEffect and return the off fn. See Subscriptions.
NotInsideHostError in browserOpened the bundle directly via file://. Run through pnpm dev so the dev-server attaches the bridge.

On this page