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-accentand--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:
{ 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,leftfirst: a row of buttons next to the title would crowd out a long title.labelis the item text (without it theidis shown),iconis one of theMiniAppIconicons: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
labelas 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”
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
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 yourplaceholder), “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 incomposer:change, the whole text incomposer:submit. Where speech cannot be recognized (a browser without the Web Speech API), the regular field stays.mic: 'voice'— the recording comes incomposer:voice.recordAudio(composer.dictation.audio) — the speech recording comes along with the dictation (voiceincomposer:submit), for example for a model that listens to pronunciation. Where the host cannot record it, there is novoice.liveTranscript— the host shows what is heard above its buttons. Not set — shown only withoutrecordAudio.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 (onecloseby default). Button types:default,ok,close,cancel,destructive;defaultanddestructiveneedtext, the others have the host text.iddefaults to the button type.prompttakestitle,message,placeholder,value,multiline,required,minLength,maxLength,okText,cancelText.- In the app an alert with one or two buttons and
promptare 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’sscreen.orientation.lock— it usually works only in fullscreen. - Without the hook:
app.viewport.requestFullscreen(),app.getViewport(),app.getInsets()and theviewport:changedevent (MiniAppViewport).
Haptics
haptic capability is replaced by navigator.vibrate (the browser allows it only after a user
gesture).
Links, “Share”, clipboard
- The host opens only
http:,https:,mailto:andtel: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. sharechecks the address the same way: an invalid one is dropped, and if notextis left,sharereturnsfalse. In the app the native “Share” sheet opens, on the web —navigator.share. Without thesharecapability and without the browser “Share”,sharereturnsfalse— the text is not copied instead.copyTextwrites to the clipboard natively in the app, vianavigator.clipboardon the web.- A hidden mini app does not open a link, and
shareandcopyTextreturnfalse.
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):
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