@stage5/lumine 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  Version: 1.26.2
4
4
  Updated: 2026-06-09
5
- Generated: 2026-06-09T01:00:18.637Z
5
+ Generated: 2026-06-11T01:12:56.894Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -29,73 +29,119 @@ files:read, user:read, users:read, dailyReflections:read, content:read, sharedDb
29
29
  ### Twinkle.capabilities
30
30
  - async get() | scopes: none
31
31
  - Returns: Capability snapshot
32
+ - Includes viewer state, available namespaces, blocked writes, and Lumine action permissions.
32
33
  - async can(actionName) | scopes: none
33
34
  - Returns: boolean
35
+ - Checks whether a named Lumine action is allowed in the current context.
34
36
  - async listActions() | scopes: none
35
37
  - Returns: { available, blocked, details }
38
+ - Returns the current Lumine action permission map.
36
39
  - async refresh() | scopes: none
37
40
  - Returns: Capability snapshot
41
+ - Forces a fresh fetch from the parent.
38
42
 
39
43
  ### Twinkle.viewer
40
44
  - async get() | scopes: none
41
45
  - Returns: { id, username, profilePicUrl, isLoggedIn, isOwner, isGuest }
46
+ - Cached; use refresh() to re-fetch.
42
47
  - async refresh() | scopes: none
43
48
  - Returns: Viewer info
49
+ - Forces a fresh fetch from the parent.
44
50
 
45
51
  ### Twinkle.preview
46
52
  - getLayout() | scopes: none
47
53
  - Returns: { mode, viewport, stage, safeInsets, playfield }
48
54
  - Read the host preview layout and derive a fixed-world scale from layout.playfield before sizing a canvas, sprites, or mobile game UI.
55
+ - Always available in the build iframe.
56
+ - Returns the current preview geometry, including viewport size, current stage size/scale, reserved safe insets, and the usable playfield rectangle.
57
+ - Use this as the source of truth for canvas/game sizing instead of guessing from raw window size alone.
58
+ - For fixed-world games, derive one scale from layout.playfield and apply it consistently to sprites, UI, and movement.
59
+ - Do not subtract guessed HUD or chrome heights from the viewport when layout.playfield already represents the usable gameplay area.
49
60
  - 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);
50
61
  - reserveInsets({ top, right, bottom, left }) | scopes: none
51
62
  - Returns: { mode, viewport, stage, safeInsets, playfield }
52
63
  - Reserve host-aware safe space for HUD bars, touch controls, or other overlays before clamping gameplay.
64
+ - Always available in the build iframe.
65
+ - Reserves in-app safe space for overlays such as HUD bars or touch controls.
66
+ - After reserving insets, clamp gameplay to playfield instead of the raw canvas or stage edge.
53
67
  - Example: Twinkle.preview.reserveInsets({ top: 72, bottom: 120, left: 0, right: 0 });
54
68
  - setPlayfield({ x, y, width, height } | null) | scopes: none
55
69
  - Returns: { playfieldBounds, playerBounds, overflowTop, overflowRight, overflowBottom, overflowLeft, status, reportedAt } | null
56
70
  - Declare the actual playable rectangle when the game area is smaller than the raw canvas.
71
+ - Always available in the build iframe.
72
+ - Declares the game or canvas playfield bounds in the app's own coordinate space.
73
+ - Use this when the playable area is smaller than the raw canvas because of HUD, touch controls, or other reserved regions.
74
+ - Do not pass DOM screen pixels from getBoundingClientRect(); use the same in-game coordinate space as your world and player logic.
57
75
  - Example: Twinkle.preview.setPlayfield({ x: layout.playfield.x, y: layout.playfield.y, width: layout.playfield.width, height: layout.playfield.height });
58
76
  - reportGameplayState({ playfieldBounds?, playerBounds? } | null) | scopes: none
59
77
  - Returns: { playfieldBounds, playerBounds, overflowTop, overflowRight, overflowBottom, overflowLeft, status, reportedAt } | null
60
78
  - Report live player or avatar bounds so the preview host can detect floor, wall, or out-of-bounds issues.
79
+ - Always available in the build iframe.
80
+ - Reports live gameplay bounds in the same coordinate space as setPlayfield.
81
+ - Use this for moving players or avatars so preview review and auto-fix can detect floor or wall escapes.
82
+ - Do not report viewport-relative or screen-pixel rectangles; keep telemetry in stable game/world coordinates.
61
83
  - Example: Twinkle.preview.reportGameplayState({ playerBounds: { x: player.x, y: player.y, width: player.width, height: player.height } });
62
84
  - getGameplayTelemetry() | scopes: none
63
85
  - Returns: { playfieldBounds, playerBounds, overflowTop, overflowRight, overflowBottom, overflowLeft, status, reportedAt } | null
64
86
  - Read the latest preview-side gameplay telemetry snapshot.
87
+ - Always available in the build iframe.
88
+ - Returns the latest gameplay telemetry snapshot known to the preview host.
65
89
  - clearGameplayState() | scopes: none
66
90
  - Returns: { playfieldBounds, playerBounds, overflowTop, overflowRight, overflowBottom, overflowLeft, status, reportedAt } | null
91
+ - Always available in the build iframe.
92
+ - Clears previously reported playfield and player telemetry.
67
93
  - clearReservedInsets() | scopes: none
68
94
  - Returns: { mode, viewport, stage, safeInsets, playfield }
95
+ - Always available in the build iframe.
96
+ - Clears previously reserved safe insets.
69
97
  - subscribe(listener, { immediate } = {}) | scopes: none
70
98
  - Returns: unsubscribe()
71
99
  - 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.
100
+ - Always available in the build iframe.
101
+ - Subscribes to preview layout changes such as resize, host fit changes, or inset updates.
102
+ - Use this when the app needs to keep a canvas or playfield synced with the host preview.
103
+ - Re-apply world scale here so embedded ContentPanel and Build Studio runtime stay aligned.
72
104
  - Example: const unsubscribe = Twinkle.preview.subscribe((layout) => syncGameLayout(layout), { immediate: true });
73
105
 
74
106
  ### Twinkle.mount
75
107
  - async get() | scopes: none
76
108
  - Returns: { type: 'subject', id: number } | null
109
+ - Always available.
110
+ - Returns the host-provided mount context, such as a subject mounted into a book app.
111
+ - Does not fetch subject metadata, comments, or files. Use Twinkle.subjects and Twinkle.subjectComments for data reads.
77
112
  - async refresh() | scopes: none
78
113
  - Returns: { type: 'subject', id: number } | null
114
+ - Forces a fresh mount context read from the parent frame.
79
115
 
80
116
  ### Twinkle.notifications
81
117
  - getLaunchTarget() | scopes: none
