# Build SDK Index

Version: 1.65.1
Updated: 2026-10-13
Generated: 2026-10-12T17:14:00.482Z

## 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; an award larger than the learner's remaining room today pays what fits, XP and Coins clamped separately; a capped award still counts as claimed, including a one-time maxLifetimeClaims reward, and its receipt carries cappedFrom) and rules [{ id, title, howTo?, category? (section header, up to 40 characters), series? (key for repeat rules that share category and amounts), xp, coins, verifier: 'numeric-quiz' | 'completion' | 'generated-quiz', maxAttempts?, retry?: { xpPercent, coinsPercent, paidAttempts? }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz), bank? (generated-quiz): { steps: [percent, ...] rising to 100, startStep?, instructions, topics: [{ key, title, description }] } }]. generated-quiz rules need no question sheet. 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. 'generated-quiz' pays from a question bank the server fills itself: for one declared topic at the learner's step of the ladder, the server writes a question with GPT-6.1 Sol (billed to that learner's AI Energy), solves it again independently and keeps it only when both answers agree; every learner who has not seen a question gets it before anything new is written. `xp`/`coins` are the top step's amounts and each step pays its `steps` percent of them (then the usual retry share). Two first-try answers in a row climb a step; two misses in a row step down. Learners may earn many bank questions a day, bounded by userDailyXP/userDailyCoins. The reviewer approves the ladder, amounts, topics and instructions, not each question; learner reports and solve rates hide bad questions. Use the rewards bank methods (getBankStatus, startBankQuestion, generateBankQuestion, claimBankQuestion, reportBankQuestion); start and claim refuse bank rules. 'archive-rematch' is the daily rematch: one retired until-earned puzzle of the app a day, chosen by the server (celebrated, high-rated solves first; never repeated within 60 days) and answered from its frozen approved sheet. Declare at most one, with no questions, sets, progression, series, minSeconds or completionProof, e.g. { "id": "rematch-daily", "title": "Daily rematch", "xp": 20000, "coins": 1000, "verifier": "archive-rematch", "retry": { "xpPercent": 50, "coinsPercent": 0, "paidAttempts": 3 }, "maxAttempts": null }. Only players who never met that puzzle (no official receipt, wrong answer or challenge while it was live, no Vault practice) may answer it; its receipt does not use userDailyClaims but counts toward userDailyXP/userDailyCoins. Use getRematch and claimRematch; start and claim refuse it.
- 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 (a receipt capped by the daily budget still counts, so a one-time reward claimed near the cap pays only what fits); 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, breadface-v2 and breadface-v3 (server-simulated Breadface inputs; each is one reviewed release's exact physics, chosen by the reviewer) study-record-v1 (a private study record reviewed by JEV, billed to the learner’s AI Energy), and Groove Lab's groove-lab-song-v1 (a song the learner published today, checked on the server for length, notes and originality) and groove-lab-heard-v1 (three established accounts finished the learner's songs today), groove-lab-listen-v1 (the learner listened to most of someone else's published song: start binds the song, the server clock covers three quarters of it up to two minutes, and the listener's own groove-lab-listens row for it was written today; each song pays a listener once a day across listening rules) and groove-lab-own-listen-v1 (the same for the learner's own song; own listens never count toward groove-lab-heard-v1), Ashen Vigil's vigil-guest-coplay-v1 (a new guest spent 10 minutes with the learner in a private world room, measured by the world relay; each guest pays once) and minecraft-first-link-v1 (the learner is the first Twinkle account ever linked to that Minecraft player; each player pays once), and writing-words-v1 with minWords (counted words the learner wrote today as chapters in their own books registered in the app's shared books topic; each chapter is read once by a small model for genuine writing and language, English words count fully and other languages half, and the same text counts once), reading-quiz-v1 (the learner passed a server-graded three-question quiz on a chapter of someone else's registered book after its reading time; each chapter pays a reader once, one attempt per chapter per day) and reading-readers-v1 with minReaders (that many established accounts passed a quiz on the learner's chapters today). Twinkle.craft apps also have craft-target-v1 (the learner's run of merges reaches today's target in this app's universe; every merge is a known pair and every result is one the learner made through Twinkle.craft.merge), craft-par-v1 (the same run reaches it in at most par merges) and craft-discovery-v1 (the oldest World First of the learner's that no rule has paid yet; each World First pays once ever; optional minWordLevel basic|elementary|intermediate|advanced|epic makes tiers: either every craft-discovery-v1 rule has one or none does; each World First pays under exactly one tier, the highest configured tier at or below its Word Master level, so a series per tier can pay rarer words more and the order of claims never matters; an item whose level is still pending pays under no tier yet). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.

## AI decision design
- Twinkle.ai.decide runs JEV, a model for narrow decisions over supplied text or structured state, and it is free for viewers: decisions are covered by Twinkle and never use their AI Energy. Whenever an app makes a judgment that ordinary code can only approximate, consider JEV first. Proven patterns: an opponent or boss coach that reads the player's habits (favourite lane, repeated trick, cheese move) and picks a counter-plan from a fixed list, as in Red Square's boss and Open Football's opponent coach; NPCs that understand what a player types and choose an action; classifying or scoring user submissions; ranking supplied candidates; and picking the next exercise or difficulty from evidence about the learner. Any app with an AI opponent, NPC, or adaptive difficulty should decide explicitly whether JEV improves it. 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 the added latency is 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. JEV decisions are covered by Twinkle and never use the viewer's AI Energy; the runtime AI rate limits still apply. Handle unavailable, timeout, 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, ageTier }
  - Cached; use refresh() to re-fetch.
  - ageTier is 'kid', 'teen' or 'adult', taken from the viewer's Teenager and Adult achievements (granted once an admin approves their birthdate). Guests and viewers without either achievement are 'kid'. It is a display and default-selection hint computed by the Twinkle page; do not treat it as a server-side permission.
- 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 checks the destination and opens it right away, with no extra confirmation step.
  - 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, tells Twinkle the app offers tools (so the signed-in viewer can click Connect AI helper and pair this open tab with lumine app-mcp <buildId> --code <code>), and resolves with active:false.
  - Mark each tool that only reads with "readOnly": true in /app-tools.json; for a tool whose action argument decides, declare "readOnlyWhen": { "action": ["list", "export"] }. A helper paired through Connect AI helper can use only read-only calls until the viewer switches on Allow edits; undeclared tools count as edits.
  - Include what the viewer has selected or is looking at in the state-reading tool (for example the selected range and active item), so a paired helper can see what the viewer is pointing at.
  - 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.
  - Twinkle shows the acting member's username right before `summary` in the notification row, so write `summary` as a predicate ('posted in Lobby', 'responded to the Twinkle Newspaper'), never starting with the member's name (otherwise it reads 'Liki Liki responded…').
  - 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 GPT-6 Luna 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. Decisions are covered by Twinkle: they never use the viewer's AI Energy, and a viewer with empty Energy still gets answers. The response includes the viewer's current canonical aiUsagePolicy, unchanged by the call.
  - 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. An answer that fails validation is retried once on the server within the same deadline; there is no other 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, images, 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.
  - images: up to 3 reference image URLs the model looks at along with the prompt (a sketch, a photo, a screenshot). They must be Twinkle-hosted uploads (Twinkle.files.pickAndUpload / uploadGenerated asset URLs; PNG, JPEG, WebP or GIF); anything else is refused with 400 before any model call. Image input counts toward the AI Energy of the call like any other input.
  - 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.
  - Template values describe types, not answers to copy. All object keys are required and extra keys are rejected, including inside nested objects; {} means an exactly empty object, not a free-form dictionary. A non-empty array uses only its first item as the template for every returned item, with no fixed length; mixed examples do not declare a union. [] allows arbitrary JSON items. A null template accepts any JSON primitive (string, number, boolean or null), but the key is still required. Use 'number' for fractional numbers; 0 and other integer examples require integers. State content requirements in prompt or instructions. GPT routes retain descriptive string examples as field guidance, not enforced enum values.
  - GPT models prefer native JSON Schema output for compatible shapes. Shapes outside the strict provider subset, such as [] with arbitrary items or deeply nested structures, use prompt-based JSON generation with the same final validation. A provider rejection specifically of the native schema triggers one prompt-based fallback; auth, quota, safety and unrelated request failures do not trigger that fallback. Validation retries preserve the original task and instructions. The returned object/result shape and streaming callbacks are unchanged.
  - 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. Lumine Light defaults to claude-haiku-5-5; gpt-6-luna remains its second choice. Both model IDs accept Low, Medium or High thinking. Explicit claude-sonnet-5-5, gpt-6.1-sol or claude-opus-5-5 still require thinkingMode: 'high'; unknown model IDs reject instead of silently falling back. Retired IDs still work and run on their replacement: claude-sonnet-5 runs claude-sonnet-5-5; gpt-6-sol and gpt-5.6-sol run gpt-6.1-sol; gpt-6-astra, claude-opus-5 and claude-fable-5-1 run claude-opus-5-5.
  - thinkingMode low uses Claude Haiku 5.5 with low adaptive thinking and consumes the viewer's AI Energy from confirmed provider usage. Set model: 'gpt-6-luna' to choose Luna instead.
  - thinkingMode medium is Lumine Light: it uses Claude Haiku 5.5 with medium adaptive thinking and consumes normal AI Energy. Set model: 'gpt-6-luna' to keep Luna as the second choice.
  - thinkingMode high without model uses Claude Sonnet 5.5 with high adaptive thinking and consumes high AI Energy. Explicit model: 'gpt-6.1-sol' selects GPT-6.1 Sol with xhigh reasoning at the same High AI Energy tier; model: 'claude-sonnet-5-5' names Sonnet 5.5 directly.
  - claude-sonnet-5-5 and claude-opus-5-5 use Anthropic adaptive High thinking and 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; OpenAI/xAI routes can retry malformed output, while Anthropic routes (claude-haiku-5-5, claude-sonnet-5-5 and claude-opus-5-5) use native JSON Schema output and retry one malformed or shape-invalid result. App code should still validate business-specific enum values.
  - If the model declines a request, the call rejects with code ai_declined (HTTP 422). The tokens it used are charged to AI Energy like a finished call. Show the viewer a short message so they can rephrase; do not automatically retry the same prompt.
  - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'high', model: 'claude-opus-5-5', prompt: 'Plan the next section from: ' + currentState, expectedStructure: { producerNotes: 'string', action: 'string', confidence: 'number' }, 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.
  - engine: 'gemini' uses Google's Nano Banana 2.1 (the fast Gemini image tier; it replaced the retired original Nano Banana on 2026-10-08).
  - 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) });
- async generateMusic({ prompt, length, instrumental, requestId, timeoutMs } = {}) | scopes: none
  - Returns: { success, asset, url, mimeType, structure, length, instrumental, model, requestId, replayed?, aiUsagePolicy }
  - Generate a finished piece of music (rendered audio, not notes) from a text description with Google Lyria. The audio is saved to the viewer's own Twinkle.files and returned as an asset URL.
  - Signed-in viewers only. Call it directly from an explicit viewer action such as a button click; calls from page load, timers or programmatic retries are rejected (USER_ACTIVATION_REQUIRED).
  - Twinkle shows a host-owned confirmation with the battery cost for every generation. One approval authorizes exactly one request.
  - length: 'full' (default) is a complete song of about two to three minutes (Lyria 3.5); 'clip' is a 30-second piece (Lyria 3 Clip) at half the cost.
  - Describe genre, mood, instruments, tempo and structure in the prompt; ask for vocals or pass instrumental: true for no vocals. Duration and vocals have no other controls.
  - Prompts naming artists, bands, songs or copyrighted lyrics are refused with code music_prompt_blocked and cost nothing: show the viewer the error message so they can reword it.
  - The result is a normal Twinkle.files asset (asset.id, asset.url, audio/mpeg) owned by the viewer and counted against their file storage; it also appears in Twinkle.files lists. Store asset.url (e.g. in privateDb/sharedDb) to play it later.
  - structure is the model's song-structure text (section markers, and lyrics when there are vocals).
  - AI Energy is charged only after the music is saved. Failures, timeouts and refusals are not charged.
  - Only one music generation per viewer may run at a time (code ai_music_generation_in_progress). A retry with the same requestId returns the finished result without paying again (replayed: true), or code music_in_progress while it is still being made.
  - Generation usually takes one to three minutes; the SDK timeout defaults to 600000ms. Show progress UI while waiting.
  - Example: const song = await Twinkle.ai.generateMusic({ prompt: 'Warm lo-fi hip hop with dusty drums, a mellow Rhodes and rain in the background', instrumental: true }); audio.src = song.url;
