# Build SDK Index

Version: 1.47.0
Updated: 2026-09-21
Generated: 2026-09-21T08:06:52.743Z

## Notes
- This SDK is injected into Build iframes via the Build preview/runtime.
- Widgets call SDK methods; the parent proxies to the API.
- Data API methods require scoped tokens handled by the parent; some namespaces include write methods.
- Use Twinkle.privateDb for LOW-frequency durable private per-user state such as preferences, drafts, settings, inventory checkpoints, and saved progress. It is NOT for high-frequency or per-frame/per-tick writes; the server rate-limits writes and returns 429.
- Match storage to update frequency: privateDb and sharedDb are for LOW-frequency durable state that changes on a user action. NEVER write per-frame/per-tick state to them (camera or cursor position, animation, live movement, presence, autosave every frame/tick). Keep live state in client memory, broadcast realtime/presence via Twinkle.world, and flush only occasional durable snapshots (on an interval or on exit, never per frame). The server enforces per-key write rate limits and returns 429 on excess; never retry-loop a 429.
- Use Twinkle.userDb only for advanced private SQLite needs such as tables, indexes, many rows, filtered queries, or aggregates.
- Use Twinkle.leaderboards for public Build scoreboards. Signed-in viewers are ranked by Twinkle username; guests can submit with a display name.
- Use Twinkle.news to read the globally shared Twinkle Daily edition, browse canonical daily archives and preserved successful press runs, or let a signed-in viewer queue today's edition. The server permits only one canonical edition per Twinkle day for ordinary viewers. In the canonical Twinkle Newspaper app, its current owner may explicitly refresh today's ready edition, appending a revision while keeping the newest successful press run canonical. A model-backed edition consumes AI Energy from the signed-in viewer whose request creates, retries, or refreshes that job; deduplicated observers and quiet editions with no editorial model call do not consume Energy.
- Use Twinkle.sharedDb for LOW-frequency durable shared multi-user state such as guestbooks, votes, room settings, submitted records, and append-only run history. It is NOT for high-frequency or per-frame/per-tick writes; keep live/realtime state in Twinkle.world or client memory. The server rate-limits writes and returns 429.
- Use Twinkle.subjects.search for in-app subject pickers. Twinkle.mount remains an optional host-provided preselection/context shortcut, not a data API.
- Use Twinkle.aiCards for read-only existing public AI Card words and example texts, including word levels for typing games.
- Use Twinkle.aiStories for read-only existing AI Story galleries, readers, quizzes, topic chapter indexes, and remix tools.
- Use Twinkle.grammarbles for public Grammarbles question-bank trainer apps and optional signed-in viewer attempt-history filtering.
- Use Twinkle.chess for chess engine play and analysis; app code still owns chess rules, legal moves, board state, and UI.
- Use Twinkle.world for realtime multiplayer rooms, avatar presence, movement, emotes, and lightweight actions; world sessions are disposable and durable MMO state belongs in sharedDb/privateDb.
- Every Twinkle-owned profilePicUrl field returned by the SDK is an absolute HTTPS URL ready for img src, or null. Fields inside app-owned JSON such as sharedDb entry data are not rewritten.
- Use Twinkle.characters.chat for real Zero/Ciel NPC dialogue with shared room context and AI Energy-aware thinking modes.
- Twinkle.ai.chat history entries must use { role, content }; map local message.text fields to content before passing history.
- Live web search is enabled by default for Twinkle.ai.chat and for Medium/High Twinkle.ai.generateObject and Twinkle.characters.chat requests. App authors can pass webSearch: false to disable it for their app. Search uses the provider's live web-search tool and is included in AI Energy usage; structured and character Lite Mode remains tool-free.
- Mobile long-press must not select game UI or open browser Copy/Look Up/image menus. The SDK provides no-selection/callout defaults for standard buttons, button-like ARIA controls, and canvases. Mark the entire custom gameplay wrapper data-twinkle-no-select, including HUD, labels, scores, menus, controls, and empty play space; also style it with user-select: none, -webkit-user-select: none, and -webkit-touch-callout: none for local previews. Protecting only one button or the canvas is insufficient. This behavior is independent of Twinkle.preview layout mode.
- Keep inputs/contenteditable usable. Mark genuinely copyable story/chat/user-written text data-twinkle-selectable and style it with user-select: text, -webkit-user-select: text, and -webkit-touch-callout: default. SDK defaults have low specificity so existing explicit copyable-text styles remain effective. Preserve document/reader selection; do not block document-wide touch/pointer events or disable scrolling/zoom to prevent selection.
- Verify mobile long presses on controls, HUD, and empty play space in Safari and Chromium; confirm held controls still work and release/cancel correctly, and scrolling, typing, and copying still work. lumine check only detects a missing no-selection rule, not selector coverage or mobile behavior.
- Build app tab mute is enforced by the host runtime automatically for standard media elements and Web Audio connections to AudioContext.destination. Apps with custom audio engines can also observe Twinkle.onAudioMuteChange and check Twinkle.isAudioMuted.
- Use Twinkle.media for camera photos and camera-only two-second clips. Twinkle confirms each capture or paid processing action. Clips are processed to canonical 480p MP4 assets before they become visible; use sharedDb or privateDb to publish/store the returned asset metadata.
- Static media published through sharedDb is app-owned feed data. A public user-generated feed must provide a visible report flow and owner removal, and must not claim that Twinkle globally moderates those posts.
- Use Twinkle.live for one-way app livestreams and Twinkle.chat for the accompanying thread. Free livestreams require a verified host, end after at most 15 minutes, and issue at most 10 private viewer grants. Twinkle keeps platform-owned live-status/end controls above active hosts, so app code cannot hide or replace the broadcaster's Stop path.
- Media Energy is separate from AI Energy. Replace Media Energy UI only from canonical mediaEnergy/getUsage responses; never decrement, reserve, or synthesize it in app code.
- Twinkle.rewards awards real XP and Coins only in the current approved published release. Drafts, local previews, private apps and superseded releases cannot earn. The server supplies a published-runtime grant; app code cannot choose a recipient or award amount.
- The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (userDailyXP, userDailyCoins, optional userDailyClaims; there is no app-wide daily or lifetime budget, only what one learner can earn per day) and rules [{ id, title, xp, coins, verifier: 'numeric-quiz' | 'completion', maxAttempts?, retry?: { xpPercent, coinsPercent, paidAttempts? }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz) }]. Wire the matching Twinkle.rewards calls with those literal rule ids. Questions and answer keys NEVER go in project files (published source is readable by every player): quiz rules get them from the private question sheet uploaded with `lumine rewards sheet <file.json>` ({ rules: { <ruleId>: { questions?, sets? } } }); `lumine rewards check` validates both together. A review request freezes the code and proposes rewards.json merged with the sheet; the administrator reads the code, checks the amounts and whether the app is exploitable, may change any amount, and approves. Creators are kids and teens: show approval status and one Send for review action; do not ask them to fill in technical forms. Every code update that retains rewards needs a new approval before publishing. Removing the SDK automatically clears its gate. Apps read amounts, tries and sets from getStatus, never from their own file.
- Verifiers: 'numeric-quiz' pays for server-checked numeric answers (retry share, attempt limits, dated sets or until-earned sets that stay up until somebody earns them, after-answer guides). 'completion' pays when the app reports an activity finished — a cleared stage, a finished round — at least minSeconds after start({ ruleId }); the server checks only the elapsed time, once per learner per site day (UTC midnight), and the budgets. Call start when the activity begins and claim({ challengeId }) with no answers when it ends; keep completion amounts and userDailyXP small enough that a player scripting the calls would not matter, because nothing else is verified.
- Numeric quiz answers are verified on the server; client scores, privateDb state, timers and completion booleans are not verified reward evidence. Limits reset at UTC midnight. Rules are earned once per viewer per UTC day; attempt limits and retry payouts come from the approved rule. Challenges expire at the UTC day boundary. Budgets apply across release changes.
- Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release; completionProof: classic-tower-v1 requires a server-simulated Classic Tower finish in addition to minSeconds. These are server-enforced controls. Existing completion rules without completionProof still verify elapsed time only. Registered proof profiles also include breadface-v1 (server-simulated Breadface inputs) and study-record-v1 (a private study record reviewed by JEV, billed to the learner’s AI Energy). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.

## AI decision design
- When planning a new app or an improvement, consider whether model-based judgments would materially improve the requested experience. Twinkle.ai.decide runs JEV, a model for narrow decisions over supplied text or structured state. Potential uses include interpreting a player's request to an NPC, choosing among legal game actions, classifying user content, ranking supplied candidates, and adapting an activity to evidence about the learner. These are examples to reason from, not a keyword checklist or a requirement to add AI to every app.
- Choose the mechanism by the work: use ordinary code for known rules, arithmetic, physics, legal moves, storage, and permissions; use ai.decide for contextual choices, yes/no probabilities, and scores; use ai.chat or ai.generateObject for generated dialogue, explanations, open-ended content, and multi-step reasoning. JEV does not generate text, browse the web, inspect images, or supply missing app data. Combine these tools when it benefits the actual request, without silently turning every decision into a more expensive text-model call.
- Before adopting JEV, identify the concrete decision, the context available at that moment, the allowed outcomes, and why latency plus AI Energy use are worthwhile. Use your model judgment to make that choice. Do not substitute regex or keyword matching for understanding human-language intent. Explain the user-visible benefit simply; the creator does not need to select a provider, enter an API key, or design question schemas.
- Send related independent questions about the same state together in one ai.decide request (up to 32). Write complete instructions and distinct option or level descriptions. Include an other/uncertain option when the choices might not cover the input. A second call is useful only when it needs newly obtained context or choices that depend on the first result; answers in one batch do not see each other.
- Keep code in control. Choice returns an option and its probability distribution; score returns a position on the declared levels; noul is the probability of yes, with 0.5 meaning uncertainty rather than medium strength. Confidence describes the distribution and is not proof of correctness. Test the app's criteria and thresholds on clear, ambiguous, and out-of-scope examples, and provide a suitable uncertain outcome. JEV decisions never authorize XP, Coins, purchases, access, or other server-owned state; use the appropriate existing SDK and server approval paths.
- Call at meaningful user actions or discrete app decision points, not every render, animation frame, keystroke, or background poll. Batch work, debounce changing input, keep one request active per decision flow, and discard results whose app state changed while waiting. Keep rendering, movement, and controls local and responsive. Signed-in viewers use AI Energy based on measured JEV cost. Handle unavailable, timeout, exhausted Energy, and rate-limit errors with a clear retry or an explicitly local fallback; never fabricate an AI answer or run an automatic retry loop. Consider the guest and offline experience before making AI essential.

## Token Scopes
files:read, media:read, media:write, live:read, live:write, user:read, users:read, dailyReflections:read, content:read, content:write, sharedDb:read, sharedDb:write, privateDb:read, privateDb:write, files:write, chat:read, chat:write, notifications:read, notifications:write, notifications:emit, reminders:read, reminders:write, rewards:claim

## Namespaces

### Twinkle
- isAudioMuted() | scopes: none
  - Returns: boolean
  - Returns whether the host runtime currently has this Build app tab muted.
  - The host automatically mutes standard <audio>/<video> elements and Web Audio nodes connected to AudioContext.destination.
  - Use this only when your app manages audio outside those standard paths.
  - Example: if (Twinkle.isAudioMuted()) pauseCustomMixerOutput();
- onAudioMuteChange(listener, options?) | scopes: none
  - Returns: unsubscribe function
  - Subscribe to host tab mute changes for custom audio engines.
  - The listener receives the current muted boolean immediately by default.
  - Pass { immediate: false } to skip the initial callback.
  - Example: const unsubscribe = Twinkle.onAudioMuteChange((muted) => customMixer.setMuted(muted));

### Twinkle.capabilities
- async get() | scopes: none
  - Returns: Capability snapshot
  - Includes viewer state, available namespaces, blocked writes, and Lumine action permissions.
- async can(actionName) | scopes: none
  - Returns: boolean
  - Checks whether a named Lumine action is allowed in the current context.
- async listActions() | scopes: none
  - Returns: { available, blocked, details }
  - Returns the current Lumine action permission map.
- async refresh() | scopes: none
  - Returns: Capability snapshot
  - Forces a fresh fetch from the parent.

### Twinkle.viewer
- async get() | scopes: none
  - Returns: { id, username, profilePicUrl, isLoggedIn, isOwner, isGuest }
  - Cached; use refresh() to re-fetch.
- async refresh() | scopes: none
  - Returns: Viewer info
  - Forces a fresh fetch from the parent.

### Twinkle.app
- async getInfo() | scopes: none
  - Returns: App info object from the parent (includes appUrl) or null
  - Cached after the first call for the iframe session.
- async getShareUrl(pathSegment) | scopes: none
  - Returns: Canonical shareable deep-link URL string, or null when app info is unavailable
  - Builds a canonical shareable deep link into this app, e.g. https://www.twin-kle.com/app/884/432-the-great-gatsby.
  - Example: await Twinkle.app.getShareUrl('432-the-great-gatsby');
- history.getState() | scopes: none
  - Returns: The current app-owned history state object, or null
  - Read the current Build app view state stored through Twinkle.app.history.
  - History state is local to this iframe session and never changes the parent Twinkle URL.
  - Use this for archive/detail/page state inside one Build document; use navigate() to load another project file.
- history.push(state) | scopes: none
  - Returns: A JSON-cloned copy of the stored state
  - Add a confirmed in-app view transition to browser history so Back stays inside the Build app.
  - State must be a JSON-serializable object no larger than 16 KB.
  - Push only after the requested view has loaded successfully; do not synthesize server-owned state.
  - Example: Twinkle.app.history.push({ view: 'edition', dayIndex: 2080, page: 'scores' });
- history.replace(state) | scopes: none
  - Returns: A JSON-cloned copy of the stored state
  - Replace the current in-app history entry without adding a Back step.
  - Use this to establish the initial confirmed view or reconcile a loading-only change.
- history.subscribe(listener, { immediate } = {}) | scopes: none
  - Returns: unsubscribe function
  - Restore app views when the viewer moves through browser Back or Forward history.
  - The listener receives a cloned app state object, or null for an entry not owned by this app.
  - The listener is called immediately by default; pass { immediate: false } to wait for Back or Forward.
  - Example: const off = Twinkle.app.history.subscribe((state) => restoreView(state), { immediate: false });
- async navigate(target) | scopes: none
  - Returns: { success, src }
  - Navigate to another Build preview route through the parent bridge without dropping Twinkle SDK access.
  - Use this for in-app Build preview/world switches instead of window.location.assign, location.replace, or setting location.href.
  - The parent validates that the target is still a Build preview URL before navigating.
  - External URLs are rejected and do not receive the Build bridge nonce.
  - Example: await Twinkle.app.navigate('./arena.html');
- async openContent(target) | scopes: none
  - Returns: { success, url }
  - Open a recognized Twinkle content page in the parent app from a viewer click or tap.
  - Call this directly from a user click or tap handler; calls without an active user action are rejected.
  - The trusted parent displays the canonical destination and requires viewer confirmation before navigating.
  - Only recognized Twinkle content URLs are accepted. The parent preserves its current signed-in origin when opening the content.
  - Use navigate() for routes inside the current Build and openContent() for Twinkle subjects, comments, apps, profiles, and other content pages.
  - Example: await Twinkle.app.openContent('https://www.twin-kle.com/subjects/432');