82
118
  - Returns: { notificationId, buildId, eventKey, eventLabel, target, payload? } | null
83
119
  - Read the current notification launch target, if the app was opened from a Build notification.
120
+ - Always available.
121
+ - Use target.focus for precise in-app jumping, such as focusing a sharedDb entry.
84
122
  - onLaunchTarget(listener, { immediate } = {}) | scopes: none
85
123
  - Returns: unsubscribe()
86
124
  - Listen for notification launch targets while the Build app is already open.
125
+ - Always available.
126
+ - By default, immediately calls the listener with the current launch target when one exists. Pass { immediate: false } to only receive future targets.
87
127
  - Example: const off = Twinkle.notifications.onLaunchTarget((launchTarget) => focusEntry(launchTarget?.target?.focus?.entryId));
88
128
  - async getSubscription(channelKey, { targetKey }) | scopes: notifications:read
89
129
  - Returns: { subscription }
90
130
  - Read whether the current viewer is subscribed to an app-defined notification channel target.
131
+ - channelKey identifies the app-defined notification class, such as room.message or leaderboard.dethroned.
132
+ - targetKey is a stable app-defined string identity, such as room:lobby, board:daily, or user:123.
91
133
  - Example: const { subscription } = await Twinkle.notifications.getSubscription('room.message', { targetKey: 'room:lobby' });
92
134
  - async subscribe(channelKey, { targetKey, launchTarget }) | scopes: notifications:write
93
135
  - Returns: { subscription }
94
136
  - Subscribe the current viewer to an app-defined notification channel target.
137
+ - launchTarget is optional app-defined JSON delivered back through Twinkle.notifications launch targets.
138
+ - target is accepted as a backwards-compatible alias for launchTarget.
95
139
  - Example: await Twinkle.notifications.subscribe('room.message', { targetKey: 'room:lobby', launchTarget: { view: 'room', roomId: 'lobby' } });
96
140
  - async subscribeMany([{ channelKey, targetKey, launchTarget }]) | scopes: notifications:write
97
141
  - Returns: { subscriptions }
98
142
  - Subscribe the current viewer to multiple app-defined notification channel targets in one request.
143
+ - Accepts up to 100 subscriptions per request.
144
+ - target is accepted as a backwards-compatible alias for launchTarget on each item.
99
145
  - Example: await Twinkle.notifications.subscribeMany([{ channelKey: 'room.message', targetKey: 'room:lobby', launchTarget: { view: 'room', roomId: 'lobby' } }]);
100
146
  - async unsubscribe(channelKey, { targetKey }) | scopes: notifications:write
101
147
  - Returns: { subscription: null }
@@ -104,18 +150,27 @@ files:read, user:read, users:read, dailyReflections:read, content:read, sharedDb
104
150
  - async unsubscribeMany([{ channelKey, targetKey }]) | scopes: notifications:write
105
151
  - Returns: { subscriptions: [], removed }
106
152
  - Unsubscribe the current viewer from multiple app-defined notification channel targets in one request.
153
+ - Accepts up to 100 subscriptions per request.
107
154
  - Example: await Twinkle.notifications.unsubscribeMany([{ channelKey: 'room.message', targetKey: 'room:lobby' }]);
108
155
  - async notifySubscribers(channelKey, { targetKey, eventKey, label, summary, launchTarget, payload }) | scopes: notifications:emit
109
156
  - Returns: { sent }
110
157
  - Notify viewers who opted into an app-defined channel target, without requiring a sharedDb write.
158
+ - Only current subscribers to the same build, channelKey, and targetKey are considered.
159
+ - The actor is never notified about their own emit.
160
+ - title/body are accepted as aliases for label/summary.
161
+ - target is accepted as a backwards-compatible alias for launchTarget.
162
+ - Twinkle applies app API rate limits, a stricter notification emit rate limit, and existing notification mutes.
111
163
  - 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 } });
112
164
  - async getSubjectUpdateSubscription(subjectId) | scopes: notifications:read
113
165
  - Returns: { subscription }
114
166
  - Read whether the current viewer is subscribed to Build notifications for updates to a subject.
167
+ - Returns the current viewer's subscription for this Build and subject, or null.
115
168
  - Example: const { subscription } = await Twinkle.notifications.getSubjectUpdateSubscription(subjectId);
116
169
  - async subscribeToSubjectUpdates(subjectId, { target } = {}) | scopes: notifications:write
117
170
  - Returns: { subscription }
118
171
  - Subscribe the current viewer to notifications when the original subject author adds a new page or update.
172
+ - target is app-defined JSON delivered back through Twinkle.notifications launch targets.
173
+ - The server sends notifications from the canonical subject comment write path when the subject author adds a page.
119
174
  - Example: await Twinkle.notifications.subscribeToSubjectUpdates(subjectId, { target: { view: 'book', subjectId } });
120
175
  - async unsubscribeFromSubjectUpdates(subjectId) | scopes: notifications:write
121
176
  - Returns: { subscription: null }
@@ -125,11 +180,20 @@ files:read, user:read, users:read, dailyReflections:read, content:read, sharedDb
125
180
  - async bestMove({ fen, depth?, skillLevel?, maxTimeMs?, timeoutMs? }) | scopes: none
126
181
  - Returns: { success, move, bestMove, from, to, promotion, evaluation, depth, mate, error, engine }
127
182
  - Ask the parent-hosted Stockfish engine for the best move from a FEN position.
183
+ - Always available in the build iframe.
184
+ - Stockfish runs in a parent-managed worker with bounded depth, timeout, and serialized requests.
185
+ - Use skillLevel 0-20 for simple difficulty selection, or depth 1-24 for explicit search depth.
186
+ - skillLevel 20 defaults to the strongest bounded search budget.
187
+ - maxTimeMs and timeoutMs are clamped between 500 and 60000 milliseconds.
188
+ - 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.
128
189
  - Example: const result = await Twinkle.chess.bestMove({ fen: game.fen(), skillLevel: 8, maxTimeMs: 1000 });
129
190
  if (result.success) game.move({ from: result.from, to: result.to, promotion: result.promotion || undefined });
130
191
  - async evaluate({ fen, depth?, skillLevel?, maxTimeMs?, timeoutMs? }) | scopes: none
131
192
  - Returns: { success, move, bestMove, from, to, promotion, evaluation, depth, mate, error, engine }
132
193
  - Analyze a FEN position and return Stockfish's current best move plus centipawn or mate evaluation.
194
+ - Always available in the build iframe.
195
+ - evaluation is the Stockfish centipawn score from the engine output when available; mate is the mate distance when Stockfish reports one.
196
+ - Do not call this from a render loop, animation loop, or high-frequency polling path.
133
197
  - Example: const analysis = await Twinkle.chess.evaluate({ fen: game.fen(), depth: 12 });