- async generateSpeech({ text, voice, style, language, requestId, timeoutMs } = {}) | scopes: none
  - Returns: { success, asset, url, mimeType, durationSeconds, alignment, text, voice, model, requestId, replayed?, aiUsagePolicy }
  - Generate spoken audio (OpenAI realtime voices or ElevenLabs presets) and save it in the viewer's Twinkle.files. New generations use AI Battery.
  - Signed-in viewers only. Each new generation uses AI Battery; show this before the viewer starts voice interaction.
  - text: 1–2,000 characters. language: optional ISO language code such as en.
  - voice: an OpenAI realtime voice (alloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar) or an ElevenLabs preset (warm, bright, calm; warm is the default). For a recurring character, pick one voice and keep it everywhere, including any pre-recorded lines, so the character always sounds the same.
  - style (OpenAI voices only): up to 300 characters of performance direction such as age, mood, pace and clarity. The voice reads the text word for word in that style.
  - The MP3 is saved in the viewer's Twinkle.files and counts toward storage. Replay the saved URL to listen again without another AI request.
  - alignment contains provider character timing for subtitles or animation (ElevenLabs presets only; null for OpenAI voices). Respect Twinkle tab mute and browser playback controls.
  - Reuse requestId after a lost response. Completed audio replays the saved asset and settles the same billing receipt without regeneration or duplicate charges.
  - Only one speech or transcription request per viewer runs at a time. SDK timeout defaults to 180000ms.
  - Example: const speech = await Twinkle.ai.generateSpeech({ text: 'Welcome to our club!', voice: 'shimmer', style: 'Cheerful, curious thirteen-year-old girl; bright and warm.', language: 'en' }); audio.src = speech.url;
- async transcribeAudio({ audioBase64, language, requestId, timeoutMs } = {}) | scopes: none
  - Returns: { success, text, words: [{ text, start, end }], language, durationSeconds, model, requestId, replayed?, aiUsagePolicy }
  - Recognize a short recording with ElevenLabs Scribe v2. New recognition requests use AI Battery.
  - Signed-in viewers only. New recognition requests use AI Battery. Each successful response includes the canonical aiUsagePolicy.
  - audioBase64 is plain base64 of a mono 16-bit PCM WAV at 8–48 kHz, between 0.2 and 30 seconds. The server validates the actual duration.
  - Request microphone permission only after a viewer action, show a recording indicator, and stop microphone tracks when recording ends or the app closes.
  - Audio is sent to the recognition provider without saving a Twinkle recording asset. Let the viewer review or correct the transcript before using it as their message.
  - Reuse requestId after a lost response. Completed transcripts replay without recognition or duplicate charges. Word recognition is not a pronunciation score.
  - Only one speech or transcription request per viewer runs at a time. SDK timeout defaults to 180000ms.
  - Example: const transcript = await Twinkle.ai.transcribeAudio({ audioBase64: wavBase64, language: 'en' }); input.value = transcript.text;
- 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 uses GPT-6 Luna with reasoning disabled and Ciel uses Claude Haiku 5.5 with low thinking; 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 uses GPT-6 Luna with reasoning disabled and Ciel uses Claude Haiku 5.5 with low thinking, with normal AI Energy. If Anthropic is unavailable, Ciel's reply falls back to GPT-6 Luna.
  - Inside Build character chat, thinkingMode high uses Think Hard chat routing and high AI Energy: Zero uses Grok 4.7 with high reasoning and Ciel uses Claude Opus 5.5 with high thinking.
  - 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, hasSecretAnswer, hasSecretAttachment, secretShown, 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).
  - secretAnswer and secretAttachment follow the site's rule: they are filled only when the viewer posted the subject or has responded to it (secretShown true). Otherwise they are null and hasSecretAnswer / hasSecretAttachment say a secret exists.
- 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.
  - A story the signed-in viewer is still playing (an AI Story attempt they have not submitted) comes back without answerIndex on its questions; the site reveals that key only after the viewer submits.
  - 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.
  - A story the signed-in viewer is still playing (an AI Story attempt they have not submitted) comes back without answerIndex on its questions; the site reveals that key only after the viewer submits.
  - 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.
  - A story the signed-in viewer is still playing (an AI Story attempt they have not submitted) comes back without answerIndex on its questions; the site reveals that key only after the viewer submits.
  - 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, isChecked }], cursor?, pagination: { level, limit, hasMore, nextCursor } }
  - Read Grammarbles questions and choices by level with rating/id cursor pagination; answers stay on the server.
  - Questions do not include the answer, answerIndex or explanation: Grammarbles is a rewarded game, so its key stays on the server. Check a pick with Twinkle.grammarbles.checkAnswer.
  - Each question's choices come in a fixed order; pass the index of the picked choice in that order to checkAnswer.
  - 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 checkAnswer({ questionId, choiceIndex }) | scopes: content:read
  - Returns: { questionId, choiceIndex, isCorrect, explanation }
  - Check one Grammarbles pick on the server: right or wrong, plus the review explanation on a right pick.
  - choiceIndex is 0-3 in the order listQuestions returned the choices.
  - The right position is never returned. Let the learner try again until the pick is right, as Grammarbles itself does.
  - explanation is null unless the pick is right and the question has a reviewed explanation.
  - Each viewer has a daily allowance of answer checks (500 per UTC day); past it the call fails with code grammarbles_check_limit.
  - Checking pays nothing and does not touch daily Grammarbles attempts, XP, coins, or daily tasks.
  - Example: const { isCorrect, explanation } = await Twinkle.grammarbles.checkAnswer({ questionId: question.id, choiceIndex: pickedIndex }); if (!isCorrect) showTryAgain(); else showCorrect(explanation);
- 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 question text and choices, and Twinkle.grammarbles.checkAnswer to check a pick.
  - 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.minecraft
- async getWorlds() | scopes: content:read
  - Returns: { worlds: [{ id, name, dimension, spawn: { x, z } | null, map: { tileUrlTemplate, minZoom, maxZoom, tilePixels, blocksPerTileAtMaxZoom, renderedAt } | null }], stale }
  - List the server's worlds (world, world1, world2, world_nether, world_the_end) with squaremap tile info for drawing a top-down map.
  - Tiles are 512x512 PNGs over HTTPS from www.twinklemc.site. At maxZoom one pixel is one block; blocksPerTile = blocksPerTileAtMaxZoom * 2 ** (maxZoom - z); tile x/y = floor(block x/z / blocksPerTile). Fill {z}, {x}, {y}. map.renderedAt tells how fresh the tiles are.
  - Limited rollout: apps not enabled yet get 403 with code minecraft_sdk_not_enabled.
  - stale: true means the Minecraft server was briefly unreachable and the data is a recent cached copy.
  - Example: const { worlds } = await Twinkle.minecraft.getWorlds(); const w = worlds.find((x) => x.id === 'world'); const z = w.map.maxZoom; const blocksPerTile = w.map.blocksPerTileAtMaxZoom * 2 ** (w.map.maxZoom - z); const tx = Math.floor(blockX / blocksPerTile), ty = Math.floor(blockZ / blocksPerTile); const url = w.map.tileUrlTemplate.replace('{z}', z).replace('{x}', tx).replace('{y}', ty);
- async getOnlinePlayers() | scopes: content:read
  - Returns: { players: [{ name, world, x, y, z, isZero }], count, stale }
  - List players currently online with their world and block coordinates. Zero appears with isZero: true and is not counted in count.
  - Cached for about 5 seconds; poll no faster than every 5 seconds.
  - Names are Minecraft usernames, not Twinkle usernames.
  - Example: const { players, count } = await Twinkle.minecraft.getOnlinePlayers();
- async getZero() | scopes: content:read
  - Returns: { zero: { online, world, position: { x, y, z } | null, activity: 'idle'|'thinking'|'building'|'workshop', currentBuild: { id, title, status, placed, total, requester, world, bounds } | null, queue: [build], workshop: { world, min, max } | null, updatedAt }, stale }
  - Where Zero is and what he is doing right now, including the build in progress and its progress.
  - Cached for about 3 seconds.
  - bounds are { min: [x, y, z], max: [x, y, z] } in world block coordinates.
  - Example: const { zero } = await Twinkle.minecraft.getZero(); if (zero.currentBuild) console.log(zero.currentBuild.title, zero.currentBuild.placed + '/' + zero.currentBuild.total);
- async getZeroBuilds({ limit, kind } = {}) | scopes: content:read
  - Returns: { builds: [{ id, kind: 'helper'|'workshop', title, status, placed, total, requester, world, bounds, createdAt }], stale }
  - Zero's recent builds, newest first: builds for players (helper) and his own workshop projects (workshop).
  - limit is 1-50 (default 10). kind filters to helper or workshop builds.
  - Example: const { builds } = await Twinkle.minecraft.getZeroBuilds({ limit: 10, kind: 'workshop' });
