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): boolean

sdk.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): () => void

Since 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(): void

onLifecycle 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

WhatLimit
One message512 KiB
Save data128 KiB
Milestone reports1 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 shapePlain objects and arrays, finite numbers, at most 22 levels deep, 1,000 keys per object and 20,000 items per array
Requests in flight32
Request rateBursts of 40, then 20 per second
Server ratesMoments 15 per minute, badges 30 per minute, tallies and events 120 per minute
TimeoutsRequests 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.

CodeMeaning and what to do
NOT_EMBEDDED · HOST_DENIED · HANDSHAKE_REJECTED · INCOMPATIBLE_API · HANDSHAKE_TIMEOUTOpened 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_READYThe capability is not granted for this version, or the input breaks a rule. Fix the call, don't retry it.
CONFLICT · IDEMPOTENCY_CONFLICT · ID_REUSEYour 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_UNCONFIRMEDThe result is unknown. The write may already have happened. Retry with the same requestId.
BUSY · RATE_LIMITToo many requests at once or too fast. Wait, then try again.
GUEST_CAP · STORAGE_LIMITThe visitor reached a guest allowance or a storage limit. Keep the state in memory and tell them gently.
DISCONNECTED · DISPOSED · UNAUTHORIZEDThe session is over. Stop work and stop saving.
UNAVAILABLE · STORAGE_UNAVAILABLEThis host doesn't offer the feature right now. Fall back to doing it yourself.
PROGRESSION_DISABLED · SAVE_NOT_READY · STALE_EVIDENCERecording 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_RESPONSEThe host did not confirm the milestone result. Keep the game save, and do not claim the decoration was recorded.