134
198
  console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
135
199
 
@@ -137,188 +201,387 @@ console.log(analysis.bestMove, analysis.evaluation, analysis.mate);
137
201
  - async saveAs({ fileName, url, dataUrl, data, text, json, bytes, blob, file, mimeType } = {}) | scopes: none
138
202
  - Returns: { success, fileName, size?, mimeType?, method }
139
203
  - Download a generated or remote file to the viewer's local device through the parent frame without opening a popup.
204
+ - Local viewer download only; does not upload to Twinkle or consume AI Energy.
205
+ - Use this for generated blobs/data/JSON/bytes or large/remote files that need parent-frame download handling.
206
+ - 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.
207
+ - Simple visible same-origin images can still use normal browser anchors with href and download.
140
208
  - Example: await Twinkle.files.saveAs({ fileName: 'fashion-guide.png', dataUrl: imageUrl, mimeType: 'image/png' });
141
209
  - async uploadGenerated({ fileName, url, dataUrl, data, text, json, bytes, blob, file, mimeType } = {}) | scopes: files:write
142
210
  - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, uploadedByUserId, createdAt }], failed?: [{ fileName, message }], canceled }
143
211
  - Upload an app-generated file to Twinkle-hosted cloud storage without opening a picker, then store the returned asset refs in sharedDb/privateDb/userDb.
212
+ - Signed-in viewers only.
213
+ - Uploads generated blobs, files, bytes, data URLs, or fetchable URLs to Twinkle-hosted cloud storage.
214
+ - Video uploads are not supported right now.
215
+ - Store the returned asset metadata in sharedDb/privateDb/userDb instead of storing raw file bytes in a DB record.
144
216
  - Example: const { assets } = await Twinkle.files.uploadGenerated({ fileName: 'fashion-guide.png', dataUrl: generatedImageUrl, mimeType: 'image/png' });
145
217
  - async pickAndUpload({ accept, multiple } = {}) | scopes: files:write
146
218
  - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, uploadedByUserId, createdAt }], failed?: [{ fileName, message }], canceled }
147
219
  - Pick supported local files and upload them to Twinkle-hosted cloud storage, then store the returned asset refs in sharedDb/privateDb/userDb.
220
+ - Signed-in viewers only.
221
+ - Uploads to Twinkle-hosted cloud storage and returns asset references.
222
+ - Video uploads are not supported right now.
223
+ - When multiple files are selected, successful uploads are still returned even if one later file fails.
224
+ - Store the returned asset metadata in sharedDb/privateDb/userDb instead of storing raw file bytes in a DB record.
148
225
  - Example: const { assets, canceled } = await Twinkle.files.pickAndUpload({ accept: 'image/*,.pdf', multiple: true });
149
226
  - async list({ cursor, limit } = {}) | scopes: files:read
150
227
  - Returns: { assets: [{ id, buildId, fileName, originalFileName, mimeType, sizeBytes, filePath, url, thumbUrl, fileType, uploadedByUserId, createdAt }], nextCursor, usage: { totalBytes, fileCount, maxRuntimeFileStorageBytes, remainingBytes } | null }
151
228
  - List the current viewer's uploaded runtime files for this build.
229
+ - Signed-in viewers only.
230
+ - Lists the current viewer's ready uploads for this build only.
152
231
  - Example: const { assets, usage } = await Twinkle.files.list({ limit: 20 });
153
232
  - async delete(assetId) | scopes: files:write
154
233
  - Returns: { success, deletedAssetId, usage: { totalBytes, fileCount, maxRuntimeFileStorageBytes, remainingBytes } | null }
155
234
  - Delete one of the current viewer's uploaded runtime files and free up Twinkle.files quota.
235
+ - Signed-in viewers only.
236
+ - Deletes one of the current viewer's uploaded runtime files and updates quota usage.
156
237
  - Example: await Twinkle.files.delete(assetId);
157
238
 
158
239
  ### Twinkle.ai
159
240
  - async listPrompts() | scopes: none
160
241
  - Returns: Array<{ id, title, description }>
242
+ - Legacy helper. Twinkle.ai.chat does not require promptId for default runtime text generation.
161
243
  - async chat({ promptId, message, history, systemPrompt, requestId, onText, onStatus } = {}) | scopes: none
162
244
  - Returns: { text, response, model, aiUsagePolicy }
163
245
  - Generate text with the default Lumine text model, optionally streaming text updates through onText.
246
+ - Signed-in viewers only.
247
+ - Uses gpt-5.4 by default.
248
+ - Each successful text generation consumes AI Energy from the signed-in viewer.
249
+ - history must be an array of { role: 'user' | 'assistant', content: string }. Twinkle.ai.chat does not read a text field.
250
+ - The server keeps the latest 12 valid history entries.
251
+ - Pass systemPrompt to define the app AI's personality, tone, role, or response rules.
252
+ - Pass onText to receive streaming accumulated text before the final result resolves.
253
+ - AI Energy is recorded after provider success when final token usage is available.
254
+ - Use this for in-app AI replies instead of creating or fetching app-local endpoints such as /api/chat.
164
255
  - Example: const chatHistory = conversation.slice(-12).map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', content: entry.text }));
165
256
  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') });
166
257
  - async generateObject({ prompt, expectedStructure, thinkingMode, mode, instructions, systemPrompt } = {}) | scopes: none
167
258
  - Returns: { object, result, model, provider, thinkingMode, requestedThinkingMode, aiUsagePolicy }
168
259
  - Generate a validated structured JSON object for app decisions, routing, grading, and game-state logic.
260
+ - Signed-in viewers only.
261
+ - Use this instead of asking Twinkle.ai.chat to return JSON.
262
+ - expectedStructure must be a JSON object that describes the exact returned object shape.
263
+ - mode is accepted as an alias for thinkingMode, and mid is accepted as an alias for medium.
264
+ - thinkingMode low uses GPT nano and records free low-energy usage.
265
+ - thinkingMode medium uses GPT mini and normal AI Energy while AI Energy remains.
266
+ - thinkingMode high uses the full GPT model and high AI Energy while AI Energy remains.
267
+ - If medium or high is requested after AI Energy is empty, the server falls back to low and returns thinkingMode: low.
268
+ - The SDK validates shape and retries malformed JSON, but app code should still validate business-specific enum values.
169
269
  - Example: const { object } = await Twinkle.ai.generateObject({ thinkingMode: 'medium', prompt: 'Classify the player intent from: ' + playerText, expectedStructure: { action: 'string', targetCharacter: 'string', confidence: 0, shouldAskFollowUp: false } });
170
270
  - onChatStatus(listener) | scopes: none
171
271
  - Returns: unsubscribe function
