A MiniApp is a web app that runs inside the Tokomi story container: a static bundle with index.html at its root, using the container-injected window.Shine to access AI, storage, characters, and story capabilities. These docs are written for front-end developers and assume familiarity with HTML / JavaScript and common build tooling.
Ship a minimal MiniApp that calls AI, persists the result, and goes through review.
The container injects the SDK on page load, so window.Shine is ready — don't include shine-app.js yourself. Single-file HTML isn't transpiled, so avoid Chromium 80+ syntax such as ?. and ??.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Hello Tokomi</title>
<style>
body {
margin: 0; font-family: system-ui; background: #111; color: #eee;
/* Runs full-screen: top inset is the larger of iOS env() and the container variable */
padding: max(env(safe-area-inset-top, 0px), var(--shine-safe-area-top, 0px)) 16px 16px;
}
button { min-height: 44px; padding: 0 16px; }
</style>
</head>
<body>
<button id="ask">Ask AI</button>
<p id="out"></p>
<script>
var btn = document.getElementById('ask');
var out = document.getElementById('out');
btn.addEventListener('click', async function () {
btn.disabled = true; // guard against double charges
try {
var reply = await Shine.ai.chat({ // paid capability, billed at standard rate
system: 'You are a concise assistant.',
message: 'Describe Tokomi in one sentence.'
});
out.textContent = reply;
await Shine.storage.set('last_reply', reply); // cloud KV, isolated per user
} catch (e) {
await Shine.ui.toast(e.message || 'Request failed'); // platform handles low balance
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>Create an app and paste the code. The simulator on the right previews live and can switch between store and story-attached launch modes. Paid calls in the preview charge real stars.
Fill in the name, category, icon, and screenshots, then declare the paid capabilities you use and how often they trigger in the star spending disclosure. The platform shows a unified notice before entry; approved apps go live automatically.
An Agent Skill for Claude Code, Cursor, Codex and similar coding assistants. Once installed, the assistant follows the platform's hard rules whenever it works on a Tokomi MiniApp — injected SDK, launch-mode branching, star billing and one-tap-one-image, the legacy WebView baseline, review red lines — and carries the full SDK reference for lookup.
Files under references/ are generated from the same data that renders this page, so they never drift from the docs.
# Claude Code unzip shine-miniapp-skill.zip -d .claude/skills/ # Cursor unzip shine-miniapp-skill.zip -d .cursor/skills/ # Codex / other agents: unzip anywhere and reference SKILL.md from AGENTS.md
Single-file HTML can be edited and previewed directly in the workbench. For multi-file projects, develop locally with Vite; at release, npm run build produces dist/, which you ZIP (index.html at the root) and upload to the workbench.
// vite.config.ts
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig(() => {
const apiTarget = process.env.VITE_API_BASE || 'http://localhost:8000';
return {
// Use relative paths so the build loads under the MiniApp container's
// file:// / subdirectory setup (absolute /assets paths 404 into a blank screen)
base: './',
plugins: [react()],
build: {
// Old Android WebViews (e.g. vivo devices shipping Chromium <80) don't
// support ?. / ??; the default esnext build throws a SyntaxError and black-screens.
// Set a real browser target so esbuild transpiles the new syntax; cssTarget
// follows it automatically, which keeps the CSS minifier from merging
// top/right/bottom/left back into the inset shorthand old devices don't understand.
target: ['chrome61'],
},
server: {
port: 3000,
host: '0.0.0.0',
proxy: {
// Keep the Shine SDK and MiniApp API same-origin to avoid CORS
'/api': { target: apiTarget, changeOrigin: true },
'/static': { target: apiTarget, changeOrigin: true },
},
},
};
});// src/lib/shine-dev.ts — recommended wrapper layer; app code only calls into here
const DEV_APP_ID = '00000000-0000-0000-0000-000000000000';
function installDevLaunchOptions() {
const sessionId = import.meta.env.VITE_SHINE_SESSION_ID;
const characterId = import.meta.env.VITE_SHINE_CHARACTER_ID;
if (!window.__shine_launch_options && sessionId) {
window.__shine_launch_options = { sessionId, characterId };
}
}
async function ensureSdk() {
if (window.Shine) return;
await new Promise<void>((resolve, reject) => {
const s = document.createElement('script');
s.src = '/static/sdk/v1/shine-app.js';
s.onload = () => resolve();
s.onerror = () => reject(new Error('Failed to load Shine SDK'));
document.head.appendChild(s);
});
}
let inited = false;
export async function initShine() {
installDevLaunchOptions();
await ensureSdk();
if (inited || !window.Shine?.init) return;
window.Shine.init({
appId: import.meta.env.VITE_SHINE_APP_ID || DEV_APP_ID,
token: import.meta.env.VITE_SHINE_TOKEN || '',
baseUrl: '', // same origin, goes through the Vite proxy
});
inited = true;
}// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { initShine } from './lib/shine-dev';
void initShine();
createRoot(document.getElementById('root')!).render(
<StrictMode><App /></StrictMode>,
);Sign in to generate a ready-to-use .env.local here (login token + debug session). Variables: VITE_API_BASE, VITE_SHINE_APP_ID, VITE_SHINE_TOKEN, VITE_SHINE_SESSION_ID, VITE_SHINE_CHARACTER_ID.
| Layer | Environment | SDK / AI | Story capabilities | Native |
|---|---|---|---|---|
| ① UI only | npm run dev, no token needed | Example wrapper falls back / mocks (see fallback strategy below) | No session → store mode | Unavailable |
| ② SDK integration | Set VITE_SHINE_TOKEN + start the backend | ai / storage / user hit real HTTP | Still no contacts / actor | Unavailable |
| ③ Story integration | Also set VITE_SHINE_SESSION_ID / CHARACTER_ID | Same as above | contact / actor / wallet available | Unavailable |
| ④ Release check | npm run build → ZIP → upload in the workbench | Same as production | Optional story session preview in the workbench | Real-device container only |
Capability availability depends on how the app was launched. Check synchronously with Shine.context.getLaunchOptions(): null means store mode; { sessionId, characterId? } means a story-attached launch.
| Launch mode | getLaunchOptions() | Available capabilities |
|---|---|---|
| Opened directly from the app store | getLaunchOptions() → null | ai.* / storage / user / ui (partial) / tts (voiceId mode, good for narration) are available; character / contact / actor / wallet are unavailable or return empty (getLaunchOptions() itself works in any mode — it just returns null here) |
| Launched from a story session | { sessionId, characterId? } | All capabilities available (including actor.speak / actor.notify, scene.inject, wallet, contacts) |
| Workbench preview | Optional "store / story" simulation | Same as production; billed capabilities really deduct stars |
If the listing form's launch mode is set to "story", the platform asks the user to pick a story before entry and sessionId is always present. With "compatible", you must handle null.
const launch = Shine.context.getLaunchOptions();
if (!launch) {
// Store mode: hide session-dependent entry points (actor / contact / wallet / scene)
}Paid capabilities are charged from the user's star balance at standard platform rates. The platform owns the spending notice and low-balance prompt — don't reimplement them inside the app.
| API | Billing |
|---|---|
Shine.ai.chat | Standard rate; the container doesn't prompt per call |
Shine.ai.image | Standard rate; the platform shows an authorization dialog per image — if the user declines, the call rejects (user_denied) with no charge |
Shine.actor.speak | Standard rate of the selected model |
Shine.tts.speak / synthesize | Billed by character count on cache miss (per 50 characters); cache hits are free |
Shine.actor.notify | One-way delivery is free; triggerReply: true is billed as an NPC reply |
Shine.wechat.sendCard | triggerReply defaults to true and is billed as an NPC reply |
| Everything else | storage / ui / device / context / character / contact / scene / credits / wallet are free |
MiniApps run inside vendor Android WebViews, some of which are stuck on Chromium 66–79. Passing on a desktop browser does not mean passing on devices. The table lists the high-frequency, high-impact constraints. React / Vite projects can rely on build.target for syntax, but missing APIs and CSS features still need to be avoided by hand.
| Topic | Avoid | Use instead |
|---|---|---|
| Build target (React/Vite projects) | Leaving build.target unset in vite.config (defaults to esnext, which ships ?. / ?? untranspiled — the whole bundle throws SyntaxError on <80 devices) | build: { target: ['chrome61'] } (a real browser target; syntax is auto-transpiled and cssTarget follows), plus base:'./'; single-file HTML gets no transpilation — simply never write ?. / ?? |
| New JS APIs/syntax | Using .at(), replaceAll, structuredClone, Object.hasOwn, ??=/||=/&&=, findLast, Promise.any, Promise.allSettled, matchAll, Object.fromEntries, or module top-level await without transpilation; in single-file HTML this also includes ?. / ?? (needs Chrome 80 — breaks on vivo 66~79) | arr[arr.length-1], split().join(), JSON.parse(JSON.stringify()), hasOwnProperty.call; expand ??=/||=/&&= into an if check plus assignment (in single-file HTML even ?? / ?. themselves are off-limits); allSettled/matchAll/fromEntries are missing APIs that transpilation can't fix — rewrite or polyfill |
| Colors & transparency | color-mix()/oklch()/lab()/@layer; Tailwind v4 browser CDN (@tailwindcss/browser@4) | Use rgb()/rgba()/#hex for colors; for Tailwind use the Play CDN cdn.tailwindcss.com (v3) |
| Tailwind opacity | Opacity modifiers that aren't multiples of 5 (bg-white/8, text-white/12) | Only multiples of 5 (/10, /15, /20…), or bg-[rgba(...)] |
| Aspect ratio | aspect-ratio / Tailwind aspect-[3/4] | Hold the ratio with padding-bottom: container position:relative;width:100%;padding-bottom:133% (3:4 means height/width = 4/3), children absolute with all four sides 0 |
| Flex spacing | gap-* on flex containers | margin / Tailwind space-x-*, space-y-* (grid can keep using gap) |
| Absolute-position inset | The inset shorthand / Tailwind inset-0 | Write out top/right/bottom/left as four separate values |
| Image border radius | Relying only on the parent's overflow:hidden + border-radius to clip the <img> | Put border-radius on the <img> itself (Tailwind rounded-full) |
| 3D flips | Double-sided card flips with transform-style:preserve-3d + backface-visibility | Single-layer rotateY flip-in + opacity fade |
| Frosted glass | backdrop-filter / Tailwind backdrop-blur | Simulate with a dark semi-transparent base + gradient + border; use rgba(0,0,0,.7) for scrims |
| Viewport units | dvh / svh / lvh | 100% + flex to fill, or vh |
| Other new CSS | :has(), :is(), :where(), accent-color, content-visibility, text-wrap:balance, subgrid | Switch to a supported equivalent or remove |
| Image formats | .avif | WebP / PNG / JPG |
| Asset paths | Site-absolute paths /assets/x.png | Relative paths ./assets/x.png; set base:'./' in Vite |
| Inner scrolling | Inner scroll containers without touch-scrolling properties | Add -webkit-overflow-scrolling:touch; touch-action:pan-y; to scroll containers |
| Low-end device performance | Lots of repeat:Infinity endless animations, too many background particles, backdrop-filter | ≤10 persistent background particles animating opacity only; keep things static whenever possible |
| Canvas / WebGL (only relevant if you use 3D) | Canvas without webglcontextlost handling — old Android reclaims the GPU under memory pressure and the context is lost for good | Listen for webglcontextlost (e.preventDefault() + pause the render loop) and webglcontextrestored (rebuild textures/buffers + resume rendering); with three.js use the corresponding WebGLRenderer events; show a static fallback if loading fails |
A MiniApp can write results back into the current story: persist to the offline mini-theater, inform an NPC, or have a character speak directly. All of the following require a story-attached launch (launch includes sessionId).
| Goal | API | Billing |
|---|---|---|
| Show narration on the offline timeline and trigger a "new plot" notice | scene.inject({ visible }) | Free |
| Inform NPCs without showing a bubble (director's notes / foreshadowing / minigame results) | scene.inject({ hidden }) | Free |
| Deliver a message to an NPC or the player from a custom sender | actor.notify() | One-way is free; triggerReply is billed as an NPC reply |
| Have a character reply in persona right now | actor.speak() | Standard model rate |
| Send a card into the WeTalk chat with a contact | wechat.sendCard() | triggerReply defaults to true and consumes stars |
notify returns no dialogue — it only makes the recipient aware so they act on their own; use speak for an immediate reply. nav.openScene() only navigates without content; to trigger new plot with content, use scene.inject().
The following are checked in every review. Apps that don't comply are rejected.
In the listing form, declare the paid capabilities you use and how often they trigger. The platform shows a unified notice before entry; inaccurate disclosures are rejected outright.
Automatic in-flow calls (e.g. per-turn resolution) are allowed if declared. Timers, polling, or idle loops that keep charging while the user does nothing are always rejected.
Disable the button or show a loading state while a call is in flight; confirm with Shine.ui.confirm before batch or expensive calls.
Shine.ai.image shows a platform authorization dialog per image. Looped, batched, or automatic consecutive generation is banned; don't add your own confirmation dialog either.
No splash spending notice or top-up flow of your own. The platform prompts on insufficient balance; the app only needs to catch the failure and restore the UI.
AI-generated text and images must not be passed off as real people, authorities, or facts. Labeling paid entry points (e.g. "consumes stars") is recommended; disguising a paid action as free is prohibited.
Sexual content, content targeting minors, or graphic violence is rejected outright and may get the account banned. System prompts passed to ai.chat / actor.speak are subject to platform moderation as well.
Runnable reference implementations. Single-file examples load into the workbench for editing; project examples compile live in the workbench IDE and can also be downloaded as a ZIP for local development.
One index.html that walks through every capability: AI chat / image generation, local storage, UI components, navigation and status bar, story context (context / character / contact), character speech, native media, and wallet payment / refund — each section is clickable. Calls marked ⭐ consume stars. Zero dependencies, copy and run — the fastest way to try out capabilities or use as a starter skeleton.
The same capability checklist as the lite version, restructured as a multi-file React + Vite project: one section component per capability, with a unified Card / Output / useAction design — clear structure and polished styling. Click "View example" to compile and preview it live in the workbench IDE; delete the section files you don't need and use it as a project template.
A complete werewolf game built with React + Vite, using the Shine SDK for AI player speeches, user info, and cloud saves. Click "View example" to browse the real multi-file source in the workbench IDE with live in-browser compilation and preview; or download the ZIP for local npm development, build the dist, and publish.
A pure-frontend, single-file Gomoku game: move placement, win detection, undo, and restart — zero dependencies, copy and play. A good way to learn how to fit a complete mini game into one index.html, then wire up the Shine SDK for leaderboards, sharing, and more.
The official landscape mini-game example: pure Canvas pong — drag the left paddle to battle the AI, first to 7 points wins. It focuses on the landscape trio: ui.setOrientation('landscape') to lock landscape, ui.setNavigationBar + ui.setFullscreen for fullscreen immersion, and max(env(), var(--shine-safe-area-*)) to handle left and right notch safe areas. Zero dependencies, copy and play.
A React + Vite "read novels together" reader: bookshelf, immersive reading, and night mode, with the Shine SDK powering an AI book mate — chat about the plot with a character as you read and get companion commentary. Click "View example" to compile and preview the complete multi-file project with motion animations live in the workbench IDE.
Every method documents its parameters, return value, billing, and runtime constraints. The source of truth is static/sdk/v1/shine-app.src.js; probe newer methods with Shine.canIUse() before calling.
The native container (Flutter WebView) injects the SDK and writes launch options automatically when the page loads, so you usually don't need to call init manually. In a browser or the workbench preview, include the script and call init yourself.
Shine.init({ appId, token?, baseUrl? })| Param | Type | Required | Description |
|---|---|---|---|
appId | string (UUID) | Yes | The MiniApp's appId; in the workbench preview you can use 00000000-0000-0000-0000-000000000000 |
token | string | No | Bearer token from the creator sign-in; required when calling the API directly from a browser |
baseUrl | string | No | API base URL, e.g. https://api.shineapp.cn; no trailing / |
void<!-- Only needed for local debugging / workbench preview; after release the platform injects the SDK automatically, so you can remove this -->
<script src="https://api.shineapp.cn/static/sdk/v1/shine-app.js"></script>
<script>
Shine.init({
appId: '<your appId>',
token: '<sign-in token>',
baseUrl: 'https://api.shineapp.cn',
});
</script>Probing capability that sits alongside the namespaces. The SDK is inlined at render time (always up to date), but the user's installed app may be an older build — new methods fail across the bridge in old containers, so use canIUse for graceful degradation.
Shine.canIUse(schema)syncCheck whether the current container supports a given SDK capability.
| Param | Type | Description |
|---|---|---|
schema | string | Capability name, matching the method names in this reference, e.g. 'device.vibrate' / 'tts.listVoices' / 'actor.notify'* |
boolean — returned synchronously
if (Shine.canIUse('device.vibrate')) {
await Shine.device.vibrate({ style: 'light' });
}
if (!Shine.canIUse('tts.listVoices')) {
// Older app build: hide the narration voice picker and fall back to silent text
}General-purpose AI. The creator fully controls system / message; the platform only handles model invocation and billing.
Shine.ai.chat(options)asyncRun one AI conversation turn and return the model's reply text.
| Param | Type | Description |
|---|---|---|
system | string | Creator-defined system prompt, up to 5,000 chars* |
message | string | User input for this turn, up to 2,000 chars* |
context | "story" | "actor" | "director" | null | Optional context-mode hint (in the current version creators mostly bake this into system themselves) · default null |
stream | boolean | Whether to stream the response · default false |
onChunk | (chunk) => void | Streaming callback, invoked with each incremental text chunk; providing it enables stream automatically. Accumulate chunks yourself if you need the full text (the native container only passes deltas, never a full-text argument) |
Promise<string> — AI reply text
const reply = await Shine.ai.chat({
system: 'You are a tarot reader — mysterious but friendly.',
message: 'Read these three cards for me: The Fool, The Lovers, Wheel of Fortune',
});Shine.ai.image(promptOrOptions)asyncGenerate an image from a text description.
| Param | Type | Description |
|---|---|---|
prompt | string | Image description, up to 1,000 chars. You can also pass { prompt: string }* |
Promise<string> — public OSS URL of the image
const url = await Shine.ai.image('Watercolor style, an orange tabby cat sitting on a windowsill');
// <img src={url} />Cloud KV storage, isolated per user (user A's data is invisible to user B). Values never expire automatically — they persist until deleted. Keys up to 255 chars; values can be string / number / boolean / object / array. Two scopes: Shine.storage.* (user level, shared across all of that user's sessions) and Shine.storage.session.* (session level, one save slot per story — see the session.* entry below); the two share identical method names. Quota is counted per "user + app" across both scopes.
Shine.storage.get(key)asyncRead the value of a key.
| Param | Type | Description |
|---|---|---|
key | string | Storage key* |
Promise<any | null> — null if never set
const save = await Shine.storage.get('game_save');
if (save) board = JSON.parse(save);Shine.storage.set(key, value)asyncWrite or overwrite a key.
| Param | Type | Description |
|---|---|---|
key | string | Storage key* |
value | any | Any JSON-serializable value* |
Promise<void>
await Shine.storage.set('coins', 128);
await Shine.storage.set('inventory', { sword: 1, potion: 3 });Shine.storage.remove(key)asyncDelete a key.
| Param | Type | Description |
|---|---|---|
key | string | Key to delete* |
Promise<void>
Shine.storage.list(opts?)asyncEnumerate keys under the current user+app (values not included), with cursor pagination.
| Param | Type | Description |
|---|---|---|
prefix | string | Only list keys with this prefix |
cursor | string | Last key of the previous page |
limit | number | Page size, max 200 · default 100 |
Promise<{ keys: string[]; nextCursor: string | null; hasMore: boolean }>const page = await Shine.storage.list({ prefix: 'save_' });
for (const k of page.keys) console.log(k);Shine.storage.mget(keys)asyncRead multiple keys in one call (up to 50).
| Param | Type | Description |
|---|---|---|
keys | string[] | Keys to read* |
Promise<Record<string, any | null>>
const vals = await Shine.storage.mget(['coins', 'inventory']); console.log(vals.coins, vals.inventory);
Shine.storage.getInfo()asyncGet key count, used space, and quota limits.
Promise<{ keyCount: number; currentSizeBytes: number; limitKeys: number; limitSizeBytes: number }>Shine.storage.clear(opts?)asyncBulk-delete keys, optionally by prefix. The platform shows no confirmation — call ui.confirm yourself.
| Param | Type | Description |
|---|---|---|
prefix | string | Only delete keys with this prefix |
Promise<{ status: 'ok'; deletedCount: number }>Shine.storage.session.get / set / remove / list / mget / clear / getInfoasyncSession-scoped storage namespace: identical methods to Shine.storage.*, but isolated per session — the same user opening the same app from different stories/sessions gets an independent save slot each, with no cross-contamination.
Same as the corresponding Shine.storage.* method
// User level: shared across stories (settings, favorites)
await Shine.storage.set('settings', { sound: true });
// Session level: one save slot per story (progress / stats / mini-games)
await Shine.storage.session.set('progress', { chapter: 3, hp: 80 });
const p = await Shine.storage.session.get('progress');Public info of the current user. Who "the current user" is depends on the launch mode (see getInfo).
Shine.user.getInfo()asyncGet current-user info. When launched with a story (launch options include sessionId) it returns the player's in-story persona (mask / user_role) — the same source as the user_identity injected by actor.speak. In store mode it returns the real account. The return shape is identical either way, so you don't need to branch.
Promise<{ id: string; nickname: string; avatar: string | null }>// With a story attached, nickname is the player's in-story name (the mask), matching what characters see
const me = await Shine.user.getInfo();
document.getElementById('name').textContent = me.nickname;System-level UI inside the native container; in browsers it degrades to confirm / prompt / a simple toast.
Shine.ui.toast(message)asyncBrief message at the top of the screen.
| Param | Type | Description |
|---|---|---|
message | string | Toast text* |
Promise<void>
Shine.ui.confirm(message)asyncConfirmation dialog.
| Param | Type | Description |
|---|---|---|
message | string | Confirmation text* |
Promise<boolean> — true if the user taps OK, false on cancel
Shine.ui.prompt(message, defaultValue?)asyncSingle-line input dialog.
| Param | Type | Description |
|---|---|---|
message | string | Input prompt* |
defaultValue | string | Default value · default "" |
Promise<string | null> — null on cancel
Shine.ui.showActionSheet(options)asyncBottom action sheet.
| Param | Type | Description |
|---|---|---|
options | string[] | Array of option labels* |
Promise<number> — index of the selected option; -1 on cancel
Shine.ui.setNavigationBar(opts)asyncControl the container's top navigation bar.
| Param | Type | Description |
|---|---|---|
visible | boolean | Whether to show the navigation bar; hidden by default (full screen), with back and other actions handled by the native capsule in the top-right corner |
title | string | Title text |
backgroundColor | string | Background color, e.g. #111827 / transparent |
Promise<void>
await Shine.ui.setNavigationBar({ visible: false });
/* Root container of a full-screen page: iOS uses env(), Android uses the container's variables — take the max; adapts to rotation automatically */
body {
padding:
max(env(safe-area-inset-top,0px), var(--shine-safe-area-top,0px))
max(env(safe-area-inset-right,0px), var(--shine-safe-area-right,0px))
max(env(safe-area-inset-bottom,0px), var(--shine-safe-area-bottom,0px))
max(env(safe-area-inset-left,0px), var(--shine-safe-area-left,0px));
}Shine.ui.getCapsuleRect()syncGet the native capsule's position on the page, so a custom top bar can avoid it.
{ visible, top, right, bottom, left, width, height } — CSS px, returned synchronously/* Pure CSS: leave room for the capsule on the right of a custom top bar */
.app-header {
padding-top: max(env(safe-area-inset-top,0px), var(--shine-safe-area-top,0px));
padding-right: calc(var(--shine-capsule-width,0px) + var(--shine-capsule-right,0px) + 8px);
}
/* JS: when you need exact coordinates */
const cap = Shine.ui.getCapsuleRect();
if (cap.visible) toolbar.style.maxWidth = cap.left - 12 + 'px';
window.addEventListener('shine:capsulechange', relayout);Shine.ui.setStatusBar({ style })asyncSet the status bar text color.
| Param | Type | Description |
|---|---|---|
style | "light" | "dark" | light = white text (dark backgrounds); dark = black text* |
Promise<void>
Shine.ui.setOrientation(orientation)asyncLock or restore the screen orientation.
| Param | Type | Description |
|---|---|---|
orientation | "portrait" | "landscape" | "auto" | Screen orientation* |
Promise<void>
Shine.ui.setFullscreen(enabled)asyncImmersive full screen: hide/restore the system status bar.
| Param | Type | Description |
|---|---|---|
enabled | boolean | true = hide the status bar and go full screen; false = restore* |
Promise<void>
// Good for distraction-free scenes: full-screen games, image viewers, video
await Shine.ui.setNavigationBar({ visible: false });
await Shine.ui.setFullscreen(true);
// When leaving the page / restoring
await Shine.ui.setFullscreen(false);Container and device info, clipboard, and haptic feedback. These are unreliable or simply missing in a WebView, so the native container provides them.
Shine.device.getSystemInfo()asyncGet platform, host app version, light/dark theme, screen size, and safe area.
Promise<{ platform: 'ios'|'android'|'other'; appVersion: string; theme: 'light'|'dark'; locale: string; screen: { width, height, pixelRatio }; safeArea: { top, right, bottom, left } }>const info = await Shine.device.getSystemInfo(); document.documentElement.dataset.theme = info.theme;
Shine.device.setClipboard({ text })asyncWrite to the system clipboard.
| Param | Type | Description |
|---|---|---|
text | string | Text to copy; you can also pass a plain string* |
Promise<{ ok: boolean }>await Shine.device.setClipboard({ text: inviteCode });
await Shine.ui.toast('Invite code copied');Shine.device.getClipboard()asyncRead text from the system clipboard.
Promise<{ text: string }>const { text } = await Shine.device.getClipboard();
if (text) input.value = text.trim();Shine.device.vibrate({ style? })asyncShort haptic feedback (game combos, button presses, etc.).
| Param | Type | Description |
|---|---|---|
style | 'light' | 'medium' | 'heavy' | 'selection' | Haptic intensity; selection suits light picker taps · default 'light' |
Promise<{ ok: boolean }>if (Shine.canIUse('device.vibrate')) {
await Shine.device.vibrate({ style: combo > 1 ? 'medium' : 'light' });
}Launch context. Synchronous methods; no network requests.
Shine.context.getLaunchOptions()syncRead the parameters the container passed when launching the MiniApp.
{ sessionId?: string; characterId?: string } | null
Opened directly from the store → null; launched from a story → includes sessionIdconst launch = Shine.context.getLaunchOptions();
if (!launch) {
// Store mode: show a friendly empty state
return;
}
console.log(launch.sessionId, launch.characterId);Public metadata of the attached story. Internal prompt fields like world_scenario / personality are not returned.
Shine.character.getDetail(characterId?)asyncFetch the public details of the attached story.
| Param | Type | Description |
|---|---|---|
characterId | string (UUID) | Defaults to launch.characterId if omitted |
Promise<{ id, name, brief, avatar_url, cover_url }>const story = await Shine.character.getDetail(); titleEl.textContent = story.name;
Contacts of the current session: story actors + user-created NPCs.
Shine.contact.list()asyncFetch the contact list.
Promise<Array<{ id: string; name: string; avatar_url: string | null; is_custom_npc: boolean }>>const contacts = await Shine.contact.list(); const partner = contacts[0];
Make a story character / NPC speak in persona. The backend miniapp_prompt_manager automatically assembles the world setting, persona, memory, and history.
Shine.actor.speak(opts)asyncHave a given actor generate a line of dialogue.
| Param | Type | Description |
|---|---|---|
actorId | string | id returned by contact.list() (a UUID or NPC:xxx)* |
message | string | This turn's instruction / user input, up to 2,000 chars* |
extraSystem | string | Extra system prompt, e.g. game rules or the current situation, up to 5,000 chars |
includeWorldScenario | boolean | Inject the story card / chapter / plot settings · default true |
includeActorPersona | boolean | Inject the actor's identity and persona; turning it off degrades them to an anonymous NPC · default true |
includeActorMemory | boolean | Inject the actor's relationship / experience memory; only effective when persona=true · default true |
includeOtherActors | boolean | Inject the cast list of other actors; turn off for information-isolation games like Werewolf · default true |
includeChatHistory | boolean | Inject the interleaved phone chat history; usually keep false for games · default false |
customHistory | Array<{role:'user'|'assistant', content:string}> | App-maintained conversation history, up to 50 entries, each ≤ 4,000 chars · default [] |
Promise<string> — the actor's line
const reply = await Shine.actor.speak({
actorId: partner.id,
message: 'Where do you think the story goes from here?',
extraSystem: 'You two are reading chapter three together; stay in character.',
includeWorldScenario: true,
includeActorPersona: true,
customHistory: [
{ role: 'user', content: 'What did we discuss last round?' },
{ role: 'assistant', content: 'You mentioned the protagonist\'s choice…' },
],
});Shine.actor.notify(opts)asyncDeliver a (one-way by default) message to a recipient under some sender identity — tip-offs, approaches, notifications, warnings, or making a character DM the player.
| Param | Type | Description |
|---|---|---|
actorId | string | Recipient actor id (from contact.list); pass 'user' to deliver to the player (visibly, into their inbox)* |
message | string | Message body, up to 2,000 chars* |
fromActorId | string | Use an existing actor as the sender (e.g. have a character DM the player); if set, the three from* fields below are ignored |
fromName | string | Sender display name (for anonymous scenes pass 'Unknown number' / 'Anonymous informant') · default This app's name |
fromAvatar | string | Sender avatar URL |
fromPersona | string | Sender persona; if omitted, a neutral template is used: a contact from this app who reached the recipient via this message |
triggerReply | boolean | Whether the recipient (NPC) reacts to the message immediately (requires NPC-to-NPC chat depth enabled; no effect when delivering to user) · default false |
Promise<{ status: 'ok' | 'blocked'; delivered: boolean }>const contacts = await Shine.contact.list();
const cop = contacts.find(c => c.name === 'Officer Zhang');
// 1) Anonymous dark-web tip to an NPC: custom sender identity, invisible to the user
await Shine.actor.notify({
actorId: cop.id,
message: 'There is a deal at the east warehouse tonight. Move fast.',
fromName: 'Unknown number',
fromPersona: 'A mysterious informant of unknown identity. Speaks tersely and never reveals who they are.',
});
// 2) Deliver to the player: the message visibly lands in their WeTalk inbox
await Shine.actor.notify({
actorId: 'user',
message: '[System] You received an anonymous threat letter…',
fromName: 'Anonymous letter',
});
// 3) Have an existing character DM the player (actor → user)
await Shine.actor.notify({
actorId: 'user',
fromActorId: cop.id,
message: 'It\'s me, Zhang. There is something I have to tell you right now.',
});Read text aloud. Two ways to pick a voice: voiceId uses a voice from the platform's voice catalog (narration, system announcements, apps opened directly from the store — no story or character needed); actorId uses that story character's own voice (voice_config; falls back to a gender-based default when unconfigured). In the native container playback is handled by the native player (just_audio) streaming mp3 as it downloads — faster first sound, more stable; background music is auto-ducked during playback and restored afterwards. Reuses the platform's MiniMax TTS; identical text+voice hits the cache and isn't billed again.
Shine.tts.listVoices({ gender?, language? })asyncGet the platform voice catalog — with an id you can speak right away, no story or character needed.
| Param | Type | Description |
|---|---|---|
gender | "male" | "female" | Only voices of a given gender |
language | string | Language code zh/yue/en/ja/ko; 'all' = every language; omitted = Chinese family |
Promise<Array<{ id: string; name: string; gender: string; tags: string[]; languages: string[]; previewUrl: string | null }>>const voices = await Shine.tts.listVoices({ gender: 'female' });
narratorVoiceId = voices[0].id; // Let the user pick one in settings and save it
await Shine.tts.speak({ voiceId: narratorVoiceId, text: 'Night falls, and you push open the door.' });Shine.tts.speak({ voiceId | actorId, text, speed?, pitch?, languageBoost? })asyncSynthesize and play (only one at a time; a new call interrupts the old one). The Promise resolves when playback ends.
| Param | Type | Description |
|---|---|---|
voiceId | string | Platform voice id (see listVoices); mutually exclusive with actorId — if given, actorId is ignored |
actorId | string | id returned by contact.list(), same as for actor.speak; uses the character's own voice |
text | string | Text to read, up to 10,000 chars; tags / stage directions are cleaned before synthesis* |
speed | number | Speed 0.5–2.0; omitted = the voice's own setting |
pitch | number | Pitch -12–12; omitted = the voice's own setting |
languageBoost | string | Language boost · default "Chinese" |
Promise<{ audioUrl: string; durationMs: number; usageCharacters: number; cached: boolean; cost: number }>// Narration: no story or character needed
await Shine.tts.speak({ voiceId: narratorVoiceId, text: 'On the morning of the third day, the rain stopped.', speed: 0.9 });
// Character line: in their own voice
const reply = await Shine.actor.speak({ actorId: him.id, message: 'Your move' });
await Shine.tts.speak({ actorId: him.id, text: reply });Shine.tts.synthesize({ voiceId | actorId, text, speed?, pitch?, languageBoost? })asyncResolve a playable URL without playing it — for custom playback or prefetching.
| Param | Type | Description |
|---|---|---|
voiceId | string | Platform voice id; mutually exclusive with actorId |
actorId | string | contact.id, uses the character's own voice |
text | string | Text to read, up to 10,000 chars* |
speed | number | Speed 0.5–2.0 |
pitch | number | Pitch -12–12 |
languageBoost | string | Language boost · default "Chinese" |
Promise<{ audioUrl: string; durationMs: number; usageCharacters: number; cached: boolean; cost: number }>const { audioUrl } = await Shine.tts.synthesize({ voiceId: narratorVoiceId, text: 'Steady. Read the room.' });
new Audio(audioUrl).play();Shine.tts.stop()syncImmediately stop the audio currently played by speak (if any).
void
Background music (BGM). The native container hands playback to the native player: it starts without a user gesture, and Shine.tts.speak automatically ducks the BGM while speaking and restores it afterwards. Browser preview falls back to <audio> (which may require a first gesture to start).
Shine.audio.playBgm({ url, loop? })asyncLoop background music (only one track at a time; calling again switches tracks).
| Param | Type | Description |
|---|---|---|
url | string | Audio URL (mp3 etc.); you can also pass a plain string as the url* |
loop | boolean | Whether to loop · default true |
Promise<boolean>
Shine.audio.playBgm({ url: 'https://.../bgm.mp3' });Shine.audio.stopBgm()asyncStop the background music.
Promise<boolean>
Native device capabilities. Only available inside the Flutter container; browsers reject.
Shine.media.pickImage(opts?)asyncPick an image from the photo library or take a photo.
| Param | Type | Description |
|---|---|---|
source | "album" | "camera" | "both" | With both, a bottom sheet lets the user choose · default "both" |
Promise<string | null> — public OSS URL; null if the user cancels
Shine.media.recordVoiceText(opts?)asyncHold-to-talk recording, returning the speech-recognition text.
| Param | Type | Description |
|---|---|---|
maxDuration | number | Max recording seconds, hard limit 180 · default 60 |
Promise<string | null> — recognized text; null on cancel or empty input
Container visibility events (aligned with mini-program onShow/onHide).
Shine.lifecycle.onShow(callback)syncFires when the MiniApp becomes visible.
| Param | Type | Description |
|---|---|---|
callback | () => void | Callback* |
void
Shine.lifecycle.onHide(callback)syncFires when the MiniApp becomes hidden.
| Param | Type | Description |
|---|---|---|
callback | () => void | Callback* |
void
Shine.lifecycle.off(event, callback?)syncRemove a listener.
| Param | Type | Description |
|---|---|---|
event | "show" | "hide" | Event name* |
callback | () => void | Omit to clear all listeners for the event |
void
Offline mini-theater: persist content produced by the MiniApp into the current offline session and trigger a "new story" beat. Persist + backend notification, no navigation — the user gets a "new story" prompt and enters to continue on their own. Requires launch from a story session.
Shine.scene.inject(opts)asyncPersist content into the current offline session and trigger a "new story" notification.
| Param | Type | Description |
|---|---|---|
visible | string | Visible content: shown on the offline timeline as narration / scene description, and triggers the new-story notification |
hidden | string | Hidden content: enters the prompt so NPCs know, but shows no bubble (director notes / foreshadowing / mini-game results) |
orderId | string | Idempotency key; the same orderId persists only once (guards against double-taps / retries) |
Promise<{ status: string; injectedVisible: boolean; injectedHidden: boolean; idempotent: boolean }>// Won the key in a mini-game → persist a new story beat; the user gets a "new story" prompt and continues offline on their own
await Shine.scene.inject({
visible: 'Clutching the copper key you just won, you push open the long-sealed wooden door.',
hidden: '[Director] The player won the cellar key in a mini-game. NPCs don\'t know yet — build suspense.',
orderId: 'scene-' + Date.now(),
});Read-only queries on platform stars (no charging). Policy: any app that uses billed capabilities (ai.chat / ai.image / actor.speak / tts.speak) just fills in the "stars consumption disclosure" on the release form truthfully — the platform shows a unified disclosure dialog before the user enters (with live unit prices attached automatically). Don't add your own launch disclosure dialog in the app (see "AI usage rules"; reviewers check this).
Shine.credits.getBalance()asyncRead the current user's spendable stars balance.
Promise<{ total: number; free: number; membership: number; permanent: number }>const bal = await Shine.credits.getBalance();
if (bal.total < 2) await Shine.ui.toast('Not enough stars');Shine.credits.getCost({ feature })asyncGet the estimated stars cost of one capability call.
| Param | Type | Description |
|---|---|---|
feature | "ai.chat" | "ai.image" | "actor.speak" | Capability identifier* |
Promise<{ feature: string; cost: number; currency: 'credits' }>WeTalk in-story virtual wallet (unit: cents), unrelated to platform stars. Requires launch from a story session.
Shine.wallet.getBalance()asyncRead the current user's WeTalk wallet balance in this session.
Promise<{ balanceCents: number; balanceYuan: number }>Shine.wallet.pay(opts)asyncCharge the wallet (a payment confirmation dialog is shown first).
| Param | Type | Description |
|---|---|---|
orderId | string | Idempotency key (≤36 chars); reuse the same value for the same transaction so retries don't double-charge. Don't concatenate a UUID into it (it would exceed the limit and be rejected by the backend)* |
amountCents | number | Amount in cents, positive integer* |
note | string | Purpose description, written to the bill |
Promise<{ status: 'ok' | 'cancelled'; charged?: boolean; balanceCents: number; balanceYuan: number }>// orderId is an idempotency key per user within this app, ≤36 chars; a hyphenated slug is fine
const res = await Shine.wallet.pay({
orderId: 'shop-sword-001',
amountCents: 990,
note: 'Buy the iron sword',
});
if (res.status === 'cancelled') return;Shine.wallet.refund(opts)asyncRefund the charge of a given orderId.
| Param | Type | Description |
|---|---|---|
orderId | string | The orderId to refund (≤36 chars)* |
Promise<{ status: string; refundedCents: number; balanceCents: number; balanceYuan: number }>Shine.wallet.credit(opts)asyncCredit the user's WeTalk wallet (virtual-wallet "top-up / reward"; no confirmation dialog).
| Param | Type | Description |
|---|---|---|
orderId | string | Idempotency key (≤36 chars); reuse the same value for the same credit so retries don't double-credit* |
amountCents | number | Amount in cents, positive integer* |
note | string | Purpose description, written to the bill |
Promise<{ status: 'ok'; credited: boolean; balanceCents: number; balanceYuan: number }>WeTalk: send a card into the current session's DM. Reuses the platform's existing link-card rendering; requires launch from a story session.
Shine.wechat.sendCard(opts)asyncSend a card, as the user, into the WeTalk DM with a given contact.
| Param | Type | Description |
|---|---|---|
to | string | Recipient contact's actor id (from contact.list)* |
title | string | Card title* |
desc | string | Card body / summary |
subtitle | string | Subtitle |
imageUrl | string | Thumbnail URL |
footer | string | Footer source label; defaults to the app name |
triggerReply | boolean | Whether the recipient NPC replies after sending (default true, costs stars) |
Promise<{ status: 'ok' | 'blocked'; sent: boolean }>const contacts = await Shine.contact.list();
await Shine.wechat.sendCard({
to: contacts[0].id,
title: "Today's fortune: Excellent",
desc: 'Great day for travel and confessions; avoid staying up late.',
footer: 'Fortune mini-app',
});All async methods reject(Error) on failure; both the native container and direct HTTP turn the backend detail into a readable message.
Billed APIs (ai.chat / ai.image / actor.speak / tts.speak) throw on insufficient balance or declined authorization; story capabilities (actor / contact / wallet / scene) reject immediately without a sessionId; media.* rejects in the browser.
try {
const reply = await Shine.actor.speak({ actorId, message: 'Hello' });
} catch (e) {
await Shine.ui.toast(e.message || 'Request failed');
}