### Twinkle.appTools
- async register({ handlers }) | scopes: none
  - Returns: { success, session } when opened by lumine app-mcp
  - Register the live iframe handlers for the published app's static MCP tool manifest.
  - Tool discovery comes only from /app-tools.json in the pinned published artifact; runtime code cannot add or rename tools.
  - Every declared tool needs a same-named handler. Outside an app-mcp session, registration stores the handlers and resolves with active:false without starting a relay.
  - Handlers run serially inside the visible app iframe and should return the confirmed post-action state.
  - Do not synthesize server-owned state; await Twinkle SDK mutations before returning.
  - Example: await Twinkle.appTools.register({ handlers: { get_state: () => ({ view, data }), open_view: ({ view }) => navigateTo(view) } });

### Twinkle.preview
- getLayout() | scopes: none
  - Returns: { mode, viewport, stage, safeInsets, playfield }
  - Read the host preview layout and derive a fixed-world scale from layout.playfield before sizing a canvas, sprites, or mobile game UI.
  - Always available in the build iframe.
  - Returns the current preview geometry, including viewport size, current stage size/scale, reserved safe insets, and the usable playfield rectangle.
  - Use this as the source of truth for canvas/game sizing instead of guessing from raw window size alone.
  - For fixed-world games, derive one scale from layout.playfield and apply it consistently to sprites, UI, and movement.
  - Do not subtract guessed HUD or chrome heights from the viewport when layout.playfield already represents the usable gameplay area.
  - Example: const WORLD = { width: 360, height: 640 }; const layout = Twinkle.preview.getLayout(); const scale = Math.min(layout.playfield.width / WORLD.width, layout.playfield.height / WORLD.height);
- reserveInsets({ top, right, bottom, left }) | scopes: none
  - Returns: { mode, viewport, stage, safeInsets, playfield }
  - Reserve host-aware safe space for HUD bars, touch controls, or other overlays before clamping gameplay.
  - Always available in the build iframe.
  - Reserves in-app safe space for overlays such as HUD bars or touch controls.
  - After reserving insets, clamp gameplay to playfield instead of the raw canvas or stage edge.
  - Example: Twinkle.preview.reserveInsets({ top: 72, bottom: 120, left: 0, right: 0 });
- setPlayfield({ x, y, width, height } | null) | scopes: none
  - Returns: { playfieldBounds, playerBounds, overflowTop, overflowRight, overflowBottom, overflowLeft, status, reportedAt } | null
  - Declare the actual playable rectangle when the game area is smaller than the raw canvas.
  - Always available in the build iframe.
  - Declares the game or canvas playfield bounds in the app's own coordinate space.
  - Use this when the playable area is smaller than the raw canvas because of HUD, touch controls, or other reserved regions.
  - Do not pass DOM screen pixels from getBoundingClientRect(); use the same in-game coordinate space as your world and player logic.
  - Example: Twinkle.preview.setPlayfield({ x: layout.playfield.x, y: layout.playfield.y, width: layout.playfield.width, height: layout.playfield.height });
- reportGameplayState({ playfieldBounds?, playerBounds? } | null) | scopes: none
  - Returns: { playfieldBounds, playerBounds, overflowTop, overflowRight, overflowBottom, overflowLeft, status, reportedAt } | null
  - Report live player or avatar bounds so the preview host can detect floor, wall, or out-of-bounds issues.
  - Always available in the build iframe.
  - Reports live gameplay bounds in the same coordinate space as setPlayfield.
  - Use this for moving players or avatars so preview review and auto-fix can detect floor or wall escapes.
  - Do not report viewport-relative or screen-pixel rectangles; keep telemetry in stable game/world coordinates.
  - Example: Twinkle.preview.reportGameplayState({ playerBounds: { x: player.x, y: player.y, width: player.width, height: player.height } });
- getGameplayTelemetry() | scopes: none
  - Returns: { playfieldBounds, playerBounds, overflowTop, overflowRight, overflowBottom, overflowLeft, status, reportedAt } | null
  - Read the latest preview-side gameplay telemetry snapshot.
  - Always available in the build iframe.
  - Returns the latest gameplay telemetry snapshot known to the preview host.
- clearGameplayState() | scopes: none
  - Returns: { playfieldBounds, playerBounds, overflowTop, overflowRight, overflowBottom, overflowLeft, status, reportedAt } | null
  - Always available in the build iframe.
  - Clears previously reported playfield and player telemetry.
- clearReservedInsets() | scopes: none
  - Returns: { mode, viewport, stage, safeInsets, playfield }
  - Always available in the build iframe.
  - Clears previously reserved safe insets.
- subscribe(listener, { immediate } = {}) | scopes: none
  - Returns: unsubscribe()
  - Listen for host layout changes so a fixed-world game scale or canvas surface stays synced after resize, mobile viewport changes, or embedded runtime layout shifts.
  - Always available in the build iframe.
  - Subscribes to preview layout changes such as resize, host fit changes, or inset updates.
  - Use this when the app needs to keep a canvas or playfield synced with the host preview.
  - Re-apply world scale here so embedded ContentPanel and Build Studio runtime stay aligned.
  - Example: const unsubscribe = Twinkle.preview.subscribe((layout) => syncGameLayout(layout), { immediate: true });

### Twinkle.mount
- async get() | scopes: none
  - Returns: { type: 'subject', id: number } | null
  - Always available.
  - Returns the host-provided mount context, such as a subject mounted into a book app.
  - Does not fetch subject metadata, comments, or files. Use Twinkle.subjects and Twinkle.subjectComments for data reads.
- async refresh() | scopes: none
  - Returns: { type: 'subject', id: number } | null
  - Forces a fresh mount context read from the parent frame.

### Twinkle.notifications
- getLaunchTarget() | scopes: none
  - Returns: { notificationId, buildId, eventKey, eventLabel, target, payload? } | null
  - Read the current notification launch target, if the app was opened from a Build notification.
  - Always available.
  - Use target.focus for precise in-app jumping, such as focusing a sharedDb entry.
- onLaunchTarget(listener, { immediate } = {}) | scopes: none
  - Returns: unsubscribe()
  - Listen for notification launch targets while the Build app is already open.
  - Always available.
  - By default, immediately calls the listener with the current launch target when one exists. Pass { immediate: false } to only receive future targets.
  - Example: const off = Twinkle.notifications.onLaunchTarget((launchTarget) => focusEntry(launchTarget?.target?.focus?.entryId));
- async getSubscription(channelKey, { targetKey }) | scopes: notifications:read
  - Returns: { subscription }
  - Read whether the current viewer is subscribed to an app-defined notification channel target.
  - channelKey identifies the app-defined notification class, such as room.message or leaderboard.dethroned.
  - targetKey is a stable app-defined string identity, such as room:lobby, board:daily, or user:123.
  - Example: const { subscription } = await Twinkle.notifications.getSubscription('room.message', { targetKey: 'room:lobby' });
- async subscribe(channelKey, { targetKey, launchTarget }) | scopes: notifications:write
  - Returns: { subscription }
  - Subscribe the current viewer to an app-defined notification channel target.
  - launchTarget is optional app-defined JSON delivered back through Twinkle.notifications launch targets.
  - target is accepted as a backwards-compatible alias for launchTarget.
  - Example: await Twinkle.notifications.subscribe('room.message', { targetKey: 'room:lobby', launchTarget: { view: 'room', roomId: 'lobby' } });
- async subscribeMany([{ channelKey, targetKey, launchTarget }]) | scopes: notifications:write
  - Returns: { subscriptions }
  - Subscribe the current viewer to multiple app-defined notification channel targets in one request.
  - Accepts up to 100 subscriptions per request.
  - target is accepted as a backwards-compatible alias for launchTarget on each item.
  - Example: await Twinkle.notifications.subscribeMany([{ channelKey: 'room.message', targetKey: 'room:lobby', launchTarget: { view: 'room', roomId: 'lobby' } }]);
- async unsubscribe(channelKey, { targetKey }) | scopes: notifications:write
  - Returns: { subscription: null }
  - Unsubscribe the current viewer from an app-defined notification channel target.
  - Example: await Twinkle.notifications.unsubscribe('room.message', { targetKey: 'room:lobby' });
- async unsubscribeMany([{ channelKey, targetKey }]) | scopes: notifications:write
  - Returns: { subscriptions: [], removed }
  - Unsubscribe the current viewer from multiple app-defined notification channel targets in one request.
  - Accepts up to 100 subscriptions per request.
  - Example: await Twinkle.notifications.unsubscribeMany([{ channelKey: 'room.message', targetKey: 'room:lobby' }]);
- async notifySubscribers(channelKey, { targetKey, eventKey, label, summary, launchTarget, payload }) | scopes: notifications:emit
  - Returns: { sent }
  - Notify viewers who opted into an app-defined channel target, without requiring a sharedDb write.
  - Only current subscribers to the same build, channelKey, and targetKey are considered.
  - The actor is never notified about their own emit.
  - title/body are accepted as aliases for label/summary.
  - target is accepted as a backwards-compatible alias for launchTarget.
  - Twinkle applies app API rate limits, a stricter notification emit rate limit, and existing notification mutes.
  - Example: await Twinkle.notifications.notifySubscribers('room.message', { targetKey: 'room:lobby', eventKey: 'room.message.created', label: 'Room messages', summary: 'posted in Lobby', launchTarget: { view: 'room', roomId: 'lobby', messageId } });
- async getSubjectUpdateSubscription(subjectId) | scopes: notifications:read
  - Returns: { subscription }
  - Read whether the current viewer is subscribed to Build notifications for updates to a subject.
  - Returns the current viewer's subscription for this Build and subject, or null.
  - Example: const { subscription } = await Twinkle.notifications.getSubjectUpdateSubscription(subjectId);
- async subscribeToSubjectUpdates(subjectId, { target } = {}) | scopes: notifications:write
  - Returns: { subscription }
  - Subscribe the current viewer to notifications when the original subject author adds a new page or update.
  - target is app-defined JSON delivered back through Twinkle.notifications launch targets.
  - The server sends notifications from the canonical subject comment write path when the subject author adds a page.
  - Example: await Twinkle.notifications.subscribeToSubjectUpdates(subjectId, { target: { view: 'book', subjectId } });
- async unsubscribeFromSubjectUpdates(subjectId) | scopes: notifications:write
  - Returns: { subscription: null }
  - Unsubscribe the current viewer from Build notifications for a subject's new pages or updates.

### Twinkle.chess
- async bestMove({ fen, depth?, skillLevel?, maxTimeMs?, timeoutMs?, multiPv? }) | scopes: none
  - Returns: { success, move, bestMove, from, to, promotion, evaluation, depth, mate, lines: [{ rank, move, from, to, promotion, evaluation, mate, depth, pv }], error, engine }
  - Ask the parent-hosted Stockfish engine for the best move from a FEN position.
  - Always available in the build iframe.
  - Stockfish runs in a parent-managed worker with bounded depth, timeout, and serialized requests.
  - Use skillLevel 0-20 for simple difficulty selection, or depth 1-24 for explicit search depth.
  - skillLevel 20 defaults to the strongest bounded search budget.
  - maxTimeMs and timeoutMs are clamped between 500 and 60000 milliseconds.
  - This returns engine analysis only. Use app code or a chess rules library to validate legal moves, manage board state, detect game over, and render the board.
  - multiPv (1-10, default 1) returns the top N moves as lines, ranked best first, all from the same search (a movetime stop can leave a line one iteration behind; keep the engine's rank order rather than re-sorting by score). Compare line evaluations against each other to grade a candidate move; do not compare evaluations from separate searches of different positions.
  - evaluation and line evaluations are centipawns from the side to move's point of view; mate is a signed mate distance (positive = side to move mates).
  - Example: const result = await Twinkle.chess.bestMove({ fen: game.fen(), skillLevel: 8, maxTimeMs: 1000 });
if (result.success) game.move({ from: result.from, to: result.to, promotion: result.promotion || undefined });
- async evaluate({ fen, depth?, skillLevel?, maxTimeMs?, timeoutMs?, multiPv? }) | scopes: none
  - Returns: { success, move, bestMove, from, to, promotion, evaluation, depth, mate, lines: [{ rank, move, from, to, promotion, evaluation, mate, depth, pv }], error, engine }
  - Analyze a FEN position and return Stockfish's current best move plus centipawn or mate evaluation.
  - Always available in the build iframe.
  - evaluation is the Stockfish centipawn score from the engine output when available; mate is the mate distance when Stockfish reports one.
  - Do not call this from a render loop, animation loop, or high-frequency polling path.
  - multiPv (1-10, default 1) returns the top N moves as lines, ranked best first, all from the same search (a movetime stop can leave a line one iteration behind; keep the engine's rank order rather than re-sorting by score). Compare line evaluations against each other to grade a candidate move; do not compare evaluations from separate searches of different positions.
  - evaluation and line evaluations are centipawns from the side to move's point of view; mate is a signed mate distance (positive = side to move mates).
  - Example: const analysis = await Twinkle.chess.evaluate({ fen: game.fen(), depth: 12 });
console.log(analysis.bestMove, analysis.evaluation, analysis.mate);

### Twinkle.files
- async saveAs({ fileName, url, dataUrl, data, text, json, bytes, blob, file, mimeType } = {}) | scopes: none
  - Returns: { success, fileName, size?, mimeType?, method }
  - Download a generated or remote file to the viewer's local device through the parent frame without opening a popup.
  - Local viewer download only; does not upload to Twinkle or consume AI Energy.
  - Use this for generated blobs/data/JSON/bytes or large/remote files that need parent-frame download handling.
  - For URL downloads, cross-origin URLs must be fetchable by browser CORS or Twinkle's CDN proxy so the parent frame can create a local Blob download.
  - Simple visible same-origin images can still use normal browser anchors with href and download.
  - Example: await Twinkle.files.saveAs({ fileName: 'fashion-guide.png', dataUrl: imageUrl, mimeType: 'image/png' });
- async uploadGenerated({ fileName, url, dataUrl, data, text, json, bytes, blob, file, mimeType } = {}) | scopes: files:write
  - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, mediaKind, durationMs, uploadedByUserId, createdAt }], failed?: [{ fileName, message }], canceled }
  - Upload an app-generated file to Twinkle-hosted cloud storage without opening a picker, then store the returned asset refs in sharedDb/privateDb/userDb.
  - Signed-in viewers only.
  - Uploads generated blobs, files, bytes, data URLs, or fetchable URLs to Twinkle-hosted cloud storage.
  - Video uploads are not supported right now.
  - Store the returned asset metadata in sharedDb/privateDb/userDb instead of storing raw file bytes in a DB record.
  - Example: const { assets } = await Twinkle.files.uploadGenerated({ fileName: 'fashion-guide.png', dataUrl: generatedImageUrl, mimeType: 'image/png' });