172
272
  - Listen to shared runtime AI chat stream events.
273
+ - Usually prefer per-call onText/onStatus callbacks on Twinkle.ai.chat.
274
+ - Events include requestId plus type status, text, done, or error.
173
275
  - async generateImage({ prompt, referenceImageB64, previousResponseId, previousImageId, engine, quality, requestId, onStatus, timeoutMs } = {}) | scopes: none
174
276
  - Returns: { success, imageUrl, responseId, imageId, engine, quality, aiUsagePolicy } or { success: false, error, reason, code, aiUsagePolicy }
175
277
  - Generate or edit an image from a prompt and optional base64/data-URL reference image.
278
+ - Signed-in viewers only.
279
+ - Each successful image generation consumes AI Energy from the signed-in viewer.
280
+ - Default engine is openai and default quality is high.
281
+ - The SDK timeout defaults to 390000ms for image generation because high-quality image runs can exceed normal request timing.
282
+ - Pass onStatus to receive real-time stages from the backend: prompt_ready, in_progress, generating, partial_image, completed, and error.
283
+ - Pass requestId when you need to correlate browser logs, backend logs, and iframe status events for one generation.
284
+ - partial_image statuses may include partialImageB64 for progressive preview UI before the final imageUrl arrives.
285
+ - referenceImageB64 may be a raw base64 string or a data:image/...;base64 URL.
176
286
  - 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) });
177
287
  - onImageGenerationStatus(listener) | scopes: none
178
288
  - Returns: unsubscribe function
179
289
  - Subscribe to real-time image generation status events forwarded into the build iframe.
290
+ - The listener receives the same status payload shape as generateImage({ onStatus }).
291
+ - Works while this build iframe has an active Twinkle.ai.generateImage request.
292
+ - Prefer generateImage({ onStatus }) when the UI only needs status for one request.
180
293
  - Example: const unsubscribe = Twinkle.ai.onImageGenerationStatus((status) => console.log(status.stage));
181
294
 
182
295
  ### Twinkle.characters
183
296
  - async chat({ character, thinkingMode, message, history, roomContext, scene, systemPrompt, instructions, includeWebsiteContext, requestId, onText, onStatus } = {}) | scopes: none
184
297
  - Returns: { text, response, character, aiUsername, thinkingMode, requestedThinkingMode, includeWebsiteContext, model, provider, aiUsagePolicy }
185
298
  - Talk to Zero or Ciel from a Build app, either as a final-response call or streaming RPG-style dialogue text with onText/onStatus.
299
+ - Signed-in viewers only.
300
+ - character must be zero or ciel.
301
+ - Recommended history shape is { role: 'user' | 'assistant', content: string, speaker?: string }; content is the canonical text field.
302
+ - The character route also accepts text or message fields for compatibility, but generated apps should use content.
303
+ - The server keeps the latest 16 valid character history entries.
304
+ - Pass onText/onStatus for streaming dialogue. Omit callbacks for non-streaming dialogue where the promise resolves with the final response.
305
+ - thinkingMode low uses Lite Mode and records free low-energy usage.
306
+ - thinkingMode medium uses normal AI Energy: Zero uses GPT mini and Ciel uses Claude Sonnet while AI Energy remains.
307
+ - thinkingMode high uses high AI Energy: Zero uses the full GPT model and Ciel uses Claude Opus 4.8 while AI Energy remains.
308
+ - Zero maps low/medium/high to GPT nano/GPT mini/full GPT. Ciel maps low/medium/high to Claude Haiku/Claude Sonnet/Claude Opus 4.8.
309
+ - If medium or high is requested after AI Energy is empty, the server falls back to low and returns thinkingMode: low.
310
+ - Pass roomContext as a short shared scene transcript so Zero and Ciel can know what happened in the same room.
311
+ - 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.
312
+ - Use this for real Zero/Ciel NPCs instead of pretending with Twinkle.ai.chat systemPrompt.
186
313
  - Example: const dialogueHistory = recentTurns.slice(-16).map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', content: entry.text, speaker: entry.speaker }));
187
314
  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) });
188
315
  - onChatStatus(listener) | scopes: none
189
316
  - Returns: unsubscribe function
190
317
  - Listen to shared Zero/Ciel runtime chat stream events.
318
+ - Usually prefer per-call onText/onStatus callbacks on Twinkle.characters.chat.
319
+ - Events include requestId plus type status, text, done, or error.
191
320
 
192
321
  ### Twinkle.userDb
193
322
  - async query(sql, params) | scopes: none
194
323
  - Returns: { rows, rowCount, truncated }
195
324
  - Run a SELECT against advanced private per-user SQLite. Use Twinkle.privateDb instead for simple preferences, drafts, settings, or small JSON state.
325
+ - Advanced private storage. Prefer Twinkle.privateDb unless the data is genuinely SQL-shaped.
326
+ - SQL is validated; SELECT/INSERT/UPDATE/DELETE/CREATE TABLE/INDEX only.
327
+ - Guest mode uses browser-local storage and does not sync across devices.
196
328
  - async exec(sql, params) | scopes: none
197
329
  - Returns: { changes, lastInsertRowid }
198
330
  - Run a write or schema statement against advanced private per-user SQLite.
331
+ - Use for CREATE TABLE/INDEX, INSERT, UPDATE, and DELETE statements.
332
+ - Do not use userDb for simple key/value state; use Twinkle.privateDb for that.
199
333
 
200
334
  ### Twinkle.subjects
201
335
  - async getMySubjects({ limit, cursor } = {}) | scopes: content:read
202
336
  - Returns: { subjects: [{ id, title, description, filePath, fileName, fileSize, thumbUrl, timeStamp, rootType, rootId, rewardLevel }], cursor? }
337
+ - Returns the current viewer's own subjects, newest first.
338
+ - Supports cursor-based pagination. Pass cursor from previous response to load more.
203
339
  - async search({ query, limit, cursor } = {}) | scopes: content:read
204
340
  - 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 } }
205
341
  - Search Twinkle subjects by text for subject picker UIs, book apps, scrapbooks, and galleries.
342
+ - Searches Twinkle subjects by text so apps can let viewers choose which subject to use.
343
+ - Returns subject ids plus rootType/rootId metadata for picker UIs. Empty queries return an empty result set.
206
344
  - Example: const { subjects } = await Twinkle.subjects.search({ query: searchText, limit: 12 });
207
345
  - async getSubject(subjectId) | scopes: content:read
208
346
  - Returns: { subject: { id, title, description, filePath, fileName, fileSize, thumbUrl, secretAnswer, secretAttachment, timeStamp, userId, username, profilePicUrl, rootType, rootId, rewardLevel } }
