castle-web-sdk 0.4.34 → 0.4.36

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/README.md CHANGED
@@ -24,7 +24,10 @@ import { setup, initCard, Store, Leaderboard } from "castle-web-sdk";
24
24
  - [Pass](#pass)
25
25
  - [Snapshot](#snapshot)
26
26
  - [Portal](#portal)
27
+ - [Decks](#decks)
28
+ - [Clipboard](#clipboard)
27
29
  - [Haptics](#haptics)
30
+ - [Agent](#agent)
28
31
  - [Lifecycle](#lifecycle)
29
32
  - [Multiplayer](#multiplayer)
30
33
  - [Setup](#setup)
@@ -249,11 +252,30 @@ guide.
249
252
 
250
253
  ### Limits
251
254
 
252
- The platform caps key length, value size, how many keys one call may
253
- touch, page size, and how much a deck may store in total, and it limits
254
- how fast a player may read and write. Going over any of them rejects
255
- with a `CastleError` whose `code` says which, so a deck can tell a
256
- player it is saving too fast apart from a deck that is out of space.
255
+ The platform caps key length, value size, how many keys one read may
256
+ name, and how much a deck may store in total, and it limits how fast a
257
+ player may read and write. Going over one of these rejects with a
258
+ `CastleError` whose `code` says which, so a deck can tell a player it is
259
+ saving too fast apart from a deck that is out of space:
260
+
261
+ | limit | value |
262
+ |---|---|
263
+ | key | 128 bytes |
264
+ | value | 256 KB |
265
+ | keys in one `get` | 32 |
266
+ | whole deck | 50 MB, 500,000 keys |
267
+ | one player's calls | 20 a second, 512 KB a second |
268
+ | every player of a deck together | 500 calls a second, 8 MB a second |
269
+
270
+ These do not reject. `set` and `remove` with more than 32 keys are split
271
+ into several calls by the SDK (each still counts against the rate above).
272
+ A `list` page holds at most 1,000 entries (a larger `limit` is lowered to
273
+ it) and ends early once it reaches 1 MB; take the next page with its
274
+ `cursor`.
275
+
276
+ A value is JSON, so binary data such as a picture or a video clip costs
277
+ about a third more once encoded, and a clip of more than a few seconds
278
+ does not fit in one. Ship media with the deck instead.
257
279
 
258
280
  ## Storage
259
281
 
@@ -265,6 +287,10 @@ player it is saving too fast apart from a deck that is out of space.
265
287
 
266
288
  `Storage` saves data for the current player. Nobody else can read it.
267
289
 
290
+ A player's whole `Storage` document is at most 2 MB. A write that takes
291
+ it past that never saves, and nothing tells the deck: `set` has already
292
+ returned.
293
+
268
294
  - **`Storage.get<T>(key): Promise<T | null>`** — the value at `key`, or
269
295
  `null` if it is not set.
270
296
  - **`Storage.set(key, value)`** — sets `key`. `value` must be something
@@ -293,7 +319,9 @@ await Store.private.set({ level: 7 });
293
319
  > and adds counters, listing, and leaderboards.
294
320
 
295
321
  `SharedStorage` saves data that other players can read. Values must be
296
- something that converts to JSON, same as `Storage`.
322
+ something that converts to JSON, same as `Storage`. Each bucket holds at
323
+ most 2 MB, and a write past that is dropped without an error, as with
324
+ `Storage`.
297
325
 
298
326
  Scopes: `'deck'` is one bucket for the whole deck, readable and writable
299
327
  by any player. `'user'` is a per-player bucket that anyone can read and
@@ -520,6 +548,9 @@ when the player collects or closes it. `options`:
520
548
 
521
549
  Collecting works only in the Castle mobile app; the website shows "collect in
522
550
  the app" and the dev server a short notice, and both resolve `unavailable`.
551
+ `offer` was added to the app on 2026-09-22; an app build from before then
552
+ rejects it with `UNKNOWN_COMMAND` (see [CastleError](#castleerror)), so catch
553
+ it where a deck has to keep going without the card.
523
554
  `SnapshotOfferResult` has a `status`:
524
555
 
525
556
  - `'collected'` — the player collected it.
@@ -534,6 +565,11 @@ const { status } = await Snapshot.offer({
534
565
  });
535
566
  ```
536
567
 
568
+ A snapshot is not the deck's cover, and no SDK call sets the cover. A cover
569
+ the creator chose by hand is kept; otherwise `save-deck` photographs the deck
570
+ as it opens, falling back to a `preview.png` the deck folder holds when that
571
+ capture does not produce one.
572
+
537
573
  ### `Snapshot.onTaken(handler): () => void`
538
574
 
539
575
  Registers a function called just before an offer's capture, the twin of the
@@ -604,7 +640,9 @@ label as the fallback. `PortalInfoResult` has a `status`:
604
640
  `null`), and `coverUrl`, a `data:` url of the deck's cover or `null`.
605
641
  - `'broken'` — the portal wouldn't work for this player: the deck doesn't
606
642
  exist, has portals turned off, or isn't visible to them.
607
- - `'unavailable'` — the host couldn't look it up.
643
+ - `'unavailable'` — the host couldn't look it up. This includes a Castle
644
+ app built before `portal.info` was added (2026-09-22): `info` never
645
+ throws `UNKNOWN_COMMAND`.
608
646
 
609
647
  ```js
610
648
  const info = await Portal.info(doorway.deckId);
@@ -616,6 +654,45 @@ if (info.status === "ok") {
616
654
 
617
655
  Answers are kept per deck for the session, so asking again is cheap.
618
656
 
657
+ ## Decks
658
+
659
+ `Decks` lists decks the player can point a portal at: their own published
660
+ decks, and the decks they saved. A deck can offer these as a picker rather
661
+ than asking for a pasted link.
662
+ Decks is limited to Castle admins for now; for every other player both calls
663
+ reject with a `PERMISSION_DENIED` error.
664
+
665
+ ### `Decks.mine(options?): Promise<DecksListResult>`
666
+
667
+ The player's own decks, newest first, leaving out private ones.
668
+
669
+ ### `Decks.saved(options?): Promise<DecksListResult>`
670
+
671
+ The decks the player bookmarked, then the decks in their playlists, each
672
+ once.
673
+
674
+ Both take `{ limit }`, from 1 through 50 (default 20), and both leave out
675
+ decks whose creator turned portals off. The result is
676
+ `{ status: 'ok', decks }`, where each deck is
677
+ `{ deckId, title, creator: { userId, username } | null, coverUrl }` — the
678
+ same shape as `Portal.info`. `coverUrl` is a `data:` url, since a deck may
679
+ not load remote images; it is null when the deck has no cover. A player
680
+ who is not signed in gets an empty list.
681
+
682
+ ```js
683
+ const { decks } = await Decks.mine({ limit: 12 });
684
+ for (const deck of decks) {
685
+ addPickerTile(deck.title, deck.coverUrl, () => placePortal(deck.deckId));
686
+ }
687
+ ```
688
+
689
+ ## Clipboard
690
+
691
+ The SDK has no clipboard command, and neither Castle host adds one: the
692
+ Castle app has no clipboard bridge, and castle.xyz's player does not grant
693
+ its frame `clipboard-write`. `navigator.clipboard` is best-effort — catch
694
+ its failure and show the text so the player can copy it by hand.
695
+
619
696
  ## Haptics
620
697
 
621
698
  `Haptics` plays a short device vibration ("buzz") for tactile feedback —
@@ -651,6 +728,103 @@ Haptics respect the player's settings — if a player has muted haptics,
651
728
  `play` does nothing and resolves `'unavailable'`. Rapid repeated calls may
652
729
  be coalesced, so it's safe to call on frequent events.
653
730
 
731
+ ## Agent
732
+
733
+ `Agent` asks a language model for one chat completion. The host sends it
734
+ through Castle's oracle on the player's own Castle account, so the deck
735
+ holds no key. Agent.chat is limited to Castle admins for now; every other
736
+ player gets `'refused'`.
737
+
738
+ ### `Agent.chat(params): Promise<AgentChatResult>`
739
+
740
+ `params`:
741
+
742
+ - `messages` — the conversation, in the OpenAI chat format
743
+ (`{ role, content }`, plus `tool_calls` on an assistant message and
744
+ `tool_call_id` on a `'tool'` message).
745
+ - `tools` — optional function tools, in the OpenAI function-tool schema.
746
+ - `toolChoice` — optional: `'auto'` (the default), `'none'`, `'required'`,
747
+ or `{ type: 'function', function: { name } }` to force one tool.
748
+ - `model` — optional OpenRouter model id. Defaults to
749
+ `'meta/muse-spark-1.3'`.
750
+ - `maxTokens` — optional cap on the reply's length.
751
+ - `reasoning` — optional `{ effort: 'minimal' | 'low' | 'medium' | 'high' }`,
752
+ how hard the model thinks before it answers. Lower effort answers sooner.
753
+ Left out, the model uses its own default. Muse can't turn reasoning off;
754
+ `'minimal'` is its least.
755
+ - `onDelta` — optional callback for the reply as it streams in. It gets
756
+ `{ type: 'text', text }` and `{ type: 'reasoning', text }` pieces, and
757
+ `{ type: 'toolCall', toolCall }` once for each tool call, when its
758
+ arguments are complete. Pieces come in the order the model wrote them.
759
+ - `signal` — optional `AbortSignal`. Aborting it stops the model and
760
+ resolves the call with `'cancelled'` at once.
761
+
762
+ The promise resolves with the whole message when the model finishes. A deck
763
+ runs any tool calls itself, appends the message and one `'tool'` message per
764
+ call, and calls Agent.chat again, until a reply has no tool calls:
765
+
766
+ ```js
767
+ import { Agent } from "castle-web-sdk";
768
+
769
+ const tools = [
770
+ {
771
+ type: "function",
772
+ function: {
773
+ name: "roll_dice",
774
+ description: "Roll six-sided dice.",
775
+ parameters: {
776
+ type: "object",
777
+ properties: { count: { type: "integer" } },
778
+ required: ["count"],
779
+ },
780
+ },
781
+ },
782
+ ];
783
+ const messages = [{ role: "user", content: "Roll two dice and add them." }];
784
+ const stop = new AbortController();
785
+
786
+ for (let round = 0; round < 8; round++) {
787
+ const result = await Agent.chat({
788
+ messages,
789
+ tools,
790
+ signal: stop.signal,
791
+ onDelta: (delta) => {
792
+ if (delta.type === "text") {
793
+ showText(delta.text);
794
+ }
795
+ },
796
+ });
797
+ if (result.status !== "ok") {
798
+ break;
799
+ }
800
+ messages.push(result.message);
801
+ const calls = result.message.tool_calls ?? [];
802
+ if (calls.length === 0) {
803
+ break;
804
+ }
805
+ for (const call of calls) {
806
+ const { count } = JSON.parse(call.function.arguments);
807
+ const faces = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * 6));
808
+ messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(faces) });
809
+ }
810
+ }
811
+ ```
812
+
813
+ `AgentChatResult` has a `status`:
814
+
815
+ - `'ok'` — `message` is the assistant's reply: `{ role, content,
816
+ tool_calls? }`, with `content` null when the reply is only tool calls.
817
+ `reasoning` is the model's reasoning summary when it gave one, for
818
+ display; it is not part of `message`. `finishReason` is `'stop'`,
819
+ `'tool_calls'` or `'length'`. `usage` carries `prompt_tokens`,
820
+ `completion_tokens` and `total_tokens`.
821
+ - `'refused'` — the player is not a Castle admin. The model was not called.
822
+ - `'unavailable'` — this host has no route to the oracle.
823
+ - `'cancelled'` — the call's `signal` was aborted.
824
+ - `'error'` — the oracle or the model failed; `reason` says how.
825
+
826
+ Every status but `'ok'` carries a `reason`.
827
+
654
828
  ## Lifecycle
655
829
 
656
830
  `Lifecycle` tells the host when the deck has painted its first frame, so
@@ -899,5 +1073,25 @@ Common codes:
899
1073
  - `CASTLE_HOST_UNAVAILABLE` — the Castle host (the app or website running
900
1074
  the deck) didn't handle the request — e.g. it timed out or wasn't
901
1075
  reachable. Usually transient; retry or surface a gentle error.
1076
+ - `UNKNOWN_COMMAND` ("Unknown Castle command.") — the Castle app running
1077
+ the deck is a build older than the command. Each app build knows only the
1078
+ commands it shipped with, and only an app update adds one, so keep the
1079
+ deck working without it. The app's last version bump, iOS v1.149, came
1080
+ before every command below, so v1.149 and earlier builds lack all of
1081
+ them. Each date is the commit that added the command to the app's code,
1082
+ not when a build carrying it reached the stores; an app built from code
1083
+ before that commit answers `UNKNOWN_COMMAND`:
1084
+ - `Multiplayer.join` — 2026-08-29
1085
+ - `Store` (every method) — 2026-09-03
1086
+ - `Lifecycle.restoreState` — 2026-09-08 (resolves `null` instead of
1087
+ throwing)
1088
+ - `User.get` — 2026-09-10
1089
+ - `Characters` — 2026-09-20
1090
+ - `Snapshot.offer` — 2026-09-22
1091
+ - `Portal.info` — 2026-09-22 (resolves `unavailable` instead of throwing)
1092
+ - `Agent.chat`, `Decks.mine`, `Decks.saved` — 2026-09-23
1093
+
1094
+ `Leaderboard.show` resolves `unavailable` rather than throwing on an app
1095
+ that lacks it.
902
1096
  - `CAULDRON_STORAGE_STALE_GENERATION` — the handle belongs to an older reset
903
1097
  generation; reopen explicitly rather than replaying work into the new one.
@@ -0,0 +1,6 @@
1
+ import type { AgentChatParams, AgentChatResult } from "./commands";
2
+ export type { AgentChatDelta, AgentChatParams, AgentChatResult, AgentMessage, AgentReasoning, AgentTool, AgentToolCall, AgentToolChoice, AgentUsage, } from "./commands";
3
+ export interface CastleAgentApi {
4
+ chat(params: AgentChatParams): Promise<AgentChatResult>;
5
+ }
6
+ export declare const Agent: CastleAgentApi;
package/dist/agent.js ADDED
@@ -0,0 +1,70 @@
1
+ // Agent — one chat completion from a language model, run by the host through
2
+ // Castle's oracle with the player's own Castle account. Messages and tools
3
+ // follow the OpenAI chat completions format. The reply streams: `onDelta` gets
4
+ // text and reasoning as they arrive and each tool call once its arguments are
5
+ // complete, and the promise resolves with the whole message. A deck runs any
6
+ // tool calls itself and calls Agent.chat again with the results.
7
+ //
8
+ // Agent.chat is limited to Castle admins for now: every other player gets
9
+ // `refused`. A host with no route to the oracle answers `unavailable`.
10
+ import { CastleError } from "./errors";
11
+ import { hostRequest } from "./transport";
12
+ async function chat(params) {
13
+ validate(params);
14
+ const onDelta = params.onDelta;
15
+ try {
16
+ return await hostRequest("agent.chat", {
17
+ messages: params.messages,
18
+ tools: params.tools,
19
+ toolChoice: params.toolChoice,
20
+ model: params.model,
21
+ maxTokens: params.maxTokens,
22
+ reasoning: params.reasoning,
23
+ }, {
24
+ onStream: onDelta ? (event) => deliverDelta(onDelta, event) : undefined,
25
+ signal: params.signal,
26
+ });
27
+ }
28
+ catch (error) {
29
+ if (error instanceof CastleError && error.code === "CANCELLED") {
30
+ return { status: "cancelled", reason: "Agent.chat was cancelled." };
31
+ }
32
+ throw error;
33
+ }
34
+ }
35
+ const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
36
+ function validate(params) {
37
+ if (!params || typeof params !== "object") {
38
+ invalid("Agent.chat requires { messages }.");
39
+ }
40
+ if (!Array.isArray(params.messages) || params.messages.length === 0) {
41
+ invalid("Agent.chat messages must be a non-empty array.");
42
+ }
43
+ if (params.tools !== undefined && !Array.isArray(params.tools)) {
44
+ invalid("Agent.chat tools must be an array.");
45
+ }
46
+ if (params.model !== undefined && (typeof params.model !== "string" || params.model === "")) {
47
+ invalid("Agent.chat model must be a model id.");
48
+ }
49
+ if (params.maxTokens !== undefined &&
50
+ (!Number.isSafeInteger(params.maxTokens) || params.maxTokens < 1)) {
51
+ invalid("Agent.chat maxTokens must be a positive integer.");
52
+ }
53
+ if (params.reasoning !== undefined && !REASONING_EFFORTS.includes(params.reasoning?.effort)) {
54
+ invalid("Agent.chat reasoning must be { effort: 'minimal' | 'low' | 'medium' | 'high' }.");
55
+ }
56
+ if (params.onDelta !== undefined && typeof params.onDelta !== "function") {
57
+ invalid("Agent.chat onDelta must be a function.");
58
+ }
59
+ }
60
+ // Keepalives only restart the wait in transport.ts; the deck never sees them.
61
+ function deliverDelta(onDelta, event) {
62
+ const type = event?.type;
63
+ if (type === "text" || type === "reasoning" || type === "toolCall") {
64
+ onDelta(event);
65
+ }
66
+ }
67
+ function invalid(message) {
68
+ throw new CastleError({ code: "INVALID_ARGUMENT", message, operation: "Agent.chat" });
69
+ }
70
+ export const Agent = Object.freeze({ chat });
package/dist/castle.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  export { isEdit } from "./context";
2
+ export { Agent } from "./agent";
3
+ export type { AgentChatDelta, AgentChatParams, AgentChatResult, AgentMessage, AgentReasoning, AgentTool, AgentToolCall, AgentToolChoice, AgentUsage, CastleAgentApi, } from "./agent";
2
4
  export { Characters } from "./characters";
3
5
  export type { CastleCharactersApi, CharacterHandle, CharacterHistoryOptions, CharacterSendInput, } from "./characters";
6
+ export { Decks } from "./decks";
7
+ export type { CastleDecksApi, DeckSummary, DecksListParams, DecksListResult } from "./decks";
4
8
  export { CastleError } from "./errors";
5
9
  export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckFile, } from "./files";
6
10
  export { Haptics } from "./haptics";
@@ -11,6 +15,7 @@ export { Lifecycle } from "./lifecycle";
11
15
  export type { CastleLifecycleApi } from "./lifecycle";
12
16
  export { Multiplayer } from "./multiplayer";
13
17
  export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
18
+ export type { MultiplayerVoice, VoiceJoinStatus, VoiceMixEntry, VoiceState, } from "./multiplayerVoice";
14
19
  export type { MultiplayerSoloReason } from "./commands";
15
20
  export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
16
21
  export type { ClientByeFrame, ClientHelloFrame, ClientMessageFrame, ClientPingFrame, ClientPongFrame, ClientReplacedFrame, ClientRosterFrame, ClientToPlatformFrame, ClientToPlatformMessageFrame, MultiplayerPlayer, PlayerIdentity, PlatformToClientFrame, SessionPlayer, SessionType, } from "./multiplayerProtocol";
package/dist/castle.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Castle Web SDK
2
2
  export { isEdit } from "./context";
3
+ export { Agent } from "./agent";
3
4
  export { Characters } from "./characters";
5
+ export { Decks } from "./decks";
4
6
  export { CastleError } from "./errors";
5
7
  export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckFile, } from "./files";
6
8
  export { Haptics } from "./haptics";
@@ -103,6 +103,14 @@ export interface MultiplayerGetSessionResult {
103
103
  /** Internal local-serve scope for restoring this Play panel after a reload. */
104
104
  devReconnectScope?: string;
105
105
  }
106
+ export type VoiceState = "unavailable" | "off" | "joining" | "live";
107
+ export type VoiceJoinStatus = "joined" | "declined" | "unavailable";
108
+ export interface VoiceMixEntry {
109
+ /** 0 (silent) to 1 (full). */
110
+ gain?: number;
111
+ /** -1 (left) to 1 (right). */
112
+ pan?: number;
113
+ }
106
114
  export interface CharacterSummary {
107
115
  id: string;
108
116
  version: string;
@@ -142,7 +150,97 @@ export interface CharacterHistory {
142
150
  operations: CharacterOperation[];
143
151
  cursor: string | null;
144
152
  }
153
+ export interface AgentToolCall {
154
+ id: string;
155
+ type: "function";
156
+ function: {
157
+ name: string;
158
+ arguments: string;
159
+ };
160
+ }
161
+ export interface AgentMessage {
162
+ role: "system" | "user" | "assistant" | "tool";
163
+ content: string | Json[] | null;
164
+ tool_calls?: AgentToolCall[];
165
+ tool_call_id?: string;
166
+ name?: string;
167
+ }
168
+ export interface AgentTool {
169
+ type: "function";
170
+ function: {
171
+ name: string;
172
+ description?: string;
173
+ parameters?: Json;
174
+ };
175
+ }
176
+ export type AgentToolChoice = "auto" | "none" | "required" | {
177
+ type: "function";
178
+ function: {
179
+ name: string;
180
+ };
181
+ };
182
+ export type AgentChatDelta = {
183
+ type: "text";
184
+ text: string;
185
+ } | {
186
+ type: "reasoning";
187
+ text: string;
188
+ } | {
189
+ type: "toolCall";
190
+ toolCall: AgentToolCall;
191
+ };
192
+ export type AgentChatStreamEvent = AgentChatDelta | {
193
+ type: "keepalive";
194
+ };
195
+ export interface AgentReasoning {
196
+ effort: "minimal" | "low" | "medium" | "high";
197
+ }
198
+ export interface AgentChatParams {
199
+ messages: AgentMessage[];
200
+ tools?: AgentTool[];
201
+ toolChoice?: AgentToolChoice;
202
+ model?: string;
203
+ maxTokens?: number;
204
+ reasoning?: AgentReasoning;
205
+ onDelta?: (delta: AgentChatDelta) => void;
206
+ signal?: AbortSignal;
207
+ }
208
+ export type AgentChatRequest = Omit<AgentChatParams, "onDelta" | "signal">;
209
+ export interface AgentUsage {
210
+ prompt_tokens: number;
211
+ completion_tokens: number;
212
+ total_tokens: number;
213
+ }
214
+ export type AgentChatResult = {
215
+ status: "ok";
216
+ message: AgentMessage;
217
+ reasoning?: string;
218
+ finishReason: string | null;
219
+ usage?: AgentUsage;
220
+ } | {
221
+ status: "unavailable" | "refused" | "error" | "cancelled";
222
+ reason: string;
223
+ };
224
+ export interface DeckSummary {
225
+ deckId: string;
226
+ title: string;
227
+ creator: {
228
+ userId: string;
229
+ username: string;
230
+ } | null;
231
+ coverUrl: string | null;
232
+ }
233
+ export interface DecksListParams {
234
+ limit?: number | null;
235
+ }
236
+ export interface DecksListResult {
237
+ status: "ok";
238
+ decks: DeckSummary[];
239
+ }
145
240
  export interface CommandParams {
241
+ "agent.chat": AgentChatRequest;
242
+ "decks.mine": DecksListParams;
243
+ "decks.saved": DecksListParams;
146
244
  "deckStorage.load": Record<string, never>;
147
245
  "deckStorage.update": {
148
246
  updates: StorageUpdate[];
@@ -205,6 +303,19 @@ export interface CommandParams {
205
303
  style: HapticStyle;
206
304
  };
207
305
  "multiplayer.getSession": MultiplayerGetSessionParams;
306
+ "voice.getState": {
307
+ sessionId: string;
308
+ };
309
+ "voice.requestJoin": {
310
+ sessionId: string;
311
+ };
312
+ "voice.leave": {
313
+ sessionId: string;
314
+ };
315
+ "voice.setMix": {
316
+ sessionId: string;
317
+ mix: Record<string, VoiceMixEntry>;
318
+ };
208
319
  "characters.open": {
209
320
  characterId: string;
210
321
  };
@@ -279,6 +390,9 @@ export interface CommandParams {
279
390
  "lifecycle.restoreState": Record<string, never>;
280
391
  }
281
392
  export interface CommandResult {
393
+ "agent.chat": AgentChatResult;
394
+ "decks.mine": DecksListResult;
395
+ "decks.saved": DecksListResult;
282
396
  "deckStorage.load": {
283
397
  blob: StorageBlob;
284
398
  };
@@ -324,6 +438,18 @@ export interface CommandResult {
324
438
  "portal.info": PortalInfoResult;
325
439
  "haptics.play": HapticsResult;
326
440
  "multiplayer.getSession": MultiplayerGetSessionResult;
441
+ "voice.getState": {
442
+ state: VoiceState;
443
+ };
444
+ "voice.requestJoin": {
445
+ status: VoiceJoinStatus;
446
+ };
447
+ "voice.leave": {
448
+ ok: true;
449
+ };
450
+ "voice.setMix": {
451
+ ok: true;
452
+ };
327
453
  "characters.open": CharacterAdmission;
328
454
  "characters.send": CharacterOperation;
329
455
  "characters.status": CharacterOperation;
@@ -365,6 +491,8 @@ export interface UserPayload {
365
491
  color: string | null;
366
492
  }
367
493
  export type CommandName = keyof CommandParams;
494
+ export declare const COMMAND_TIMEOUTS_MS: Partial<Record<CommandName, number>>;
495
+ export declare const STREAM_IDLE_TIMEOUT_MS = 60000;
368
496
  export interface SerializedCommandError {
369
497
  code: string;
370
498
  message: string;
@@ -385,6 +513,17 @@ export interface CommandResponseEnvelope {
385
513
  error?: SerializedCommandError;
386
514
  }
387
515
  export declare function isResponseEnvelope(value: unknown): value is CommandResponseEnvelope;
516
+ export interface CommandStreamEnvelope {
517
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
518
+ requestId: string;
519
+ stream: unknown;
520
+ }
521
+ export declare function isStreamEnvelope(value: unknown): value is CommandStreamEnvelope;
522
+ export interface CommandCancelEnvelope {
523
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
524
+ requestId: string;
525
+ cancel: true;
526
+ }
388
527
  export type LifecycleEvent = "ready" | "unloadInterest";
389
528
  export interface LifecycleEnvelope {
390
529
  castleSdk: typeof CASTLE_SDK_PROTOCOL;
@@ -404,3 +543,18 @@ export interface UnloadDoneEnvelope {
404
543
  state?: string;
405
544
  };
406
545
  }
546
+ export type VoiceEvent = {
547
+ type: "state";
548
+ sessionId: string;
549
+ state: VoiceState;
550
+ } | {
551
+ type: "speaking";
552
+ sessionId: string;
553
+ userIds: string[];
554
+ };
555
+ export interface VoiceEventEnvelope {
556
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
557
+ voice: VoiceEvent;
558
+ }
559
+ export declare function isVoiceState(value: unknown): value is VoiceState;
560
+ export declare function voiceEventFrom(value: unknown): VoiceEvent | null;
package/dist/commands.js CHANGED
@@ -6,6 +6,18 @@
6
6
  // Marker + protocol version. Doubles as a discriminator so host messages can't
7
7
  // be confused with embed.js `castlexyz:` strings or RN console messages.
8
8
  export const CASTLE_SDK_PROTOCOL = 1;
9
+ // How long the deck waits for a reply to these commands, in place of the
10
+ // data-command timeout. A deck list fetches every cover on the list. A host
11
+ // from before streaming reads a whole model reply before answering agent.chat.
12
+ export const COMMAND_TIMEOUTS_MS = {
13
+ "agent.chat": 180_000,
14
+ "decks.mine": 30_000,
15
+ "decks.saved": 30_000,
16
+ };
17
+ // Once a host streams a command, each stream envelope restarts the wait at this
18
+ // length instead. The oracle sends a heartbeat every 15 seconds, and the host
19
+ // passes each one on.
20
+ export const STREAM_IDLE_TIMEOUT_MS = 60_000;
9
21
  export function isResponseEnvelope(value) {
10
22
  if (typeof value !== "object" || value === null)
11
23
  return false;
@@ -14,6 +26,15 @@ export function isResponseEnvelope(value) {
14
26
  typeof record.requestId === "string" &&
15
27
  typeof record.ok === "boolean");
16
28
  }
29
+ export function isStreamEnvelope(value) {
30
+ if (typeof value !== "object" || value === null) {
31
+ return false;
32
+ }
33
+ const record = value;
34
+ return (record.castleSdk === CASTLE_SDK_PROTOCOL &&
35
+ typeof record.requestId === "string" &&
36
+ "stream" in record);
37
+ }
17
38
  export function unloadRequestId(value) {
18
39
  if (typeof value !== "object" || value === null)
19
40
  return null;
@@ -23,3 +44,28 @@ export function unloadRequestId(value) {
23
44
  const unload = record.unload;
24
45
  return typeof unload?.unloadId === "string" ? unload.unloadId : null;
25
46
  }
47
+ const VOICE_STATES = ["unavailable", "off", "joining", "live"];
48
+ export function isVoiceState(value) {
49
+ return typeof value === "string" && VOICE_STATES.includes(value);
50
+ }
51
+ export function voiceEventFrom(value) {
52
+ if (typeof value !== "object" || value === null)
53
+ return null;
54
+ const record = value;
55
+ if (record.castleSdk !== CASTLE_SDK_PROTOCOL)
56
+ return null;
57
+ const event = record.voice;
58
+ if (typeof event !== "object" || event === null)
59
+ return null;
60
+ if (typeof event.sessionId !== "string")
61
+ return null;
62
+ if (event.type === "state" && isVoiceState(event.state)) {
63
+ return { type: "state", sessionId: event.sessionId, state: event.state };
64
+ }
65
+ if (event.type === "speaking" &&
66
+ Array.isArray(event.userIds) &&
67
+ event.userIds.every((userId) => typeof userId === "string")) {
68
+ return { type: "speaking", sessionId: event.sessionId, userIds: event.userIds.slice() };
69
+ }
70
+ return null;
71
+ }
@@ -0,0 +1,7 @@
1
+ import type { DecksListParams, DecksListResult } from "./commands";
2
+ export type { DeckSummary, DecksListParams, DecksListResult } from "./commands";
3
+ export interface CastleDecksApi {
4
+ mine(options?: DecksListParams): Promise<DecksListResult>;
5
+ saved(options?: DecksListParams): Promise<DecksListResult>;
6
+ }
7
+ export declare const Decks: CastleDecksApi;