- async pickAndUpload({ accept, multiple } = {}) | scopes: files:write
  - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, mediaKind, durationMs, uploadedByUserId, createdAt }], failed?: [{ fileName, message }], canceled }
  - Pick supported local files and upload them to Twinkle-hosted cloud storage, then store the returned asset refs in sharedDb/privateDb/userDb.
  - Signed-in viewers only.
  - Uploads to Twinkle-hosted cloud storage and returns asset references.
  - Video uploads are not supported right now.
  - When multiple files are selected, successful uploads are still returned even if one later file fails.
  - Store the returned asset metadata in sharedDb/privateDb/userDb instead of storing raw file bytes in a DB record.
  - Example: const { assets, canceled } = await Twinkle.files.pickAndUpload({ accept: 'image/*,.pdf', multiple: true });
- async list({ cursor, limit } = {}) | scopes: files:read
  - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, mediaKind, durationMs, uploadedByUserId, createdAt }], nextCursor, usage: { totalBytes, fileCount, maxRuntimeFileStorageBytes, remainingBytes } | null }
  - List the current viewer's uploaded runtime files for this build.
  - Signed-in viewers only.
  - Lists the current viewer's ready uploads for this build only.
  - Example: const { assets, usage } = await Twinkle.files.list({ limit: 20 });
- async delete(assetId) | scopes: files:write
  - Returns: { success, deletedAssetId, usage: { totalBytes, fileCount, maxRuntimeFileStorageBytes, remainingBytes } | null }
  - Delete one of the current viewer's uploaded runtime files and free up Twinkle.files quota.
  - Signed-in viewers only.
  - Deletes one of the current viewer's uploaded runtime files and updates quota usage.
  - Example: await Twinkle.files.delete(assetId);

### Twinkle.media
- async capturePhoto({ facingMode?, maxWidth?, quality?, settleMs?, fileName? } = {}) | scopes: files:write
  - Returns: { asset, assets, failed }
  - Ask for camera permission, capture one JPEG photo, and upload it to the current viewer's Twinkle file storage.
  - Signed-in viewers only. Call from an explicit viewer action; Twinkle shows its own one-action confirmation before the browser may show camera permission.
  - The photo is saved in the viewer's Twinkle file storage. The returned asset is canonical server state and can be stored in sharedDb/privateDb/userDb.
  - Example: const { asset } = await Twinkle.media.capturePhoto({ facingMode: 'user' });
if (asset) await Twinkle.sharedDb.addEntry('photos', asset);
- async recordClip({ previewElement?, facingMode?, fileName?, waitForReady?, timeoutMs? } = {}) | scopes: media:write
  - Returns: { clip: { id, status, durationMs, failureCode, asset }, mediaEnergy }
  - Record a camera-only short video, upload it, and by default wait for the canonical two-second 480p MP4 asset.
  - Call from an explicit viewer action. Twinkle confirms each recording before requesting camera permission.
  - The recording is camera-only; use Twinkle.live when audio is part of the experience.
  - The server targets a two-second maximum input window and processes it to 480p MP4. Encoder frame boundaries can differ by one frame; app code cannot raise the limit.
  - By default this method polls confirmed server state until ready. Pass waitForReady: false to receive the processing ID immediately, then call getClip().
  - Example: const { clip } = await Twinkle.media.recordClip({ previewElement: '#cameraPreview' });
await Twinkle.sharedDb.addEntry('clips', clip.asset);
- async uploadClip({ file?, blob?, fileName?, mimeType?, requestId?, waitForReady?, timeoutMs? }) | scopes: media:write
  - Returns: { clip: { id, status, durationMs, failureCode, asset }, mediaEnergy }
  - Upload a generated or selected video through the same server-bounded two-second clip pipeline.
  - Call from an explicit viewer action. Twinkle confirms each selected or generated video before upload and paid processing.
  - Input is limited to 8 MB. The canonical output is a server-produced 480p MP4 targeting a two-second maximum, with at most a frame of encoder-boundary variance.
  - Use a stable requestId when retrying the same user action.
  - Example: const result = await Twinkle.media.uploadClip({ file: recordedFile });
- async getClip(assetId) | scopes: media:read
  - Returns: { clip: { id, status, durationMs, failureCode, asset }, mediaEnergy }
  - Load and reconcile canonical processing state for one of the current viewer's clips.
  - Example: const { clip } = await Twinkle.media.getClip(assetId);
- async listClips({ cursor?, limit? } = {}) | scopes: media:read
  - Returns: { assets, nextCursor, mediaEnergy }
  - List the current viewer's ready short clips for this Build app.
  - Example: const { assets } = await Twinkle.media.listClips({ limit: 20 });
- async getUsage() | scopes: media:read
  - Returns: { monthKey, resetsAt, global, user, build, energyPercent, energySegments, energySegmentsRemaining }
  - Load canonical current Media Energy for this viewer and app.
  - Replace displayed state only from this response or a newer mediaEnergy response. Never decrement or synthesize the battery locally.
  - global.carryoverMicroUsd accounts for reservations that crossed the UTC month boundary so the global reset cannot double the budget.
  - Example: const mediaEnergy = await Twinkle.media.getUsage();
renderBattery(mediaEnergy.energyPercent);

### Twinkle.live
- async start({ previewElement?, facingMode?, audio?, durationSeconds?, maxViewers?, saveReplay?, requestId? } = {}) | scopes: live:write
  - Returns: { session: { id, replayId, buildId, hostUserId, status, maxViewers, viewersGranted, durationSeconds, saveReplay, updatedAt, hardEndsAt }, mediaEnergy }
  - Create an IVS channel, attach the camera/microphone, begin broadcasting, and optionally save a seven-day replay.
  - Call from an explicit viewer action. Twinkle confirms each new broadcast before camera/microphone permission or paid channel creation.
  - saveReplay defaults to false. When true, the same action confirmation says the stream will be saved, and Twinkle records it to private storage for seven days after it becomes ready.
  - Replay storage is included in the live Media Energy reservation. Replay viewing has its own Media Energy reservation.
  - previewElement must be a canvas element or selector because the IVS Broadcast SDK draws a composited preview.
  - Free sessions broadcast at 854x480 and are capped server-side at 15 minutes and 10 private viewer grants. Lower durationSeconds/maxViewers values are allowed.
  - Twinkle confirms that a platform-owned live indicator and End stream action are present before returning broadcast credentials to the app, and keeps the control until server cleanup is canonically terminal. Fullscreen and Picture-in-Picture are unavailable while hosting so that Stop control stays visible.
  - The SDK does not include broadcast credentials in its returned value. Credentials are ephemeral, and the SDK stops local broadcasting at hardEndsAt while the API independently stops and deletes the IVS channel.
  - Example: const { session } = await Twinkle.live.start({ previewElement: '#broadcastPreview', audio: true });
await Twinkle.sharedDb.setKvItems('live', [{ key: 'current', value: session }]);
- async list() | scopes: live:read
  - Returns: Array<LiveSession>
  - List currently available livestream sessions for this Build app.
  - Only sessions canonically acknowledged as live are listed; channels still being prepared are never advertised to viewers.
  - Example: const sessions = await Twinkle.live.list();
- async get(sessionId) | scopes: live:read
  - Returns: LiveSession | null
  - Load canonical server status for a livestream in this Build app.
  - Example: const session = await Twinkle.live.get(sessionId);
- async watch(sessionId, { videoElement, requestId? }) | scopes: live:write
  - Returns: { session, viewerGrantId, mediaEnergy, playbackStarted }
  - Use a private single-use playback grant to attach a livestream to an HTML video element.
  - Call from an explicit viewer action. Twinkle confirms admission before allocating the private viewer grant or using Media Energy.
  - The first watch action consumes one of at most 10 grants for the session. Repeated watch() calls in the same page reuse the local grant until leave().
  - Playback authorization is single-use and capped at SD. The watch() result omits the signed playback URL; bridge traffic is still app-visible and must be treated as ephemeral.
  - playbackStarted becomes true only after IVS or the HTML video element confirms a playing state. If browser autoplay is blocked or no playing state is confirmed, it is false and the video controls remain available so the viewer can start playback explicitly.
  - Viewers may use the video element's ordinary fullscreen and Picture-in-Picture controls.
  - Example: await Twinkle.live.watch(session.id, { videoElement: '#liveVideo' });
- async leave(sessionId) | scopes: live:write
  - Returns: { success }
  - Destroy the local player and revoke/settle its private viewer session.
  - Example: await Twinkle.live.leave(sessionId);
- async stop(sessionId) | scopes: live:write
  - Returns: { session, cleanupInProgress, mediaEnergy }
  - Stop local broadcasting and ask the server to stop and delete the host's ephemeral IVS channel.
  - Use the returned canonical session state. Do not locally synthesize an ended status.
  - Example: await Twinkle.live.stop(sessionId);
- async listReplays({ limit? } = {}) | scopes: live:read
  - Returns: Array<LiveReplay>
  - List canonical saved replays for this Build app.
  - Ready, unexpired replays are visible to viewers in the app. A creator can also see processing or failed state for their own opted-in stream.
  - Replays expire seven days after becoming ready. Replace displayed state from this canonical response; do not synthesize processing or ready state locally.
  - Example: const replays = await Twinkle.live.listReplays({ limit: 20 });
- async getReplay(replayId) | scopes: live:read
  - Returns: LiveReplay | null
  - Load canonical processing, ready, or failed state for a visible replay.
  - Only the creator can see a processing or failed replay; ready replays are visible to signed-in viewers in this app.
  - Example: const replay = await Twinkle.live.getReplay(replayId);
- async watchReplay(replayId, { videoElement, requestId? }) | scopes: live:write
  - Returns: { replay, viewerGrantId, mediaEnergy, playbackStarted }
  - Open a short-lived private playback grant and attach a saved replay to an HTML video element.
  - Call from an explicit viewer action. Twinkle confirms each playback grant before using Media Energy.
  - Admission reserves the replay's maximum delivery estimate; leave, page close, or playback end settles the canonical elapsed viewing window instead of charging unused playback time.
  - The grant lasts at most 20 minutes and is settled automatically when playback ends, on leaveReplay(), when the page closes, or at server expiry.
  - playbackStarted becomes true only after IVS or the HTML video element confirms a playing state. If browser autoplay is blocked or no playing state is confirmed, it is false and the video controls remain available so the viewer can start playback explicitly.
  - Viewers may use the video element's ordinary fullscreen and Picture-in-Picture controls.
  - Example: await Twinkle.live.watchReplay(replay.id, { videoElement: '#replayVideo' });
- async leaveReplay(replayId) | scopes: live:write
  - Returns: { success }
  - Destroy the local replay player and canonically settle its private viewer grant.
  - The returned canonical settlement charges the conservative elapsed playback estimate, capped by the replay duration.
  - Example: await Twinkle.live.leaveReplay(replayId);
- async deleteReplay(replayId, { requestId? } = {}) | scopes: live:write
  - Returns: { replay, cleanupInProgress, mediaEnergy }
  - Permanently remove an opted-in replay through the canonical private-storage cleanup path.
  - Twinkle asks for an action-specific confirmation. Use the canonical returned state; cleanupInProgress means provider recording finalization or deletion is still being confirmed.
  - Example: await Twinkle.live.deleteReplay(replayId);

### Twinkle.ai
- async getUsagePolicy() | scopes: none
  - Returns: BuildAiUsagePolicy | null
  - Load the signed-in viewer's canonical current AI Energy battery policy.
  - Signed-in viewers only.
  - Returns canonical server state and does not consume AI Energy.
  - Use energyPercent for a percentage meter and energySegments plus energySegmentsRemaining for segmented battery UI.
  - The Build-safe response includes battery/day/usage fields only; account identity, email, risk, and recharge-eligibility metadata are not exposed to the app iframe.
  - Successful AI calls return a newer aiUsagePolicy snapshot. Energy-related SDK errors expose the confirmed snapshot as error.aiUsagePolicy. Replace displayed state only from those confirmed values; do not decrement or synthesize battery state locally.
  - Example: const policy = await Twinkle.ai.getUsagePolicy();
renderBattery(policy?.energyPercent, policy?.energySegmentsRemaining);
- async listPrompts() | scopes: none
  - Returns: Array<{ id, title, description }>
  - Legacy helper. Twinkle.ai.chat does not require promptId for default runtime text generation.
- async chat({ promptId, message, history, systemPrompt, webSearch, requestId, onText, onStatus } = {}) | scopes: none
  - Returns: { text, response, model, webSearch, aiUsagePolicy }
  - Generate text with the default Lumine text model, optionally using live web search and streaming text updates through onText.
  - Signed-in viewers only.
  - Uses Grok 4.6 by default.
  - Each successful text generation consumes AI Energy from the signed-in viewer.
  - history must be an array of { role: 'user' | 'assistant', content: string }. Twinkle.ai.chat does not read a text field.
  - The server keeps the latest 12 valid history entries.
  - Pass systemPrompt to define the app AI's personality, tone, role, or response rules.
  - Live web search is enabled by default, and the model decides whether searching is useful. Pass webSearch: false to disable it for the app.
  - When streaming, onStatus may receive searching_web while the provider is searching.
  - Pass onText to receive streaming accumulated text before the final result resolves.
  - AI Energy is recorded after provider success when final token and web-search tool usage are available.
  - Use this for in-app AI replies instead of creating or fetching app-local endpoints such as /api/chat.
  - Example: const chatHistory = conversation.slice(-12).map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', content: entry.text }));
const result = await Twinkle.ai.chat({ message, history: chatHistory, systemPrompt: 'You are a cheerful pirate helper who answers in one sentence.', onText: (text, meta) => renderReply(text), onStatus: (status) => setThinking(status === 'thinking') });
- async decide({ state, questions }) | scopes: none
  - Returns: { answers, model, provider, aiUsagePolicy }
  - Use JEV for fast contextual choices, yes/no probabilities, and scores inside a running app. Send several independent questions about the same state together.
  - Read the AI decision design guidance when deciding whether this capability benefits the app. This method evaluates supplied context; it does not generate prose, browse, inspect images, run tools, grant access, or award XP/Coins.
  - state is non-empty text, a JSON object, or a JSON array. questions maps your question IDs to { type, instructions, criteria? }. Instructions may be non-empty text or structured JSON. Every question sees the same state and answers independently; put the complete question in instructions, not just its ID.
  - Choice: { type: 'choice', instructions, criteria: { optionName: description, other: description } }. Supply 2–255 named options. Each description is text, structured JSON, or null. Returns { type: 'choice', choice, probabilities, confidence } under answers[questionId].
  - Score: { type: 'score', instructions, criteria: [lowestLevelDescription, ..., highestLevelDescription] }. Supply 2–10 concrete ordered descriptions. Returns { type: 'score', score, probabilities, confidence, legend }. Levels start at 0; score may be fractional and is a probability-weighted position between 0 and criteria.length - 1.
  - Yes/no probability: { type: 'noul', instructions, criteria?: { true: description, false: description } }. Returns { type: 'noul', noul }, where noul is the probability of yes between 0 and 1. There is no separate confidence field. If criteria is supplied, describe both true and false.
  - A request accepts 1–32 questions, at most 64 KB of JSON and 20 levels of nesting. Question IDs and option names must be non-empty, at most 128 characters, and cannot be __proto__, constructor, or prototype. Descriptions may use structured JSON; state must contain only data the app is allowed to access.
  - Requires a signed-in viewer and uses the normal runtime AI rate limits. Twinkle selects and calls JEV on its server; never put provider credentials or an external endpoint in app code. Each successful decision settles measured JEV usage through the existing AI Energy policy and returns the canonical aiUsagePolicy. Empty Energy blocks provider work. Authorized App MCP invocations retain the existing system-covered billing policy.
  - Errors include invalid_ai_decision (bad input), ai_decision_unavailable, ai_decision_timeout, ai_decision_rate_limited, ai_decision_invalid_response, and ai_usage_unavailable, plus the existing auth, access, Energy, and rate-limit errors. Invalid answers are rejected. There is no automatic provider retry, fabricated answer, or paid LLM fallback. Do not automatically retry a rejected request.
  - Call on meaningful app events with bounded frequency, batch questions, and keep rendering and deterministic game rules in local code. Ignore a result if the app state or turn changed while it was pending. Test uncertain inputs and choose a fallback appropriate to the experience; confidence is not a correctness guarantee.
  - Example: const { answers } = await Twinkle.ai.decide({ state: { playerRequest, availableActions }, questions: { action: { type: 'choice', instructions: 'Which available companion action best fits playerRequest? Use wait when unclear.', criteria: { follow: 'Follow the player', guard: 'Stay and keep watch', wait: 'Do nothing until clarified' } }, needsClarification: { type: 'noul', instructions: 'Is playerRequest too ambiguous to act on?' } } }); const action = answers.needsClarification.noul > 0.5 ? 'wait' : answers.action.choice;