347
+ - Returns full detail for a single subject, including uploader info and attachments.
348
+ - Any subject can be fetched (not limited to viewer's own).
209
349
  - async getSubjectComments(subjectId, { limit, cursor } = {}) | scopes: content:read
210
350
  - Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp }], cursor? }
351
+ - Returns only the current viewer's own comments on the given subject.
352
+ - Supports cursor-based pagination. Pass cursor from previous response to load more.
211
353
 
212
354
  ### Twinkle.aiCards
213
355
  - async list({ limit, cursor, level, minLevel, maxLevel, quality, userId, hasImage, hasExample } = {}) | scopes: content:read
214
356
  - 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 }
215
357
  - List existing public AI Cards newest first, including each card word, example sentence text, word level, and quality.
358
+ - Use card.word for word typing modes and card.exampleText for sentence typing modes.
359
+ - Filter by level/minLevel/maxLevel to match game difficulty to AI Card word level.
360
+ - Mystery/unrevealed cards return style as ??? and omit imagePath/imageUrl until the card image is available.
216
361
  - Example: const { cards } = await Twinkle.aiCards.list({ level: 2, hasExample: true, limit: 20 });
217
362
  - async search({ query, limit, cursor, level, minLevel, maxLevel, quality, userId, hasImage, hasExample } = {}) | scopes: content:read
218
363
  - 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 }
219
364
  - Search existing public AI Cards by word, with optional level and quality filters.
365
+ - Search matches AI Card words; use list(...) for broad level-based typing pools.
366
+ - This namespace is read-only and does not summon, trade, sell, burn, or mutate AI Cards.
367
+ - Mystery/unrevealed cards return style as ??? and omit imagePath/imageUrl until the card image is available.
220
368
  - Example: const { cards } = await Twinkle.aiCards.search({ query: searchText, minLevel: 1, maxLevel: 3, hasExample: true, limit: 12 });
221
369
  - async get(cardId) | scopes: content:read
222
370
  - Returns: { card: { id, contentType, contentId, word, text, exampleText, prompt, level, wordLevel, quality, style, imagePath, imageUrl, isMysteryCard, isImageGenerating, creatorId, ownerId, username, profilePicUrl, timeStamp, lastInteraction } }
223
371
  - Fetch one existing public AI Card by id, including word, example text, and level metadata.
372
+ - Fetches one live, unburned AI Card by id.
373
+ - This namespace is read-only and does not expose market or ownership actions.
374
+ - Mystery/unrevealed cards return style as ??? and omit imagePath/imageUrl until the card image is available.
224
375
  - Example: const { card } = await Twinkle.aiCards.get(cardId);
225
376
 
226
377
  ### Twinkle.aiStories
227
- - async list({ limit, cursor, order, difficulty, type, topicKey, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
228
- - 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 }
229
- - List completed existing user-generated AI Stories, optionally filtered by exact level/type/topicKey book and ordered newest or oldest first.
378
+ - async list({ limit, cursor, order, difficulty, type, topicKey, storyBy, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
379
+ - 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 }
380
+ - 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.
381
+ - Lists completed existing AI Stories newest first by default; order:'oldest' is allowed only with difficulty, type, and topicKey for chronological book pages.
382
+ - Use difficulty with type and topicKey to load one exact AI Story book without scanning the full corpus in the iframe.
383
+ - Filter with hasImage or hasQuestions when building visual galleries or quiz apps.
230
384
  - Example: const { stories } = await Twinkle.aiStories.list({ difficulty: 1, type: 'science', topicKey: 'Astronomy', order: 'oldest', limit: 20 });
231
- - async chapters({ limit, cursor, difficulty, type, topicKey, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
232
- - Returns: { chapters: [{ difficulty, type, topicKey, title, sampleTopic, storyCount, readingCount, listeningCount, imageCount, questionCount, latestStoryId, latestTimeStamp }], cursor?, pagination: { limit, hasMore, nextCursor }, filters }
233
- - List server-built AI Story books grouped by level, type, and topic, with counts and navigation metadata but no story bodies.
234
- - Example: const page = await Twinkle.aiStories.chapters({ limit: 200 });
235
- - async search({ query, limit, cursor, order, difficulty, type, topicKey, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
385
+ - async chapters({ limit, cursor, groupBy, difficulty, type, topicKey, storyBy, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
386
+ - 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, readingCount, listeningCount, imageCount, questionCount, minDifficulty, maxDifficulty, latestStoryId, latestTimeStamp }], ... } — one row per generating model (the story's author).
387
+ - 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.
388
+ - 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.
389
+ - 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.
390
+ - Returns book/chapter metadata only; use Twinkle.aiStories.list({ difficulty, type, topicKey, storyBy, order:'oldest', cursor }) to load story pages inside a book.
391
+ - Use cursor pagination for large chapter indexes instead of loading every story record.
392
+ - Example: const { authors } = await Twinkle.aiStories.chapters({ groupBy: 'author' });
393
+ - async search({ query, limit, cursor, order, difficulty, type, topicKey, storyBy, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
236
394
  - 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 }
237
395
  - Search completed existing user-generated AI Stories by topic or story text, optionally within an exact level/type/topicKey book.
396
+ - Searches completed existing AI Stories by topic/story text.
397
+ - Use difficulty, type, and topicKey to search within one book of the AI Story corpus; order:'oldest' is rejected without all three filters.
398
+ - Returned questions are normalized to an array even when stored as JSON text.
238
399
  - Example: const { stories } = await Twinkle.aiStories.search({ query: searchText, difficulty: 2, type: 'history', topicKey: 'Ancient Rome', order: 'oldest', limit: 12 });
239
400
  - async get(storyId) | scopes: content:read
240
401
  - Returns: { story: { id, contentType, contentId, topic, topicKey, type, story, explanation, difficulty, isListening, imagePath, imageUrl, audioPath, audioUrl, questions, questionsBy, hasImage, hasQuestions, userId, username, profilePicUrl, timeStamp } }
241
402
  - Fetch one completed existing AI Story by id, including story text for passage typing, media URLs, and normalized questions when available.
403
+ - Fetches one completed existing AI Story by id.
404
+ - This namespace is read-only and does not generate new AI Stories.
242
405
  - Example: const { story } = await Twinkle.aiStories.get(storyId);
243
406
 
244
407
  ### Twinkle.grammarbles
245
408
  - async listQuestions({ level, limit, cursor } = {}) | scopes: content:read
246
409
  - Returns: { questions: [{ id, level, rating, question, choices, answerIndex, correctChoice, correctChoiceKey, isChecked, explanation }], cursor?, pagination: { level, limit, hasMore, nextCursor } }
247
410
  - Read public Grammarbles questions and answers by level with rating/id cursor pagination.
411
+ - Questions are public Grammarbles training data and include the canonical answer.
412
+ - level is clamped from 1 through 5.
413
+ - Pagination is stable by rating then id. Pass cursor from the previous response to load more questions in the same level.
414
+ - This method does not expose daily attempt state, XP, coins, or daily-task progression.
248
415
  - Example: const page = await Twinkle.grammarbles.listQuestions({ level: 3, limit: 100 }); const question = page.questions[Math.floor(Math.random() * page.questions.length)];
249
416
  - async getMyQuestionHistory({ level, limit, cursor } = {}) | scopes: content:read
250
417
  - Returns: { attempts: [{ id, questionId, level, grade, gradeRank, isCorrect, attemptNumber, timeStamp }], cursor?, pagination: { level, limit, hasMore, nextCursor } }
251
418
  - Read the signed-in viewer's real Grammarbles attempt rows for trainer filtering.
419
+ - Returns real Grammarbles attempt outcome rows for the signed-in viewer, newest first.
420
+ - History rows intentionally omit choice indexes because real Grammarbles choices are shuffled per run and the per-run shuffle order is not persisted.
421
+ - Use Twinkle.grammarbles.listQuestions for canonical question text, choices, and answers.
422
+ - 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.
423
+ - This method is read-only and does not submit, cancel, or mutate daily Grammarbles attempts.
252
424
  - Example: const history = await Twinkle.grammarbles.getMyQuestionHistory({ level: selectedLevel, limit: 500 }); const answeredIds = new Set(history.attempts.map((attempt) => attempt.questionId));
253
425
 
254
426
  ### Twinkle.subjectComments
255
427
  - async list(subjectId, { limit, cursor, sortBy, includeReplies, author, authorUserId, replyScope } = {}) | scopes: content:read
256
428
  - 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 } }
257
429
  - Read a subject's comment stream with stable keyset pagination, oldest/newest ordering, author filters, and optional same-author reply scoping.
430
+ - Use for subject-wide comment streams. Twinkle.subjects.getSubjectComments is the legacy viewer-own-comments helper.
431
+ - sortBy accepts newest or oldest.
432
+ - author accepts all, viewer, or subjectPoster. Pass authorUserId for an explicit user filter.
433
+ - includeReplies defaults to false so book/page apps read top-level subject comments unless they opt into replies.
434
+ - replyScope accepts all or ownThread and defaults to all.
435
+ - 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.
436
+ - 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.
437
+ - Supports cursor-based pagination. Pass cursor from the previous response to load more.
258
438
  - 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 });
259
439
 
260
440
  ### Twinkle.profileComments
261
441
  - async getProfileComments({ profileUserId, limit, offset, sortBy, includeReplies, range, since, until } = {}) | scopes: content:read
262
442
  - 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 } }
443
+ - Reads profile-page comments (rootType='user'). Defaults to current viewer's profile if profileUserId is not provided.
444
+ - Use sortBy: newest | oldest.
445
+ - Set range:'today' for today-only filters, or pass since/until Unix timestamps.
263
446
  - async getProfileCommentIds({ profileUserId, limit, offset, sortBy, includeReplies, range, since, until } = {}) | scopes: content:read
264
447
  - Returns: { ids: number[], pagination: { limit, offset, hasMore, nextOffset }, filters: { profileUserId, sortBy, includeReplies, since, until } }
448
+ - Atomic step 1: fetches only matching profile comment IDs with stable pagination.
449
+ - Use this when you want custom pipelines such as fetching IDs first, then selective hydration/counts.
265
450
  - async getCommentsByIds(idsOrOpts) | scopes: content:read
266
451
  - Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp, userId, username, profilePicUrl, commentId, replyId }] }