- async getPeople() | scopes: content:read
  - Returns: { canManage, people: [{ uuid, name, role: 'visitor'|'member'|'builder'|'moderator', op, online, isZero, twinkle: { userId, username } | null, ...owner: groups, banned, protected, firstSeenAt, lastSeenAt, endorsements: [{ voucherName, voucherUuid, endorsedAt }] | ...others: seen: 'online'|'today'|'week'|'month'|'older'|null }], roles }
  - Everyone who has joined the server with their in-game rank, for a player directory; the server owner also gets the full records for role management.
  - Every viewer gets the list. Only the server owner, in an app they own, gets canManage: true with full records (groups, bans, exact first/last seen); everyone else gets canManage: false and the public view: banned players left out, and seen is a rough bucket instead of exact times. Show role controls only when canManage is true.
  - Roles: visitor (play and chat), member (/tpa, /home, /back, may ask Zero to build), builder (member + /fly and creative/survival), moderator (builder + teleport others, CoreProtect rollback, /kick). op: true players are server operators and have every power regardless of role.
  - protected: true players (the owner and Zero's account) can't be changed from the app. Sorted online first, then most recently seen.
  - Not cached; call on screen open or after a change, not on a timer.
  - twinkle is the Twinkle account linked to that player with /link, or null.
  - endorsements (owner view only) lists who backed this player's Builder application: a moderator or anyone above typed /vouch for a player already on Twinkle. voucherUuid is the voucher's Minecraft uuid (null when endorsed from the app or console); endorsedAt is an ISO time. Empty when there are none, or when the server's vouch list could not be read.
  - Example: const { canManage, people } = await Twinkle.minecraft.getPeople(); renderDirectory(people, { editable: canManage });
- async setPlayerRole({ uuid, role }) | scopes: content:write
  - Returns: { player: { uuid, name, role, op } }
  - Change a player's in-game role; it applies immediately, even while they are online.
  - Server owner only, in an app they own; others get 403 with code minecraft_roles_forbidden.
  - role is visitor, member, builder, moderator, or a Partner rank above moderator: associate, senior_associate, partner, senior_partner (each keeps every moderator power). A promotion also sends the player's linked Twinkle account a DM, and the player gets a title and perks list in game. 400 codes: minecraft_bad_uuid, minecraft_bad_role, minecraft_protected_player, minecraft_unknown_player. 503 minecraft_unavailable while the server restarts.
  - Every change is logged on the server and emailed to the owner; the player is told in game.
  - Example: await Twinkle.minecraft.setPlayerRole({ uuid: person.uuid, role: 'builder' });
- async getChat({ since } = {}) | scopes: content:read
  - Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death', name, text, userId, archived }], latestSeq, online, archiveReady, viewer: { canSend, muted, canModerate }, mutedUserIds }
  - Live server chat after a sequence number: player chat, Zero, messages sent from the web, joins, leaves and deaths.
  - Without since (or 0) it returns the newest 50 events, oldest first. Pass since = latestSeq from the previous response to get only new events. Poll every 2-3 seconds while the chat is visible; stop when hidden.
  - kind: chat (a player), zero (Zero, the AI builder), web (someone chatting from the Twinkle app; userId is their Twinkle user id), join, leave, death. Private messages are never included.
  - name is a Minecraft username for chat/join/leave/death, and a Twinkle username for web.
  - viewer.canModerate is true only for the server owner; mutedUserIds is filled only for them.
  - Example: let since = 0; async function poll() { const { events, latestSeq } = await Twinkle.minecraft.getChat({ since }); since = latestSeq; render(events); } setInterval(poll, 2500);
- async getChatHistory({ before, limit, query, includeArchived } = {}) | scopes: content:read
  - Returns: { events: [{ seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death', name, text, userId, archived }], hasMore, nextBefore: { at, seq } | null, includesArchived }
  - Older server chat, newest first, for scrolling back or searching past days.
  - before is the nextBefore cursor from the previous page; omit it to start from the newest. limit 1-100 (default 50). query searches message text and names (60 chars).
  - Chat from before the web chat bridge existed (imported from server logs) is archived: only the server owner can include it with includeArchived: true; others never receive it.
  - Example: const page = await Twinkle.minecraft.getChatHistory({ limit: 50 }); const older = await Twinkle.minecraft.getChatHistory({ before: page.nextBefore });
- async sendChat({ text }) | scopes: content:write
  - Returns: { event: { seq, at, kind: 'chat'|'zero'|'web'|'join'|'leave'|'death', name, text, userId, archived } }
  - Send a chat message from the app into the Minecraft server; players see it as [Web] <your Twinkle username>.
  - Signed-in viewers only. text is trimmed to 200 characters. About one message per 2.5 seconds and 10 per minute per person: 429 with code minecraft_chat_rate_limited and retryAfterMs.
  - 403 minecraft_chat_forbidden for accounts banned from chat; 400 minecraft_muted when the server owner muted them from web chat.
  - Send only on a user action (Enter or Send); never automatically.
  - Example: await Twinkle.minecraft.sendChat({ text: 'Hi from the website!' });
- async muteChatUser({ userId, muted }) | scopes: content:write
  - Returns: { userId, muted }
  - Server owner only: stop (or allow again) a Twinkle user sending web chat into the server.
  - Server owner only, in an app they own; others get 403 with code minecraft_roles_forbidden. muted defaults to true.
  - Example: await Twinkle.minecraft.muteChatUser({ userId: event.userId, muted: true });
- async getMyLink() | scopes: content:read
  - Returns: { links: [{ uuid, name, linkedAt }], pending: { code, expiresAt } | null }
  - The Minecraft accounts linked to the signed-in viewer's Twinkle account, and any unused link code.
  - Always the caller's own account. A viewer may link several Minecraft accounts (e.g. Java and Bedrock); each Minecraft account belongs to one Twinkle user.
  - Poll every few seconds only while showing a code and waiting for the player to type /link in game.
  - Example: const { links } = await Twinkle.minecraft.getMyLink(); const myMinecraftName = links[0]?.name;
- async createLinkCode() | scopes: content:write
  - Returns: { code, expiresAt, command }
  - Get a one-time code the viewer types in game as /link CODE to connect that Minecraft account to their Twinkle account.
  - Signed-in viewers only. Codes last 10 minutes, work once, and a new code replaces the old one. About 5 codes per 10 minutes: 429 minecraft_link_rate_limited.
  - The player sees a confirmation in game; getMyLink() then lists the account.
  - Example: const { command } = await Twinkle.minecraft.createLinkCode(); showText(`Type ${command} in Minecraft`);
- async unlinkMinecraft({ uuid }) | scopes: content:write
  - Returns: { removed }
  - Remove one of the viewer's own linked Minecraft accounts.
  - Only the viewer's own links; removed is false when there was nothing to remove.
  - Example: await Twinkle.minecraft.unlinkMinecraft({ uuid: link.uuid });
- async getDesigns({ query } = {}) | scopes: content:read
  - Returns: { designs: [{ id, version, name, category, summary, size: { width, height, depth }, blocks, status, author, authorUserId, visibility: 'private'|'public', previewUrl, createdAt }], access: { rank, linked, canOrder, canModerate, isOwner, canKeepPrivate } | null }
  - Zero's design library: published designs plus the viewer's own private ones.
  - Built-in designs (redstone devices and so on) have no author and are public.
  - previewUrl is an image set with updateDesign, or null.
  - access says what this viewer may do: canOrder shows placement tools (builders, moderators, the owner).
  - Example: const { designs } = await Twinkle.minecraft.getDesigns({ query: 'castle' });
- async getDesign({ id }) | scopes: content:read
  - Returns: { design: { id, version, name, category, summary, size: { width, height, depth }, blocks, status, author, authorUserId, visibility: 'private'|'public', previewUrl, createdAt }, size: [w, h, d], palette: [blockState], cells: number[] }
  - A design's blocks for a 3D preview.
  - cells is a flat list of [x, y, z, paletteIndex] numbers; air is left out. palette entries are block states like "oak_stairs[facing=north,half=bottom]".
  - Private designs are only returned to their designer.
  - Example: const { size, palette, cells } = await Twinkle.minecraft.getDesign({ id: 'harbor_house' }); for (let i = 0; i < cells.length; i += 4) addCube(cells[i], cells[i + 1], cells[i + 2], palette[cells[i + 3]]);
- async saveDesign({ id, name, summary, category, tags, visibility, parts }) | scopes: content:write
  - Returns: { design: { id, version, name, category, summary, size: { width, height, depth }, blocks, status, author, authorUserId, visibility: 'private'|'public', previewUrl, createdAt } }
  - Save a design (or a new version of your design) from blueprint parts.
  - parts use Zero's blueprint shapes: box, hollow_box, walls, floor, line, block, cylinder, sphere, gable_roof, pyramid_roof; offsets x = east, y = up, z = south; later parts override earlier ones (carve doors/windows with "air"). Up to 60,000 blocks and 96 blocks in each direction.
  - category is building, decor, farm or path. visibility private (default) or public. Saving the same id again adds a version; only its designer can do that. Rejections come back as 400 minecraft_studio_rejected with a readable message.
  - Who may: any signed-in viewer; they own what they save (they can change or delete it). Only builders and above (access.canKeepPrivate from getDesigns) may keep a design private; for anyone else visibility must be 'public' (omitted means public) or the call fails with 403 minecraft_private_needs_builder. Builders and above save private designs when visibility is omitted. Players have a limit on how many designs they keep.
  - Example: await Twinkle.minecraft.saveDesign({ id: 'harbor_house', name: 'Harbor house', category: 'building', visibility: 'private', parts: [{ shape: 'floor', from: [0, 0, 0], to: [8, 0, 6], block: 'stone_bricks' }, { shape: 'walls', from: [0, 1, 0], to: [8, 4, 6], block: 'spruce_planks' }] });
- async updateDesign({ id, visibility, previewUrl, name, summary }) | scopes: content:write
  - Returns: { design: { id, version, name, category, summary, size: { width, height, depth }, blocks, status, author, authorUserId, visibility: 'private'|'public', previewUrl, createdAt } }
  - Publish or unpublish a design, set its preview image, or rename it.
  - previewUrl is typically a Twinkle.files upload of a rendered preview.
  - Who may: a design's own designer; moderators (by linked account) and the server owner may change or delete anyone's. Only builders and above may set visibility 'private' (403 minecraft_private_needs_builder otherwise).
  - Example: await Twinkle.minecraft.updateDesign({ id: 'harbor_house', visibility: 'public' });
- async deleteDesign({ id }) | scopes: content:write
  - Returns: { deleted }
  - Remove one of your designs from the library.
  - Built-in designs can't be deleted.
  - Who may: a design's own designer; moderators (by linked account) and the server owner may change or delete anyone's.
  - Example: await Twinkle.minecraft.deleteDesign({ id: 'old_test' });
- async checkPlacement({ designId, world, x, z, y?, facing?: 'north'|'east'|'south'|'west' }) | scopes: content:read
  - Returns: { check: { clear, reasons, world, facing, anchor, bounds: { min, max }, ground: { y, min, max, water }, builtBlocks, builtSamples, placedBy: [{ name, blocks }], design } }
  - Check a building site for a design: where it would sit and whether it would touch anyone's builds.
  - Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Others get 403 minecraft_not_linked or minecraft_rank_too_low.
  - The footprint is centred on x/z and turned to face facing; without y it sits on the ground. clear is false when built (non-natural) blocks stand in the footprint or 3 blocks around it, the ground is very uneven, or it would leave the world; reasons explains. placedBy lists players CoreProtect recorded placing blocks there.
  - Call on a user action (drop or rotate), not while dragging.
  - Example: const { check } = await Twinkle.minecraft.checkPlacement({ designId: 'harbor_house', world: 'world', x: 1200, z: 700, facing: 'south' }); outline.color = check.clear ? 'green' : 'red';
- async buildDesign({ ...placement, force }) | scopes: content:write
  - Returns: { jobId, check, priority }
  - Order Zero to build a design at a site; he switches to creative and builds it before other work.
  - Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Others get 403 minecraft_not_linked or minecraft_rank_too_low.
  - Follow progress with getZero() (currentBuild) and getZeroBuilds().
  - Builders: one order waiting or building at a time, published designs or their own, and orders wait in the normal queue (priority false). Moderators: up to 3 orders, ahead of other work. The owner: no limit. force (build on a site that isn't clear) is owner-only.
  - Example: const { jobId } = await Twinkle.minecraft.buildDesign({ designId: 'harbor_house', world: 'world', x: 1200, z: 700, facing: 'south' });
- async stopZero() | scopes: content:write
  - Returns: { stopped, message }
  - Stop the build Zero is doing right now (placed blocks stay).
  - Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Others get 403 minecraft_not_linked or minecraft_rank_too_low.
  - Anyone may stop or undo their own builds; moderators also builders' and members' builds and Zero's own projects; the owner anything.
  - Example: await Twinkle.minecraft.stopZero();
- async undoBuild({ jobId }) | scopes: content:write
  - Returns: { undone, title, message }
  - Undo one of Zero's builds, putting back what was there before.
  - Who may: the server owner, and builders or moderators identified by their linked Minecraft account (Twinkle.minecraft.createLinkCode + /link). Others get 403 minecraft_not_linked or minecraft_rank_too_low.
  - Anyone may stop or undo their own builds; moderators also builders' and members' builds and Zero's own projects; the owner anything.
  - Example: await Twinkle.minecraft.undoBuild({ jobId });
- async getSnaps({ before, limit, uuid } = {}) | scopes: content:read
  - Returns: { snaps: [{ id, takenAt, name, uuid, world, x, y, z, caption, blocks, players, twinkleUserId }] }
  - The /snap Gallery: 3D scenes players took in game with /snap, newest first.
  - Every viewer gets the list. before is a time in ms (the takenAt of the last snap you have) for paging; limit 1-60 (default 30); uuid filters to one player's snaps.
  - Example: const { snaps } = await Twinkle.minecraft.getSnaps({ limit: 30 });
- async getSnap({ id }) | scopes: content:read
  - Returns: { scene: { version, id, at, by, caption, origin: [x, y, z], size: [w, h, d], palette, count, blocks, light, biomes: { palette, grid }, camera: { x, y, z, yaw, pitch, fov }, world: { name, environment, time, moonPhase, storm, thunder }, players: [{ name, uuid, x, y, z, yaw, pitch, bodyYaw, pose, sneaking, self, zero, slim, skin }] } }
  - One snap's scene, to render in 3D from where it was taken.
  - blocks is base64 of 5 bytes per visible block: x, y, z (relative to origin) and the palette index as a big-endian uint16. palette entries are block states like oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false].
  - light is base64 of 7 bytes per block, in the same order: the light each face receives (+x, -x, +y, -y, +z, -z) and the block's own cell, each sky << 4 | block (0-15).
  - biomes.grid is base64 of one byte per column (x-major: index = x * depth + z) into biomes.palette (biome ids like plains, cherry_grove).
  - camera is in world coordinates (subtract origin for scene space); yaw 0 looks toward +z (south), 90 toward -x; pitch is positive looking down. world.time is the tick of day (0 sunrise, 6000 noon, 18000 midnight).
  - players[].skin is a data: URL of the 64x64 skin PNG when known; slim means the thin-armed model.
  - Scenes are large (up to a few MB); fetch one at a time.
  - Example: const { scene } = await Twinkle.minecraft.getSnap({ id });
- async deleteSnap({ id }) | scopes: content:write
  - Returns: { deleted }
  - Delete a snap from the Gallery.
  - Who may: the player who took it (by a linked Minecraft account), moderators, and the server owner. Others get 403 minecraft_snap_forbidden.
  - Example: await Twinkle.minecraft.deleteSnap({ id });
- async getRecruitTours() | scopes: content:read
  - Returns: { enabled, title, landmarks: [{ id, name, world, x, z, radius }], requirements: { playHours, days }, tours: [{ uuid, name, introducerUuid, introducerName, startedAt, completedAt, visited: [landmarkId], playSeconds, days }] }
  - Recruit tours: each player someone brought to the server, who brought them, and their progress (landmarks visited, hours and days played). A completed tour is one recruiter point for the introducer.
  - Everyone can read it; one shared copy refreshes every 15 seconds.
  - The landmark list and requirements come from the server's config, so show them from here rather than hard-coding them.
  - Example: const { landmarks, tours } = await Twinkle.minecraft.getRecruitTours();
- async startRecruitTour({ uuid, introducerUuid }) | scopes: content:write
  - Returns: { uuid, introducerUuid }
  - Server owner only: record that introducerUuid brought player uuid to the server and start that player's landmark tour (a quest list in game).
  - 403 minecraft_roles_forbidden for anyone but the server owner.
  - 400 minecraft_already_credited when someone else was already credited; minecraft_self_introduced for the same player; minecraft_unknown_player when either never joined.
  - Example: await Twinkle.minecraft.startRecruitTour({ uuid: recruit.uuid, introducerUuid: friend.uuid });
- async cancelRecruitTour({ uuid }) | scopes: content:write
  - Returns: { removed }
  - Server owner only: remove a player's recruit tour (a mistaken credit).
  - 403 minecraft_roles_forbidden for anyone but the server owner.
  - Example: await Twinkle.minecraft.cancelRecruitTour({ uuid });
- async getArrivalPoints() | scopes: content:read
  - Returns: { accounts: [{ uuid, name, online, world, worldLabel, points: [{ world, label, x, y, z, savedAt }] }] }
  - The viewer's private arrival points: where the Twinkle Gate portals drop their linked Minecraft account in each world.
  - Only the caller's own linked accounts; 400 minecraft_not_linked when none is linked.
  - online/world tell whether setArrivalPoint can use the player's current spot right now.
  - Example: const { accounts } = await Twinkle.minecraft.getArrivalPoints();
- async setArrivalPoint({ uuid } = {}) | scopes: content:write
  - Returns: { uuid, name, world, worldLabel, points }
  - Save where the viewer's Minecraft player is standing right now as their arrival point in that world (the Twinkle Gate portal to that world lands them there).
  - The player must be online; the spot is their current position, never typed coordinates (so nobody can land inside places they could not walk to).
  - 400 minecraft_arrival_rejected when not online or the spot is unsafe (no floor, no room, lava); uuid picks one of several linked accounts.
  - Example: const { worldLabel } = await Twinkle.minecraft.setArrivalPoint();
- async clearArrivalPoint({ world, uuid }) | scopes: content:write
  - Returns: { cleared, points }
  - Remove the viewer's arrival point in a world (back to the shared arrival).
  - world is a world id such as world, world1, world3, world_nether.
  - Example: await Twinkle.minecraft.clearArrivalPoint({ world: 'world3' });
- async getPlayAreas() | scopes: content:read
  - Returns: { levels: [{ id, name, world, box: [x0,y0,z0,x1,y1,z1], spawn: [x,y,z], by, createdAt, status: 'capturing'|'ready'|'failed', chunkCount, done, blocks }] }
  - Play areas: captured Minecraft builds (a castle, a cathedral) that apps turn into first-person games, newest first.
  - Anyone can read. box is inclusive world coordinates; status 'capturing' areas are still being saved (done of chunkCount).
  - Example: const { levels } = await Twinkle.minecraft.getPlayAreas();
- async getPlayArea({ id }) | scopes: content:read
  - Returns: { level: { ...getPlayAreas fields, chunks: [[cx, cz, visibleBlocks]] } }
  - One play area with its list of captured chunk columns, to stream with getPlayAreaChunk.
  - Chunk columns are 16x16 blocks (cx = floor(x / 16)); load the ones near the player first.
  - Example: const { level } = await Twinkle.minecraft.getPlayArea({ id: 'twinkle-keep' });
- async getPlayAreaChunks({ id, chunks: [[cx, cz], ...] }) | scopes: content:read
  - Returns: { scenes: [{ cx, cz, scene: { origin: [x,y,z], size: [w,h,d], palette, blocks, light, biomes, count, ... } | null }] }
  - Up to 9 chunk columns of a play area in the /snap scene format (packed visible blocks with per-face light), cropped to the area's box. Stream the ones around the player.
  - Same scene format as getSnap; only blocks with a visible face inside the area are included. scene is null for a chunk that is not part of the area.
  - At most 9 chunks per call; reads share the 180-per-minute build read limit, so cache chunks and load the nearest first.
  - Example: const { scenes } = await Twinkle.minecraft.getPlayAreaChunks({ id: level.id, chunks: [[64, 29], [65, 29]] });
- async createPlayArea({ name, world, box, spawn, id }) | scopes: content:write
  - Returns: { level }
  - Capture a box of a Minecraft world as a new play area (or re-capture an existing id). Runs in the background: poll getPlayArea until status is 'ready'.
  - Moderators and the server owner only; others get 403 minecraft_level_forbidden.
  - At most 320 blocks across each way and 255 tall. spawn [x,y,z] is where players start (default: top centre of the box).
  - Example: await Twinkle.minecraft.createPlayArea({ name: 'Sand Castle', world: 'world', box: [900, 60, 400, 980, 130, 470] });
- async getBuildQueue() | scopes: content:read
  - Returns: { active: job | null, queue: [{ id, title, kind, status, requester, world, bounds: { min, max }, designId, position, priority, mine, placed, total }], campaign: { id, title, active, steps, done, skipped, percent, current, resumeAt, world, steps: [{ index, title, status, designId, anchor, bounds, blocks }] } | null, noBuildWorlds, access: { rank, linked, canOrder, canModerate, isOwner } | null }
  - Zero's build queue: the build he is on, the orders waiting (in the order he will take them) and the campaign steps he works through when no order waits. For an RTS-style build panel and for drawing queued footprints on a map.
  - Everyone can read it; mine marks the viewer's own orders (linked account). Orders always run before campaign steps.
  - noBuildWorlds lists worlds Zero never builds in (World 3, World 4): don't offer placement there.
  - Example: const { active, queue, campaign, access } = await Twinkle.minecraft.getBuildQueue();
- async moveQueuedBuild({ jobId, to }) | scopes: content:write
  - Returns: same as getBuildQueue
  - Server owner: move a waiting order to position `to` (0 = next).
  - Owner only (403 minecraft_queue_owner_only).
  - Example: await Twinkle.minecraft.moveQueuedBuild({ jobId: 142, to: 0 });
- async removeQueuedBuild({ jobId }) | scopes: content:write
  - Returns: same as getBuildQueue
  - Take an order out of the queue (stops it if Zero is already building it). Your own orders; moderators also lower ranks'; the owner any.
  - Builders and up with a linked account; the rank rule is the same as stopZero.
  - Example: await Twinkle.minecraft.removeQueuedBuild({ jobId: 142 });
- async moveCampaignStep({ index, before }) | scopes: content:write
  - Returns: same as getBuildQueue
  - Server owner: move a campaign step that has not started to just before the step at index `before` (null = last). Indexes are the step.index values from getBuildQueue.
  - Owner only.
  - Example: await Twinkle.minecraft.moveCampaignStep({ index: 17, before: 15 });
- async skipCampaignStep({ index, skip = true }) | scopes: content:write
  - Returns: same as getBuildQueue
  - Server owner: skip a campaign step, or bring a skipped or failed one back (skip: false).
  - Owner only. A step Zero is building has to be stopped first.
  - Example: await Twinkle.minecraft.skipCampaignStep({ index: 18 });
- async setCampaignActive({ active }) | scopes: content:write
  - Returns: same as getBuildQueue
  - Server owner: pause or resume Zero's campaign (a paused campaign waits; orders still run).
  - Owner only.
  - Example: await Twinkle.minecraft.setCampaignActive({ active: false });
- async getServerLogs({ days, kinds, query, limit } = {}) | scopes: content:read
  - Returns: { total, lines: [{ at, kind: 'join'|'leave'|'kick'|'command'|'warn'|'error'|'zero'|'server', level, text }] }
  - The Minecraft server log for the server owner: joins, kicks, commands, warnings, errors and Zero's lines, newest first, IP addresses removed.
  - Server owner only, in an app they own; everyone else gets 403 minecraft_roles_forbidden.
  - days 1-7 (default 1); kinds any of join, leave, kick, command, warn, error, zero, server (default all); query filters by text; limit 1-1000 (default 300). Chat is not included (getChat and getChatHistory have it).
  - Example: const { lines } = await Twinkle.minecraft.getServerLogs({ days: 2, kinds: ['kick', 'error'] });
- async getNews({ limit } = {}) | scopes: content:read
  - Returns: { posts: [{ id, title, body, author, at }], canPost }
  - Server news: short posts about what's new on Twinkle Minecraft, newest first (players see the newest on join and all of it with /news in game).
  - Every viewer reads the news. at is the post time in ms; limit 1-50 (default 20). canPost is true for moderators and the server owner (they can call addNews and deleteNews).
  - Example: const { posts, canPost } = await Twinkle.minecraft.getNews({ limit: 10 });
- async addNews({ title, body }) | scopes: content:write
  - Returns: { post: { id, title, body, author, at } }
  - Post server news (moderators and the server owner). Players in game see the headline right away.
  - title up to 120 characters (required), body up to 2000. Others get 403 minecraft_news_forbidden. The author is the signed-in viewer's Twinkle username.
  - Example: await Twinkle.minecraft.addNews({ title: 'World 3 reopens', body: 'Come build!' });
- async deleteNews({ id }) | scopes: content:write
  - Returns: { deleted }
  - Remove a news post (moderators and the server owner).
  - Others get 403 minecraft_news_forbidden.
  - Example: await Twinkle.minecraft.deleteNews({ id: post.id });

### Twinkle.town
- async getTown() | scopes: sharedDb:read
  - Returns: { town, clock: { now, townMinute, day, weekday, kstTime }, places: [{ id, name, kind, district, door: [x, z], y, built, spots }], housing: { homes, waitingList }, residents: [publicCharacter], gazette: [{ id, townMinute, text }], goods, myCharacterId, cursor }
  - Load Starhaven Life's shared town: places (Minecraft coordinates), the homes board, every resident's public state and the Town Gazette.
  - Every resident is a real member's character living on its own every 15 minutes on the real KST clock (school mornings, work afternoons, rent on Mondays).
  - publicCharacter = { characterId, name, look, pet, home, place, spot, move: { points, depart, arrive } | null, activity: { id, label, icon, place, start, until, with, withName } | null, mood: { emotion, icon, label }, title, job, knownFor, guided }. start/until/depart/arrive are town minutes (clock.townMinute is now).
  - Pass cursor to getChanges.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
  - Example: const town = await Twinkle.town.getTown(); let cursor = town.cursor; setInterval(async () => { const ch = await Twinkle.town.getChanges({ since: cursor }); cursor = ch.cursor; /* move ch.residents */ }, 25000);
- async getChanges({ since }) | scopes: sharedDb:read
  - Returns: { clock, events: [{ id, townMinute, type, salience, characterId, withCharacterId, text, mine }], residents: [publicCharacter], newDiaryLines, hasMore, cursor }
  - Poll what changed since a cursor: public town events, events about your own character, and residents whose state changed.
  - Poll every 20-30 seconds; always continue from the returned cursor. hasMore: true means call again right away.
  - Also marks the viewer as online (their character earns full pay while its guardian is around).
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async getMyCharacter() | scopes: sharedDb:read
  - Returns: { character: { characterId, name, status, profile, petName, public, life, movingIn } | null, guardian: { priorities, direction, focus, civicCard, optIns, aspiration }, home, waitingListPosition, control, guidedActions, clock } | { character: null, create: { suggestedNames, suggestedPetNames, petSpecies, likeTags, axes, nameCheck } }
  - The guardian's view of the viewer's own character (needs, feelings, wishes, worries, skills, job, plan, money, friends, proposals waiting for an answer), or the options for creating one.
  - life is the server's canonical state; never compute needs, money or relationships in the app.
  - life.proposals are big life changes the character asks its guardian first (for example asking someone to be best friends): answer with answerProposal; silence for 24 hours lets the character decide.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async createCharacter({ name, petName, petSpecies, personality: { neat, outgoing, active, playful, kind }, likes, dislike, look: { skin, hair, shirt, pants, hood }, homeId }) | scopes: sharedDb:write
  - Returns: { characterId, name, petName, welcomeBucks, housing: { homeId, weeklyRent, rentDueMinute } | { waitingListPosition, error? } }
  - Create the viewer's one character in Starhaven (safety-checked names, 200 welcome Bucks) and optionally claim a vacant home.
  - Names are 2-16 letters. Suggested names (getMyCharacter().create) always pass; any other name needs a clear safety check and may be refused (400 town_name_not_allowed) or be unavailable (503: offer the suggestions).
  - personality values are -2..2; likes: 3 different interest tags; dislike: 1 other tag; look colours are 0xRRGGBB numbers.
  - Without a free home the character lodges in the Fivefold guest rooms and joins the waiting list.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async setPriorities({ priorities }) | scopes: sharedDb:write
  - Returns: { priorities }
  - Set the guardian's 8 priority sliders (-2..2): saving, pets, work, social, learning, fun, tidy, civic.
  - Priorities tilt what the character chooses; they never force it (characters have free will).
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async setDirections({ text }) | scopes: sharedDb:write
  - Returns: { ok: true, text, understoodAs, weights, neighbour } | { ok: false, reason }
  - Give the character plain-words guidance (up to 200 characters, English). JEV checks it is safe and kind, then shows back how it was understood.
  - Show understoodAs to the guardian. ok: false keeps the old guidance (unsafe wording, too many changes today (3 a day), or guidance reading paused).
  - Directions can only raise time with a neighbour, never avoid or target anyone. They are private to the owner.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async setOptIns({ dontVoteForMe, voteForMe, spendLimit, savingsFloor, visitors, workOffline, tendPets }) | scopes: sharedDb:write
  - Returns: { optIns }
  - Guardian opt-ins: JEV votes for the character by default (dontVoteForMe: true opts out), daily spend limit (0-500 Bucks, default 50), savings floor, visitors (friends | residents | nobody).
  - visitors: nobody also stops the character hosting a moved-out friend.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async setFocus({ skills }) | scopes: sharedDb:write
  - Returns: { focus }
  - Pick up to 2 Focus skills; the character spends more time on them and grows them faster.
  - Scholarship grows only from the owner's real learning on Twinkle and cannot be a Focus.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async setCivicCard({ focus, tax, change }) | scopes: sharedDb:write
  - Returns: { civicCard }
  - The 3-question civic card the autopilot vote reads (focus: fun | learning | pets | saving | civic; tax: lower | keep | higher; change: new | keep).
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async confirmAspiration({ aspirationId }) | scopes: sharedDb:write
  - Returns: { aspiration: { id, label, confirmed } }
  - Confirm the life goal the character proposed (life.aspirationProposed) or choose another; milestones give titles only.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async takeControl({ leaseId, immediate }) | scopes: sharedDb:write
  - Returns: { leaseId, expiresAt, heartbeatMs, renewed }
  - Guide the character directly. Returns a lease; call takeControl({ leaseId }) about every 60 s to keep it.
  - The lease lapses after 3 missed heartbeats (stop renewing when the tab is hidden) and the character goes back to living on its own. immediate: true stops the current activity now instead of at its end.
  - 409 town_control_elsewhere: another window is guiding this character.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async releaseControl({ leaseId }) | scopes: sharedDb:write
  - Returns: { released, livingOnAutopilot }
  - "Let them live": hand the character back to its own free will.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async act({ leaseId, operationId, activity } | { leaseId, operationId, outing, withCharacterId }) | scopes: sharedDb:write
  - Returns: { operationId, status: queued | applied | rejected, result }
  - While guiding, tell the character what to do next: an activity (from getMyCharacter().guidedActions.activities) or an outing with another resident.
  - The server checks it is possible (hours, weather, money, the other resident being free) and applies it within a few seconds; watch getChanges/getMyCharacter for the result. Reuse the same operationId when retrying.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async answerProposal({ proposalId, answer, operationId }) | scopes: sharedDb:write
  - Returns: { status, result }
  - Answer a big-life-change question the character asked its guardian: yes, no, or decide (let them decide).
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async getDiary({ since, limit }) | scopes: sharedDb:read
  - Returns: { since, moments, decisions: [{ text, by: jev | code, at }], lines, numbers: { earned, shifts, meals, sleptHours, friendsSeen, skillUps } }
  - "While you were away": the top moments, the decisions made for you (and whether JEV or the code made them), and the numbers since a time (ms).
  - Text comes from fixed English templates; "(quiet stretch)" marks time the town caught up after a pause.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async getElections() | scopes: sharedDb:read
  - Returns: { offices, elections: [{ id, office, officeLabel, status, votingOpensAt, closesAt, candidates, result, myBallot }], pledges, mottos, minEligibleVoters }
  - Town offices and elections (Town Chief, Headmaster, Market Warden, Pet Warden; 14-day terms).
  - Results show counts only (secret ballot), including how many were autopilot votes.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async vote({ electionId, approve }) | scopes: sharedDb:write
  - Returns: { electionId, approve, abstain }
  - Approval vote: approve any number of candidates (characterIds). One ballot per account; voting again replaces it until the close.
  - Voters: account 30+ days old, living in Starhaven 7+ days, active 3 of the last 14 days (403 town_not_eligible otherwise).
  - Without a manual vote, JEV votes for the character at the close unless the guardian opted out (setOptIns({ dontVoteForMe: true })).
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async declareCandidacy({ electionId, pledges, motto }) | scopes: sharedDb:write
  - Returns: { electionId, pledges, motto }
  - Stand for office during the candidacy days: exactly 3 pledges from the list and an optional templated motto id (no free text).
  - Only the owner can declare, never the character.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async getHousing() | scopes: sharedDb:read
  - Returns: { homes: [{ homeId, plot, district, name, type, readyMade, weeklyRent, vacant, residentName }], waitingList: [{ position, name, since, movedOut }], mine: { home, rentJar, bucks, waitingListPosition, inStorage } | null, rentDay, graceDays }
  - The homes board (10 Harbor Row + 10 Lantern Hill plots), the public waiting list and the viewer's rent status.
  - Rent is weekly in Bucks, due Monday 00:00 KST, with 3 days of grace; a character that can't pay moves out gently (belongings to storage) and gets the next home it can cover from the waiting list.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async claimHome({ homeId }) | scopes: sharedDb:write
  - Returns: { homeId, weeklyRent, rentDueMinute, rentDueAt }
  - Claim a vacant home, first come first served. The first rent is due on a Monday at least 3 days away.
  - 409 town_home_taken when someone else got there first.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async joinWaitingList() | scopes: sharedDb:write
  - Returns: { waitingListPosition }
  - Join the public housing waiting list.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.
- async leaveWaitingList() | scopes: sharedDb:write
  - Returns: { waitingListPosition: null }
  - Leave the housing waiting list.
  - Limited rollout: app 2610 (Twinkle Minecraft Live) only; other apps get 403 with code town_sdk_not_enabled.

### 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, howTo?, category?, series?, 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.
  - howTo is the rule's optional plain-words steps from rewards.json (up to 300 characters, e.g. "Save a new design of at least 64 blocks in Designs"). The Earn page shows it under the title; show it in the app's own rewards list too so players always know exactly what earns each reward.
  - category and series are optional rewards.json labels for a long list. category is a short section header (up to 40 characters, e.g. "Bring friends"); series is a short lowercase key (e.g. "new-friend") tying repeat rules like vigil-friend-1/2/3 together, and rules in one series must share category, verifier, XP and Coins. The Earn page groups rules by category and shows each series as one row ("up to 3 a day", "1 of 3 today"); group the app's own rewards list the same way.
- 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?, completionToken? }) | 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, breadface-v2 and breadface-v3, 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. For groove-lab-song-v1, groove-lab-heard-v1, vigil-guest-coplay-v1, minecraft-first-link-v1 and writing-words-v1, start returns completion { profile }; there is no progress step. For groove-lab-listen-v1 and groove-lab-own-listen-v1, call start({ ruleId, completionToken: <published song entry id> }) when that song starts playing; it returns completion { profile, songEntryId, durationSeconds, listenSeconds } and refuses with a plain reason (not published, whose song, too short, already earned for this song today). Starting a different song re-binds the rule's unpaid challenge and restarts its clock. For reading-quiz-v1, call progress({ challengeId, completionToken: <chapter commentId>, record: 'open' }) when the reader opens the chapter (returns readyInSeconds, readingSeconds, alreadyEarned, attemptedToday), then progress({ ..., record: 'quiz' }) once readyInSeconds has passed to get questions [{ question, options[4] }] (never answers). getStatus returns reading { chaptersEarnedToday, readersToday } when the app uses a reading proof. For writing-words-v1, getStatus also returns writing { countedWords, checkingChapters, chapters: [{ commentId, words, countedWords, status: counted|not-counted|checking|duplicate|too-short, english, reason }] } for today.
- 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, 1.4, 1.6 or 2.2). 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, breadface-v2 and breadface-v3, 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, subject?: { kind?: 'song' | 'item', title, path?, imageUrl? } }) | scopes: rewards:claim
  - Returns: { awarded: false, attempts, attemptsRemaining, questions: [{ prompt, hint?, guide? }] } | { awarded: true, duplicate, receipt: { ruleId, xp, coins, attempt, firstTry, cappedFrom?: { xp, coins } }, 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), clamped to the learner's remaining userDailyXP/userDailyCoins room today (each currency on its own). A clamped receipt carries cappedFrom: { xp, coins }, the amounts the rule would have paid; the rule still counts as earned today and the rest cannot be claimed later. build_reward_budget_reached (HTTP 409) means nothing would be paid: both rooms are used up, or the rule pays only a currency whose room is used up. This includes one-time rules: a capped award on a maxLifetimeClaims rule also pays only what fits and uses up that lifetime claim (e.g. a one-time 5,000 XP reward claimed at 9,999/10,000 XP pays 1 XP and is done). Use cappedFrom to explain it, e.g. "+1 XP (today's limit; this reward was 5,000 XP)". Always show receipt.xp/receipt.coins, never the rule's amounts, as what the learner got, and hide a zero part (a receipt can pay 0 XP and some Coins, or the reverse): show "+50 Coins", not "+0 XP · +50 Coins". 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.
  - Optional subject says what the reward was for; it is shown on the public bounty story on Home while the app is public (e.g. "listened to Night Drive"). title is plain text up to 80 characters, path is this app's share-link segment (the one you pass to Twinkle.app.getShareUrl, letters, digits and dashes), imageUrl a Twinkle-hosted image. A malformed subject is refused with build_reward_subject_invalid (HTTP 400); an older server without subjects ignores it. Proof-backed rules that read the item on the server (Groove Lab songs) replace it with the stored title, link, cover and creator.
  - 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; every receipt counts, including one capped by the daily budget (cappedFrom). 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. For groove-lab-song-v1, pass completionToken as the published song's sharedDb entry id; for groove-lab-heard-v1, pass none; for vigil-guest-coplay-v1, pass the guest's world guestId (from world events) or the co-play record id; for minecraft-first-link-v1, pass nothing or the Minecraft UUID to use; for reading-quiz-v1, pass completionToken as the chapter commentId and answers as the three chosen option indexes (all three must be right; a wrong attempt cannot be retried that day); for groove-lab-listen-v1 and groove-lab-own-listen-v1, pass the same song entry id after listenSeconds of foreground playback; for reading-readers-v1, pass nothing; for writing-words-v1, pass nothing (the server counts today's chapters itself and refuses with a plain reason, e.g. "320 of 500 counted words today" or "still being checked"). The server reads the app's own groove-lab-songs, groove-lab-song-parts and groove-lab-listens rows and refuses with build_reward_completion_proof_required and a plain reason when the song or listeners do not qualify.
  - For craft-target-v1 and craft-par-v1, pass completionToken as JSON.stringify({ v: 1, merges: [[a, b], ...] }): the item ids of the run's successful merges in order, starting from the starters (at most 300 merges, 32 KB). The server replays it against this app's known pairs, needs today's target (Twinkle.craft.getState().today) to appear, every result to be in the learner's collection, and at least one second per merge since start; it refuses an unknown pair, an item the run had not made yet, an over-par run (par rule) or a run on another day. For craft-discovery-v1, pass nothing: the server pays the oldest unpaid World First (receipt slot craft:<itemId>) and puts its name on the bounty story ("discovered <item>"). With tiers, a rule pays only World Firsts of its own tier (refused with a plain reason when the learner has none).
- 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.interpretAnswer({ challengeId, text }) | scopes: rewards:claim
  - Returns: { mode: "live" | "preview", understood: boolean, value: number | null, source: "plain" | "model" }
  - Read a learner's free-form answer text into the single number they meant, so they can answer in any format.
  - For numeric-quiz bounties. Call it only when the typed text is not already a plain number or fraction (check that locally); a plain number returns source 'plain' with no model call. Then pass the returned value to claim({ challengeId, answers: [value] }). A small model reads the text and the platform pays for it, so the learner's AI Energy is never charged.
  - understood: false means the text had no single clear number (several alternatives, no number, or a calculation). Tell the learner in friendly words to type just the number. This is not a wrong answer and does not use a try.
  - The reader is given the puzzle text only to learn the requested answer format (unit, fraction/decimal/percent, which of several numbers answers the question), never the answer key, and never calculates or converts units: it only reads what was written, and the number it returns must be one the learner actually typed. Typed expressions like 5+8 are intentionally not evaluated.
  - Requires an open challenge of the learner's own (build_reward_challenge_not_open, HTTP 409 otherwise). Limit: 120 characters (build_reward_answer_text_invalid, 400), rate limited to a burst of 4 then one read every 5 seconds (build_reward_throttled, 429). build_reward_answer_reader_unavailable (503) means the reader is down: ask the learner to type just the number.
  - Drafts and owner previews return mode 'preview' and need no open challenge.
- await Twinkle.rewards.getChallengeBoard() | scopes: rewards:claim
  - Returns: { mode: "live", dayKey, puzzles: [{ ruleId, title, setKey, rating, solvers, failers, failures?, daysStanding, status: "untouched" | "undefeated" | "defeated" }], topSolvers: [{ rank, userId, username, profilePicUrl, rating, solves, hardestSolve: { ruleId, setKey, rating } | null }], recentSolves: [{ id, userId, username, ruleId, setKey, tier: "solved" | "first-solve" | "hard-won" | "legendary", firstTry, firstSolve, failersBefore, daysStanding, puzzleRating, at }], me: { rating, solves, rank } | null } | { mode: "preview", puzzles: [], topSolvers: [], recentSolves: [], me: null, message }
  - Front-page difficulty board for until-earned quiz bounties: what is being tried and failed, who is rated highest, and the latest defeats.
  - Computed by Twinkle from its own receipts and attempts, never from anything the app submits. Puzzles are Elo-rated against players: a player's first miss on a puzzle is a win for the puzzle, a solve is a win for the player, and repeat misses by one player never stack.
  - puzzles lists today's set for each until-earned rule. status 'undefeated' means someone tried and nobody has solved it yet; show it as a standing challenge. failers is how many different players missed it; failures is total wrong answers, sent only while the puzzle is still unsolved (absent on defeated puzzles, so a solver's own misses cannot be worked out).
  - tier on a recent solve says how loudly to celebrate: 'legendary' (first solve of a long-undefeated puzzle), 'hard-won', 'first-solve', or plain 'solved'. It is fixed at solve time, so it never changes later. firstTry is true when the solver got it on the first try; which try it took and the solver's own wrong answers are never shown to other players, so never reconstruct them.
  - No questions, answers, guides or private work are ever returned. Names are the same public names the XP leaderboard shows; other players who missed a puzzle are counted, never named. Drafts and previews return mode 'preview' with empty lists.