- async generateObject({ prompt, expectedStructure, thinkingMode, mode, model, instructions, systemPrompt, webSearch, requestId, onText, onStatus, onReasoning } = {}) | scopes: none
  - Returns: { object, result, model, provider, thinkingMode, requestedThinkingMode, requestedModel, webSearch, aiUsagePolicy }
  - Generate a validated structured JSON object for app decisions, routing, grading, and game-state logic, with optional live output/status callbacks and web search.
  - Signed-in viewers only.
  - Use this instead of asking Twinkle.ai.chat to return JSON.
  - expectedStructure must be a JSON object that describes the exact returned object shape.
  - mode is accepted as an alias for thinkingMode, and mid is accepted as an alias for medium.
  - Omit model to use the normal Lite/Medium/High routing. model accepts gpt-6-astra, gpt-5.6-sol, claude-opus-5, or claude-fable-5-1, and every explicit model must be paired with thinkingMode: 'high'; unknown model IDs reject instead of silently falling back.
  - thinkingMode low uses GPT-5.6 Luna and consumes the viewer's AI Energy from confirmed provider usage; its smaller model is usually cheaper than Medium or High.
  - thinkingMode medium uses Grok 4.6 with medium reasoning and consumes normal AI Energy.
  - thinkingMode high without model uses GPT-5.6 Sol with high reasoning and consumes high AI Energy. Explicit model: 'gpt-5.6-sol' selects Sol with xhigh reasoning at the same High AI Energy tier. Explicit model: 'gpt-6-astra' selects GPT-6 Astra with xhigh reasoning and debits confirmed usage at its own model rates in the High tier.
  - claude-opus-5 uses Anthropic adaptive High thinking. claude-fable-5-1 uses Anthropic xhigh thinking and normally consumes more AI Energy for comparable token use. Both debit confirmed provider usage at the High tier.
  - Pass onStatus, onReasoning, and/or onText to stream progress from the same structured generation. onStatus receives high-level phases such as thinking, searching_web, responding, validating, and completed.
  - onReasoning receives accumulated provider-supplied, app-visible reasoning summaries plus { done, delta, requestId, status }. A provider retry may replace the accumulated summary; treat each callback's first argument as the current source of truth. This callback never exposes hidden/private model chain-of-thought.
  - onText receives accumulated structured-output text plus { done, delta, requestId, status }. Partial output is intentionally incomplete and may include provider formatting; parse only when done is true, when the callback receives the canonical object serialized as JSON, and use the resolved object as the source of truth.
  - Put a user-facing field such as producerNotes in expectedStructure when commentary must be part of the validated final object rather than transient reasoning progress.
  - When AI Energy is empty, every automatic or named model choice rejects before new provider work; there is no free fallback mode.
  - Live web search is enabled by default in Medium and High modes. Pass webSearch: false to disable it for the app. Low/Lite Mode remains tool-free; explicitly forcing webSearch: true in Low Mode returns an error.
  - The server validates the final shape; automatic OpenAI/xAI routes can retry malformed output, while explicit Anthropic routes use native JSON Schema output and retry one malformed or shape-invalid result. App code should still validate business-specific enum values.
  - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'high', model: 'claude-opus-5', prompt: 'Plan the next section from: ' + currentState, expectedStructure: { producerNotes: 'string', action: 'string', confidence: 0 }, onStatus: (phase) => showPhase(phase), onReasoning: (summary, meta) => showReasoningProgress(summary, meta), onText: (partialJson, meta) => showStructuredProgress(partialJson, meta) });
- onChatStatus(listener) | scopes: none
  - Returns: unsubscribe function
  - Listen to shared runtime AI chat stream events.
  - Usually prefer per-call onText/onStatus callbacks on Twinkle.ai.chat.
  - Events include requestId plus type status, text, done, or error.
- async generateImage({ prompt, referenceImageB64, previousResponseId, previousImageId, engine, model, quality, requestId, onStatus, timeoutMs } = {}) | scopes: none
  - Returns: { success, imageUrl, responseId, imageId, engine, model, quality, aiUsagePolicy } or { success: false, error, reason, code, aiUsagePolicy }
  - Generate or edit an image from a prompt and optional base64/data-URL reference image.
  - Signed-in viewers only.
  - Each successful image generation consumes AI Energy from the signed-in viewer.
  - Call generateImage directly from an explicit viewer action such as a button click. Calls from page load, timers, background work, or programmatic retries are rejected.
  - Twinkle shows a host-owned confirmation for every generation. One approval authorizes exactly one request.
  - Only one image generation may be active at a time. Do not queue or automatically retry cancellation, ai_image_generation_in_progress, USER_ACTIVATION_REQUIRED, or 429 errors.
  - Default engine is openai and default quality is high.
  - The SDK timeout defaults to 390000ms for image generation because high-quality image runs can exceed normal request timing.
  - Pass onStatus to receive real-time stages from the backend: prompt_ready, in_progress, generating, partial_image, completed, and error.
  - Pass requestId when you need to correlate browser logs, backend logs, and iframe status events for one generation.
  - partial_image statuses may include partialImageB64 for progressive preview UI before the final imageUrl arrives.
  - referenceImageB64 may be a raw base64 string or a data:image/...;base64 URL.
  - Optional model: gpt-image-2.5-flare or gpt-image-2.5-sunburst. Without a model, OpenAI uses Flare for new images and Sunburst when a reference image or continuation is supplied. Explicit gpt-image-2 remains supported.
  - Quality accepts low, medium, high, xhigh, or max. xhigh and max require a GPT Image 2.5 model. Gemini has one quality tier.
  - GPT Image 2.5 battery spending uses actual image-model input and output token usage. The confirmation shows an image-output estimate; prompts and reference images use additional energy.
  - responseId and imageId are opaque continuation handles. Pass them back unchanged to edit a prior result; do not assume an OpenAI ID format. Existing GPT Image 2 continuations remain usable.
  - Example: const result = await Twinkle.ai.generateImage({ prompt: 'Create a fashion guide portrait for this face with flattering colors and outfit ideas', referenceImageB64, quality: 'high', onStatus: (status) => console.log(status.stage) });
- onImageGenerationStatus(listener) | scopes: none
  - Returns: unsubscribe function
  - Subscribe to real-time image generation status events forwarded into the build iframe.
  - The listener receives the same status payload shape as generateImage({ onStatus }).
  - Works while this build iframe has an active Twinkle.ai.generateImage request.
  - Prefer generateImage({ onStatus }) when the UI only needs status for one request.
  - Example: const unsubscribe = Twinkle.ai.onImageGenerationStatus((status) => console.log(status.stage));

### Twinkle.characters
- async chat({ character, thinkingMode, message, history, roomContext, scene, systemPrompt, instructions, includeWebsiteContext, webSearch, requestId, onText, onStatus } = {}) | scopes: none
  - Returns: { text, response, character, aiUsername, thinkingMode, requestedThinkingMode, includeWebsiteContext, webSearch, model, provider, aiUsagePolicy }
  - Talk to Zero or Ciel from a Build app, optionally using live web search and streaming RPG-style dialogue text with onText/onStatus.
  - Signed-in viewers only.
  - character must be zero or ciel.
  - Recommended history shape is { role: 'user' | 'assistant', content: string, speaker?: string }; content is the canonical text field.
  - The character route also accepts text or message fields for compatibility, but generated apps should use content.
  - The server keeps the latest 16 valid character history entries.
  - Pass onText/onStatus for streaming dialogue. Omit callbacks for non-streaming dialogue where the promise resolves with the final response.
  - Inside Build character chat, thinkingMode low uses Lite Mode: Zero and Ciel both use GPT-5.6 Luna with reasoning disabled; confirmed provider usage consumes the viewer's AI Energy and is usually cheaper than High.
  - Inside Build character chat, thinkingMode medium uses the same normal chat model routing: Zero and Ciel both use GPT-5.6 Luna with reasoning disabled and normal AI Energy.
  - Inside Build character chat, thinkingMode high uses Think Hard chat routing and high AI Energy: Zero uses Grok 4.6 with high reasoning and Ciel uses GPT-5.6 Terra with high reasoning.
  - When AI Energy is empty, Low, Medium, and High all reject before new provider work; there is no free fallback mode.
  - Pass roomContext as a short shared scene transcript so Zero and Ciel can know what happened in the same room.
  - includeWebsiteContext defaults to true. Set includeWebsiteContext: false for in-world NPC dialogue that should only use Zero/Ciel's basic character identity plus your scene/instructions.
  - Live web search is enabled by default in Medium and High modes. Pass webSearch: false to disable it for the app. Low/Lite Mode remains tool-free; explicitly forcing webSearch: true in Low Mode returns an error.
  - includeWebsiteContext controls Twinkle persona context and is unrelated to webSearch.
  - When streaming, onStatus may receive searching_web while the provider is searching.
  - Use this for real Zero/Ciel NPCs instead of pretending with Twinkle.ai.chat systemPrompt.
  - Example: const dialogueHistory = recentTurns.slice(-16).map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', content: entry.text, speaker: entry.speaker }));
const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode: thinkHard ? 'high' : 'medium', message: playerText, history: dialogueHistory, roomContext, scene: { location: 'classroom', nearbyCharacters: ['zero', 'ciel'] }, includeWebsiteContext: false, onText: (text) => renderDialogue(text) });
- onChatStatus(listener) | scopes: none
  - Returns: unsubscribe function
  - Listen to shared Zero/Ciel runtime chat stream events.
  - Usually prefer per-call onText/onStatus callbacks on Twinkle.characters.chat.
  - Events include requestId plus type status, text, done, or error.

### Twinkle.userDb
- async query(sql, params) | scopes: none
  - Returns: { rows, rowCount, truncated }
  - Run a SELECT against advanced private per-user SQLite. Use Twinkle.privateDb instead for simple preferences, drafts, settings, or small JSON state.
  - Advanced private storage. Prefer Twinkle.privateDb unless the data is genuinely SQL-shaped.
  - SQL is validated; SELECT/INSERT/UPDATE/DELETE/CREATE TABLE/INDEX only.
  - Guest mode uses browser-local storage and does not sync across devices.
- async exec(sql, params) | scopes: none
  - Returns: { changes, lastInsertRowid }
  - Run a write or schema statement against advanced private per-user SQLite.
  - Use for CREATE TABLE/INDEX, INSERT, UPDATE, and DELETE statements.
  - Do not use userDb for simple key/value state; use Twinkle.privateDb for that.

### Twinkle.subjects
- async getMySubjects({ limit, cursor } = {}) | scopes: content:read
  - Returns: { subjects: [{ id, title, description, filePath, fileName, fileSize, thumbUrl, timeStamp, rootType, rootId, rewardLevel }], cursor? }
  - Returns the current viewer's own subjects, newest first.
  - Supports cursor-based pagination. Pass cursor from previous response to load more.
- async search({ query, limit, cursor } = {}) | scopes: content:read
  - Returns: { subjects: [{ id, contentType, contentId, title, description, filePath, fileName, fileSize, thumbUrl, timeStamp, userId, username, profilePicUrl, rootType, rootId, rewardLevel, numComments }], cursor?, pagination: { limit, hasMore, nextCursor }, filters: { query } }
  - Search Twinkle subjects by text for subject picker UIs, book apps, scrapbooks, and galleries.
  - Searches Twinkle subjects by text so apps can let viewers choose which subject to use.
  - Returns subject ids plus rootType/rootId metadata for picker UIs. Empty queries return an empty result set.
  - Example: const { subjects } = await Twinkle.subjects.search({ query: searchText, limit: 12 });
- async getSubject(subjectId) | scopes: content:read
  - Returns: { subject: { id, title, description, filePath, fileName, fileSize, thumbUrl, secretAnswer, secretAttachment, timeStamp, userId, username, profilePicUrl, rootType, rootId, rewardLevel } }
  - Returns full detail for a single subject, including uploader info and attachments.
  - Any subject can be fetched (not limited to viewer's own).
- async getSubjectComments(subjectId, { limit, cursor } = {}) | scopes: content:read
  - Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp }], cursor? }
  - Returns only the current viewer's own comments on the given subject.
  - Supports cursor-based pagination. Pass cursor from previous response to load more.
- async getWriteStatus({ subjectId, commentId } = {}) | scopes: content:read
  - Returns: { writeStatus: { serverNow, subjectCreate, commentCreate, subjectEdit, commentEdit } }
  - Each operation slice is { cooldownSeconds, availableAt, retryAfterSeconds }.
  - serverNow is unix seconds so progress bars ignore client clock skew.
  - Pass subjectId/commentId to include per-target edit cooldowns.
- async create({ title, description, attachment }) | scopes: content:write
  - Returns: { subject: { id, title, description, filePath, fileName, fileSize, thumbUrl, userId, username, ... }, writeStatus }
  - Creates a normal site subject. title up to 200 characters, description up to 20,000.
  - attachment: { runtimeFileId } names a file the viewer uploaded through Twinkle.files.uploadGenerated (its asset id); the server verifies it is this viewer’s upload for this app, copies it into the site’s attachment storage and stores it as the subject’s real attachment (cover). Images get a thumbUrl from the site optimizer shortly after.
  - Site-wide durable cooldown: 600s between creates. 429 includes writeStatus.
- async edit({ subjectId, title, description, attachment }) | scopes: content:write
  - Returns: { subject, writeStatus }
  - Own subjects only (userId === uploader). Never uses moderator edit rights.
  - attachment: { runtimeFileId } replaces the subject attachment with one of the viewer’s uploads; attachment: null removes it; omit to leave it unchanged.
  - Per-subject edit cooldown: 10s.