452
+ - Atomic step 2: fetches comment records for provided IDs.
453
+ - Accepts either an array of IDs or an object like { ids: [...] }.
267
454
  - async getProfileCommentCounts(idsOrOpts) | scopes: content:read
268
455
  - Returns: { countsById: { [commentId]: { likes, replies } } }
456
+ - Atomic step 3: fetches likes/replies aggregates for provided IDs.
457
+ - Accepts either an array of IDs or an object like { ids: [...] }.
269
458
 
270
459
  ### Twinkle.leaderboards
271
460
  - async get({ boardKey = 'default', limit, cursor } = {}) | scopes: none
272
461
  - 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 }
273
462
  - Read score-sorted personal-best leaderboard rows for this Build app.
463
+ - Works for signed-in viewers and public-build guests.
464
+ - Results are sorted by score descending, then earliest achieved time.
465
+ - limit defaults to 20 and maxes at 100.
466
+ - Use cursor from the previous response to load more rows.
467
+ - personalBest is included when the current signed-in viewer or guest session has a row.
274
468
  - async submit({ boardKey = 'default', score, displayName, meta } = {}) | scopes: none
275
469
  - 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 }
276
470
  - Submit a score to a public Build leaderboard using server-owned viewer identity.
471
+ - score is required and must be an integer from 0 through 1000000000000.
472
+ - Signed-in viewers are identified by their Twinkle user id and display under their Twinkle username; do not pass a custom displayName for them.
473
+ - Guests must pass displayName. Ask once and keep it in app state for later submits.
474
+ - Submit only after computing the final score when a run, shift, match, or level attempt ends; do not submit every frame or every tick.
475
+ - Only improved personal-best scores replace the existing score row.
476
+ - meta is optional JSON object data, max 2 KB.
277
477
 
278
478
  ### Twinkle.sharedDb
279
479
  - async getTopics() | scopes: sharedDb:read
280
480
  - Returns: { topics: [{ id, name, createdBy, createdAt }] }
481
+ - Lists all topics for this build.
281
482
  - async createTopic(name) | scopes: sharedDb:write
282
483
  - Returns: { topic: { id, name, createdBy, createdAt } }
484
+ - Creates a topic or returns the existing one if name already exists.
485
+ - Name max 100 characters.
283
486
  - async getEntries(topicName, { limit, pageSize, cursor, order, sort, direction } = {}) | scopes: sharedDb:read
284
487
  - Returns: { entries: [{ id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt }], cursor?, hasMore }
285
488
  - Read shared topic rows with cursor pagination.
489
+ - Returns entries from a topic, newest first by default.
490
+ - Use limit or pageSize to choose how many entries to fetch per page. Default is 20, max is 100.
491
+ - For oldest-first chronological reads, pass order: 'asc' or order: 'oldest'. sort and direction are accepted as aliases.
492
+ - Supports cursor-based pagination. Newest-first cursors page toward older entries; oldest-first cursors page toward newer entries.
286
493
  - async loadMoreEntries(topicName, { limit, pageSize, cursor, order, sort, direction } = {}) | scopes: sharedDb:read
287
494
  - Returns: { entries: [{ id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt }], cursor?, hasMore }
