Skip to main content
Since SDK 0.4 a mini app can use the host chrome, as the built-in Exode AI chats do: the header with the ”…” menu, the “Back” button, a main button at the bottom of the screen, a composer with a mic, popups, haptics, fullscreen and the “Share” sheet. In the Exode mobile app all of these are native; on the web they are the school interface.

What the host can do

On the handshake the host passes the list of its capabilities (capabilities) in the context. Check them with app.isSupported(capability) or the useExodeSupports() hook:
A host older than 0.4 declares nothing. Then the SDK methods fall back to the web (window.alert / confirm / prompt, navigator.share, navigator.clipboard, the Fullscreen API, navigator.vibrate), while the header, “Back”, the main button and the composer do nothing — the hooks return isSupported: false, and the app renders its own UI.

Edges and theme

ExodeMiniAppProvider turns the native look on right away; for ExodeMiniApp it is turned on by constructor options: Variables on <html>:
  • --exode-inset-{top,right,bottom,left} — what to keep free at each edge (the larger of the host chrome and the safe area);
  • --exode-safe-area-inset-*, --exode-content-inset-* — the device safe area and the host chrome separately;
  • --exode-viewport-width, --exode-viewport-height, --exode-content-height — the frame size and the height without the chrome;
  • --exode-accent and --exode-color-*: background, content, elevated, text, text-secondary, text-tertiary, accent, accent-foreground, separator, destructive, positive.
<html> attributes: data-exode-platform (native / web), data-exode-os (ios / android / web), data-exode-scheme, data-exode-fullscreen, data-exode-orientation, data-exode-edge. Set the screen height as min-height: var(--exode-content-height, 100dvh), and your own position: fixed panels with bottom: var(--exode-inset-bottom).

No flash on load

The host puts the theme and the edges into the frame name (window.name prefixed with exode-miniapp:) — the SDK applies them before the handshake. For a server-rendered app add the exodeBootScript() script to <head>: the theme and the insets are on the page before the first paint. The script changes the <html> attributes before React, hence suppressHydrationWarning:
The script takes the same options as the provider ({ edgeToEdge: true, applyTheme: true } by default).

Header and the ”…” menu

  • title — the page title; null — the title the school gave the page.
  • The host shows the buttons (left — up to one, right — up to six) as items of one ”…” menu on the right, left first: a row of buttons next to the title would crowd out a long title. label is the item text (without it the id is shown), icon is one of the MiniAppIcon icons: add, more, settings, search, share, info, help, edit, history, filter, sort, refresh, close, check, delete, download, upload, favorite, notifications, user, calendar, list, grid, qr. A button with another icon is dropped.
  • On the web the ”…” menu also has the host items Home and Reload (the mini app loads anew) after a separator. In a page window on desktop the buttons are icons in the window header, with label as the tooltip.
  • The hook acts while the component is mounted: after unmount — the page title and no buttons. The host has one header, so use one such hook per screen (the last mounted one wins).

”Back”

While the app holds “Back”, the header “Back” button sends backButton:pressed instead of leaving the page. On a phone the browser “Back” button and swipe lead there too, and in the iOS app the back swipe is turned off.

Main button

The button is pinned to the bottom of the screen (native in the app). Without text the host does not show it. loading — a spinner instead of the text, disabled — dimmed and not pressable, color and textColor — CSS colors of the background and the text (the school accent by default). One main button per screen.

Composer

A field at the bottom of the screen, as in the chats: the keyboard, attachments, the mic.
Events: composer:submit ({ text, files, voice? }), composer:voice ({ file, duration } — a File and seconds), composer:change ({ text }), composer:stop, composer:scrollDown. The hook returns isSupported, canDictate, canRecordVoice, canVoiceOnly, canDictateAudio, canReview, canNoCancel, canAttach, plus setText(text) ('' clears the field) and dismissKeyboard(). While the field is shown, the host bottom menu is hidden. The host has one field, so use one such hook per screen. In the app dragging the content down hides the keyboard of the native field, as in the chats.

Voice only

For speech trainers: no field and no keyboard, only buttons that say what they do. Tap to listen (the button shows your placeholder), “Send” to send, the cross to drop; a pause in the speech sends nothing. Attachments are not offered in this mode, and busy turns the button into “Stop” (composer:stop).
  • mic: 'dictation' — while speaking, what is heard comes in composer:change, the whole text in composer:submit. Where speech cannot be recognized (a browser without the Web Speech API), the regular field stays.
  • mic: 'voice' — the recording comes in composer:voice.
  • recordAudio (composer.dictation.audio) — the speech recording comes along with the dictation (voice in composer:submit), for example for a model that listens to pronunciation. Where the host cannot record it, there is no voice.
  • liveTranscript — the host shows what is heard above its buttons. Not set — shown only without recordAudio.
  • review (composer.voiceOnly.review) — what was said goes into a host field to edit, the user sends it. A host without this capability sends at once.
  • noCancel (composer.voiceOnly.noCancel) — for trainers that count attempts: no cross while listening and no “Again” in the review. In a browser it applies to dictation; a voice recording (mic: 'voice') keeps the cross of the recorder.

Popups

  • show — one to three buttons (one close by default). Button types: default, ok, close, cancel, destructive; default and destructive need text, the others have the host text. id defaults to the button type.
  • prompt takes title, message, placeholder, value, multiline, required, minLength, maxLength, okText, cancelText.
  • In the app an alert with one or two buttons and prompt are native. The answer is awaited for as long as it takes, with no timeout.
  • A hidden mini app (the user went to another page) shows no popup — it gets null.

Fullscreen and rotation

  • On a phone fullscreen hides the host header and bottom menu; on desktop it is the Fullscreen API of the frame itself (the app learns about an exit by Esc from viewport:changed).
  • Screen rotation (orientation) is available only in the app. Without this capability the SDK tries the browser’s screen.orientation.lock — it usually works only in fullscreen.
  • Without the hook: app.viewport.requestFullscreen(), app.getViewport(), app.getInsets() and the viewport:changed event (MiniAppViewport).

Haptics

In the app — the phone’s native haptics, on the web — browser vibration; where there is none, the call does nothing. A host without the haptic capability is replaced by navigator.vibrate (the browser allows it only after a user gesture).
  • The host opens only http:, https:, mailto: and tel: links and passes the address as the browser parsed it (new URL(url).href). The host silently ignores links with another scheme and invalid addresses. On the web the link opens in a new tab.
  • share checks the address the same way: an invalid one is dropped, and if no text is left, share returns false. In the app the native “Share” sheet opens, on the web — navigator.share. Without the share capability and without the browser “Share”, share returns false — the text is not copied instead.
  • copyText writes to the clipboard natively in the app, via navigator.clipboard on the web.
  • A hidden mini app does not open a link, and share and copyText return false.

Loading silhouette and devtools

While the mini app code loads, the host draws the page’s loading silhouette. You do not have to write the silhouette line by hand: the SDK devtools (0.9+) measure the first screen and build it for you. They never get into a production build — it is a separate module, @exode-team/sdk/miniapp and miniapp.global.js do not contain it. From the browser console, with no code changes (inside the school frame, switch the console to the frame context):
In code — for development only:
The module also exports the functions themselves: measureSkeleton({ root }) — the line, copySkeleton() — the line to the console and the clipboard, showSkeleton(line?), hideSkeleton(). If the app has its own skeleton, mark its root with the data-exode-skeleton attribute — the devtools take the silhouette from it (the skeleton must be on screen while measuring). Paste the finished line into the Loading silhouette field of the school page.
Updated: 2026-10-22 23:06 UTC