### Twinkle.aiCards
- async list({ limit, cursor, level, minLevel, maxLevel, quality, userId, hasImage, hasExample } = {}) | scopes: content:read
  - Returns: { cards: [{ id, contentType, contentId, word, text, exampleText, prompt, level, wordLevel, quality, style, imagePath, imageUrl, isMysteryCard, isImageGenerating, creatorId, ownerId, username, profilePicUrl, timeStamp, lastInteraction }], cursor?, pagination: { limit, hasMore, nextCursor }, filters }
  - List existing public AI Cards newest first, including each card word, example sentence text, word level, and quality.
  - Use card.word for word typing modes and card.exampleText for sentence typing modes.
  - Filter by level/minLevel/maxLevel to match game difficulty to AI Card word level.
  - Mystery/unrevealed cards return style as ??? and omit imagePath/imageUrl until the card image is available.
  - Example: const { cards } = await Twinkle.aiCards.list({ level: 2, hasExample: true, limit: 20 });
- async search({ query, limit, cursor, level, minLevel, maxLevel, quality, userId, hasImage, hasExample } = {}) | scopes: content:read
  - Returns: { cards: [{ id, contentType, contentId, word, text, exampleText, prompt, level, wordLevel, quality, style, imagePath, imageUrl, isMysteryCard, isImageGenerating, creatorId, ownerId, username, profilePicUrl, timeStamp, lastInteraction }], cursor?, pagination: { limit, hasMore, nextCursor }, filters }
  - Search existing public AI Cards by word, with optional level and quality filters.
  - Search matches AI Card words; use list(...) for broad level-based typing pools.
  - This namespace is read-only and does not summon, trade, sell, burn, or mutate AI Cards.
  - Mystery/unrevealed cards return style as ??? and omit imagePath/imageUrl until the card image is available.
  - Example: const { cards } = await Twinkle.aiCards.search({ query: searchText, minLevel: 1, maxLevel: 3, hasExample: true, limit: 12 });
- async get(cardId) | scopes: content:read
  - Returns: { card: { id, contentType, contentId, word, text, exampleText, prompt, level, wordLevel, quality, style, imagePath, imageUrl, isMysteryCard, isImageGenerating, creatorId, ownerId, username, profilePicUrl, timeStamp, lastInteraction } }
  - Fetch one existing public AI Card by id, including word, example text, and level metadata.
  - Fetches one live, unburned AI Card by id.
  - This namespace is read-only and does not expose market or ownership actions.
  - Mystery/unrevealed cards return style as ??? and omit imagePath/imageUrl until the card image is available.
  - Example: const { card } = await Twinkle.aiCards.get(cardId);

### Twinkle.aiStories
- async list({ limit, cursor, order, difficulty, type, topicKey, storyBy, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
  - Returns: { stories: [{ id, contentType, contentId, topic, topicKey, type, storyBy, story, explanation, difficulty, isListening, imagePath, imageUrl, audioPath, audioUrl, questions, questionsBy, hasImage, hasQuestions, userId, username, profilePicUrl, timeStamp }], cursor?, pagination: { limit, hasMore, nextCursor }, filters }
  - List completed existing user-generated AI Stories, optionally filtered by exact level/type/topicKey book, by storyBy (the generating model, i.e. the story's author), and ordered newest or oldest first.
  - Lists completed existing AI Stories newest first by default; order:'oldest' is allowed only with difficulty, type, and topicKey for chronological book pages.
  - Use difficulty with type and topicKey to load one exact AI Story book without scanning the full corpus in the iframe.
  - Filter with hasImage or hasQuestions when building visual galleries or quiz apps.
  - Example: const { stories } = await Twinkle.aiStories.list({ difficulty: 1, type: 'science', topicKey: 'Astronomy', order: 'oldest', limit: 20 });
- async chapters({ limit, cursor, groupBy, difficulty, type, topicKey, storyBy, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
  - Returns: Default (groupBy:'topicKey'): { chapters: [{ difficulty, type, topicKey, title, sampleTopic, storyCount, readingCount, listeningCount, imageCount, questionCount, latestStoryId, latestTimeStamp }], cursor?, pagination, filters }. groupBy:'type': { books: [{ difficulty, type, title, sampleTopic, chapterCount, storyCount, readingCount, listeningCount, imageCount, questionCount, latestStoryId, latestTimeStamp }], ... } — one row per (level, topic) book. groupBy:'author': { authors: [{ storyBy, title, bookCount, chapterCount, storyCount, minDifficulty, maxDifficulty, latestStoryId }], ... } — one row per generating model (the story's author); an index-only landing, so it omits media counts (use a scoped books/chapters call for those).
  - List the AI Story library index. Default groups by (level, type, topicKey) for per-subtopic chapter rows. groupBy:'type' returns one row per (level, topic) book; groupBy:'author' returns one row per generating model (storyBy = the author) — a tiny top-level set. Filter by storyBy to scope books/chapters/stories to one author, and by difficulty/type to scope further. Counts and navigation metadata only, no story bodies.
  - groupBy:'author' returns one row per generating model under an authors key (the library's authors); groupBy:'type' returns (level, topic) books under a books key; default groupBy:'topicKey' returns per-subtopic chapter rows under a chapters key.
  - storyBy is the generating model id (e.g. 'gpt-5.1', 'gpt-4o') and acts as the story's author. Pass a single id, or an array of ids to scope to a model family (e.g. fold gpt-4o snapshots into one author). Scopes books, chapters, and stories.
  - Returns book/chapter metadata only; use Twinkle.aiStories.list({ difficulty, type, topicKey, storyBy, order:'oldest', cursor }) to load story pages inside a book.
  - Use cursor pagination for large chapter indexes instead of loading every story record.
  - Example: const { authors } = await Twinkle.aiStories.chapters({ groupBy: 'author' });
- async search({ query, limit, cursor, order, difficulty, type, topicKey, storyBy, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
  - Returns: { stories: [{ id, contentType, contentId, topic, topicKey, type, story, explanation, difficulty, isListening, imagePath, imageUrl, audioPath, audioUrl, questions, questionsBy, hasImage, hasQuestions, userId, username, profilePicUrl, timeStamp }], cursor?, pagination: { limit, hasMore, nextCursor }, filters }
  - Search completed existing user-generated AI Stories by topic or story text, optionally within an exact level/type/topicKey book.
  - Searches completed existing AI Stories by topic/story text.
  - Use difficulty, type, and topicKey to search within one book of the AI Story corpus; order:'oldest' is rejected without all three filters.
  - Returned questions are normalized to an array even when stored as JSON text.
  - Example: const { stories } = await Twinkle.aiStories.search({ query: searchText, difficulty: 2, type: 'history', topicKey: 'Ancient Rome', order: 'oldest', limit: 12 });
- async get(storyId) | scopes: content:read
  - Returns: { story: { id, contentType, contentId, topic, topicKey, type, story, explanation, difficulty, isListening, imagePath, imageUrl, audioPath, audioUrl, questions, questionsBy, hasImage, hasQuestions, userId, username, profilePicUrl, timeStamp } }
  - Fetch one completed existing AI Story by id, including story text for passage typing, media URLs, and normalized questions when available.
  - Fetches one completed existing AI Story by id.
  - This namespace is read-only and does not generate new AI Stories.
  - Example: const { story } = await Twinkle.aiStories.get(storyId);

### Twinkle.grammarbles
- async listQuestions({ level, limit, cursor } = {}) | scopes: content:read
  - Returns: { questions: [{ id, level, rating, question, choices, answerIndex, correctChoice, correctChoiceKey, isChecked, explanation }], cursor?, pagination: { level, limit, hasMore, nextCursor } }
  - Read public Grammarbles questions and answers by level with rating/id cursor pagination.
  - Questions are public Grammarbles training data and include the canonical answer.
  - level is clamped from 1 through 5.
  - Pagination is stable by rating then id. Pass cursor from the previous response to load more questions in the same level.
  - This method does not expose daily attempt state, XP, coins, or daily-task progression.
  - Example: const page = await Twinkle.grammarbles.listQuestions({ level: 3, limit: 100 }); const question = page.questions[Math.floor(Math.random() * page.questions.length)];
- async getMyQuestionHistory({ level, limit, cursor } = {}) | scopes: content:read
  - Returns: { attempts: [{ id, questionId, level, grade, gradeRank, isCorrect, attemptNumber, timeStamp }], cursor?, pagination: { level, limit, hasMore, nextCursor } }
  - Read the signed-in viewer's real Grammarbles attempt rows for trainer filtering.
  - Returns real Grammarbles attempt outcome rows for the signed-in viewer, newest first.
  - History rows intentionally omit choice indexes because real Grammarbles choices are shuffled per run and the per-run shuffle order is not persisted.
  - Use Twinkle.grammarbles.listQuestions for canonical question text, choices, and answers.
  - Use app-private history in Twinkle.privateDb for trainer-only results, and combine it with this method only when the viewer chooses to include real Grammarbles history.
  - This method is read-only and does not submit, cancel, or mutate daily Grammarbles attempts.
  - Example: const history = await Twinkle.grammarbles.getMyQuestionHistory({ level: selectedLevel, limit: 500 }); const answeredIds = new Set(history.attempts.map((attempt) => attempt.questionId));

### Twinkle.subjectComments
- async list(subjectId, { limit, cursor, sortBy, includeReplies, author, authorUserId, replyScope } = {}) | scopes: content:read
  - Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp, userId, username, profilePicUrl, commentId, replyId }], cursor?, pagination: { limit, hasMore, nextCursor }, filters: { subjectId, sortBy, includeReplies, author, replyScope, authorUserId } }
  - Read a subject's comment stream with stable keyset pagination, oldest/newest ordering, author filters, and optional same-author reply scoping.
  - Use for subject-wide comment streams. Twinkle.subjects.getSubjectComments is the legacy viewer-own-comments helper.
  - sortBy accepts newest or oldest.
  - author accepts all, viewer, or subjectPoster. Pass authorUserId for an explicit user filter.
  - includeReplies defaults to false so book/page apps read top-level subject comments unless they opt into replies.
  - replyScope accepts all or ownThread and defaults to all.
  - With includeReplies: true and a single-author filter, replyScope: ownThread keeps that author's top-level comments and only includes that author's replies when the direct or nested reply target is also authored by that author.
  - For subject-poster books that include poster replies, use author: subjectPoster, includeReplies: true, and replyScope: ownThread so the poster's replies to other people do not become pages.
  - Supports cursor-based pagination. Pass cursor from the previous response to load more.
  - Example: const { subjects } = await Twinkle.subjects.search({ query: searchText, limit: 12 }); const subjectId = pickedSubject.id; const page = await Twinkle.subjectComments.list(subjectId, { sortBy: 'oldest', author: 'subjectPoster', includeReplies: true, replyScope: 'ownThread', limit: 50 });
- async create({ subjectId, content, attachment }) | scopes: content:write
  - Returns: { comment, writeStatus }
  - Adds a top-level subject comment (book page). Own subject only. content up to 10,000 characters (longer text is cut at 10,000, so split chapters yourself).
  - attachment: { runtimeFileId } attaches one of the viewer’s Twinkle.files.uploadGenerated uploads to the comment as a real attachment.
  - Site-wide durable cooldown: 20s between comment creates. 429 includes writeStatus.
- async edit({ commentId, content }) | scopes: content:write
  - Returns: { comment, writeStatus }
  - Own comments only. content up to 10,000 characters. Per-comment edit cooldown: 10s.

### Twinkle.profileComments
- async getProfileComments({ profileUserId, limit, offset, sortBy, includeReplies, range, since, until } = {}) | scopes: content:read
  - Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp, userId, username, profilePicUrl, likes, replies, commentId, replyId }], pagination: { limit, offset, hasMore, nextOffset }, filters: { profileUserId, sortBy, includeReplies, since, until } }
  - Reads profile-page comments (rootType='user'). Defaults to current viewer's profile if profileUserId is not provided.
  - Use sortBy: newest | oldest.
  - Set range:'today' for today-only filters, or pass since/until Unix timestamps.
- async getProfileCommentIds({ profileUserId, limit, offset, sortBy, includeReplies, range, since, until } = {}) | scopes: content:read
  - Returns: { ids: number[], pagination: { limit, offset, hasMore, nextOffset }, filters: { profileUserId, sortBy, includeReplies, since, until } }
  - Atomic step 1: fetches only matching profile comment IDs with stable pagination.
  - Use this when you want custom pipelines such as fetching IDs first, then selective hydration/counts.
- async getCommentsByIds(idsOrOpts) | scopes: content:read
  - Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp, userId, username, profilePicUrl, commentId, replyId }] }
  - Atomic step 2: fetches comment records for provided IDs.
  - Accepts either an array of IDs or an object like { ids: [...] }.
- async getProfileCommentCounts(idsOrOpts) | scopes: content:read
  - Returns: { countsById: { [commentId]: { likes, replies } } }
  - Atomic step 3: fetches likes/replies aggregates for provided IDs.
  - Accepts either an array of IDs or an object like { ids: [...] }.

### Twinkle.news
- async getCurrentEdition() | scopes: none
  - Returns: { dayIndex, nextEditionAt, generationStatus, edition: { id, dayIndex, status, coverageStartedAt, coverageEndedAt, sourceEventCount, edition, model, provider, generatedAt, revisionNumber, revisionCount } | null, pendingEdition }
  - Read today's shared Twinkle newspaper, or the latest ready edition while today's is being generated.
  - Works for signed-in viewers and public-build guests.
  - generationStatus is available, pending, generating, ready, or failed.
  - While today's first edition is pending, edition remains the latest ready shared edition. During an owner refresh, edition remains the earlier same-day edition. pendingEdition describes the canonical queued work in both cases.
  - Poll gently while generation is pending; once every 5-10 seconds is sufficient.
- async listEditions({ limit = 12, cursor } = {}) | scopes: none
  - Returns: { editions: [{ id, dayIndex, dateKey, headline, deck, coverageStartedAt, coverageEndedAt, sourceEventCount, generatedAt, revisionNumber, revisionCount }], cursor, hasMore }
  - List the canonical daily newspaper archive newest-first.
  - Works for signed-in viewers and public-build guests.
  - Returns compact publication summaries rather than full newspaper JSON.
  - limit defaults to 12 and is capped at 30. Pass cursor from the previous response to load older editions.
  - Each item describes the latest canonical revision for that Twinkle day.
- async getEdition({ dayIndex, revisionNumber } = {}) | scopes: none
  - Returns: { edition: { id, revisionId?, dayIndex, status, coverageStartedAt, coverageEndedAt, sourceEventCount, edition, model, provider, generatedAt, revisionNumber, revisionCount }, revisions: [{ revisionId, revisionNumber, coverageStartedAt, coverageEndedAt, sourceEventCount, model, provider, generatedAt, createdAt }], selectedRevisionNumber, canonicalRevisionNumber }
  - Read one canonical daily edition or an exact preserved successful press run.
  - Works for signed-in viewers and public-build guests.
  - dayIndex is required. Omit revisionNumber to read that day's latest canonical edition.
  - Pass a revisionNumber returned in revisions to read that exact successful press run.
  - Historical revisions remain subject to canonical privacy and deletion redactions.