288
495
  - Fetch the next sharedDb page.
496
+ - Convenience alias for getEntries used by Load more buttons.
497
+ - Pass the previous response cursor to fetch the next page using the same order and page size.
289
498
  - async addEntry(topicName, data, { notify } = {}) | scopes: sharedDb:write
290
499
  - Returns: { entry: { id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt } }
291
500
  - Append a shared JSON row, optionally creating a Twinkle notification from the canonical write.
501
+ - Adds a JSON object entry to a topic. Auto-creates the topic if it doesn't exist.
502
+ - data must be a JSON object, max 10 KB.
503
+ - notify may include eventKey, label, summary, recipients, and target. Supported recipients start with { kind: 'buildOwner' }.
504
+ - Use target.focus, such as { kind: 'sharedDbEntry', entryId: '$createdEntryId' }, so Twinkle.notifications can focus the item when opened.
505
+ - Use Twinkle.leaderboards for standard top-score rankings and personal-best scoreboards.
292
506
  - async updateEntry(entryId, data, { notify } = {}) | scopes: sharedDb:write
293
507
  - Returns: { entry: { id, topicId, userId, username, profilePicUrl, data, createdAt, updatedAt } }
294
508
  - Update a viewer-owned shared row, optionally notifying safe recipients from the canonical write.
509
+ - Updates an entry. Only the entry creator or the build owner can update.
510
+ - data must be a JSON object, max 10 KB.
511
+ - notify may include eventKey, label, summary, recipients, and target. Supported recipients include { kind: 'sharedDbEntryAuthor', entryId }.
295
512
  - async deleteEntry(entryId) | scopes: sharedDb:write
296
513
  - Returns: { success: true }
514
+ - Deletes an entry. Only the entry creator or the build owner can delete.
515
+ - async kv.get(namespace, key) | scopes: sharedDb:read
516
+ - Returns: { item: { id, key, value, version, changeSeq, deleted, updatedBy, createdAt, updatedAt } | null }
517
+ - Read one key from the keyed shared store (shared mutable state). Deleted keys read as null.
518
+ - Reads one key from the keyed shared store. Deleted keys read as null.
519
+ - Example: const { item } = await Twinkle.sharedDb.kv.get('world', 'block:10:4');
520
+ - async kv.list(namespace, { limit, cursor, since } = {}) | scopes: sharedDb:read
521
+ - Returns: { items: [{ id, key, value, version, changeSeq, deleted, updatedBy, createdAt, updatedAt }], cursor?, hasMore }
522
+ - 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).
523
+ - 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.
524
+ - Example: const { items } = await Twinkle.sharedDb.kv.list('world', { since: lastChangeSeq });
525
+ - async kv.set(namespace, key, value) | scopes: sharedDb:write
526
+ - Returns: { item: { id, key, value, version, changeSeq, deleted, updatedBy, createdAt, updatedAt } }
527
+ - 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.
528
+ - 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.
529
+ - Example: await Twinkle.sharedDb.kv.set('world', 'block:10:4', { color: 'red' });
530
+ - async kv.setMany(namespace, items) | scopes: sharedDb:write
531
+ - Returns: { items: [{ id, key, value, version, changeSeq, deleted, updatedBy, createdAt, updatedAt }] }
532
+ - Atomically upsert up to 100 { key, value } items of shared mutable state in one request (batch write).
533
+ - Upserts up to 100 { key, value } items atomically in one request.
534
+ - Example: await Twinkle.sharedDb.kv.setMany('world', [{ key: 'block:1:1', value: { color: 'red' } }, { key: 'block:1:2', value: { color: 'blue' } }]);
535
+ - async kv.remove(namespace, key) | scopes: sharedDb:write
536
+ - Returns: { success: true, deleted: boolean }
537
+ - Remove a key of shared mutable state, tombstoning it so kv.list incremental sync observes the removal.
538
+ - Tombstones the key so kv.list incremental sync can observe the removal.
539
+ - Example: await Twinkle.sharedDb.kv.remove('world', 'block:10:4');
297
540
 
298
541
  ### Twinkle.chat
299
542
  - async listRooms() | scopes: chat:read
300
543
  - Returns: { rooms: [{ id, buildId, key, name, createdByUserId, createdAt, updatedAt }] }
544
+ - Returns chat rooms created by this Build app.
301
545
  - async createRoom({ roomKey, name }) | scopes: chat:write
302
546
  - Returns: { room: { id, buildId, key, name, createdByUserId, createdAt, updatedAt } }
547
+ - Creates a room or returns the existing one.
548
+ - roomKey may include letters, numbers, '.', '_', ':', and '-'.
303
549
  - async listMessages(roomKey, { cursor, limit } = {}) | scopes: chat:read
304
550
  - Returns: { messages: [{ id, roomId, roomKey, userId, username, profilePicUrl, role, status, text, metadata, clientMessageId, createdAt, updatedAt }], cursor? }
551
+ - Returns messages in chronological order.
552
+ - Use the returned cursor to fetch older messages.
305
553
  - async sendMessage(roomKey, textOrOptions, options) | scopes: chat:write
306
554
  - 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 }
555
+ - Accepts sendMessage('lobby', 'hi') or sendMessage('lobby', { text, metadata, clientMessageId }).
556
+ - Pass clientMessageId when manually retrying a send; the SDK also includes one per send request.
307
557
  - async deleteMessage(messageId) | scopes: chat:write
308
558
  - Returns: { success: true, messageId }
559
+ - A viewer can delete their own messages; the build owner can delete any message in the build.
309
560
  - subscribe(roomKey, listener) | scopes: chat:read
310
561
  - Returns: unsubscribe function
562
+ - listener receives realtime events like { type: 'message.created', roomKey, message }.
563
+ - Call the returned function to unsubscribe.
311
564
 
312
565
  ### Twinkle.world
313
566
  - async join({ worldKey = 'default', roomKey = 'main', instanceId = 'main', presence, player } = {}) | scopes: none
314
567
  - Returns: { sessionId, session, room, players, snapshot, subscribe(listener), updatePresence(patch), send(actionOrType, data), leave() }
315
568
  - Join a realtime Build world room and receive a snapshot plus a session handle for presence updates, actions, and room events.
569
+ - Always available in the build iframe.
570
+ - World state is ephemeral and heartbeat/TTL based. Use sharedDb/privateDb for durable inventory, XP, quests, ownership, and saved progress.
571
+ - Events are room-scoped and include serverTime, seq, eventId, schemaVersion, sessionId, player, and room metadata.
572
+ - 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.
573
+ - Use updatePresence for live avatar snapshots and send for lightweight actions such as emotes, interactions, and chat bubbles.
574
+ - Throttle movement updates in app code, usually 5-15 updates per second. Do not call updatePresence from every animation frame.
575
+ - Rooms are addressed by worldKey, roomKey, and instanceId so the contract can later move to sharded or dedicated game backends.
316
576
  - Example: const world = await Twinkle.world.join({ roomKey: 'town-square', presence: { x: 0, y: 0, z: 0, facing: 'south' }, player: { name: avatarName } });
