SDK reference
Everything below comes from the package's main entry. Calls to host methods return promises. Each host call checks its capability and input in your frame first and fails with FORBIDDEN or INVALID_INPUT before anything is sent. Local getters and subscriptions are synchronous.
Connect
import {connect, applyTheme, SDKError} from '@zenclub/sdk'
connect(options: {
pluginId: string
version: string // the manifest version
allowedHostOrigins: string[] // exact origins, such as 'https://zenclub.app'
hostApi?: {major: number, minMinor: number} // default {major: 1, minMinor: 0}
timeoutMs?: number // default 10000
}): Promise<ZenClubClient>Reads the host origin and instance id from the URL hash, checks the origin against allowedHostOrigins and completes the handshake. It resolves with a client, or rejects with NOT_EMBEDDED, HOST_DENIED, HANDSHAKE_REJECTED, INCOMPATIBLE_API or HANDSHAKE_TIMEOUT (after timeoutMs, 10 seconds by default).
Context
sdk.context: {
pluginId: string, instanceId: string, sessionId: string
protocol: 'zenclub.rpc/1'
hostApi: {major: 1, minor: 6} // undeclared legacy manifests receive 1.4 or 1.5
permissions: string[] // the granted capabilities
theme: ThemeSnapshot
sound?: {enabled: boolean} // only for 1.5 manifests, once a preference was chosen
progression?: {enabled: boolean, importExisting: boolean} // approved city source only
storageMode: 'postgres' | 'device' | 'memory' | 'development'
user?: {displayName: string} | null
locale?: string // a BCP 47 tag, such as 'ja-JP' or 'zh-TW'
timeZone?: string // such as 'Asia/Tokyo'
entitlements?: {tier: 'free' | 'basic' | 'pro'}
campaign?: {source?: string, medium?: string, campaign?: string} | null
}
sdk.supports(capability: string): booleansdk.context describes this session. storageMode tells you where saves go: postgres when saves are kept on ZenClub's servers and device when they stay encrypted in this browser; test hosts use memory or development. Optional fields may be missing on older hosts, so read them with a fallback.
Theme
sdk.theme.current(): ThemeSnapshot
sdk.theme.subscribe(listener: (theme: ThemeSnapshot) => void): () => void
applyTheme(theme: ThemeSnapshot, root?: HTMLElement): void
ThemeSnapshot: {
themeId: string
mode: 'light' | 'dark'
modePreference?: 'light' | 'dark' | 'system'
reducedMotion: boolean
tokens: {background, surface, ink, muted, accent, accentInk, border, soft,
radius, font, step1 … step12, onStepLow, onStepHigh}
}subscribe calls you at once and again on every change. applyTheme writes the 24 tokens as --zc-* CSS variables and sets data-mode, data-motion and color-scheme on your root element. Respect reducedMotion.
Sound preference
// Require hostApi: {major: 1, minMinor: 5} in your manifest and connect().
sdk.sound.current(): boolean | undefined
sdk.sound.subscribe(listener: (enabled: boolean) => void): () => voidSince SDK 0.8 and host API 1.5. Only plugins requiring 1.5 or later receive optional context.sound and sound.changed events. Both accept only {enabled: boolean}. The context field is absent if no choice was made. current() returns undefined when the host has not supplied a choice: keep your own default. subscribe receives a boolean immediately when a choice is known, then on preference changes; it returns an unsubscribe function.
This is a default, not a forced mute. When enabled, start with your sound switch on and begin audio only after a gesture inside your game. When disabled, stay silent until the visitor turns sound on in your game. Keep your own sound control and update it when the platform preference changes. Pausing or hiding the game must still silence it, regardless of the preference.
let soundOn = sdk.sound.current() ?? false
let paused = false, hidden = false
function applySound() {
setSoundSwitch(soundOn) // your own control, including a header action if used
if (!soundOn || paused || hidden) stopAudio()
}
applySound()
const unsubscribe = sdk.sound.subscribe(enabled => {
soundOn = enabled
applySound() // a host event is not an audio-unlocking gesture
})
sdk.onLifecycle(command => {
if (command.type === 'pause') paused = true
if (command.type === 'resume') paused = false
if (command.type === 'visibility') hidden = !command.visible
if (command.type === 'dispose') { paused = true; unsubscribe(); stopAudio(); return }
applySound()
})
gameSurface.addEventListener('click', () => {
if (soundOn && !paused && !hidden) startAudioFromGesture()
})
soundButton.addEventListener('click', () => {
soundOn = !soundOn
applySound()
if (soundOn && !paused && !hidden) startAudioFromGesture()
})
sdk.onDisconnect(() => { paused = true; unsubscribe(); stopAudio() })Storage
sdk.storage.read(slot = 'primary'): Promise<SaveEnvelope | null>
sdk.storage.write(slot: string, data: Json, options: {
expectedRevision: number // 0 for a new slot
schemaVersion: string // your save format, such as 'garden-v2'
requestId?: string // the same id for every retry of one change
}): Promise<SaveEnvelope>
sdk.storage.createAutosave(save: () => Promise<unknown>, options?: {
intervalMs?: number // 10000 to 300000, default 10000
onError?: (error: SDKError) => void
}): {schedule(): void, flush(): Promise<void>, dispose(): void}
sdk.storage.flush(): Promise<void>
SaveEnvelope: {slot: string, schemaVersion: string, revision: number, data: Json, updatedAt: string}Slots are named like ids, and primary is the default. A save holds up to 128 KiB of plain JSON. A write succeeds only when expectedRevision matches; otherwise it fails with CONFLICT and nothing is overwritten. Use 0 for a slot that does not exist yet.
Private city milestones
sdk.progression.current(): {enabled: boolean, importExisting: boolean}
sdk.progression.subscribe(listener: (preference: {
enabled: boolean, importExisting: boolean
}) => void): () => void
sdk.milestones.report(value: {
key: 'homestead' | 'pond' | 'clouds' | 'bamboo' | 'tea' | 'stream' | 'bell' | 'lotus'
tier: 1 | 2
ruleVersion: string // exactly '<key>.1' in this release
evidence: {saveRevision: number} // confirmed primary save revision
requestId: string // one id per immutable report, 1-80 ID characters
}): Promise<{status: 'recorded' | 'already-recorded', tier: 1 | 2}>Since SDK 0.9 and host API 1.6. An approved source receives optional context.progression and progression.changed with exactly {enabled: boolean, importExisting: boolean}. Without support, the local getter returns both as false. Subscriptions receive a copy immediately, then on valid changes, and return an unsubscribe function. The import choice belongs only to the selected game. The capability and context are available only with postgres or equivalent development storage.
Wait for a successful primary save before reporting its revision. A report contains only the fixed key, tier 1 or 2, rule version, one saved revision and a required request id; it cannot contain identity, text, artwork, positions or other properties. The server checks its own minimal save projection against the approved rule. Tier 2 includes tier 1. Results are recorded or already-recorded, with the current tier. A confirmed result means the decoration is available; the player decides whether to place it.
// Call with the envelope returned by a successful primary storage.write.
// This rule counts completed cups, never play time or sound settings.
async function reportTea(confirmedSave) {
const serverStored = ['postgres', 'development'].includes(sdk.context.storageMode)
if (!sdk.supports('milestones.report') || !serverStored || !sdk.progression.current().enabled) return
const cups = confirmedSave.data.cups
const tier = cups >= 3 ? 2 : cups >= 1 ? 1 : 0
if (!tier) return
return sdk.milestones.report({
key: 'tea', tier, ruleVersion: 'tea.1',
evidence: {saveRevision: confirmedSave.revision},
requestId: crypto.randomUUID(),
})
}
const unsubscribe = sdk.progression.subscribe(({enabled}) => {
if (!enabled) clearInMemoryCityCandidates()
})
sdk.onLifecycle(command => { if (command.type === 'dispose') unsubscribe() })
sdk.onDisconnect(unsubscribe)
// A result confirms the decoration was recorded; placement is the player's choice.
// Keep no plugin-owned outbox and never block game saves or leaving.When recording stops, clear in-memory candidates. Keep no plugin-owned queue, poll no server, and never delay saves or leaving. The host owns bounded retries. For an uncertain result, keep the exact report and request id. Within seven days, a matching receipt returns the original result without using a new-report allowance; different content fails IDEMPOTENCY_CONFLICT. Read and reevaluate newer saves with a new id after STALE_EVIDENCE. Missing permission, disabled recording, unknown rules or an expired session stop the current report.
These decorations are private appearance choices. Confirmed snapshots are client-reported, and explicitly imported history is marked imported. Neither is proof of real manual play or breathing, and neither may drive public rankings, trading or payments. Plugins never learn the owner's identity, city layout, host generation or another game's progress. Stopping or clearing city records leaves the original game save intact.
Loading and sessions
sdk.loading.start(): Promise<{ok: true}>
sdk.loading.ready(): Promise<{ok: true}>
sdk.loading.fail(message: string): Promise<{ok: true}> // up to 240 characters
sdk.session.report(status: {
state: 'running' | 'paused' | 'ended'
mode: 'watch' | 'manual' | 'listen'
runId?: string
}): Promise<{ok: true}>Report ready once the visitor can begin. Report the session state whenever it changes and at least every 12 seconds while it runs; that report is how the platform measures time. Reporting running before ready fails with NOT_READY.
Runs, moments and badges
sdk.runs.finish(run: {
id: string, reason: 'finished' | 'restarted', control: 'auto' | 'manual' | 'mixed'
metrics: Record<string, number>, endedAt: string
}): Promise<{recorded: true}>
sdk.moments.create(moment: {title: string, image: string, metadata?: Json})
: Promise<{id: string, stored: true}>
sdk.achievements.request(id: string, evidence: Json)
: Promise<{status: 'recorded', verified: boolean, awarded?: string[]}>runs.finish saves pending progress first, then records a run with up to 20 numeric metrics. A moment's image is a PNG data URL of up to 350,000 characters, and the platform may ask the visitor before keeping it. A badge request carries your evidence. The server decides, and in device mode the result is never verified.
Tallies, events, sharing and leaving
sdk.counters.add(key: string, delta: number, options?: {reason?: string, requestId?: string})
: Promise<{key: string, value: number, applied: boolean}>
sdk.events.report(name: string, props?: Record<string, string | number | boolean | null>)
: Promise<{recorded: boolean}>
sdk.shares.request(request: {kind: 'experience'} | {kind: 'badge', badgeCode: string})
: Promise<{presented: boolean}>
sdk.navigation.exit(reason?: string): Promise<{ok: true}>A tally only changes if the operator has defined it and listed you as a writer; keep a requestId so a retry is applied once. Event names are lowercase, up to 64 characters, with up to 20 flat properties. navigation.exit saves pending progress and takes the visitor to your detail page.
Immersive display
if (sdk.supports('display.immersive')) {
const {immersive, applied} = await sdk.display.immersive(true)
}Since host API 1.3. The platform hides its frame only when you are ready and the visitor has not turned immersive mode off. applied tells you whether anything changed. Leaving immersive mode always works.
Header actions
if (sdk.supports('header.setActions')) {
sdk.header.onAction(action => {
if (action.id === 'sound') toggleSound()
if (action.id === 'water') waterGarden()
})
await sdk.header.setActions([
{kind: 'standard', id: 'sound', intent: 'sound', pressed: soundOn},
{kind: 'custom', id: 'water', title: 'Water the garden',
icon: {paths: ['M12 3 C8 9 6 12 6 15 C6 18 9 21 12 21 C15 21 18 18 18 15 C18 12 16 9 12 3 Z']}},
])
}
sdk.header.clear()Since host API 1.4. Up to six actions: the platform's share action, standard intents (sound, restart, settings, help, undo, redo) or custom actions with a title of up to 40 characters and a 24 × 24 icon drawn in up to four SVG paths. Keep an in-frame fallback for hosts without a header.
Lifecycle and disconnects
sdk.onLifecycle(handler: (command: {
type: 'pause' | 'resume' | 'visibility' | 'dispose'
visible?: boolean
}) => void | Promise<void>): () => void
sdk.onDisconnect(handler: (error: SDKError) => void): () => void
sdk.disconnect(): voidonLifecycle receives pause, resume, visibility and dispose. After your handler, the SDK flushes every autosave on pause, hide and dispose. onDisconnect tells you the channel has closed for good. Do not call disconnect() from inside a dispose handler, because the host then waits out its one-second timeout.
Limits
| What | Limit |
|---|---|
| One message | 512 KiB |
| Save data | 128 KiB |
| Milestone reports | 1 KiB; 2 new reports per owner/game in 10 seconds, 40 per owner/game per UTC day, and 160 per owner per UTC day |
| JSON shape | Plain objects and arrays, finite numbers, at most 22 levels deep, 1,000 keys per object and 20,000 items per array |
| Requests in flight | 32 |
| Request rate | Bursts of 40, then 20 per second |
| Server rates | Moments 15 per minute, badges 30 per minute, tallies and events 120 per minute |
| Timeouts | Requests 8 s, handshake 10 s, ready check 15 s, keep-alive 12 s, dispose 1 s |
Error codes
Every rejection is an SDKError with a stable code. Messages are for logs, not for visitors.
| Code | Meaning and what to do |
|---|---|
NOT_EMBEDDED · HOST_DENIED · HANDSHAKE_REJECTED · INCOMPATIBLE_API · HANDSHAKE_TIMEOUT | Opened outside a frame, by a host you did not allow, or by a host that does not offer your host API. Show a calm notice. |
FORBIDDEN · INVALID_INPUT · NO_METHOD · TOO_LARGE · NOT_READY | The capability is not granted for this version, or the input breaks a rule. Fix the call, don't retry it. |
CONFLICT · IDEMPOTENCY_CONFLICT · ID_REUSE | Your save was changed elsewhere, or a requestId was reused with different data. Read the save again and let the visitor decide. Never overwrite it silently. |
TIMEOUT · INTERNAL · SAVE_UNCONFIRMED | The result is unknown. The write may already have happened. Retry with the same requestId. |
BUSY · RATE_LIMIT | Too many requests at once or too fast. Wait, then try again. |
GUEST_CAP · STORAGE_LIMIT | The visitor reached a guest allowance or a storage limit. Keep the state in memory and tell them gently. |
DISCONNECTED · DISPOSED · UNAUTHORIZED | The session is over. Stop work and stop saving. |
UNAVAILABLE · STORAGE_UNAVAILABLE | This host doesn't offer the feature right now. Fall back to doing it yourself. |
PROGRESSION_DISABLED · SAVE_NOT_READY · STALE_EVIDENCE | Recording is disabled: clear candidates. The referenced save has not arrived: wait for its confirmation. A newer snapshot exists: read it, reevaluate and use a new report id. |
INVALID_RESPONSE | The host did not confirm the milestone result. Keep the game save, and do not claim the decoration was recorded. |