- async generateCurrentEdition({ refresh = false } = {}) | scopes: none
  - Returns: { dayIndex, nextEditionAt, generationStatus, edition, pendingEdition }
  - Atomically queue the current Twinkle day's globally shared edition.
  - Requires a signed-in viewer.
  - The first request for a Twinkle day creates the canonical pending edition; concurrent and later ordinary requests return that same server state.
  - In the canonical Twinkle Newspaper app, its current owner may pass { refresh: true } to revise an already-ready same-day edition using the latest canonical events. Every successful refresh is appended as a preserved press run and becomes that day's canonical revision. Ownership is checked by the server at request time and therefore follows an app transfer.
  - When this request creates, retries, or refreshes a model-backed edition, confirmed provider usage consumes AI Energy from this requesting viewer. Concurrent or later callers that deduplicate onto the same pending or ready edition are not charged.
  - A quiet edition with no editorial events does not call an AI provider and does not consume AI Energy.
  - A failed attempt may be queued again on the same day. A ready edition is immutable for ordinary viewers.

### Twinkle.leaderboards
- async get({ boardKey = 'default', limit, cursor } = {}) | scopes: none
  - Returns: { entries: [{ rank, id, buildId, boardKey, viewerKind, userId, displayName, score, meta, achievedAt, createdAt, updatedAt }], scores, cursor, hasMore, personalBest: { id, buildId, boardKey, viewerKind, userId, displayName, score, meta, achievedAt, createdAt, updatedAt } | null }
  - Read score-sorted personal-best leaderboard rows for this Build app.
  - Works for signed-in viewers and public-build guests.
  - Results are sorted by score descending, then earliest achieved time.
  - limit defaults to 20 and maxes at 100.
  - Use cursor from the previous response to load more rows.
  - personalBest is included when the current signed-in viewer or guest session has a row.
- async submit({ boardKey = 'default', score, displayName, meta } = {}) | scopes: none
  - Returns: { entry: { id, buildId, boardKey, viewerKind, userId, displayName, score, meta, achievedAt, createdAt, updatedAt } | null, personalBest: { id, buildId, boardKey, viewerKind, userId, displayName, score, meta, achievedAt, createdAt, updatedAt } | null, improved, previousScore }
  - Submit a score to a public Build leaderboard using server-owned viewer identity.
  - score is required and must be an integer from 0 through 1000000000000.
  - Signed-in viewers are identified by their Twinkle user id and display under their Twinkle username; do not pass a custom displayName for them.
  - Guests must pass displayName. Ask once and keep it in app state for later submits.
  - Submit only after computing the final score when a run, shift, match, or level attempt ends; do not submit every frame or every tick.
  - Only improved personal-best scores replace the existing score row.
  - meta is optional JSON object data, max 2 KB.

### Twinkle.sharedDb
- async getTopics() | scopes: sharedDb:read
  - Returns: { topics: [{ id, name, createdBy, createdAt }] }
  - Lists all topics for this build.
- async createTopic(name) | scopes: sharedDb:write
  - Returns: { topic: { id, name, createdBy, createdAt } }
  - Creates a topic or returns the existing one if name already exists.
  - Name max 100 characters.
- async getEntries(topicName, { limit, pageSize, cursor, order, sort, direction } = {}) | scopes: sharedDb:read
  - Returns: { entries: [{ id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt }], cursor?, hasMore }
  - Read shared topic rows with cursor pagination.
  - Returns entries from a topic, newest first by default.
  - Use limit or pageSize to choose how many entries to fetch per page. Default is 20, max is 100.
  - For oldest-first chronological reads, pass order: 'asc' or order: 'oldest'. sort and direction are accepted as aliases.
  - Supports cursor-based pagination. Newest-first cursors page toward older entries; oldest-first cursors page toward newer entries.
- async loadMoreEntries(topicName, { limit, pageSize, cursor, order, sort, direction } = {}) | scopes: sharedDb:read
  - Returns: { entries: [{ id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt }], cursor?, hasMore }
  - Fetch the next sharedDb page.
  - Convenience alias for getEntries used by Load more buttons.
  - Pass the previous response cursor to fetch the next page using the same order and page size.
- async getEntriesByIds(entryIds) | scopes: sharedDb:read
  - Returns: { entries: [{ id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt }] }
  - Read up to 100 shared rows by their app-scoped entry ids.
  - Accepts 1-100 unique positive entry ids and returns the rows that still exist in the same order as requested.
  - Ids from another Build app are never returned.
  - Use this for bounded manifest references; use getEntries for browseable topic feeds.
- async addEntry(topicName, data, { notify } = {}) | scopes: sharedDb:write
  - Returns: { entry: { id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt } }
  - Append a shared JSON row, optionally creating a Twinkle notification from the canonical write.
  - Adds a JSON object entry to a topic. Auto-creates the topic if it doesn't exist.
  - data must be a JSON object, max 10 KB.
  - notify may include eventKey, label, summary, recipients, and target. Supported recipients start with { kind: 'buildOwner' }.
  - Use target.focus, such as { kind: 'sharedDbEntry', entryId: '$createdEntryId' }, so Twinkle.notifications can focus the item when opened.
  - Use Twinkle.leaderboards for standard top-score rankings and personal-best scoreboards.
- async addEntries(topicName, items) | scopes: sharedDb:write
  - Returns: { entries: [{ id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt }] }
  - Atomically append up to 100 owner-controlled JSON rows for one low-frequency user action.
  - items must contain 1-100 JSON objects; each object remains capped at 10 KB.
  - All rows are created atomically in one topic and returned as canonical server entries.
  - Batch rows remain controlled by their creator or the Build owner, just like addEntry rows.
  - This method intentionally does not support subjectRef or notifications; use addEntry when either is needed.
  - Use for one bounded durable action, not per-frame or per-tick logging.
- async updateEntry(entryId, data, { notify } = {}) | scopes: sharedDb:write
  - Returns: { entry: { id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt } }
  - Update a viewer-owned shared row, optionally notifying safe recipients from the canonical write.
  - Updates an entry. Only the entry creator or the build owner can update.
  - data must be a JSON object, max 10 KB.
  - notify may include eventKey, label, summary, recipients, and target. Supported recipients include { kind: 'sharedDbEntryAuthor', entryId } and { kind: 'subjectAuthor', subjectId } (subject must be referenced by this build via a subject-linked sharedDb entry).
- async deleteEntry(entryId) | scopes: sharedDb:write
  - Returns: { success: true }
  - Deletes an entry. Only the entry creator or the build owner can delete.
- async deleteEntries(entryIds) | scopes: sharedDb:write
  - Returns: { success: true, deletedEntryIds: number[], missingEntryIds: number[] }
  - Atomically delete up to 100 owned shared rows.
  - Accepts 1-100 unique positive entry ids.
  - Every existing target must be writable by the viewer or Build owner; otherwise the whole request rejects without deleting anything.
  - Missing rows are reported and ignored, making cleanup idempotent.
- async kv.get(namespace, key) | scopes: sharedDb:read
  - Returns: { item: { id, key, value, version, changeSeq, deleted, updatedBy, createdAt, updatedAt } | null }
  - Read one key from the keyed shared store (shared mutable state). Deleted keys read as null.
  - Reads one key from the keyed shared store. Deleted keys read as null.
  - Example: const { item } = await Twinkle.sharedDb.kv.get('world', 'block:10:4');
- async kv.list(namespace, { limit, cursor, since } = {}) | scopes: sharedDb:read
  - Returns: { items: [{ id, key, value, version, changeSeq, deleted, updatedBy, createdAt, updatedAt }], cursor?, hasMore }
  - List keys in a namespace ordered by changeSeq for incremental sync of shared mutable state; pass since = highest changeSeq already seen to fetch only changed keys (including removals as deleted: true).
  - Lists keys in a namespace ordered by changeSeq (a monotonic write counter). For incremental sync, pass the returned cursor or since = highest changeSeq already seen; incremental results include removed keys with deleted: true (drop them locally). Full scans (no cursor/since) exclude deleted keys.
  - Example: const { items } = await Twinkle.sharedDb.kv.list('world', { since: lastChangeSeq });
- async kv.set(namespace, key, value) | scopes: sharedDb:write
  - Returns: { item: { id, key, value, version, changeSeq, deleted, updatedBy, createdAt, updatedAt } }
  - Upsert one key of shared mutable state with server-side last-write-wins. Preferred for shared world/block/grid/game state instead of append-only entry logs with client-side compaction.
  - Upserts one key with server-side last-write-wins. Any viewer with write scope may overwrite, so use kv for shared mutable state (world/block/game state) instead of append-only entry logs with client-side compaction.
  - Example: await Twinkle.sharedDb.kv.set('world', 'block:10:4', { color: 'red' });
- async kv.setMany(namespace, items) | scopes: sharedDb:write
  - Returns: { items: [{ id, key, value, version, changeSeq, deleted, updatedBy, createdAt, updatedAt }] }
  - Atomically upsert up to 100 { key, value } items of shared mutable state in one request (batch write).
  - Upserts up to 100 { key, value } items atomically in one request.
  - Example: await Twinkle.sharedDb.kv.setMany('world', [{ key: 'block:1:1', value: { color: 'red' } }, { key: 'block:1:2', value: { color: 'blue' } }]);
- async kv.remove(namespace, key) | scopes: sharedDb:write
  - Returns: { success: true, deleted: boolean }
  - Remove a key of shared mutable state, tombstoning it so kv.list incremental sync observes the removal.
  - Tombstones the key so kv.list incremental sync can observe the removal.
  - Example: await Twinkle.sharedDb.kv.remove('world', 'block:10:4');

### Twinkle.chat
- async listRooms() | scopes: chat:read
  - Returns: { rooms: [{ id, buildId, key, name, createdByUserId, createdAt, updatedAt }] }
  - Returns chat rooms created by this Build app.
- async createRoom({ roomKey, name }) | scopes: chat:write
  - Returns: { room: { id, buildId, key, name, createdByUserId, createdAt, updatedAt } }
  - Creates a room or returns the existing one.
  - roomKey may include letters, numbers, '.', '_', ':', and '-'.
- async listMessages(roomKey, { cursor, limit } = {}) | scopes: chat:read
  - Returns: { messages: [{ id, roomId, roomKey, userId, username, profilePicUrl, role, status, text, metadata, clientMessageId, createdAt, updatedAt }], cursor? }
  - Returns messages in chronological order.
  - Use the returned cursor to fetch older messages.
- async sendMessage(roomKey, textOrOptions, options) | scopes: chat:write
  - Returns: { message: { id, buildId, roomId, roomKey, userId, username, profilePicUrl, role, status, text, metadata, clientMessageId, createdAt, updatedAt }, room: { id, buildId, key, name, createdByUserId, createdAt, updatedAt }, created }
  - Accepts sendMessage('lobby', 'hi') or sendMessage('lobby', { text, metadata, clientMessageId }).
  - Pass clientMessageId when manually retrying a send; the SDK also includes one per send request.
- async deleteMessage(messageId) | scopes: chat:write
  - Returns: { success: true, messageId }
  - A viewer can delete their own messages; the build owner can delete any message in the build.
- subscribe(roomKey, listener) | scopes: chat:read
  - Returns: unsubscribe function
  - listener receives realtime events like { type: 'message.created', roomKey, message }.
  - Call the returned function to unsubscribe.

### Twinkle.world
- async join({ worldKey = 'default', roomKey = 'main', instanceId = 'main', presence, player } = {}) | scopes: none
  - Returns: { sessionId, session, room, players, snapshot, subscribe(listener), updatePresence(patch), send(actionOrType, data), leave() }
  - Join a realtime Build world room and receive a snapshot plus a session handle for presence updates, actions, and room events.
  - Always available in the build iframe.
  - World state is ephemeral and heartbeat/TTL based. Use sharedDb/privateDb for durable inventory, XP, quests, ownership, and saved progress — but write those LOW-frequency only (on a user action or an occasional snapshot, never per frame/tick); per-frame/live state stays in world presence or client memory. The server rate-limits sharedDb/privateDb writes and returns 429.
  - Events are room-scoped and include serverTime, seq, eventId, schemaVersion, sessionId, player, and room metadata.
  - Signed-in player identity comes from the canonical Twinkle user record; player.profilePicUrl is only used for guests and is returned only when it is a valid absolute HTTPS URL.
  - Subscribe to session.ended and catch updatePresence/send errors. Stop using stale handles and reconnect only when Twinkle.world.isSessionEndedError(error) is true; for other Twinkle.world.isRecoverableSessionError(error) cases, drop the transient presence/action and keep the handle.
  - Use updatePresence for live avatar snapshots and send for lightweight actions such as emotes, interactions, and chat bubbles.
  - Treat the render/input loop as local-only. Queue presence only after relevant fields change, replace any queued snapshot with the newest one, and flush on a fixed 5-15 updates-per-second schedule with at most one updatePresence request in flight. Never call or await updatePresence every animation frame, resend unchanged snapshots, overlap requests, or build a backlog.
  - Send discrete actions only when they happen; do not poll or automatically retry them. The parent limits updatePresence and send together to protect the website connection. WORLD_EVENT_RATE_LIMITED is recoverable: drop that attempted update without an immediate retry and keep the current session.
  - Rooms are addressed by worldKey, roomKey, and instanceId so the contract can later move to sharded or dedicated game backends.
  - Example: const world = await Twinkle.world.join({ roomKey: 'town-square', presence: { x: 0, y: 0, z: 0, facing: 'south' }, player: { name: avatarName } });
world.subscribe((event) => updateRemotePlayers(event.players));
world.updatePresence({ x, y, z, facing });
- isRecoverableSessionError(error) | scopes: none
  - Returns: boolean
  - Return true when a world request error is expected to be handled by app code instead of crashing.
  - Recoverable session errors include ended, missing, socket-disconnected, socket-not-ready, room-missing, rate-limited, preview-updating, and timed-out world session requests.
  - Only session-ended errors prove that the current handle should be discarded. WORLD_EVENT_RATE_LIMITED, timed-out, or preview-updating presence/action requests must be dropped without reconnecting and without an immediate retry.
  - For durable game state, write through sharedDb/privateDb instead of relying on world presence — but LOW-frequency only (on a user action or an occasional snapshot, never per frame/tick).
  - Example: try {
  await world.updatePresence({ x, y, z, facing });
} catch (error) {
  if (Twinkle.world.isSessionEndedError(error)) {
    world = null;
    scheduleReconnect();
  } else if (Twinkle.world.isRecoverableSessionError(error)) {
    // Drop this transient presence update and keep the current handle.
  } else {
    throw error;
  }
}
- isSessionEndedError(error) | scopes: none
  - Returns: boolean
  - Return true when a world request error means the current session handle is stale and app code should reconnect with a fresh Twinkle.world.join call.
  - Session-ended errors include ended, missing, room-missing, and socket-disconnected world session failures.
  - Request timeouts and preview-updating skips are recoverable but not session-ended.
  - Example: if (Twinkle.world.isSessionEndedError(error)) {
  world = null;
  scheduleReconnect();
}
- leaveAll() | scopes: none
  - Returns: void
  - Leave every active world session in the current iframe.
  - The SDK also leaves active sessions on pagehide/beforeunload when possible.
  - Use this when switching maps or resetting a multiplayer game.

### Twinkle.users
- async getUser(userId) | scopes: user:read
  - Returns: { id, username, profilePicUrl, realName } | null