317
577
  world.subscribe((event) => updateRemotePlayers(event.players));
318
578
  world.updatePresence({ x, y, z, facing });
319
579
  - isRecoverableSessionError(error) | scopes: none
320
580
  - Returns: boolean
321
581
  - Return true when a world request error is expected to be handled by app code instead of crashing.
582
+ - Recoverable session errors include ended, missing, socket-disconnected, socket-not-ready, room-missing, preview-updating, and timed-out world session requests.
583
+ - Only session-ended errors prove that the current handle should be discarded. Timed-out or preview-updating presence requests can be dropped without reconnecting.
584
+ - For durable game state, write through sharedDb/privateDb instead of relying on world presence.
322
585
  - Example: try {
323
586
  await world.updatePresence({ x, y, z, facing });
324
587
  } catch (error) {
@@ -334,6 +597,8 @@ world.updatePresence({ x, y, z, facing });
334
597
  - isSessionEndedError(error) | scopes: none
335
598
  - Returns: boolean
336
599
  - 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.
600
+ - Session-ended errors include ended, missing, room-missing, and socket-disconnected world session failures.
601
+ - Request timeouts and preview-updating skips are recoverable but not session-ended.
337
602
  - Example: if (Twinkle.world.isSessionEndedError(error)) {
338
603
  world = null;
339
604
  scheduleReconnect();
@@ -341,46 +606,69 @@ world.updatePresence({ x, y, z, facing });
341
606
  - leaveAll() | scopes: none
342
607
  - Returns: void
343
608
  - Leave every active world session in the current iframe.
609
+ - The SDK also leaves active sessions on pagehide/beforeunload when possible.
610
+ - Use this when switching maps or resetting a multiplayer game.
344
611
 
345
612
  ### Twinkle.users
346
613
  - async getUser(userId) | scopes: user:read
347
614
  - Returns: { id, username, profilePicUrl, realName } | null
348
615
  - async getUsers({ search, userIds, cursor, limit } = {}) | scopes: users:read
349
616
  - Returns: { users: [{ id, username, profilePicUrl, realName }], cursor? }
617
+ - Prefer explicit userIds when possible.
618
+ - Search is prefix-based and requires at least 2 characters.
619
+ - Use search sparingly and with small limits.
350
620
 
351
621
  ### Twinkle.reflections
352
622
  - async getDailyReflections({ userIds, cursor, lastId, limit } = {}) | scopes: dailyReflections:read
353
623
  - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question }], cursor? }
354
624
  - Read only currently public Daily Reflection shares. response is the exact text the author chose to share publicly, never an unshared raw/private answer.
625
+ - Daily reflections are Daily Question answers shown in the Twinkle feed.
626
+ - 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.
627
+ - submittedAt and sharedAt are Unix timestamps (seconds). Use new Date(value * 1000) to convert to JS Date.
355
628
  - async getDailyReflectionsByUser(userId, { cursor, lastId, limit } = {}) | scopes: dailyReflections:read
356
629
  - Returns: { reflections: [{ id, userId, response, questionId, submittedAt, sharedAt, username, profilePicUrl, question }], cursor? }
357
630
  - 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.
631
+ - Daily reflections are Daily Question answers shown in the Twinkle feed.
632
+ - 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.
633
+ - submittedAt and sharedAt are Unix timestamps (seconds). Use new Date(value * 1000) to convert to JS Date.
358
634
 
359
635
  ### Twinkle.privateDb
360
636
  - async get(key) | scopes: privateDb:read
361
637
  - Returns: { item: { id, key, value, updatedAt } | null }
362
638
  - Read one key from the default private per-user JSON store.
639
+ - Reads a single key from the current viewer's private store.
363
640
  - async list({ prefix, limit, cursor } = {}) | scopes: privateDb:read
364
641
  - Returns: { items: [{ id, key, value, updatedAt }], cursor? }
365
642
  - List keys from the default private per-user JSON store.
643
+ - Lists private keys for the current viewer. Supports prefix filter and cursor pagination.
366
644
  - async set(key, value) | scopes: privateDb:write
367
645
  - Returns: { item: { id, key, value, updatedAt } }
368
646
  - Upsert one JSON-serializable value in the default private per-user store.
647
+ - Upserts one key for the current viewer. Value must be JSON-serializable (max 16 KB).
369
648
  - async remove(key) | scopes: privateDb:write
370
649
  - Returns: { success: true, deleted: boolean }
371
650
  - Delete one key from the default private per-user JSON store.
651
+ - Deletes one key for the current viewer.
372
652
 
373
653
  ### Twinkle.reminders
374
654
  - async list({ includeDisabled, limit } = {}) | scopes: reminders:read
375
655
  - Returns: { reminders: [{ id, buildId, userId, title, body, targetPath, payload, isEnabled, schedule, lastTriggeredAt, createdAt, updatedAt }] }
656
+ - Lists reminder rules for the current signed-in viewer.
657
+ - includeDisabled includes turned-off reminders in the result.
376
658
  - async create({ title, body, targetPath, payload, schedule, isEnabled }) | scopes: reminders:write
377
659
  - Returns: { reminder: { id, buildId, userId, title, body, targetPath, payload, isEnabled, schedule, lastTriggeredAt, createdAt, updatedAt } | null }
660
+ - Creates one reminder rule for the current signed-in viewer.
661
+ - schedule.type supports once, daily, and weekly.
378
662
  - async update(reminderId, patch) | scopes: reminders:write
379
663
  - Returns: { reminder: { id, buildId, userId, title, body, targetPath, payload, isEnabled, schedule, lastTriggeredAt, createdAt, updatedAt } | null }
664
+ - Updates one reminder rule for the current signed-in viewer.
380
665
  - async remove(reminderId) | scopes: reminders:write
381
666
  - Returns: { success: true, deleted: boolean }
667
+ - Deletes one reminder rule for the current signed-in viewer.
382
668
  - async getDue({ now, autoAcknowledge, limit } = {}) | scopes: reminders:read
383
669
  - Returns: { now, reminders: [{ id, buildId, userId, title, body, targetPath, payload, isEnabled, schedule, lastTriggeredAt, createdAt, updatedAt }] }
670
+ - Returns reminders that are due right now for the current signed-in viewer.
671
+ - autoAcknowledge defaults to true and prevents the same reminder from retriggering immediately.
384
672
 
385
673
  ## Examples
386
674