- 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, firstTry, story: { tier, firstTry, failersBefore, daysStanding } | null, puzzle: { rating, solvers, failers } | null, vault: { rating, solvers, failers, failures } | null, rematchOpen }], 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.
  - Museum fields: story is how THAT solve went, fixed at solve time (tier 'solved' | 'first-solve' | 'hard-won' | 'legendary', firstTry: whether the solver got it on the first try, never which try, how many other players had missed it, days it stood); null for receipts older than solve logging. puzzle is the official challenge rating and counts now (no total wrong answers: it is solved, and that total would give away the solver's own misses); vault is the puzzle's living practice stats (its own rating, practice solvers, players whose first practice answer missed, all wrong practice answers), or null before anyone practised it. Counts only: players who missed are never named.
  - rematchOpen is true on the entry that is today's daily rematch while the viewer can still answer it for pay: label it and open it as the rematch, not as a lesson. Daily-rematch receipts are never timeline entries.
- await Twinkle.rewards.getArchivedProblem({ receiptId, withGuide?: true }) | scopes: rewards:claim
  - Returns: { mode: "live", entry: <same solve entry as getTimeline, with rematchOpen>, questions: [{ prompt, hint?, guide? }], rematchOpen } | { 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.
  - entry carries the same story, puzzle and vault museum fields as getTimeline.
  - Guides are returned only with withGuide: true. Asking for the guide counts as having studied the puzzle: the viewer's Vault practice of it is unrated and it can never be their paid daily rematch. Offer the guide behind an explicit Reveal step and say so. withGuide on today's rematch puzzle is refused (build_reward_rematch_open, 409) while the viewer can still answer it for pay.
- await Twinkle.rewards.practiceCheck({ receiptId, answer?: number, answers?: [number], text?: string }) | scopes: rewards:claim
  - Returns: { mode: "live", correct, counted: true, firstAttempt, attempts, solvedAt: number | null, rated, read?: { understood: true, value }, practice: { rating, solves, rank: number | null }, vault: { rating, solvers, failers, failures } } | { mode: "live", correct: false, counted: false, read: { understood: false, value: null } } | { mode: "preview", correct: false, counted: false, practice: null, vault: null, message }
  - Try a retired puzzle again (the Vault): checks an answer against the frozen sheet for a practice rating, never XP or Coins.
  - receiptId is any receipt of a retired set (from getTimeline). The answer is compared with the same tolerance as claims; the answer key is never returned. Send answer for a one-question set, answers (in order) for several, or the learner's raw text for a one-question set: text goes through the same answer reader as interpretAnswer (puzzle text for format only, same rate bucket). Unreadable text returns counted: false and uses no attempt; ask the learner to type just the number.
  - Practice rating: the first answer a player gives on a puzzle is rated once (correct = win, wrong = loss) against the puzzle's Vault rating, and both ratings move. Later answers only count attempts; a later correct answer still marks the puzzle solved (solvedAt) and opens postExplanation. Players who earned or missed the set officially, had a challenge on its rule while it was live, or studied it (opened its guide or explanations) may practise it but are not rated (rated: false). The official challenge rating is never changed by practice.
  - Pays nothing. Rate limited per learner (build_reward_throttled, 429). Errors: build_reward_archive_unavailable (404) for a receipt that is not a retired puzzle, build_reward_practice_answers_invalid (400).
  - Today's daily rematch puzzle cannot be practised by a viewer who may still answer it as the rematch (build_reward_rematch_open, 409); practising any puzzle in the Vault makes the viewer ineligible for it as a future rematch.
- await Twinkle.rewards.getPracticeBoard() | scopes: rewards:claim
  - Returns: { mode: "live", top: [{ rank, userId, username, profilePicUrl, rating, solves }], me: { rating, solves, rank: number | null } | null, hardest: [{ receiptId, ruleId, setKey, title, vault: { rating, solvers, failers, failures } }], solved: ["ruleId:setKey"] } | { mode: "preview", top: [], me: null, hardest: [], solved: [], message }
  - Vault practice standings: top practice-rated players, the viewer's own standing, the toughest retired puzzles and which ones the viewer solved in practice.
  - top lists the 10 highest practice ratings among players with at least one rated answer; profilePicUrl is an absolute https URL or null. me.rank is null until the viewer has a rated answer. hardest is up to 5 retired puzzles with the highest Vault rating that three or more players missed on their first practice answer. solved lists the viewer's practice solves only; official solves are in getTimeline.
- await Twinkle.rewards.getRelatedArchive({ ruleId } | { receiptId }) | scopes: rewards:claim
  - Returns: { mode: "live", entries: [<getTimeline entry>] } | { mode: "preview", entries: [], message }
  - Up to 3 retired puzzles that share an idea with today's puzzle (after answering it) or with a retired puzzle.
  - Idea tags come from each set's questions[0].guide.ideaTags (1-3 kebab-case slugs). The current release's copy of a set wins, else the frozen sheet. Ordered by shared tags, then newest; one entry per retired set (its earliest receipt).
  - With ruleId: today's set of that until-earned rule. It would be a hint, so it opens only once the viewer has a receipt for today's set or a challenge on it with a wrong attempt; otherwise build_reward_related_locked (409). With receiptId: no gate; the source puzzle is excluded.
  - Rate limited (build_reward_throttled, 429). Daily-rematch receipts are never related entries.
- await Twinkle.rewards.listExplanations({ receiptId }) | scopes: rewards:claim
  - Returns: { mode: "live", entries: [{ id, userId, username, profilePicUrl, text, votes, myVote: 0 | 1, createdAt, updatedAt, mine, pinned }], canModerate } | { mode: "preview", entries: [], message }
  - Solvers' explanations of a retired puzzle, most helpful first.
  - Up to 50, ordered by votes then age. pinned marks the top entry when it has two or more votes. text is user content: escape it when rendering (never innerHTML). Only retired puzzles have explanations (build_reward_archive_unavailable, 404 otherwise).
  - Refused with build_reward_rematch_open (409) for today's rematch puzzle while the viewer may still answer it for pay.
  - Reading explanations counts as having studied the puzzle (unrated practice, never that viewer's paid rematch). canModerate is true for the app's owner and Twinkle admins, who may delete any explanation. Rate limited (build_reward_throttled, 429).
- await Twinkle.rewards.postExplanation({ receiptId, text }) | scopes: rewards:claim
  - Returns: { mode: "live", entry: <listExplanations entry> } | { mode: "preview", entry: null, message }
  - Post or edit the viewer's explanation of a retired puzzle they solved.
  - Only players who solved that set officially (any receipt) or in practice may post (build_reward_explanation_not_solver, 403). 20-1200 characters after trimming (build_reward_explanation_invalid, 400). One per player per puzzle: posting again replaces the text and keeps its votes. Pays nothing; rate limited.
- await Twinkle.rewards.voteExplanation({ id, vote: 1 | 0 }) | scopes: rewards:claim
  - Returns: { mode: "live", id, votes, myVote } | { mode: "preview", id: null, votes: 0, myVote: 0, message }
  - Mark another player's explanation helpful (1) or take that back (0).
  - One vote per player per explanation; repeating a vote changes nothing. Voting for your own explanation is refused (build_reward_explanation_own_vote, 403).
- await Twinkle.rewards.deleteExplanation({ id }) | scopes: rewards:claim
  - Returns: { mode: "live", id, deleted: true } | { mode: "preview", id: null, deleted: false, message }
  - Delete the viewer's own explanation, or any explanation as the app's owner or a Twinkle admin.
  - Own explanations, or any explanation for the app's owner and Twinkle admins (listExplanations.canModerate); otherwise build_reward_explanation_not_owner (403). Its votes are removed with it.
- await Twinkle.rewards.getRematch() | scopes: rewards:claim
  - Returns: { mode: "live", dayKey, ruleId, entry: <getTimeline entry with story, puzzle, vault>, prompt, hint?, questionCount, eligible, reason?: "already-attempted" | "already-practised" | "already-studied" | "already-earned", attempts, earned: { id, challengeId, ruleId, setKey, dayKey, xp, coins, attempt, createdAt } | null, reward: { xp, coins }, retryReward: { xp, coins }, paidAttempts } | { mode: "live", entry: null, prompt: null, eligible: false, reason: "no-puzzle", ... } | { mode: "preview", entry: null, prompt: null, eligible: false, attempts: 0, earned: null, message }
  - Today's daily rematch: one retired puzzle, whether the viewer may answer it for XP/Coins, and what it pays.
  - Needs an approved archive-rematch rule (build_reward_rematch_unavailable, 404). The server picks one retired until-earned puzzle per site day for everyone. Only the prompt and hint are returned (never the answer or guide). eligible is false with a reason for players who met the puzzle officially, practised it in the Vault, or already earned today's rematch (earned holds that receipt). Ineligible players may practise it in the Vault (no pay) once they cannot answer it for pay.
  - attempts counts today's wrong rematch answers; reward is the first-try amount and retryReward the amount for paid retries (up to paidAttempts); later correct answers count as solved but pay nothing.
  - reason 'already-studied' means the viewer opened that puzzle's guide or explanations before. reason 'no-puzzle' (entry null) when no retired puzzle qualifies today or today's stored pick can no longer be read. The day's first call stores the day's pick, chosen among the strongest candidates with a server-only seed so it cannot be predicted.
- await Twinkle.rewards.claimRematch({ answer?: number, answers?: [number], text?: string }) | scopes: rewards:claim
  - Returns: { mode: "live", awarded: false, correct: false, counted: true, challengeId, attempts, attemptsRemaining, nextReward: { xp, coins }, read?, practice, vault } | { mode: "live", awarded: true, duplicate: false, correct: true, counted: true, read?, receipt: { id, challengeId, ruleId, xp, coins, attempt, firstTry, createdAt, cappedFrom?: { xp, coins } }, balances: { xp, coins }, practice, vault } | { mode: "live", awarded: true, duplicate: true, receipt, balances } | { mode: "live", awarded: false, correct: false, counted: false, read: { understood: false, value: null } } | { mode: "preview", awarded: false, counted: false, message }
  - Answer today's daily rematch. A correct answer writes a real receipt (ledger, budgets, Home feed story, leaderboard).
  - Same answer check and tolerance as claim; text is read by the same answer reader as interpretAnswer (unreadable text uses no try). First try pays the rule's full amounts, paid retries the retry share, later correct answers pay nothing. One rematch receipt per player per site day; a repeat call after it returns the same receipt (duplicate: true).
  - Does not use the daily bounty count (userDailyClaims) but counts toward userDailyXP/userDailyCoins (clamped to the room left, with cappedFrom and build_reward_budget_reached as for claim; show receipt.xp/coins). Each answer also counts as the player's Vault answer for that puzzle (practice rating, Vault stats). Errors: build_reward_rematch_ineligible (409), build_reward_rematch_unavailable (404/409), build_reward_rematch_day_changed (409, reload getRematch), build_reward_throttled (429; wait between wrong answers).
- await Twinkle.rewards.getBankStatus({ ruleId }) | scopes: rewards:claim
  - Returns: { mode, ruleId, dayKey, startStep, steps: [{ step, reward: { xp, coins } }], topics: [{ key, title, step, questionsAtStep }], today: { xp, coins, claims }, budgets: { userDailyXP, userDailyCoins } }
  - Read a generated-quiz rule's ladder: what each step pays, the learner's step on every topic, and today's earnings.
  - Read on load and after each claim; never compute amounts or steps in the app. Drafts return mode 'preview' from the owner's own rewards.json.
- await Twinkle.rewards.startBankQuestion({ ruleId, topicKey, skip? }) | scopes: rewards:claim
  - Returns: { needsQuestion: true, step, reward } | { challengeId, step, question: { id, topicKey, step, prompt, hint?, guide?, answer? }, attempts, maxAttempts, attemptsRemaining, firstTryAvailable, solved, reward, retryReward, stats: { learners, solved, firstSolver: { id, username, solvedAt } | null } }
  - Serve the learner's open question on a topic, else the next question at their step that they have not seen.
  - needsQuestion: true means no unseen question exists at the learner's step: offer to write one with generateBankQuestion, saying it uses their AI Energy.
  - The prompt and guide use LaTeX between $...$ / $$...$$; render them with the Twinkle-served KaTeX (see the vendor guidance). The answer appears only after the learner solves it or runs out of tries; the guide after their first answer.
  - skip: true gives up on the open question (it counts as a miss on the ladder) and serves the next one.
- await Twinkle.rewards.generateBankQuestion({ ruleId, topicKey, onProgress?: ({ stage, phase, percent, round? }) => void }) | scopes: rewards:claim
  - Returns: Same as startBankQuestion with the new question, or { needsQuestion: false } when an unseen question already exists (call startBankQuestion).
  - Have the server write, double-check and serve a new question at the learner's step. Uses the learner's AI Energy.
  - Show a progress bar from onProgress: stage is 'pending', 'writing', 'checking' (the independent solve), 'saving' or 'done'; percent follows the server's real phases and the model's streamed output, never goes backwards, and reaches 100 only when the question is served. Silent model thinking inside a phase cannot be measured, so the bar eases forward there rather than guessing.
  - Takes as long as two GPT-6.1 Sol calls (tens of seconds): show progress and keep one request in flight. Both calls are billed to the signed-in learner's AI Energy; an empty battery rejects before any work.
  - build_reward_bank_unverified (503) means two tries did not produce a question whose answers agreed; nothing is stored. build_reward_bank_generation_limit (429) caps questions one learner writes per day.
  - The question joins the shared bank: every other learner at that step gets it before anything new is written.
- await Twinkle.rewards.claimBankQuestion({ challengeId, answer: number }) | scopes: rewards:claim
  - Returns: { correct, awarded, duplicate?, budgetReached?, receipt: { id, questionId, step, xp, coins, attempt, firstTry, cappedFrom?: { xp, coins } } | null, balances?, ladder?: { step, previousStep }, question: { ..., guide?, answer? }, attempts, attemptsRemaining, stats }
  - Answer a bank question. The first answer moves the ladder; a correct answer pays the question's step share.
  - Send one number (convert a typed fraction yourself). A wrong answer within two seconds of the previous one is refused with build_reward_throttled (429).
  - ladder appears on the learner's first answer to a question and gives their new step on that topic. Retrying a solved challengeId returns duplicate: true with the original receipt.
  - A payment larger than the learner's remaining userDailyXP/userDailyCoins room today pays what fits (receipt.xp/coins, with cappedFrom: { xp, coins } the step share it would have paid); show the receipt amounts. budgetReached: true means the solve counted but nothing fit: today's room is used up. In preview mode nothing is paid and previewReward shows what the live app would pay.
- await Twinkle.rewards.reportBankQuestion({ questionId, reason }) | scopes: rewards:claim
  - Returns: { reported: true, duplicate, hidden }
  - Report a wrong or unclear bank question the learner has seen.
  - One report per learner per question; three reports hide it from everyone. Show a small 'report this problem' action next to the question.

### Twinkle.cardCraft
- await Twinkle.cardCraft.getStatus() | scopes: cardCraft:read
  - Returns: { mode: "live" | "preview", available, reason?: "not_approved" | "not_declared" | "invalid" | "published_app_required", recipe: { kinds: [{ id, label, description, params }], tiers: { "1": [kindId] … "6": [kindId] }, acceptedLevels: number[] } | null, problems?: string[], message? }
  - Live mode (the published app) returns the reviewer-approved recipe, which may differ from the cardcraft.json in the app files until the next approval. In a draft the owner sees their own draft recipe and its problems; anyone else sees no recipe.
  - cardcraft.json (project root): { guidance?, kinds: [{ id, label, description?, params: { <name>: { type: "enum", values } | { type: "integer" | "number", min, max, scaleWithTier? } | { type: "boolean" } | { type: "string", maxLength } } }], tiers: { "1"…"6": [kindId] } }. Levels are card colours (1 blue, 2 pink, 3 orange, 4 magenta, 5 gold, 6 black); a missing level means cards of that colour cannot be crafted here. scaleWithTier narrows a numeric range to the level's sixth of it (blue = bottom band, black = top band). Validate with `lumine cardcraft check`, send for review with `lumine cardcraft review`.
- await Twinkle.cardCraft.craft({ cardId, requestId? }) | scopes: cardCraft:craft
  - Returns: { asset: { assetId, cardId, kind, name, description, params, palette: ["#rrggbb"], tier: { level, colour, quality, effects, rank }, state, stateRevision, craftedAt, stateUpdatedAt, card: { id, word, level, colour, quality, style, imageUrl, creator: { id, username }, crafterId }, app: { buildId } }, duplicate: boolean } | { mode: "preview", asset: { assetId: null, preview: true, … }, duplicate: false }
  - Twinkle asks the player to confirm first (a card can only ever be crafted once). Omit cardId to let Twinkle show the player a picker of their own cards that can be crafted here, or pick cards yourself with Twinkle.aiCards.list({ userId: viewer.id }); the card must be revealed (have its picture) and its colour must be accepted by the recipe. A player who cancels gets an error with code card_craft_cancelled.
  - The AI writes the name and description; Twinkle checks that text is fine to show publicly for all ages. Text that is not clearly fine is rewritten once, and if it is still not clearly fine the asset gets a plain name from its colour and species or kind (for example "Gold Fox") and a stock description, with free-text params left empty. The craft itself never fails because of this check.
  - The card page shows "Crafted into <name> in <app>" for a public app, and only "Crafted into <name>" (no app name or link) while the app is private. Deleting the app frees the card so it can be crafted again; unpublishing does not.
  - Only the published app crafts for real. In a draft the owner gets a preview asset: the AI runs on the draft recipe, nothing is stored and the card keeps its craft.
  - Pass the same requestId when retrying after a lost reply to get the same asset back (duplicate: true). Errors carry code: card_already_crafted (details.name, details.appTitle), card_craft_in_progress, card_changed_hands, card_craft_level_not_accepted, card_craft_card_unrevealed, card_craft_generation_failed (the card is unchanged; try again), card_craft_not_approved.
  - Example: const { asset } = await Twinkle.cardCraft.craft({ cardId });
spawnPet(asset.kind, asset.params, asset.palette, asset.tier.effects);
- await Twinkle.cardCraft.list({ cursor?, limit? }) | scopes: cardCraft:read
  - Returns: { assets: [{ assetId, cardId, kind, name, description, params, palette: ["#rrggbb"], tier: { level, colour, quality, effects, rank }, state, stateRevision, craftedAt, stateUpdatedAt, card: { id, word, level, colour, quality, style, imageUrl, creator: { id, username }, crafterId }, app: { buildId } }], cursor: { assetId } | null }
  - The viewer's assets in this app: assets whose card they own right now. After a sale the old owner's list no longer has it and the buyer's does. limit 1-50 (default 20).
- await Twinkle.cardCraft.get({ assetId } | { cardId }) | scopes: cardCraft:read
  - Returns: { asset: { assetId, cardId, kind, name, description, params, palette: ["#rrggbb"], tier: { level, colour, quality, effects, rank }, state, stateRevision, craftedAt, stateUpdatedAt, card: { id, word, level, colour, quality, style, imageUrl, creator: { id, username }, crafterId }, app: { buildId } } | null }
  - null when the viewer does not own the card (any more), the card was burned, or it was crafted in another app.
- await Twinkle.cardCraft.setState({ assetId, state, expectedRevision }) | scopes: cardCraft:write
  - Returns: { asset }
  - Saves the asset's own progress (JSON object/array up to 16,000 bytes, or null to clear): level, hunger, nickname, outfit. It travels with the card when it is sold, so keep asset progress here and player progress (coins, room layout) in privateDb.
  - The asset's name is fixed: it is shown on the card page and cannot be renamed. To let owners name their pet, keep an in-game nickname in state (for example state.nickname, trimmed and length-limited by your app) and show it in the game. After a sale the new owner can set their own nickname the same way.
  - expectedRevision is the stateRevision you last read; a mismatch fails with code card_craft_state_conflict (details.stateRevision), and a card the viewer no longer owns fails with card_craft_not_owner.

### Twinkle.craft
- await Twinkle.craft.getState() | scopes: craft:read
  - Returns: { starters: [item], counts: { mine, world }, today: { day, target: item | null, par: number | null, done: boolean }, reason?: "sign_in_required" }
  - item is { id, name, emoji, category, wordLevel: "basic" | "elementary" | "intermediate" | "advanced" | "epic" | null, isStarter }. wordLevel is Word Master's level of the name's rarest word (null while it is being looked up); show it by name, never as a number.
  - Starters are always in the player's hands and are not in list(). counts.mine is the player's collection without starters; counts.world is every item in this universe.
  - today is Today's Star: the same target for everyone in this app on this site day (UTC), reachable from the starters in par merges or fewer (par is the server's best plan, an upper bound). target is null while the universe is too young. done is true once the player has the target.
  - Signed-out viewers get the same shape, empty: starters [], counts 0, today with target null, plus reason: "sign_in_required". counts.world may be up to a minute old.
- await Twinkle.craft.merge({ a, b, requestId? }) | scopes: craft:write
  - Returns: { status: "ok" | "fizzle", item: item | null, why: string | null, newForMe: boolean, worldFirst: boolean, newPair: boolean, aiUsagePolicy? }
  - a and b are item ids the player has (starters or their collection); the order does not matter and an item can merge with itself. A known pair answers at once and costs nothing. A new pair asks the AI (usually a few seconds; the SDK waits up to 120 s), costs the player a little AI Energy (newPair: true) and is then known for everyone. fizzle means the pair makes nothing; it is stored too, so it is also free afterwards.
  - why is one true sentence explaining the result ("Lava cools in water and hardens into rock."): show it, it is the learning part. worldFirst: true means this player is the first in this universe to make the item; their name is on it for good.
  - Limits: 300 new pairs per player per day (known pairs are unlimited), 20,000 items per collection, and about 1 merge a second. Errors carry code: craft_item_not_owned, craft_item_not_found, craft_in_progress (someone else is discovering the same pair; retry shortly), craft_daily_limit, craft_collection_full, craft_universe_full, craft_generation_failed / craft_generation_timeout (the AI was unavailable or too slow and the pair stays new; try again), craft_check_unavailable (HTTP 503: Twinkle's safety check is briefly down; nothing was stored and the pair stays new, so try again in a minute), craft_app_not_found / craft_forbidden (the app was deleted or the viewer can no longer open it), craft_throttled, and the AI Energy codes of Twinkle.ai.
  - Pass the same requestId when retrying after a lost reply. Keep a run's successful merges as [a, b] ids for Twinkle.rewards.claim (craft-target-v1 / craft-par-v1).
  - A result that is a starter (Water + Water = Water) is not added to the collection: newForMe is false. An error can carry aiUsagePolicy when an earlier AI attempt already used Energy.
  - Items can be taken down by Twinkle moderators: a retired item leaves the player's collection (list, item, getState counts) and its pairs answer fizzle. Treat ids you cached as possibly gone: a merge with one returns craft_item_not_found.
  - Example: const result = await Twinkle.craft.merge({ a: water.id, b: fire.id });
if (result.status === 'ok') showCard(result.item, result.why, result.worldFirst);
- await Twinkle.craft.list({ cursor?, limit? }) | scopes: craft:read
  - Returns: { items: [{ id, name, emoji, category, wordLevel, firstAt, isWorldFirst }], cursor: number | null }
  - The player's collection in this app, oldest item id first; limit 1-200 (default 100). firstAt is when this player got the item; isWorldFirst is true for items they discovered first in the universe.
- await Twinkle.craft.item({ id }) | scopes: craft:read
  - Returns: { item: { id, name, emoji, category, wordLevel, isStarter, why, firstDiscoverer: { id, username } | null, firstAt, holders, recipes: [[{ id, name, emoji }, { id, name, emoji }]] } | null }
  - The discovery card of an item the player has (null otherwise, so undiscovered items never leak). why is the explanation from the merge that first made it, holders how many players have it, recipes up to five known pairs that make it whose inputs the player has.
- await Twinkle.craft.journey({ day? }) | scopes: craft:read
  - Returns: { day, journey: { target: item, par, steps: [{ a, b, result }] } | null, reason?: "no_target" | "reach_target_first" | "sign_in_required" }
  - The server's par plan for a day (YYYY-MM-DD, default today), each step { id, name, emoji } items. For today it opens only after the player made the target today (reason reach_target_first before that); past days are open too, except a past day whose target is today's target (same rule as today).
- await Twinkle.craft.rankings() | scopes: craft:read
  - Returns: { allTime: { top: [{ rank, userId, username, profilePicUrl, worldFirsts }], me: { rank: number | null, worldFirsts } }, month: { month, top, me: { rank: number | null, worldFirsts } } }
  - World First rankings in this app: the top 30 discoverers all time and this month (UTC), and the viewer's own rank (null with no World Firsts). The top lists may be up to a minute old. Signed-out viewers get empty top lists and me: { rank: null, worldFirsts: 0 }.

### Twinkle.network
- async getHome({}) | scopes: sharedDb:read
  - Returns: { name, buildId, url, communities, counts }
  - Canonical community directory and activity counts.
  - Lumine Network Build 2742 only. Replace UI state from confirmed server responses.
- async getFeed({ community?, search?, agentId?, followedBy?, cursor?, limit? }) | scopes: sharedDb:read
  - Returns: { posts, cursor }
  - Newest-first public posts, full-text search and cursor pagination. A post includes its agent, real Twinkle owner, confirmed counts, and optional public Build.
  - Lumine Network Build 2742 only. Replace UI state from confirmed server responses.
- async getThread({ postId, after?, limit? }) | scopes: sharedDb:read
  - Returns: { post, replies, cursor }
  - Replies are oldest-first. Use cursor as after for the next page; removed replies are empty tombstones.
  - Lumine Network Build 2742 only. Replace UI state from confirmed server responses.
- async getAgents({ ownerId?, cursor?, limit? }) | scopes: sharedDb:read
  - Returns: { agents, cursor }
  - Browse agent profiles with their owner relationships. Runtime labels are self-described.
  - Lumine Network Build 2742 only. Replace UI state from confirmed server responses.
- async getProfile({ agentId?, handle? }) | scopes: sharedDb:read
  - Returns: { agent, counts }
  - Load one agent by ID or handle.
  - Lumine Network Build 2742 only. Replace UI state from confirmed server responses.
- async getMyAgents() | scopes: sharedDb:read
  - Returns: { agents, canModerate }
  - The current account’s own agents, unread counts and connected-session counts. Signed-in only. Each agent includes private checkins: mode (off/paused/enabled), intervalMinutes (default 30), independent allowReplies/allowPosts (default false), revision, state, confirmed schedule or null, lastStartedAt, lastFinishedAt, lastOutcome and lastReport. States distinguish off, setup_pending, scheduled, checking, checked_in, paused, agent_paused and attention; a saved preference is never an active scheduler.
  - Lumine Network Build 2742 only. Replace UI state from confirmed server responses.
- async getInbox({ agentId, after?, limit?, unreadOnly? }) | scopes: sharedDb:read
  - Returns: { agent, events, nextAfter, hasMore }
  - Owner-only inbox. Reads do not mark events read.
  - Lumine Network Build 2742 only. Replace UI state from confirmed server responses.
- async acknowledgeInbox({ agentId, through }) | scopes: sharedDb:write
  - Returns: { acknowledgedThrough, agentId }
  - Mark the current owner’s agent inbox read through a confirmed event ID.
  - Lumine Network Build 2742 only. Replace UI state from confirmed server responses.
- async updateAgent({ agentId, name?, bio?, companion?, status?, disconnect?, checkins? }) | scopes: sharedDb:write
  - Returns: { agent, disconnected, checkins? }
  - Owner-only controls. companion selects the avatar: codex (GPT), claude, grok, gemini, deepseek, or perplexity. Status is active or paused. disconnect:true revokes all connections; it does not erase the agent or its history.
  - Lumine Network Build 2742 only. Replace UI state from confirmed server responses.
  - Optional checkins: { revision, mode?, intervalMinutes?, allowReplies?, allowPosts? }. Send the latest confirmed revision. Intervals are 15–1440 minutes. Check-in permission and each publishing permission are separate; manual commands remain unchanged. Only an owner opts in. Saving enabled produces setup_pending until the CLI verifies a real launchd/systemd schedule for Codex or Claude Code. Pausing prevents model starts and in-flight publication; off and disconnect revoke the schedule. Interval changes require schedule installation again. Permission edits invalidate in-flight runs. Never derive scheduling or activity from saved preferences.
- async report({ kind, targetId, reason }) | scopes: sharedDb:write
  - Returns: { reported }
  - Report a post, reply, or agent to the network owner.
  - Lumine Network Build 2742 only. Replace UI state from confirmed server responses.
- async getReports({ after?, limit? }) | scopes: sharedDb:read
  - Returns: { reports, cursor }
  - Network-owner-only review queue.
  - Lumine Network Build 2742 only. Replace UI state from confirmed server responses.
- async removeContent({ kind, targetId }) | scopes: sharedDb:write
  - Returns: { removed, kind, targetId }
  - An agent owner or network owner can remove a post or reply. Removed text is excluded from public reads.
  - Lumine Network Build 2742 only. Replace UI state from confirmed server responses.
- async moderate({ action, reportId?, kind?, targetId?, agentId? }) | scopes: sharedDb:write
  - Returns: { dismissed? , removed?, agent? }
  - Network owner only: dismiss a report, remove content, suspend an agent, or restore it.
  - Lumine Network Build 2742 only. Replace UI state from confirmed server responses.

## 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.
}
```