- async getUsers({ search, userIds, cursor, limit } = {}) | scopes: users:read
  - Returns: { users: [{ id, username, profilePicUrl, realName }], cursor? }
  - Prefer explicit userIds when possible.
  - Search is prefix-based and requires at least 2 characters.
  - Use search sparingly and with small limits.

### Twinkle.reflections
- async getDailyReflections({ userIds, cursor, lastId, limit } = {}) | scopes: dailyReflections:read
  - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question }], cursor? }
  - Read only currently public Daily Reflection shares. response is the exact text the author chose to share publicly, never an unshared raw/private answer.
  - Daily reflections are Daily Question answers shown in the Twinkle feed.
  - Only currently shared public reflections are returned. The response field is the stored shared response version, which may be raw or polished depending on what the author explicitly shared.
  - submittedAt and sharedAt are Unix timestamps (seconds). Use new Date(value * 1000) to convert to JS Date.
- async getDailyReflectionsByUser(userId, { cursor, lastId, limit } = {}) | scopes: dailyReflections:read
  - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question }], cursor? }
  - Read one user's currently public Daily Reflection shares. response is the exact text the author chose to share publicly, never an unshared raw/private answer.
  - Daily reflections are Daily Question answers shown in the Twinkle feed.
  - Only currently shared public reflections are returned. The response field is the stored shared response version, which may be raw or polished depending on what the author explicitly shared.
  - submittedAt and sharedAt are Unix timestamps (seconds). Use new Date(value * 1000) to convert to JS Date.

### Twinkle.privateDb
- async get(key) | scopes: privateDb:read
  - Returns: { item: { id, key, value, updatedAt } | null }
  - Read one key from the default private per-user JSON store.
  - Reads a single key from the current viewer's private store.
- async list({ prefix, limit, cursor } = {}) | scopes: privateDb:read
  - Returns: { items: [{ id, key, value, updatedAt }], cursor? }
  - List keys from the default private per-user JSON store.
  - Lists private keys for the current viewer. Supports prefix filter and cursor pagination.
- async set(key, value) | scopes: privateDb:write
  - Returns: { item: { id, key, value, updatedAt } }
  - Upsert one JSON-serializable value in the default private per-user store.
  - Upserts one key for the current viewer. Value must be JSON-serializable (max 16 KB).
- async remove(key) | scopes: privateDb:write
  - Returns: { success: true, deleted: boolean }
  - Delete one key from the default private per-user JSON store.
  - Deletes one key for the current viewer.
- async compareAndSet(key, expectedValue, value, { operationId, expectedUserId }) | scopes: privateDb:write
  - Returns: { item: { id, key, value, updatedAt }, applied, duplicate, conflict }
  - Atomically save only when the current JSON value matches expectedValue, with a permanent idempotency receipt.
  - Pass null as expectedValue for an absent/null value. Both values are limited to 16 KB. Optional expectedUserId prevents a held operation from crossing an account change.
  - operationId is required: 8–64 letters, digits, underscores or hyphens. Reuse it for retries of the same logical change.
  - On conflict, rebase the intent onto the returned canonical item before comparing again. Do not retry-loop a 429.
  - A duplicate operation returns the current canonical item without applying again. Ordinary set/remove remain unconditional; use a dedicated key for a compare-and-save workflow.

### Twinkle.reminders
- async list({ includeDisabled, limit } = {}) | scopes: reminders:read
  - Returns: { reminders: [{ id, buildId, userId, title, body, targetPath, payload, isEnabled, schedule, lastTriggeredAt, createdAt, updatedAt }] }
  - Lists reminder rules for the current signed-in viewer.
  - includeDisabled includes turned-off reminders in the result.
- async create({ title, body, targetPath, payload, schedule, isEnabled }) | scopes: reminders:write
  - Returns: { reminder: { id, buildId, userId, title, body, targetPath, payload, isEnabled, schedule, lastTriggeredAt, createdAt, updatedAt } | null }
  - Creates one reminder rule for the current signed-in viewer.
  - schedule.type supports once, daily, and weekly.
- async update(reminderId, patch) | scopes: reminders:write
  - Returns: { reminder: { id, buildId, userId, title, body, targetPath, payload, isEnabled, schedule, lastTriggeredAt, createdAt, updatedAt } | null }
  - Updates one reminder rule for the current signed-in viewer.
- async remove(reminderId) | scopes: reminders:write
  - Returns: { success: true, deleted: boolean }
  - Deletes one reminder rule for the current signed-in viewer.
- async getDue({ now, autoAcknowledge, limit } = {}) | scopes: reminders:read
  - Returns: { now, reminders: [{ id, buildId, userId, title, body, targetPath, payload, isEnabled, schedule, lastTriggeredAt, createdAt, updatedAt }] }
  - Returns reminders that are due right now for the current signed-in viewer.
  - autoAcknowledge defaults to true and prevents the same reminder from retriggering immediately.

### Twinkle.arena
- async board({ ruleset, cursor, revision, limit } = {}) | scopes: sharedDb:read
  - Returns: { ruleset, revision, total, fighters, me, targets, dailyUsed, cursor, hasMore }
  - Load a ranked page plus your fighter and all challengeable opponents independently of the page.
  - limit defaults to 50 and is at most 100. Pass returned cursor and revision together for more rows.
  - A 409 means the ladder changed between pages: restart from the first page. Records are canonical and do not require replaying history.
- async publish({ ruleset, expectedUserId }) | scopes: sharedDb:write
  - Returns: { fighter }
  - Publish or update your own fighter from your confirmed saved career.
  - The server derives identity, stats, gameplan and appearance from the viewer’s saved career. Supplied fighter snapshots or user IDs are not accepted.
  - Existing rank and records are preserved; a new fighter joins at the bottom.
- async challenge({ ruleset, opponentUserId, operationId, expectedUserId }) | scopes: sharedDb:write
  - Returns: { bout, duplicate }
  - Issue and adjudicate one ranked match, atomically saving its result, quota usage and ranking.
  - operationId must contain 8–64 letters, digits, underscores or hyphens. Preserve it across ambiguous failures and reloads.
  - The server issues the seed and uses its pinned ruleset and saved fighters. Never submit a winner, seed, or fighter snapshot.
  - The bout contains id, ruleset, seed, a, b, outcome, winner, reason, round, tookSpot, at and by. a/b contain userId, name and snap.
  - Three challenges per UTC day, against fighters one to three ranks above you. Duplicate requests never consume another challenge.
  - Subscribed defender owners receive a ruleset-bound result notification from the canonical transaction. HTTP 400/409 eligibility errors with writeStatus=not_applied are definitive rejections; retain the same operationId after an ambiguous network failure.
- async bouts({ ruleset, cursor, limit } = {}) | scopes: sharedDb:read
  - Returns: { bouts, cursor, hasMore }
  - Read immutable ranked bout history, newest first, with cursor pagination.
  - limit defaults to 50 and is at most 100. There is no three-page history cutoff.
- async getBout({ ruleset, id, legacyEntryId }) | scopes: sharedDb:read
  - Returns: { bout }
  - Read one immutable bout in this build and ruleset.
  - Supply id, or legacyEntryId for an imported legacy notification. Replay new bouts only with their exact ruleset; the stored outcome is authoritative. Legacy records explicitly identify their unversioned simulation.

### Twinkle.rewards
- await Twinkle.rewards.getStatus() | scopes: rewards:claim
  - Returns: { mode: "live", dayKey, userDailyClaims, claimsToday, budgets: { userDailyXP, userDailyCoins }, rules: [{ id, title, xp, coins, verifier: "numeric-quiz" | "completion", minSeconds?, progression?, retryReward: { xp, coins }, maxAttempts, available, setKey, questionCount }], challenges: [{ challengeId, ruleId, attempts, attemptsRemaining, state: "open" | "finished" | "earned", setKey, questions: [{ prompt, hint?, guide? }] }], history: [{ ruleId, xp, coins, attempt, createdAt }], balances: { xp, coins } } | { mode: "preview", dayKey?, rules, challenges: [], history: [], balances?, problems?: string[], message }
  - Read canonical earning rules (without answer keys), today’s started challenges, today’s receipts and balances. Drafts return preview mode: for the app's owner the rules come from the draft's own rewards.json and question sheet (problems lists what is still wrong with them); anyone else sees no rules. Unapproved or revoked published releases return an error.
  - rules[].available is false on a site day (UTC) the reviewer scheduled no questions for; show the rule as not available instead of starting it. xp/coins are the first-try amounts; retryReward is what a correct answer pays after a wrong one (equal to xp/coins unless the reviewer set a retry share). maxAttempts null means unlimited wrong answers until the site's daily reset (UTC midnight, 9:00 AM in Korea). retry.paidAttempts, when set, is the last attempt number a correct answer is still paid on: a later correct answer is recorded as solved (receipt xp 0, coins 0) and pays nothing — tell the learner before they pass it.
  - challenges lists challenges this viewer already started today with their questions, so an app can resume after a reload without calling start. A question's guide (reviewer-approved JSON teaching content: explanation, interactive-model configuration) is present only once the viewer has answered at least once, right or wrong; render it as the after-attempt lesson. claimsToday against userDailyClaims (null = uncapped) tells whether another bounty can still pay today.
  - Under progression 'until-earned' the same set stays up day after day until somebody earns it; setKey names the set currently up. Completion rules are always available and have questionCount 0.
  - Rules may set maxLifetimeClaims, a per-learner limit for that rule across days and releases. rules[].lifetime contains the server-confirmed limit and remaining claims. App storage never enforces this limit.
- await Twinkle.rewards.getReceipt({ challengeId }) | scopes: rewards:claim
  - Returns: { mode: "live", status: "awarded" | "pending" | "expired" | "not_found", receipt: { id, challengeId, ruleId, reviewId, artifactVersionId, dayKey, xp, coins, attempt, createdAt } | null, balances: { xp, coins } } | { mode: "preview", status: "not_found", receipt: null, message }
  - Read an existing receipt for this app and signed-in viewer by server-issued challengeId, including previous UTC days and previous approved versions. Requires the current approved published release and runtime grant; a stale frame must reload first. Never awards, retries a claim, returns answer keys, or restores removed rewards permission.
  - Reconcile a durable local reward outbox after a lost claim reply: awarded confirms the exact payment; pending means no receipt yet for a current unexpired challenge, so retry the same challengeId. expired or not_found confirms no paid receipt and no claim possible for that ID under the current release. Never refund app items just because getStatus history omitted an older claim or a network request failed. Preview has no durable paid receipts; keep it separate from live recovery.
- await Twinkle.rewards.start({ ruleId, levelIndex? }) | scopes: rewards:claim
  - Returns: { mode: "live", challengeId, questions: [{ prompt, hint?, guide? }], setKey, reward: { xp, coins }, retryReward: { xp, coins }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, expiresAt }
  - Creates or resumes a server-issued challenge for the signed-in viewer. Render its questions (prompt and optional hint) and collect numeric answers in the same order. One daily challenge per rule/review; repeat starts cannot reset attempts. A challenge stays open until the site's daily reset (UTC midnight, 9:00 AM in Korea) (expiresAt). Resuming after a wrong answer includes each question's guide.
  - Errors: build_reward_not_scheduled when the rule has no questions for today; build_reward_daily_claims_reached when the viewer already earned today’s cap. attemptsRemaining is null for unlimited rules.
  - For a completion rule call start when the activity begins (the moment the stage starts); the challenge's age is what the claim is measured against. In preview mode start also works for the owner (a stateless simulation).
  - For completionProof: classic-tower-v1, start also returns completion { profile, token, maxFrames, completed, failed }. A new start resets only the simulated climb to its canonical spawn; it cannot reset daily or lifetime rewards. Record inputs from the first physics frame. The completion token is bound to the viewer, challenge, rule and published release.
  - For breadface-v1, pass the zero-based canonical levelIndex. Record [dt, inputBits] from the first physics frame; start returns its server token and maxFrames. For study-record-v1, start returns completion { profile, usesAiEnergy: true }; there is no client-authored proof token.
- await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
  - Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
  - Verify a bounded batch of inputs for an approved server-simulated climb.
  - Only for completionProof: classic-tower-v1. Send 1 to 600 chronological physics frames, each [dt, moveX, moveY, cameraForwardX, cameraForwardZ, jumpPressed, jumpHeld, speedMultiplier]. dt is in seconds, at most 0.05; movement axes are -1 through 1; the camera values are the horizontal components before normalization; jump flags are 0 or 1; speedMultiplier is an existing Classic Tower trail speed (1, 1.1, 1.2, 1.3, 1.35 or 1.4). Geometry and player state are owned by the registered server simulation.
  - Send occasional batches with at most one request in flight. Keep the previous token and the exact batch until a response confirms it; an identical retry is safe. Use the returned token for the next batch. Simulation time cannot outrun wall time. A completed token is evidence of a legal simulated run, not proof that a human played or that inputs were not automated.
  - On respawn or a return from another world, begin a fresh run at the canonical spawn via start. Preserve other worlds and gameplay. Do not submit positions, scores, secret keys, or a client completion flag. Preview tokens can never be redeemed in the published app.
  - For breadface-v1, send 1–1,000 chronological [dt, inputBits] frames (dt > 0 and <= 0.033). Bits are left=1, right=2, jumpHeld=4, jumpQueued=8, fireHeld=16. The server replays the registered frozen game; only a legitimate goal and any rule-specific bonus qualify. Honor maxFrames and retain unacknowledged inputs for a bounded retry.
  - For study-record-v1, send record { work, learning, nextStep? } and one stable requestId (16–64 ASCII letters, numbers, hyphens or underscores, e.g. crypto.randomUUID()). The combined record is at most 4,000 UTF-8 bytes. Show that each check uses AI Energy before sending; accept, revise and uncertain decisions all incur measured check cost. Reuse the requestId for a lost response; do not poll in a loop. An accepted, billed review returns completion.completed and a bound token; use that token with claim. A preview charges for the check but never awards real XP/Coins. Keep the record and show completion.message on a refusal or uncertain result.
- await Twinkle.rewards.claim({ challengeId, answers?: [number], completionToken?: string }) | scopes: rewards:claim
  - Returns: { awarded: false, attempts, attemptsRemaining, questions: [{ prompt, hint?, guide? }] } | { awarded: true, duplicate, receipt: { ruleId, xp, coins, attempt, firstTry }, questions: [{ prompt, hint?, guide? }], balances: { xp, coins } }
  - Twinkle verifies every answer, approval, current published artifact and budget before atomically recording XP and Coins. The receipt’s xp/coins are what was actually paid: the full amounts on a first try, the retry share after a wrong answer (attempt > 1). Retry the same challengeId after a lost response; a confirmed claim returns its original receipt without another award. Never update balance UI optimistically. Under retry.paidAttempts a correct answer past that attempt returns awarded: true with a zero receipt: solved, not paid.
  - Every claim response, wrong or right, returns the questions with their guides unlocked: show the teaching content right after the first answer. Answer keys are never returned.
  - A wrong answer within two seconds of the previous one is refused with build_reward_throttled (HTTP 429) and does not count; wait for the person to try again rather than retry-looping.
  - Completion rules take no answers: claim({ challengeId }) when the activity is finished. build_reward_too_fast (HTTP 409) means fewer than minSeconds passed since start; show nothing and let play continue. In preview mode the receipt carries preview: true and nothing is paid.
  - A completionProof rule also requires the signed completionToken from a successful rewards.progress response. The server simulates the registered game physics and must reach the goal. A timer, forged position, client win flag, altered inventory or token from another viewer, challenge or release cannot authorize payment. maxLifetimeClaims is enforced from receipts in the same award transaction. build_reward_lifetime_claims_reached means all rewards for this rule have been collected; do not retry it.
  - For study-record-v1, the server verifies the settled private review row and its viewer, app, exact release, challenge and day binding. It never trusts the client’s decision, requested award amount or AI answer. Each successful daily study record is claimable once; display only canonical receipt/balances.
