Quick start
Scaffold a working experience, run it next to a small test host and watch your first save go through. It takes about fifteen minutes.
What you need
- Node.js 22 or later.
- The
@zenclub/sdkpackage. Write to us to receive it. - Two free local ports between 58000 and 65535: one for your experience and one for the test host.
1. Create a project
Install the package, then let its command-line tool scaffold a project. --port is where your experience will run, and --host is the exact origin allowed to embed it.
npm install ./zenclub-sdk-0.9.0.tgz
npx zenclub-plugin init my-garden --id=my-garden --port=58301 --host=http://localhost:58297
cd my-garden
npm run devThe project contains a manifest, a tiny static server, the page and its script, and a copy of the SDK in public/vendor/sdk. It imports nothing from the platform.
2. Connect to the host
Open public/app.js. The heart of every experience is a short sequence: connect, start loading, follow the theme, read the save, then report that you are ready and running.
import {connect, applyTheme} from './vendor/sdk/client.js'
const sdk = await connect({
pluginId: 'my-garden',
version: '0.1.0',
hostApi: {major: 1, minMinor: 2},
allowedHostOrigins: ['http://localhost:58297'],
})
await sdk.loading.start()
sdk.theme.subscribe(theme => applyTheme(theme))
const save = await sdk.storage.read('primary')
let count = save?.data.count ?? 0, savedCount = count
let revision = save?.revision ?? 0
let paused = false, hidden = false
const report = () => sdk.session.report({state: paused || hidden ? 'paused' : 'running', mode: 'watch'})
sdk.onLifecycle(command => {
if (command.type === 'pause') paused = true
if (command.type === 'resume') paused = false
if (command.type === 'visibility') hidden = !command.visible
return report()
})
await sdk.loading.ready()
await report()
setInterval(report, 4000)allowedHostOrigins must list the exact origins that may embed you. Use your test host while you build and https://zenclub.app for release. A frame opened anywhere else rejects with HOST_DENIED.
3. Save progress
Saves use revisions. Pass the revision you last read as expectedRevision, and keep one requestId per change so that a retry can never write twice. An autosave gathers frequent changes and writes them at a calm pace.
let pending
const autosave = sdk.storage.createAutosave(async () => {
if (!pending && savedCount === count) return
// One request id per change, kept for every retry of that change.
pending ??= {count, requestId: crypto.randomUUID()}
const saved = await sdk.storage.write('primary', {count: pending.count}, {
expectedRevision: revision,
schemaVersion: 'counter-v1',
requestId: pending.requestId,
})
revision = saved.revision
savedCount = pending.count
const latest = pending.count === count
pending = undefined
return latest
}, {onError: error => console.warn('Save not confirmed', error.code)})
const button = document.querySelector('#count')
button.disabled = false
button.onclick = () => {
if (paused || hidden) return
count++
document.querySelector('#value').textContent = count
autosave.schedule()
}4. Try it in a test host
You don't need the ZenClub platform while you build. The SDK's host entry mounts a plugin exactly as the platform does, so one page with an in-memory save store can stand in for it. Put these two files in a test-host folder inside your project.
<!doctype html>
<meta charset="utf-8">
<title>Test host</title>
<style>body{margin:0;font:14px system-ui}#stage{height:80vh}#stage iframe{width:100%;height:100%;border:0}</style>
<p id="status">Loading…</p>
<button id="pause">Pause</button> <button id="resume">Resume</button>
<div id="stage"></div>
<script type="module">
import {mountPlugin} from './sdk/host.js'
const manifest = await (await fetch('./plugin.manifest.json')).json()
const steps = Array.from({length: 12}, (_, i) => '#' + (0xf4 - i * 18).toString(16).repeat(3))
const theme = {
themeId: 'test', mode: 'light', reducedMotion: false,
tokens: {
background: '#f4f1e9', surface: '#fbf9f4', ink: '#293b32', muted: '#66756d',
accent: '#375c47', accentInk: '#ffffff', border: '#d9d4c7', soft: '#e9e4d8',
radius: '12px', font: 'Georgia, serif',
...Object.fromEntries(steps.map((c, i) => ['step' + (i + 1), c])),
onStepLow: '#293b32', onStepHigh: '#ffffff',
},
}
const saves = new Map()
const plugin = mountPlugin({
container: document.querySelector('#stage'),
manifest,
grantedPermissions: manifest.permissions,
context: {sessionId: crypto.randomUUID(), storageMode: 'memory', theme, locale: 'en', timeZone: 'UTC'},
onStatus(state, detail) {
document.querySelector('#status').textContent = state + (detail ? ': ' + detail : '')
// Like the platform: tell the plugin whether it can be seen, right after it connects.
if (state === 'connected') showVisibility()
},
async handle(method, input) {
console.log(method, input)
if (method === 'storage.read') return saves.get(input.slot) ?? null
if (method === 'storage.write') {
const current = saves.get(input.slot)
if ((current?.revision ?? 0) !== input.expectedRevision) throw Object.assign(new Error('Revision changed'), {code: 'CONFLICT'})
const next = {slot: input.slot, schemaVersion: input.schemaVersion, revision: input.expectedRevision + 1, data: input.data, updatedAt: new Date().toISOString()}
saves.set(input.slot, next)
return next
}
return {ok: true}
},
})
function showVisibility() { plugin.command({type: 'visibility', visible: !document.hidden}) }
document.addEventListener('visibilitychange', showVisibility)
document.querySelector('#pause').onclick = () => plugin.command({type: 'pause'})
document.querySelector('#resume').onclick = () => plugin.command({type: 'resume'})
</script>import http from 'node:http'
import {readFile} from 'node:fs/promises'
import path from 'node:path'
const files = {
'/': 'index.html',
'/plugin.manifest.json': '../plugin.manifest.json',
}
const sdk = path.resolve('../public/vendor/sdk')
const types = {'.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json'}
http.createServer(async (req, res) => {
try {
const url = new URL(req.url, 'http://localhost').pathname
const file = url.startsWith('/sdk/') ? path.join(sdk, path.basename(url)) : files[url]
if (!file) throw new Error('not found')
res.writeHead(200, {'Content-Type': types[path.extname(file)] ?? 'text/plain'})
res.end(await readFile(file))
} catch {
res.writeHead(404).end('Not found')
}
}).listen(58297, '127.0.0.1', () => console.log('Test host: http://localhost:58297'))Start both servers, then open the test host. Change something, press Pause, and the console shows the save that the SDK flushed for you.
npm run dev # your experience on :58301
cd test-host && node serve.mjs # the test host on :58297
# open http://localhost:58297The test host grants every permission in your manifest and keeps saves in memory. It is a development aid, not a copy of the platform's review and rate limits.
Next steps
Read Lifecycle and saves before you build anything long-running, and the Hosting and review checklist before you ask us to list your experience.