@cloudparse/up-miniapps-sdk
v0.15.0
Published
UP Mini-App SDK for bridge communication between WebView and Host Shell
Readme
UP Mini-Apps SDK
The UP Mini-Apps SDK connects a web Mini-App running in the UP shell's WebView to the native host: device location and sensors, storage, identity, payments, maps, QR codes, haptics, network proxying, and shell chrome such as exit, scroll behaviour and system bars.
It is written for developers building Mini-Apps for the UP super-app. The same API also runs in a plain browser through a built-in simulator, so Mini-Apps can be developed and demoed without a device.
Contents
- Installation
- Quickstart
- Concepts
- Capability reference
- Components and hooks
- Service factories
- Standalone development and simulation
- Host entry
- Storybook
- Showcase app
- Claude Code plugin
- Architecture
- Versioning and publishing
- Development
- Contributing
- Known limitations
Installation
npm install @cloudparse/up-miniapps-sdkRuntime requirements for a Mini-App:
- React 19 (
reactandreact-dom^19.2.7). The SDK declares them as regulardependencies, not peer dependencies, so deduplicate them in your bundler to avoid two React copies (see the Vite example below). - A bundler. Vite is used by every UP Mini-App; the examples assume it.
maplibre-glships with the SDK. If you use<Map>in a Vite dev server, exclude it from dependency pre-bundling.
Toolchain requirements for working on the SDK itself: Node >=25.6.1 (engines in package.json).
Recommended Vite configuration:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
dedupe: ["react", "react-dom"],
},
optimizeDeps: {
exclude: ["maplibre-gl"],
},
});Quickstart
Wrap the whole tree in SDKProvider. It enables the standalone simulator when the app runs outside the native shell, and is a no-op inside it.
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { SDKProvider } from "@cloudparse/up-miniapps-sdk";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<SDKProvider debug={import.meta.env.DEV}>
<App />
</SDKProvider>
</StrictMode>,
);Initialize once, apply the shell's insets, and handle the back gesture:
// src/App.tsx
import { useEffect, useState } from "react";
import {
GlobalEvent,
type IContextState,
type IEdgeInsets,
sdk,
UIEvent,
} from "@cloudparse/up-miniapps-sdk";
const NO_INSETS: IEdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
export function App() {
const [insets, setInsets] = useState<IEdgeInsets>(NO_INSETS);
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
const boot = () => {
setStatus("loading");
sdk
.initialize({ isDark: true })
.then((res) => {
setInsets(res.insets);
setStatus("ready");
})
.catch(() => setStatus("error"));
};
boot();
const offContext = sdk.on(
GlobalEvent.UPDATE_CONTEXT,
(ctx: Partial<IContextState>) => {
if (ctx.insets) setInsets(ctx.insets);
},
);
const offBack = sdk.on(UIEvent.BACK_GESTURE, () => {
sdk.ui.exit();
});
return () => {
offContext();
offBack();
};
}, []);
if (status === "loading") return <p>Loading</p>;
if (status === "error") return <p>Could not start. Check your connection.</p>;
return (
<main
style={{
boxSizing: "border-box",
minHeight: "100dvh",
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingTop: insets.top,
}}
>
<h1>Hello from a Mini-App</h1>
</main>
);
}Replace the back handler with your own navigation stack in production: close the most specific open UI first, and call sdk.ui.exit() only when nothing is left to close. Replace the placeholder loading and error screens with your own components.
Concepts
The bridge
The Mini-App is a WebView inside the native shell. The SDK talks to the host in two directions:
- Requests (app to host).
sdk.*methods post a message throughwindow.ReactNativeWebView.postMessageand return a Promise. The host answers with the samecallbackId. Each attempt rejects after 30 seconds unless a per-call timeout is given (sdk.network.requestacceptstimeoutMs). Only idempotent reads are retried once after a timeout. Writes, payments (3-minute timeout), FPoints spends, activity starts, and any network method other than GET are never retried, because the host may already have executed them. - Events (host to app). The host pushes messages to
window.__MiniAppBridge.onNativeMessage. The SDK dispatches them to listeners registered withsdk.on. Only native injections and same-window messages are accepted; messages from other windows, including an embedding parent frame, are ignored.
The bridge is internal. Mini-App code uses only the sdk facade and the exported types.
Initialization and context
sdk.initialize(options) must be the first SDK call. It resolves with:
insets: IEdgeInsets- safe-area padding around the Mini-App's viewport. Apply it on your root element and on any top header.env- shell-provided environment values such asbackendUrl. Read them later withsdk.getEnv().profile,name,uuid,version,userData,params.
The shell can push updates at any time through GlobalEvent.UPDATE_CONTEXT. Merge the partial payload into your state; do not replace it wholesale, because pushes can be partial. sdk.updateContext(context) does the reverse: it sends app state (for example a theme choice) to the shell.
Pass the theme in initialize({ isDark }), not in a separate updateContext call. The shell applies isDark before it answers, so there is no flash of the shell's own theme.
Events
sdk.on(event, callback) returns an unsubscribe function. sdk.off(event, callback) is the explicit form. Events are typed by the SDKEvent union, which also accepts any string.
| Event | Source | Payload |
| --- | --- | --- |
| UIEvent.BACK_GESTURE | Native back button or back gesture | none |
| UIEvent.TOP_SCROLL_MORE | Native pull-to-refresh | none |
| UIEvent.BOTTOM_SCROLL_MORE | Scroll near the bottom (about 200px) | none |
| GlobalEvent.UPDATE_CONTEXT | Shell pushes context changes | partial context, including insets and env |
| GeolocationEvent.LOCATION_STREAM_UPDATE | Active location stream | StreamUpdatePayload (location, heading, accelerometer) |
Capability reference
Every method below is a member of the shared sdk instance, unless marked otherwise. Methods return Promises unless the table says void.
Lifecycle and context
| Method | Description |
| --- | --- |
| sdk.initialize(options) | Initialize the Mini-App. Pass {} for defaults; options.isDark sets the theme. Resolves with IInitializationResponse. |
| sdk.getEnv<T>() | Last known shell env (for example backendUrl), or null before the first response. |
| sdk.updateContext(context) | Merge app state into the shell's context. |
| sdk.config({ routingBackend }) | SDK-wide settings. Sets the direct routing backend used by sdk.routing.getRoute. |
| sdk.on(event, cb) / sdk.off(event, cb) | Subscribe to and unsubscribe from shell events. |
| sdk.achievement(slug) | Progress for one achievement, as IAchievement. |
| sdk.achievements() | Progress for every achievement visible to this Mini-App. |
Identity and profile
| Method | Description |
| --- | --- |
| sdk.user.getProfile(fields?) | Current user's profile, optionally limited to fields (IUserProfile keys). |
| sdk.user.getProfilePicture(uuids?) | One profile picture URL, or a map of URLs for a list of user UUIDs. |
| sdk.permission.request(type, fields?) | Request 'location', 'camera', or 'profile' (with fields). Resolves with boolean. |
Network
| Method | Description |
| --- | --- |
| sdk.network.request<T>(options) | HTTP request proxied through the shell (bypasses CORS, uses host auth). options: url, method, body?, headers?, timeoutMs?. Resolves with INetworkResponse<T>. |
The shell caps proxied requests at a default timeout. Raise timeoutMs only for calls that are legitimately slow.
Storage
| Method | Description |
| --- | --- |
| sdk.storage.get(key) / sdk.storage.set(key, value) | Device-local key/value storage, isolated per Mini-App. |
| sdk.storage.getAll() | Every key/value pair stored for this Mini-App. |
| sdk.secureStore.get(key) / sdk.secureStore.set(key, value) | Hardware-backed storage (Keychain / Keystore). |
| sdk.database.getUserData<T>() / sdk.database.setUserData<T>(data) | The Mini-App's persistent per-user data, stored in the backend. |
Location
| Method | Description |
| --- | --- |
| sdk.location.getCurrentPosition() | One-shot position, ILocationData. |
| sdk.location.startStream(config?) | Start the continuous location and sensor stream. config: interval, distanceFilter, headingThreshold, accelerometerThreshold. |
| sdk.location.stopStream() | Stop the stream. |
Stream data arrives as GeolocationEvent.LOCATION_STREAM_UPDATE. useUserLocation() manages a shared stream for you, so most apps do not call these directly.
Navigation (journeys)
| Method | Description |
| --- | --- |
| sdk.navigation.createJourney(options) | Create a journey preview. options: destination: [lng, lat], origin?, profile? (driving, cycling, walking), arrivalThresholdMeters?. Returns void. |
| sdk.navigation.start() | Switch the current journey to active guidance. Returns void. |
| sdk.navigation.abort() | Cancel the current journey. Returns void. |
These calls are dispatched inside the Mini-App, not to the shell. They take effect only where a <Map> or useJourney() is mounted to consume them.
Routing
| Method | Description |
| --- | --- |
| sdk.routing.getRoute(payload) | Calculate a route. Uses the backend set with sdk.config({ routingBackend }), otherwise the shell. Rejects if routing fails. |
| sdk.routing.drawRoute(routeData) | Draw a route returned by getRoute on the host map. |
| sdk.routing.clearRoute() | Remove the route drawn on the host map. |
Maps
| Method | Description |
| --- | --- |
| sdk.maps.render(containerId, props?) | Render a MapLibre map into the DOM element with the given id, for non-React code. Throws if the element is missing. |
For React apps use the <Map> component (see Components and hooks).
Media
| Method | Description |
| --- | --- |
| sdk.media.pickImage() | Open the gallery picker. Resolves with IPickImageResult: success, plus base64 (a data URI) and mimeType when success is true. |
| sdk.media.saveImage(base64, filename?) | Save an image to the device gallery. Browser <a download> does not work in the WebView, so use this. |
QR codes
| Method | Description |
| --- | --- |
| sdk.qrCode.generate(data, options?) | Generate a QR code as a base64 PNG data URI. Runs in the WebView, no host round trip. |
| sdk.qrCode.decode(imageDataUrl) | Decode a QR code from a static image. Resolves with the string, or null if none is found. |
For live scanning use the <QRScanner> component.
Haptics
| Method | Description |
| --- | --- |
| sdk.haptic.trigger(style) | Vibrate. style is one of light, medium, heavy, rigid, soft, success, warning, error. |
Payments
| Method | Description |
| --- | --- |
| sdk.payment.request(payload) | Start the native payment flow. payload is an IPaymentRequest (amount, currency, total, itemised list). Resolves with { success: true, transactionId }. |
A payment whose outcome cannot be confirmed rejects with PaymentOutcomeUnknownError: no reply within 3 minutes, a reply that does not confirm success with a transaction id, or a dispatch that threw. The payment may still have completed on the host, so reconcile with your server before retrying or telling the user it failed. Only an explicit host decline (success: false) or a failure before dispatch rejects with a plain Error.
Fidelity points (FPoints)
| Method | Description |
| --- | --- |
| sdk.fidelity.getFidelityBalance() | Current balance, IFidelityBalance. |
| sdk.fidelity.getFidelityHistory(params?) | Transaction history, IFidelityTransaction[]. |
| sdk.fidelity.showHistory(options?) | Open the native history view. options.isDark sets its theme. Returns void. |
| sdk.fidelity.spendFPoints(intentUuid) | Authorize a spend using a backend-generated intent (two-phase authorization). Resolves with { success, transactionId }. |
Live activities
| Method | Description |
| --- | --- |
| sdk.activity.start(id, title, status) | Start a live activity shown in the shell. |
Shell UI
| Method | Description |
| --- | --- |
| sdk.ui.exit() | Close the Mini-App and return to the shell. |
| sdk.ui.setScrollConfig(config) | Configure native pull-to-refresh and infinite-scroll triggers (ScrollConfig). |
| sdk.ui.setSystemBarsHidden({ statusBar?, navigationBar? }) | Hide or show the status and navigation bars while this Mini-App is active. Omitted keys keep their value. false shows a bar again. The shell never hides a lock screen and resets overrides when the Mini-App leaves the foreground. |
| sdk.ui.setSystemBarsStyle({ statusBar?, navigationBar? }) | Sets the icon style of each bar for this Mini-App: light (light icons, for dark backgrounds), dark (dark icons, for light backgrounds), or auto (host default, icons adapt to what is behind the bar). Omitted keys keep their style. |
Debug
| Method | Description |
| --- | --- |
| sdk.debug.log(...message) | Write to the shell's debug console. |
Components and hooks
All components and hooks are exported from the package root. Import from @cloudparse/up-miniapps-sdk.
Map
<Map> is a MapLibre map with POIs, user location, routing, journeys and a drawer. It creates its own camera-control context when none is present, so it works without a provider.
import { Map, useLiveRouting } from "@cloudparse/up-miniapps-sdk";
export function Navigate({ destination }: { destination: [number, number] }) {
const nav = useLiveRouting({ destination, profile: "walking" });
return (
<Map
followUser
initialViewState={{ latitude: 45.906, longitude: 6.13, zoom: 16 }}
navigationData={nav}
onNavigationAbort={() => {}}
pois={[{ id: "1", name: "Cafe", longitude: 6.128, latitude: 45.908 }]}
showNavigationUI
showUserLocation
/>
);
}Main MapProps:
| Prop | Purpose |
| --- | --- |
| initialViewState, mapStyle, style, className, maxBounds | Camera and style. mapStyle is a URL or a style spec object. |
| showUserLocation, showUserLocationButton, followUser, lockToUser | User location marker, recentre button, and camera follow. lockToUser keeps the camera following even when the user pans. |
| renderUserLocation | Custom user marker, given coords and heading. |
| pois, onPoiClick | Points of interest. Each POI has id, name, longitude, latitude, and optional displayMode (gl or marker), emoji, spriteName, imageUrl, markerComponent. |
| route, routeStyle | Route geometry (GeoJSON.LineString) and its line style. |
| navigation | Declarative journey: destination, profile, origin, mode, recalculateThreshold, arrivalThresholdMeters. The Map manages routing itself. |
| navigationData, showNavigationUI, onNavigationAbort, onNavigationModeChange | Drive the built-in navigation UI from useLiveRouting or useJourney data. |
| showControls, menuItems, menuContent, drawerState, drawerContentRef, controlProps, enableTouchDrag | Drawer and floating controls. |
| onLoad, onClick, onMapClickMiss, onContextMenu, interactiveLayerIds | MapLibre event hooks. |
The camera locks to the route automatically when route or navigation is set.
Map hooks and context
| Export | Purpose |
| --- | --- |
| SDKMapProvider | Provides camera-control context to components outside <Map>. Needed only for custom controls. |
| useMapControls() | isFollowing, centerOnUser(params?), resetBearing(params?), flyTo(coords, params?), setInsets(insets), drawerHandleVisible. Throws outside a provider. |
| useJourney(options?) | Journey state and actions for a custom UI: isActive, mode, route, isCalculating, error, distanceRemainingMeters, distanceRemainingKm, totalDistanceMeters, etaMinutes, progress, hasArrived, start(), cancel(), recalculate(). Options: navigation?, onAbort?, onModeChange?. |
| useLiveRouting(options) | Live route with recalculation. destination is required (null disables it). Options: origin?, profile?, recalculateThreshold (default 50 m), enabled (default true), initialMode. Returns route, totalDistance, currentDistanceMeters, mode, isCalculating, error, recalculate, start. |
| useRoute(origin, destination, profile?) | One-off route between two points. Returns route, isCalculating, error. |
| useTrimmedRoute(route, userLocation) | Route geometry starting at the user's position. |
| useUserLocation() | Shared, smoothed user position: coords, heading, accuracy. Safe to call from many components; one stream serves them all. |
Gestures and controls
| Component | Props | Notes |
| --- | --- | --- |
| PullToRefresh | onRefresh, children, className?, disabled?, pullThreshold? (default 64), maxPull? (default 96), renderIndicator? | Pull-to-refresh with its own scrolling. Give it a bounded height (h-full or h-dvh, and min-h-0 inside flex layouts). Otherwise the content grows instead of scrolling, and the bug is silent. |
| SwipeCarousel | items ({ id, content, className? }), activeId, onActiveChange, peek?, gap?, className?, itemClassName?, axisPriority?, verticalDrag?, overscan?, visibleOverscan? | Horizontal carousel. verticalDrag adds swipe-to-dismiss on the active slide. Responds to touch and mouse drag (desktop browsers). |
| QRScanner | onScan(data), onError?(message), className? | Camera scanner. Requests camera permission itself and stops after the first result. |
MobileBottomDrawer is not exported from the package, so it is not part of the public API.
Development components
These are for simulation. Use them in development builds; they do nothing useful in the shell.
| Export | Purpose |
| --- | --- |
| SDKProvider | Wraps the app. Enables the simulator when outside the shell. Props: children, debug?, plus any BridgeMocker prop. |
| BridgeMocker | The simulator itself. Props: appId?, initialLocation?, isMoving?, simulationPath?, simulationSpeed?, mockJWTToken?, edgeToEdge? (see Edge-to-edge apps). |
| DebugConsole | Bridge traffic log. Props: isOpen?, onClose?. |
| useIsMocked() | true when running in the simulator. |
| useMockLocation() | Simulator location state, or null outside the simulator. |
Service factories
Two factories help build a thin, app-specific data layer. Both are exported from the root.
createApiService(options)
Builds a REST and GraphQL client on top of sdk.network. The base URL resolves in this order: the shell's env.backendUrl, then defaultBaseUrl.
import { createApiService } from "@cloudparse/up-miniapps-sdk";
export const api = createApiService({
defaultBaseUrl: import.meta.env.VITE_BACKEND_URL,
defaultWsBackendUrl: import.meta.env.VITE_WS_BACKEND_URL,
});
const { data } = await api.get<{ items: unknown[] }>("/items");ApiServiceClient: baseUrl, wsUrl, get<T>(path), post<T>(path, body?, timeoutMs?), put<T>(path, body?), patch<T>(path, body?), delete(path), graphqlQuery<T>(query, variables?). Every method rejects (never throws synchronously) on a rejected path. Absolute URLs must share the backend origin and are sent in validated form. Relative paths containing .. segments are rejected, and dynamic path segments must be wrapped in encodeURIComponent.
createWsService(options)
A reconnecting WebSocket client with ticket-based authentication and exponential backoff. It runs in two modes.
- Channel mode (pass
channelField): room-scoped subscriptions,subscribe(channelId, cb). - Broadcast mode (omit
channelField): one global socket,onMessage(cb).
import { createApiService, createWsService } from "@cloudparse/up-miniapps-sdk";
const api = createApiService({ defaultBaseUrl: import.meta.env.VITE_BACKEND_URL });
const chat = createWsService({
api,
channelField: "roomUuid",
devToken: import.meta.env.VITE_DEV_TOKEN,
});
const unsubscribe = chat.subscribe("room-42", (message) => {
console.log(message);
});Options: api (from createApiService), channelField?, ticketPath? (defaults to /ws-ticket, null disables), devToken? (standalone development only), baseUrl? (overrides the resolved URL), query?, requireAuth? (default true).
Clients expose connect(), disconnect(), getStatus(), onStatusChange(cb), and onMessage(cb). Channel clients add subscribe(channelId, cb). The client refuses to connect without authentication unless requireAuth: false is set for a genuinely public endpoint.
Standalone development and simulation
Outside the shell (a plain browser), SDKProvider mounts the simulator. It answers SDK requests with mock data and renders a phone frame with a Simulation Controls panel on the right and a Debug Console on the left, both closed by default.
The simulator answers at least these requests with mock data: INITIALIZE, location (GET_CURRENT_POSITION, the stream), NETWORK_REQUEST (through fetch, with the mock JWT as a Bearer token), storage, secure storage, profile, profile picture, and fidelity balance and history. Haptics, exit and updateContext are no-ops.
Money and permission requests (PAYMENT_REQUEST, SPEND_FPOINTS, REQUEST_PERMISSION) are never answered by the SDK's standalone fallback. Outside the shell they reject with Native shell required unless the simulator is mounted, so a production build opened in a plain browser cannot report a fake payment or grant.
Phone frame
Every simulated app renders inside a fake device with a bezel and rounded corners. SDKProvider adds the frame automatically whenever the simulator is active. A second frame is never added inside one, and frame={false} renders unframed. The frame is centered in the browser window, both axes. Storybook docs pages lay it out inline instead.
The Debug Console and the Simulation Controls live in their own panels OUTSIDE the phone — left and right, both closed by default — not inside the screen, so dev tooling never covers the phone it's inspecting. The three regions sit in a 3-column layout where the two side columns share the same width: opening a panel only grows its own column, it never re-centers or shifts the phone. Closed, each panel is just a small toggle button next to the phone; open, its full content (bridge traffic logs on the left, every control described below on the right) renders in that column. Outside a phone context (frame={false}, or no simulator at all), both fall back to their original floating-corner positioning on the page.
Devices, selected in the Simulation Controls panel (Device):
| Device | Screen (CSS px) | Notes | |---|---|---| | Pixel 10 (phone) | 412 × 924 | Default. Punch-hole camera. | | Galaxy Tab S9 (tablet) | 800 × 1280 | Tablet corner radii. | | iPad Air (tablet) | 820 × 1180 | Keeps the Android system bars (known simplification). | | iPhone 16 (phone) | 393 × 852 | Dynamic Island, 59 px status bar. Selecting it switches the nav bar to the iOS home indicator (34 px). |
The choice persists in localStorage under the key up-sdk-simulator-device. An unknown or unreadable value falls back to Pixel 10. The frame scales to fit the browser window and never exceeds 1000 px in height: scale = min(1, (min(innerHeight, 1000) - 16) / bezelHeight, (innerWidth - 16) / bezelWidth), where the bezel adds 12 px per side on phones and 16 px on tablets. Device profiles live in src/components/dev/devices.ts.
<Map>'s own left-vs-bottom drawer auto-detection (no drawerSide prop given) measures the simulated phone's logical screen width inside the simulator — not the real browser window. A phone profile (< 768 px) gets the bottom sheet; a tablet profile (Galaxy Tab S9, iPad Air) gets the left drawer, automatically, the moment you switch devices in the Simulation Controls panel.
The simulator bars and the mobile bottom drawer stay inside the screen, trapped by its transform, exactly as on a device — only the Debug Console and Simulation Controls (dev tooling, not part of the simulated phone) live outside it, in their own side panels. The app wrapper scrolls internally, the way a WebView scrolls its document. Storybook frames every story with the same phone.
Limitations: 100vh/100dvh/h-screen/w-screen inside the screen measure the real browser window, not the phone — 100dvh has exactly the same flaw as 100vh here, it is not a safe substitute; both ignore the scaled, repositioned box the app actually mounts in. Use h-full/w-full instead, chained from a real height: 100% on html, body and your root element (not a viewport unit) — that correctly fills whichever box you're given, in the simulator and on a real edge-to-edge device alike. Overlays that a component portals to the page root, instead of the phone's mount point, render outside the frame.
Edge-to-edge apps
The real native shell is edge-to-edge (react-native-edge-to-edge): its WebView already spans the whole device, and translucent system bars float on top of it. By default, the simulator instead mounts a Mini-App's content in the area between the bars — a safe default for demo/Storybook content that doesn't read context.insets itself.
An app that already self-pads via context.insets (header paddingTop, bottom nav paddingBottom, per the standard convention above) can opt into matching the real device exactly: <SDKProvider edgeToEdge>. The app then mounts full-bleed — 0 to the phone screen's full width/height — with its own background visible under both bars, while the bars stay on top. Off by default; set once at mount, not meant to be toggled afterward. MobileBottomDrawer's own sheet mount point is unaffected either way — that's a separate, always-inset-aware concern.
Simulation controls
- Auto-Move (path or random walk) and Speed (1x to 250x).
- Lng / Lat inputs, a Heading slider, and Drop location on map (click the map to place the simulated position).
- Scan radius in meters or kilometers. The radius is drawn on the map.
- Device: Pixel 10, Galaxy Tab S9, iPad Air or iPhone 16 (see Phone frame above).
- System nav bar (see below). Defaults to Android 3 buttons, the layout of most phones.
- Nav bar style and Status bar style (Auto, Light icons, Dark icons), one per bar.
- Bar indicator: a read-only line per bar showing the effective icon style and whether the app has hidden it, for example
status: dark, hidden by app.
System bars simulation
The simulator reproduces the bars of a real phone. Both bars are transparent, so the page shows through them as it does on a device.
System nav bar (select) simulates the bottom bar. Choosing a bar sets insets.bottom. The default is Android 3 buttons, the layout of most phones.
| Mode | Bottom inset | Behaviour |
| --- | --- | --- |
| None | 0 | No bar. |
| Android 3 buttons (default) | 48 px | Back, Home, Recent apps. Back dispatches UIEvent.BACK_GESTURE. Home and Recent are visual only. |
| Android gesture bar | 16 px | Swipe inward from either edge of the bar, at least 56 px, to fire UIEvent.BACK_GESTURE. |
| iOS home indicator | 34 px | Indicator only. No back gesture. |
Status bar (eye toggle, on by default) simulates the clock, signal, Wi-Fi and battery row at the top of the screen. Its icons use a difference blend, so they stay readable on light and dark themes. It sets insets.top:
| Platform | Top inset | Selected when | | --- | --- | --- | | Android | 24 px | Any nav mode except iOS home indicator | | iOS | 47 px | iOS home indicator nav mode |
The platform rule and the heights live in src/components/dev/systemBars.ts (computeInsets, SYSTEM_STATUS_BAR_HEIGHT).
Icon style (Auto, Light icons, Dark icons) is set per bar in the panel. It is the simulator's host default, and Auto keeps the difference blend that stays readable on any background. Precedence per bar:
- The app's
sdk.ui.setSystemBarsStylevalue, when the app has set one, overrides the panel. - An app
autoclears the app override and falls back to the panel selector. - The panel selector applies otherwise.
App hide follows the same override idea: sdk.ui.setSystemBarsHidden overrides the eye and nav toggles, and an app show clears only the app's hide.
Every change, to either control, pushes one GlobalEvent.UPDATE_CONTEXT with the full insets object (top, right, bottom, left). A fresh initialize() reads the same values. The app wrapper is pinned between insets.top and insets.bottom, and fixed-position children stop at the edges, as they would in a WebView. Apps that handle context updates adapt without a reload.
App-driven control. The app drives the bars with sdk.ui.setSystemBarsStyle and sdk.ui.setSystemBarsHidden, the same calls it makes on a device. light and dark set the icon colour explicitly; auto restores the difference blend. setSystemBarsHidden hides a bar and drops its inset to 0: a hidden status bar gives insets.top 0, a hidden nav bar gives insets.bottom 0. false clears the app's hide.
Precedence: an app hide overrides the simulator's own controls (eye and nav select) until the app shows the bar again. A bar the simulator has switched off stays off after an app show.
DebugConsole
SDKProvider debug (or <DebugConsole /> directly) shows every bridge request and response, which helps when a call behaves differently in the shell.
Host entry
@cloudparse/up-miniapps-sdk/host exports shell-side helpers for drawing routes on a MapLibre map the shell owns:
| Export | Signature |
| --- | --- |
| handleRouteRequest | (req: RouteRequest, config: { backend: "osrm" \| "valhalla"; baseUrl: string }) => Promise<RouteResponse> |
| handleDrawRoute | (map: Map, miniAppId: string, req: DrawRouteRequest) => void |
| handleClearRoute | (map: Map, miniAppId: string) => void |
Mini-Apps never import this entry. The same three helpers are also re-exported from the root entry for compatibility.
Storybook
npm run storybook # http://localhost:6006
npm run build-storybook # static buildStories live under stories/, grouped by feature area: getting-started, lifecycle, location, map, media, navigation, platform, storage-network, ui-kit, and dev-tools. stories/_shared/withSdkMock.tsx is a decorator that mounts the simulator; stories that need it reference it explicitly, and stories with a specific setup wrap their own SDKProvider. The layout is still being refined and may shift between versions.
Every story renders inside the phone frame (see "Standalone development and simulation" above). Map/navigation/location stories render <Map> edge-to-edge — under the status and nav bars, not just below them — with their controls as overlays on top (stories/_shared/mapStage.tsx's FullscreenMapStage/EdgeToEdgeMapLayer) — the same shape a real mapping screen has, bars floating translucently over the map rather than pushing it down.
Showcase app
up-miniapps-showcase/ is a complete Mini-App that exercises every area above in one app. It is the reference implementation and the starting point for new Mini-Apps.
cd up-miniapps-showcase
npm install
npm run dev # Vite dev server, runs in the simulator
npm run build # tsc + vite build + shell bundle via up-sdkThe showcase depends on the SDK through file:.., so it always runs against this repository's build.
Claude Code plugin
claude-plugin/ contains the Claude Code plugin that ships with the SDK for building Mini-Apps with Claude. It has a skill (skills/up-miniapp-builder), scaffolding templates (templates/minimal and templates/full), and a scaffold script (scripts/scaffold.mjs). The plugin manifest and commands are being finalized, so treat that folder as work in progress until its README says otherwise.
Architecture
Layers, from the app down to the host. Dependencies point downward only.
Mini-App code (React, or vanilla JS via maps.render)
|
+-- components/ Map, controls, gestures, scanner, dev simulator
+-- hooks/ journey, live routing, user location, tap action
|
+-- sdk.ts MiniAppSDK facade: one property per domain
| |
| +-- modules/ location, storage, user, permission, media,
| payment, fidelity, activity, haptics, network,
| navigation, routing, ui, debug, maps, qrCode
| |
| +-- bridge/ protocol (wire types), transport,
| Bridge (requests, timeouts, events),
| mock/ (standalone responses)
|
+-- services/ apiService, wsService, routingService, qr codec
+-- types/ public types, shared by every layer
+-- host/ shell-side helpers (separate "./host" entry)
Native shell (ReactNativeWebView) or simulator (standalone browser)Rules that follow from this layout:
- Components and hooks call
sdk. The simulator (components/dev) is the only component that touches the bridge, on purpose. - Modules depend on the bridge, types, services and the per-instance state. They must not depend on components.
- Services depend on
sdkand types only.
Versioning and publishing
- Current version:
0.11.1(seepackage.json). - The package is published from GitLab CI by a manual job (
publishstage,when: manual) using npm trusted publishing. Nothing publishes automatically. - Each user-visible change is recorded as a numbered entry in
AGENTS.md, the repository changelog.
Development
| Script | Purpose |
| --- | --- |
| npm run build | Typecheck, bundle to dist/ (ESM, CJS, browser IIFE, type declarations), build CSS. |
| npm run clean | Remove dist/. Runs before every build. |
| npm run storybook / npm run build-storybook | Storybook dev server and static build. |
| npm run lint / npm run lint.fix | Biome lint, optionally with fixes. |
| npm run format | Biome format. |
Typecheck the SDK alone with npx tsc --noEmit. The compiler runs in strict mode with noEmit. Biome is the only linter and formatter. Vitest is configured for browser tests but needs Playwright's Chromium installed locally.
Do not run npm run build while the showcase or another consumer depends on dist/ in a live session; it removes dist/ first.
Contributing
- Import from
@miniapps-sdk/*insidesrc/. Relative imports are not allowed there. - Keep the layering in Architecture. New SDK capabilities go in
modules/, behind a method onsdk. - Every new feature needs a Storybook story in the matching
stories/area. - Keep Biome clean on every file you touch. Do not add comments unless the reason is non-obvious.
- Public API changes need a numbered entry in
AGENTS.md. Removing or renaming an export is a breaking change; avoid it. - Do not add a
fetchcall inside a component. Network traffic goes throughsdk.network.
Known limitations
These are behaviours of the simulator or the SDK that are verified in the source. They are not bugs in the shell.
- Simulator viewport units.
100vhin a Mini-App still measures the browser viewport, so it extends under a simulated bar. Use100dvhorh-full, which the shell's WebView also needs. - Simulator navigation bar. Home and Recent apps do nothing. The iOS indicator has no back gesture, because iOS has no hardware back button.
- Simulator payments and haptics.
sdk.payment.requestreturns success without charging anything. Haptics,exitandupdateContextare no-ops. Do not treat simulator results as proof of a real payment or device behaviour. - Simulator permissions. Native permission dialogs and the camera are not modelled.
QRScannerneeds a real camera to scan; usesdk.qrCode.decodeon a picked image in the simulator. - Journey events are in-app.
sdk.navigation.*does not reach the shell. It takes effect only where a<Map>oruseJourney()is mounted. - Routing failures reject.
sdk.routing.getRoutethrows when the routing backend or the shell fails. Handle the rejection in the calling code. - Unauthenticated WebSockets are refused.
createWsServicewill not connect without a ticket or dev token unlessrequireAuth: falseis set for a public endpoint. - Map camera. The camera locks to a route as soon as
routeornavigationis set, so users cannot pan away during guidance.