- await Twinkle.rewards.getLeaderboard({ metric?: "xp" | "coins", period?: "day" | "week" | "all", limit? }) | scopes: rewards:claim
  - Returns: { mode: "live", metric, period, limit, dayKey, from, available: { xp, coins }, entries: [{ rank, userId, username, profilePicUrl, xp, coins, claims, lastAt }], me: { rank, xp, coins, claims } | null } | { mode: "preview", metric, period, available, entries: [], me: null, message }
  - Standings of who earned the most XP or Coins in THIS app, computed by Twinkle from its own receipts (never from anything the app submits). period 'day' is today (site day, UTC), 'week' the last 7 site days, 'all' (default) every day since approval. limit defaults to 20, max 100.
  - available says which boards this app's approved rules can pay: show a Coins board only when available.coins is true (an app whose rules pay XP only has no Coins standings). me is the signed-in viewer's own standing even when they fall outside the page, or null when they earned nothing in the period.
  - Drafts and previews return mode 'preview' with no entries. Use Twinkle.leaderboards for app-defined scores; use this for real XP and Coins earned.
- await Twinkle.rewards.getTimeline({ ruleId?, cursor?, limit? } = {}) | scopes: rewards:claim
  - Returns: { mode: "live", dayKey, entries: [{ receiptId, ruleId, ruleTitle, setKey, title, promptPreview, dayKey, closedAt, solvedAt, solver: { userId, username }, firstSolver, xp, coins, attempt }], nextCursor } | { mode: "preview", entries: [], nextCursor: null, message }
  - Browse confirmed solves of retired until-earned quiz bounties, newest first.
  - A solve appears only after the next site reset (UTC midnight, 9 AM Korea). Today’s solves, unsolved sets, standing quizzes and dated quizzes are excluded. The server decides retirement; client dates cannot unlock content. Zero-reward correct solves are included.
  - One entry per solve receipt, with firstSolver identifying the first receipt for that rule and set across approved versions. limit defaults to 20, max 50; pass nextCursor unchanged for older solves and omit it when changing ruleId. Pages may have fewer entries when old question sheets cannot be recovered; continue while nextCursor is present.
  - No daily-claim limit applies to reading. Requires the current approved published runtime grant; previews return an empty timeline. Never awards or changes balances. Open a receipt with getArchivedProblem to load the original question and guide.
- await Twinkle.rewards.getArchivedProblem({ receiptId }) | scopes: rewards:claim
  - Returns: { mode: "live", entry: <same solve entry as getTimeline>, questions: [{ prompt, hint?, guide? }] } | { mode: "preview", entry: null, questions: [], message }
  - Read a retired bounty’s original questions and guides from the approval attached to its solve receipt.
  - Use a receiptId returned by getTimeline. Both reads independently check retirement and app ownership; guessing an active or other app’s receipt cannot reveal its questions or guides. Returns build_reward_archive_unavailable (404) if unavailable.
  - Uses that receipt’s frozen approved sheet, never today’s edited question or the mutable draft. Answer keys and tolerances are never returned. Render the question first and offer Reveal guide for learning, without a reward-claim button.

## Examples

### Daily reflection feed

```js
const feed = await Twinkle.reflections.getDailyReflections({ limit: 20 });
```

### Advanced per-user SQLite
Use Twinkle.userDb only when the app needs private relational tables, indexes, filtered queries, or aggregates. Use Twinkle.privateDb for simple settings and small JSON state.
Keywords: sqlite, userDb, advanced private data, relational, tables, indexes

```js
await Twinkle.userDb.exec('CREATE TABLE IF NOT EXISTS follows (userId INT PRIMARY KEY, followedAt INTEGER)');
```

### List my subjects

```js
const { subjects } = await Twinkle.subjects.getMySubjects({ limit: 10 });
subjects.forEach(s => console.log(s.id, s.title));
```

### Search subjects for a picker
Use this when the app should ask the viewer which Twinkle subject to turn into a book, scrapbook, or gallery.
Keywords: subject, search, picker, book, mount

```js
const { subjects } = await Twinkle.subjects.search({ query: searchText, limit: 12 });
// Let the viewer pick one, then use picked.id with getSubject and subjectComments.list.
```

### Build a book from a subject
Ask the viewer to choose a subject with Twinkle.subjects.search, optionally preselecting Twinkle.mount.get() when the host provides a subject. Then fetch metadata and read the subject comment stream oldest-first. Filter to author:'subjectPoster' when only the poster's comments should become pages, and use replyScope:'ownThread' when including poster replies.
Keywords: subject, comments, book, pages, search, picker, mount, subject poster

```js
const mount = await Twinkle.mount.get();
const initialSubjectId = mount?.type === 'subject' ? mount.id : null;
const { subjects } = initialSubjectId
  ? { subjects: [] }
  : await Twinkle.subjects.search({ query: searchText, limit: 12 });
const subjectId = initialSubjectId || pickedSubject.id;
const { subject } = await Twinkle.subjects.getSubject(subjectId);
const { comments, pagination } = await Twinkle.subjectComments.list(subjectId, {
  sortBy: 'oldest',
  author: 'subjectPoster',
  includeReplies: true,
  replyScope: 'ownThread',
  limit: 50
});
console.log('Title:', subject.title, 'Pages:', comments.length, 'hasMore:', pagination?.hasMore);
```

### Typing game content from AI Cards and AI Stories
Use AI Card words for word mode, AI Card exampleText for sentence mode, and AI Story story text for passage mode. Keep leaderboards separate per mode.
Keywords: typing, ai cards, ai stories, words, sentences, passages, leaderboard

```js
const wordPage = await Twinkle.aiCards.list({ level: 1, hasExample: true, limit: 20 });
const wordTargets = wordPage.cards.map((card) => ({
  text: card.word,
  level: card.level,
  sourceId: card.id
}));
const sentenceTargets = wordPage.cards.map((card) => ({
  text: card.exampleText,
  level: card.level,
  sourceId: card.id
}));

const passagePage = await Twinkle.aiStories.list({ difficulty: 2, limit: 10 });
const passageTargets = passagePage.stories.map((story) => ({
  text: story.story,
  level: story.difficulty,
  sourceId: story.id
}));

await Twinkle.leaderboards.submit({
  boardKey: 'arcade-typing-words',
  score: finalScore,
  meta: { mode: 'words', level }
});
```

### Search AI Stories for a quiz app
Use existing user-generated AI Stories as source material for visual galleries, readers, and question-based games.
Keywords: ai stories, story, quiz, questions, image, gallery

```js
const { stories } = await Twinkle.aiStories.search({
  query: searchText,
  hasQuestions: true,
  hasImage: true,
  limit: 12
});
const picked = stories[0];
console.log(picked.topic, picked.imageUrl, picked.questions.length);
```

### Recent profile comments

```js
const { comments, pagination } = await Twinkle.profileComments.getProfileComments({
  sortBy: 'newest',
  includeReplies: true,
  limit: 20
});
console.log('Loaded comments:', comments.length, 'hasMore:', pagination?.hasMore);
```

### Build leaderboard
Submit personal-best scores for signed-in viewers and guests, then read score-sorted rankings.
Keywords: leaderboard, leaderboards, scoreboard, scores, rankings, personal best, guest scores

```js
const boardKey = 'main';
const finalScore = computeFinalScoreForFinishedRun();
const viewer = await Twinkle.viewer.get();
const guestName = savedGuestName || readGuestNameFromInput();
if (viewer.isGuest && !guestName) {
  showGuestNameForm();
  return;
}

await Twinkle.leaderboards.submit({
  boardKey,
  score: finalScore,
  displayName: viewer.isGuest ? guestName : undefined,
  meta: { mode: 'classic' }
});

const page = await Twinkle.leaderboards.get({ boardKey, limit: 10 });
renderLeaderboard(page.entries, page.personalBest);
```

### Paginated shared feed

```js
const pageSize = 12;
let nextCursor = null;

async function loadFirstPage() {
  const page = await Twinkle.sharedDb.getEntries('posts', { pageSize });
  nextCursor = page.cursor;
  renderPosts(page.entries, { append: false, hasMore: page.hasMore });
}

async function loadMorePosts() {
  if (!nextCursor) return;
  const page = await Twinkle.sharedDb.loadMoreEntries('posts', { pageSize, cursor: nextCursor });
  nextCursor = page.cursor;
  renderPosts(page.entries, { append: true, hasMore: page.hasMore });
}
```

### Update a shared entry

```js
const { entry } = await Twinkle.sharedDb.updateEntry(entryId, { name: 'Alice', score: 99 });
console.log('Updated:', entry.data);
```

### Store private user settings
Default private per-user key/value storage for preferences, drafts, settings, and small JSON state.
Keywords: storage, private storage, privateDb, settings, preferences, drafts, small JSON

```js
await Twinkle.privateDb.set('prefs.theme', { mode: 'mint' });
const { item } = await Twinkle.privateDb.get('prefs.theme');
console.log('Theme:', item?.value?.mode);
```

### Build a lobby chat

```js
await Twinkle.chat.createRoom({ roomKey: 'lobby', name: 'Lobby' });
const unsubscribe = Twinkle.chat.subscribe('lobby', (event) => console.log('chat event', event));
await Twinkle.chat.sendMessage('lobby', 'hello');
```

### Realtime MMO town room
Use Twinkle.world for live avatar presence and lightweight room actions, recover stale session handles, and keep durable state like inventory and quests in sharedDb/privateDb — written low-frequency (never per frame/tick).
Keywords: multiplayer, mmo, town, presence, avatars, movement, three.js, realtime

```js
let world = null;
let worldConnection = null;
let reconnectTimer = 0;
let reconnectDelayMs = 1000;
let latestPresence = { x: 0, y: 0, z: 0, facing: 'south', animation: 'idle' };
let latestPresenceKey = JSON.stringify(latestPresence);
let queuedPresence = null;
let presenceInFlight = false;

async function connectWorld() {
  if (world) return world;
  if (worldConnection) return worldConnection;
  const presenceAtJoin = latestPresence;
  const presenceKeyAtJoin = latestPresenceKey;
  worldConnection = Twinkle.world.join({
    worldKey: 'town', roomKey: 'square', presence: presenceAtJoin,
    player: { name: avatarName }
  });
  try {
    const session = await worldConnection;
    world = session;
    reconnectDelayMs = 1000;
    session.subscribe((event) => {
      renderPlayers(event.players);
      if (event.type === 'session.ended') handleWorldDrop(session);
      if (event.type === 'action.received' && event.action?.type === 'emote') {
        showEmote(event.sessionId, event.action.data.emote);
      }
    });
    if (latestPresenceKey !== presenceKeyAtJoin) queuedPresence = latestPresence;
    return session;
  } finally {
    worldConnection = null;
  }
}

function handleWorldConnectError(error) {
  if (Twinkle.world.isRecoverableSessionError(error)) {
    scheduleReconnect();
  } else {
    console.error('World connection failed', error);
  }
}

function scheduleReconnect() {
  if (reconnectTimer || world || worldConnection) return;
  const delay = reconnectDelayMs;
  reconnectDelayMs = Math.min(30000, reconnectDelayMs * 2);
  reconnectTimer = setTimeout(() => {
    reconnectTimer = 0;
    connectWorld().catch(handleWorldConnectError);
  }, delay);
}

function handleWorldDrop(session = world) {
  if (session && world && world !== session) return;
  world = null;
  queuedPresence = null;
  scheduleReconnect();
}

function queuePresence(next) {
  const key = JSON.stringify(next);
  if (key === latestPresenceKey) return;
  latestPresenceKey = key;
  latestPresence = next;
  if (world) queuedPresence = next; // Coalesce to the newest unsent snapshot.
}

async function flushPresence() {
  if (presenceInFlight || !queuedPresence || !world) return;
  const session = world;
  const next = queuedPresence;
  queuedPresence = null;
  presenceInFlight = true;
  try {
    await session.updatePresence(next);
  } catch (error) {
    if (Twinkle.world.isSessionEndedError(error)) {
      handleWorldDrop(session);
    } else if (!Twinkle.world.isRecoverableSessionError(error)) {
      console.error('World update failed', error);
    }
    // Recoverable errors drop this transient snapshot without an immediate retry.
  } finally {
    presenceInFlight = false;
  }
}

// The render/input loop only queues changed local state.
function onPlayerStateChanged() {
  queuePresence({
    x: player.x, y: player.y, z: player.z,
    facing, animation: player.animation
  });
}

connectWorld().catch(handleWorldConnectError);
setInterval(() => { void flushPresence(); }, 100); // Fixed 10 Hz cap.
```

### Play chess against the computer
Use the parent-managed Stockfish helper for the computer move while app code owns the board, legal moves, and game-over state.
Keywords: chess, stockfish, computer opponent, board game, fen

```js
const result = await Twinkle.chess.bestMove({ fen: game.fen(), skillLevel: 7, maxTimeMs: 1000 });
if (result.success) {
  game.move({ from: result.from, to: result.to, promotion: result.promotion || undefined });
  renderBoard(game);
}

const strongest = await Twinkle.chess.bestMove({ fen: game.fen(), skillLevel: 20, maxTimeMs: 60000 });
```

### Upload files and store shared metadata

```js
const { assets, canceled } = await Twinkle.files.pickAndUpload({ accept: 'image/*,.pdf', multiple: true });
if (!canceled) {
  for (const asset of assets) {
    await Twinkle.sharedDb.addEntry('uploads', { assetId: asset.id, url: asset.url, thumbUrl: asset.thumbUrl, fileName: asset.fileName, mimeType: asset.mimeType });
  }
}
```

### List and remove uploaded files

```js
const { assets, usage } = await Twinkle.files.list({ limit: 20 });
if (assets[0]) {
  await Twinkle.files.delete(assets[0].id);
}
console.log('Remaining quota bytes:', usage?.remainingBytes);
```

### Create a daily focus reminder

```js
await Twinkle.reminders.create({
  title: 'Pick your top 3',
  body: 'Choose your focus tasks for today.',
  schedule: { type: 'daily', timeZone: 'America/Los_Angeles', hour: 9, minute: 0 },
  targetPath: '/focus'
});
```

### Claim an approved learning reward
Keywords: xp, coins, rewards, quiz, approval

```js
const status = await Twinkle.rewards.getStatus();
if (status.mode === 'live') {
  const challenge = await Twinkle.rewards.start({ ruleId: 'daily-question' });
  // Render challenge.questions and collect numbers in the same order.
  // const result = await Twinkle.rewards.claim({ challengeId: challenge.challengeId, answers });
  // Display only result.balances and result.receipt after awarded === true.
}
```
