WispOS

Building apps for WispOS

How to build a game, tool or agent for the WispOS App Store with any AI you like, or with Wisper: the package, making and installing a .wapp yourself, the manifest, phone and desktop screens, agents, chat cards, routines, in-app purchases, Unity and Unreal ports, and apps from other stores.

Updated

Every listing in the WispOS App Store is an app: a game, a tool or an agent, packaged as one .wapp file that WispOS checks and installs. Nothing runs inside the store itself; an app runs only after someone installs it.

The goal for every app: on a phone it feels like it was made for the phone, and on a computer it feels like it was made for the space it’s given.

The short version

Make it with Wisper, or with any AI you like (the building guide ships inside WispOS). Build a web app (or an agent: instructions plus the files it answers from), or bring a game from Unity, Unreal, Godot or an app from another store. Carry everything it runs inside the package. Declare how you want each screen in web.screens. Fill whatever space you’re given; never letterbox a fixed-size canvas. Keep controls inside the safe area. Make targets finger-sized on touch and give the mouse hover. Follow WispOS’s light and dark theme. Pause when you’re hidden. Test at phone and desktop sizes, then submit it.

Build it with any AI

You don’t have to use Wisper to make a WispOS app. Any AI that writes code can, because the building guide is one plain document that ships inside WispOS, and the package it describes has no place for a vendor.

  1. Print the guide. Run the WispOS program with --app-guide. It is the same guide Wisper reads, and the copy on your computer always matches the WispOS that will run your app.
  2. Hand it to your AI with your idea: “make a game where you dodge asteroids”, “make a chat card for recipes”. It tells the AI which shape to pick (a game, a full-window app, a chat card, an agent or a routine), the folder, the manifest and every rule the store checks.
  3. Pack and check what came back. Run the WispOS program with --pack-app <folder> (no Node needed), or use node wapp.mjs pack from Make your .wapp yourself. A folder that breaks a rule is not packed, and the message names the rule, so your AI can fix it.
  4. Install it with More → App Store → Installed → Install from a file…, or submit it.

What comes out is the same package whichever AI made it: the manifest has no field for the AI and the checks don’t ask. Change tools between versions if you like. Raise build, pack again and install over the last one; the app keeps its storage.

Make it with Wisper

You don’t have to write it yourself. Ask Wisper in plain words: “vibe code a game where you dodge asteroids”, “build me an app that tracks my runs”, “make a card for recipes”. Every WispOS carries a building guide with the same rules as this page, and a turn about making an app gets all of it, so she knows the folder, the manifest and the rules below without being told.

  1. She picks the shape: a game, a full-window app, a chat card, an agent or a routine, the smallest one that does the job.
  2. She builds it, in the conversation or, for a bigger one, through a project’s agent that gets the same guide.
  3. WispOS checks and packs it into a .wapp next to its folder, with every check the App Store runs, and puts an Install button under her reply.
  4. You try it, then ask for changes. Each new version installs over the last and keeps the app’s storage.
  5. Share it: send the .wapp to a friend (they use Install from a file…), or submit it to the App Store from the app’s page or by asking her.

An agent from a folder of PDFs, Word files, slides or spreadsheets works the same way: see Make an agent from your documents. Any other coding agent can use the same guide: see Build it with any AI.

What an app is

Kind Store shelf What it is
game Games A web app (HTML, JavaScript, CSS) that’s played, or a game made in Unity, Unreal, Godot or another engine, converted to a web build (how).
tool Apps A web app that does a job. It can also add cards to Wisper’s chat.
agent Agents No screen of its own: Wisper asks it when a question is one it knows, and says who answered.

An app can carry both parts: a tool with an agent, or an agent with a settings page.

Where a game or tool runs:

  • On the computer, in the drawer beside Wisper’s conversation, at the width you ask for. The drawer’s fill-the-window button gives it the whole window. People open it from the Apps page, or by asking Wisper (“let’s play Starbreak”).
  • On a phone, through the phone link: the app is installed on the computer and the phone opens it from there. Its storage is the same on both, so a list made on the computer is the list on the phone.

What’s in the package

Your app is a folder. The package is that folder zipped in a strict way, with checksums. You don’t have to pack it yourself: send us the folder and we build and check the .wapp. Or make it yourself and install it in your own WispOS.

my-game/
├── manifest.json      who the app is and what it carries
├── app/               the web app; web.entry points in here
│   └── index.html
├── docs/              help pages, .html or .md
│   └── index.html
├── agent/             an agent: card.json, PROMPT.md, knowledge/
├── store/             the listing: icon and screenshots
│   ├── icon.svg
│   └── screenshots/
└── locales/           translations of the listing, <lang>.json (optional)

Every folder except store/ may be left out. File names use letters, numbers, ., _ and - only: no spaces, nothing starting with a dot (.DS_Store, .git), and no two names that differ only by case. Limits: 256 MB packed, 512 MB unpacked, 10,000 files.

The manifest

manifest.json says who the app is. Unknown fields are refused, so stick to the ones below. A game:

{
  "format": "wispos.app/1",
  "id": "my-game",
  "name": "My Game",
  "version": "1.0.0",
  "build": 1,
  "kind": "game",
  "category": "Puzzle",
  "summary": "One line under the name, 80 characters at most.",
  "description": ["What it is, one paragraph per string."],
  "whatsNew": "What changed in this version.",
  "keywords": ["puzzle", "cards"],
  "publisher": { "id": "my-studio", "name": "My Studio", "email": "[email protected]" },
  "distribution": { "visibility": "public" },
  "price": { "amount": 0, "currency": "USD" },
  "ageRating": "4+",
  "languages": ["en"],
  "icon": "store/icon.svg",
  "screenshots": [
    { "image": "store/screenshots/1.png", "caption": "In a desktop window", "device": "desktop" },
    { "image": "store/screenshots/phone-1.png", "caption": "On a phone", "device": "phone" }
  ],
  "web": {
    "entry": "app/index.html",
    "window": { "width": 960, "height": 600, "resizable": true,
                "backgrounds": { "dark": "#05070f", "light": "#b9bde3" } },
    "screens": {
      "mobile":  { "orientation": "portrait", "fullscreen": true },
      "desktop": { "orientation": "landscape", "width": 960, "height": 600,
                   "minWidth": 480, "minHeight": 320, "resizable": true }
    }
  },
  "docs": [{ "title": "How to play", "path": "docs/index.html" }],
  "permissions": [],
  "privacy": { "collects": [], "tracking": false }
}
Field What to put
id Lowercase letters, numbers and dashes, up to 64. It never changes: every update uses the same id.
version, build version is what people see (1.2.0). build is a whole number that goes up with every release; updates are compared by build, never by version.
kind, category game, tool or agent, and the shelf within it (Puzzle, Productivity, Team agent).
name, summary, description Name up to 30 characters, summary up to 80, description as plain-text paragraphs.
publisher id and name (plus email, url if you like). Apps with the same publisher.id show as “More from …”.
price 0 for now: the store lists free apps, and a priced submission is held for the reviewer.
ageRating 4+, 9+, 12+ or 17+.
icon store/icon.svg, or a square PNG at least 512 × 512. Full bleed: WispOS rounds the corners, so don’t draw them.
screenshots Up to 10 PNG or JPEG of the real app, at least 320 px on the short side. device is desktop or phone: the store shows phone shots on a phone and desktop shots on a computer.
web For a game or tool. See Declaring your screens; web.routines is in Routines.
purchases Optional. See In-app purchases.
agent For an agent: { "folder": "agent" }. See Building an agent.
docs Your help pages; the first is the help home.
permissions [{ "kind", "reason" }], may be empty. See What your app can reach.
privacy collects: [] and tracking: false reads “Data Not Collected”. Otherwise list each kind of data, what it’s for, and whether it’s linked to the person.

Two screens

  • Phone: the whole screen, held in one hand. A finger, no hover, no keyboard until a field is focused. A notch or Dynamic Island at the top, a home indicator at the bottom, rounded corners. People leave and come back constantly.
  • Computer: a panel beside Wisper that people can widen to the whole window at any moment. A mouse or trackpad with hover, a keyboard. Sometimes far more room than you need, sometimes less than you asked for.

Make a deliberate choice for each, not one layout scaled down or stretched up. A brick breaker gets a tall field on a phone and a wide one on the computer. A list stays a list, but a readable column when it’s wide and finger-sized rows on a phone.

Declaring your screens

web.screens (in the example above) tells WispOS how you want each screen.

Field What WispOS does How to choose
mobile.orientation portrait or landscape while the app is open; any follows the device. Most games and nearly all tools: portrait. Landscape only if the game truly needs width.
mobile.fullscreen true: no WispOS bar, the app covers the whole screen, with a gesture to close it. false: under WispOS’s bar with the app’s name and Done. true for games, false for tools.
desktop.orientation The shape you want: landscape, portrait or any. Landscape for most games; portrait for list-like tools that sit beside the conversation.
desktop.width, .height The width the drawer opens at, if the window has room. Games around 900 × 600; tools around 420-560 wide.
desktop.minWidth, .minHeight The smallest you’ll accept (at least 320). With less room than that, the app fills the window. The smallest size you’ve tested.
desktop.resizable false keeps the width you asked for. Leave it true unless your layout truly can’t stretch.

Keep web.window with the same width, height and resizable: older WispOS versions read only that. Without screens, a phone follows the device and shows WispOS’s bar, and the computer uses web.window.

Layout

Start every page with:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
  • Size from the screen, with vw, vh, min() and clamp(), so the main thing is as big as the space allows. Expect the space to change while you’re open.
  • Change the arrangement for the shape: one layout for wide spaces, one for tall ones. On a phone put the actions people use most at the bottom, in thumb reach.
  • Don’t stretch reading content: text and lists in a wide space are a column around 720-760 px wide.
  • Nothing scrolls sideways. A game page shouldn’t scroll at all.
  • Text in fields is 16 px or larger, or iPhones zoom in when one is tapped.

Canvas games

Don’t scale a fixed-size canvas with object-fit: contain: it’s a thin strip on an upright phone and blurry on a sharp display. Give the game a world whose short side is fixed and whose long side is whatever the screen has, and draw at devicePixelRatio. Then let the world’s shape change the game, not just its size.

Safe areas

With viewport-fit=cover a full-screen app draws under the notch and home indicator. Keep anything people read or touch inside env(safe-area-inset-*); let backgrounds run edge to edge.

Touch, mouse and keys

  • Use pointer events, one code path for mouse, finger and pen; handle pointercancel.
  • Targets at least 44 × 44 pt on touch (@media (pointer: coarse)).
  • Hover effects only under @media (hover: hover); anything that appears on hover must always be visible on touch.
  • Games own their gestures: touch-action: none on the play area.

Light and dark

WispOS has a light and a dark theme, and your app should follow it. Read it with @media (prefers-color-scheme: light) (or matchMedia for a canvas), switch live when it changes, and keep the color-scheme meta tag above. In the manifest, give both backdrops in web.window so the app never flashes the wrong colour: "backgrounds": { "dark": "#05070f", "light": "#b9bde3" }, instead of a single background. A single background is for an app with only one look. Don’t give both. Light doesn’t have to be white: a night game can go to dusk.

What your app can reach

Your app runs on its own origin with a strict Content Security Policy:

  • The package carries everything it runs. No scripts, styles, fonts, frames or media from the internet; the checks refuse a package that tries.
  • Network only by permission. If the app needs data from a server, list each origin in web.network (for example "network": ["https://api.example.com"]) and ask for the network permission with a reason. WispOS lets it talk to exactly those origins.
  • Permissions are asked at first use, not at install, and people can say no: notifications, network, microphone, camera, clipboard, files (files the person picks), windows, calendar, location. Every one needs a reason people will understand, and the app has to actually use it. An app with no permissions gets its own space, its own storage and nothing else.
  • It can’t see the person’s files, projects, Wisper’s memory, WispOS itself or other apps.

The runtime: window.wispos

WispOS defines window.wispos before your first script runs. Every method returns a Promise.

Call Does
wispos.app { id, name, version, build, kind } from your manifest.
wispos.storage.get(key), .set(key, value), .remove(key), .keys() JSON values, per app. Kept across launches and updates, the same on the computer and the phone, removed on uninstall. At least 5 MB.
wispos.notify({ title, body }) A system notification. Needs the notifications permission; resolves false if the person said no.
wispos.docs.list(), .open(path?) Your help pages; open shows one (the first by default).
wispos.routines.list(), .get(id), .set(id, { at, days }), .clear(id) Your app’s routines and the time the person picked. set asks them first and resolves null if they say no.
wispos.purchases.products(), .buy(id), .owned(), .balance(id), .consume(id, n), .restore() Your in-app purchases. WispOS keeps the records, so an app can’t grant itself one.

Feature-detect it (if (window.wispos) …) so your app also works in a plain browser, where you’ll do most of your testing.

Leaving and coming back

Pause when the page is hidden (visibilitychange), and save state as you go: people leave a phone app constantly, and the drawer can be closed at any moment.

Building an agent for the store

A store agent is a shared team agent in a package: no screen, just instructions and the files it answers from. When someone installs it, WispOS adds it to their agents, and Wisper asks it whenever a question is one it knows.

Starting from documents? You don’t have to write any of this yourself: give Wisper the folder and ask her to make it into an agent app. She lays out the folder below, WispOS packs and checks it, and you get a .wapp to install or share. See Make an agent from your documents.

Set "kind": "agent", add "agent": { "folder": "agent" }, leave out web, and put the agent in agent/:

agent/
├── card.json          the card: name, team, owner, the topics it knows, who may ask
├── PROMPT.md          how it works: its instructions
└── knowledge/         the files it answers from, any depth: Markdown, text, PDF, .docx, .pptx, .xlsx
{
  "card": 1,
  "name": "education",
  "title": "Education team agent",
  "team": "Education",
  "owner": "Dana Ruiz <[email protected]>",
  "description": "The district's grading, late-work, attendance and device policy.",
  "knows": ["grading", "late work", "attendance", "absences", "tardies", "student devices"],
  "askers": ["*"],
  "model": "sonnet"
}
  • name must equal the manifest’s id.
  • knows is what routes questions to it: use the phrases people actually type. The card fields and routing rules are in Build a team agent, and how to make one answer well is in Agent best practices.
  • askers is "*" for a public agent.
  • The agent answers from the package’s copy of its files. New knowledge = a new build, sent as an update.
  • Give it a help page in docs/ (what it answers, what it won’t, sample questions) and screenshots of real answers in Wisper’s chat.
  • Like every shared team agent, it needs Wisper’s brain to be Claude Code (see Choosing Wisper’s brain), and it runs on the asker’s own AI plan.

Adding cards to Wisper’s chat

A tool can teach Wisper a new kind of card. With Places installed, “where should I get breakfast?” comes back as a card for each spot, with the rating, a line from the reviews and a Directions button. WispOS draws the cards itself, the same way on the computer and the phone; your app only says when to send one and what goes in it.

Declare your card types in app/chat-cards.json:

{ "cards": [
  { "type": "place", "title": "Places", "instructions": "app/cards/place.md" }
] }

type is lowercase letters, numbers and dashes, up to 32. instructions is a short .md file under app/ (2,400 characters at most) that tells Wisper when to send a card and which fields to fill: title, subtitle, image, rating, reviews, price, snippet, checks, address, hours, phone, url, and up to three actions buttons. Pictures from the web need the network permission. The first installed app to claim a type keeps it, so pick a name that’s yours.

Routines

An app can offer to come to people on a schedule they pick: a morning paper, a review, a reminder page. Morning Review posts the day as a card in Wisper’s chat; News opens today’s paper. Nothing is scheduled until the person chooses a time.

  • Declare what can come under web.routines (four at most), with no time in it: { "id": "edition", "title": "Today's paper", "does": "open", "path": "app/index.html" } opens that page and says so in Wisper’s chat, and { "id": "review", "title": "Morning Review", "does": "card", "card": "morning" } posts your chat card of that type.
  • Ask from your own “Routine” control: wispos.routines.set("edition", { at: "07:15", days: "weekdays" }). at is 24-hour local time; days is daily, weekdays or weekends. WispOS asks the person once (“News would like to bring you Today’s paper weekdays at 7:15”) and resolves null if they say no.
  • The person is in charge. Every routine is on the Apps page under Routines, where they can set, change or stop it. It lands in Wisper’s chat, so the phone gets it too. A time missed while the computer slept runs once on waking, up to four hours late, never twice. Uninstalling the app removes its routines.

In-app purchases

Sell things inside your app: declare them in the manifest, sell them with wispos.purchases, and test them in a browser and in WispOS. Every step here works today, in test mode.

Where it stands (October 2026).

  • Built: declaring purchases, the store listing them, the runtime API, WispOS’s own purchase sheet (with Touch ID on a Mac), purchase records and consumable balances, and the stand-in in wapp run.
  • Every purchase is a test purchase: no money moves. The sheet says “Test purchase: no money moves”, nobody is charged and nobody is paid. There are no accounts, receipts or payouts yet, and purchases stay on the computer they were made on. Real payments are being built.
  • Priced submissions are held for the reviewer. You can build and test purchases now, and the code you write won’t change when payments open. Until then the store lists only free apps from outside developers: a submission with a price or purchases is held for the reviewer, not published.

Two store apps already sell something: Night Match (the Aurora Deck, a non-consumable) and Merge Drift (10 Undos, a consumable).

1. Pick a type

type What the person gets Use it for Extra field
non-consumable Bought once, kept for good. Buying it again costs nothing. A theme, a level pack, a pro feature, removing a limit.
consumable A number of units, used up as the app spends them. Can be bought again. Hints, undos, lives, credits. quantity: units per purchase (1 to 10,000, default 1).
subscription Renews every period. Today it never lapses. Ongoing content or service. period: P1W, P1M, P3M, P6M or P1Y (required).

A free item isn’t a purchase: put it in the app. Agents have no purchases (there’s no screen to buy from); purchases need web.

2. Declare it in manifest.json

Add a purchases array at the top level. Starting from the wapp new starter (Tally), a gold counter for $1.99:

"purchases": [
  { "id": "gold", "type": "non-consumable", "title": "Gold Counter",
    "description": "A gold button for your tally. Yours for good.",
    "price": { "amount": 1.99, "currency": "USD" } }
]
Field Rule
id 1 to 64 of a-z 0-9 . -, starting and ending with a letter or digit; unique in the app. Never rename it after release: it’s what owners own. The global product id is <app id>/<id> (here tally/gold).
title Up to 40 characters. Shown on the purchase sheet and the product page.
description Up to 200 characters. Say exactly what they get.
price { amount, currency }, amount in major units (1.99 = $1.99), more than 0, at most 10,000, and in the same currency as the app’s own price.
quantity, period Only on consumables and subscriptions, as above.

Up to 100 items per app. Translations of title and description go in locales/<tag>.json under purchases, by id.

wapp validate (and wapp pack) refuse a manifest that breaks a rule, and say which:

purchases: gold must cost something (a free item belongs in the app)
purchases: gold is in EUR, the app in USD
purchases: club: period is for subscriptions, and every subscription needs one
purchases: gold: quantity is for consumables only
purchases: gold appears twice
purchases need web (they are bought from inside the app)

3. Sell it in the app

Everything goes through window.wispos.purchases. WispOS shows the purchase sheet and keeps the record itself, outside your page.

Call Resolves
products() Every item in your manifest, each with displayPrice (formatted by WispOS), owned (non-consumables and subscriptions) and balance (consumables; null for the others).
buy(id) Shows the sheet; { id, status }, status "purchased", "cancelled" or "pending". An owned non-consumable resolves "purchased" at once, with no sheet. Rejects for an id not in the manifest. Also fires wispos:purchase on window.
owned() Ids of owned non-consumables and subscriptions.
balance(id) Units left of a consumable.
consume(id, n = 1) Spends units; resolves the units left. Rejects if there aren’t enough.
restore() Re-reads what this person owns; the same as owned().

The whole of Tally’s app/app.js with the gold counter. Four things to copy: check for window.wispos (so the app still runs in a plain browser), ask WispOS what’s owned at every launch, show the price WispOS gives you, and unlock only on "purchased".

// Keeps the count in wispos.storage; sells a gold counter (manifest: purchases "gold").
const rt = window.wispos;                       // absent in a plain browser
const button = document.getElementById("n");
const goldButton = document.getElementById("buy-gold");
const note = document.getElementById("note");
let count = 0;

function showGold(owned, price) {
  button.classList.toggle("gold", owned);
  goldButton.hidden = owned;
  if (price) goldButton.textContent = "Gold counter · " + price;
}

if (rt) {
  rt.storage.get("count").then((v) => { count = v || 0; button.textContent = count; });
  // Ask WispOS at every launch: it keeps the record, not your storage.
  rt.purchases.products().then((items) => {
    const gold = items.find((p) => p.id === "gold");
    if (gold) showGold(gold.owned, gold.displayPrice);
  });
} else {
  goldButton.disabled = true;
  note.textContent = "The gold counter is an in-app purchase. Get Tally in WispOS to buy it.";
}

goldButton.addEventListener("click", () => {
  note.textContent = "";
  rt.purchases.buy("gold").then((r) => {
    if (r.status === "purchased") { showGold(true); note.textContent = "Gold counter unlocked. Thank you!"; }
    else if (r.status === "pending") note.textContent = "Waiting for the purchase to be approved.";
    // "cancelled": they said no; say nothing.
  }, (e) => { note.textContent = "Couldn't buy it: " + e.message; });
});

button.addEventListener("click", () => {
  count += 1;
  button.textContent = count;
  if (rt) rt.storage.set("count", count);
});

And in app/index.html, after the counter (keep the price text empty: it comes from products()):

<p><button id="buy-gold" type="button">Gold counter</button></p>
<p id="note" role="status"></p>
button.gold { background: linear-gradient(135deg, #f7d774, #c9962b); color: #2a1d00; }
#buy-gold { font: 600 16px system-ui; padding: 12px 18px; border-radius: 12px; min-height: 44px; }

Consumables: read the balance, then spend. From Merge Drift: show the balance at launch, spend one unit with consume when the person uses it, and offer to buy more when it’s 0. consume resolves the units left.

let undos = 0;
rt.purchases.balance("undo-10").then((b) => { undos = b; showUndo(); });

function undo() {
  if (undos > 0) {
    return rt.purchases.consume("undo-10").then((left) => { undos = left; takeBackOneMove(); showUndo(); });
  }
  rt.purchases.buy("undo-10").then((r) => {
    if (r.status !== "purchased") return;
    return rt.purchases.balance("undo-10").then((b) => { undos = b; showUndo(); });
  });
}

Spend first, then give: if consume rejects (not enough units), don’t do the thing.

Subscriptions. Declare a period:

{ "id": "club", "type": "subscription", "title": "Puzzle Club",
  "description": "A new puzzle pack every month.", "price": { "amount": 2.99, "currency": "USD" }, "period": "P1M" }

Check it the same way as a non-consumable: (await rt.purchases.owned()).includes("club"). Read Limits today before you rely on one.

A Restore button. Give owners a way to re-check what they own: call restore() and redraw from what it resolves. Purchases are kept when the app is updated, uninstalled or reinstalled.

restoreButton.onclick = () => rt.purchases.restore().then((ids) => showGold(ids.includes("gold")));

4. Test in a browser

node wapp.mjs pack tally                 # → tally-1.0.0.wapp, or the rule it breaks
node wapp.mjs validate tally-1.0.0.wapp
node wapp.mjs run tally-1.0.0.wapp       # serves it the way WispOS runs it

(wapp.mjs is the one-file developer tool from Make your .wapp yourself.)

wapp run gives the page a stand-in window.wispos with a test purchase sheet in the page (“Test purchase · no money moves”). Try every path: Buy, Cancel, buying an owned item again, a consumable running out, and a reload (what’s owned must come back). What you “bought” is kept in the browser’s localStorage under wispos:<app id>:$owned and wispos:<app id>:$balances: clear them to start again as a new customer.

Also open app/index.html without wapp run: with no window.wispos the app must still work and say purchases need WispOS.

5. Test in WispOS

  1. Install your .wapp in WispOS from the file.
  2. Open the app and buy. WispOS shows its own sheet: “Buy Gold Counter for $1.99?”, your description, “In Tally”, and “Test purchase: no money moves. Real payments aren’t built into WispOS yet.” It asks for Touch ID or the computer’s password.
  3. Quit and reopen the app: the gold counter must still be gold. Uninstall and reinstall: still gold.

WispOS keeps the records in purchases.json in its app data folder (on a Mac, ~/Library/Application Support/com.wispos.app/apps/purchases.json), one entry per app id: { "tally": { "owned": ["gold"], "balances": {} } }. To test as a new customer again, quit WispOS and remove your app’s entry.

6. Listing and review

  • The store reads your purchases: “In-App Purchases” under Get, and each item with its price in the product page’s In-App Purchases and Information sections. Nothing else to fill in.
  • Your description should say what’s free and what’s paid. Don’t put prices in it: WispOS shows them.
  • The privacy label’s purchases data type is for purchase data you collect (for example, sent to your own server). WispOS keeping its own records doesn’t need it.
  • Submit as usual (Submit an app). A submission with a price or purchases passes the checks, but the reviewer sees “asks for money (price or in-app purchases); the store can’t take payments yet”, and it is held until payments open.

Rules

  • WispOS keeps the records, not your app. Never mark something bought in wispos.storage, localStorage or a server of your own, and never unlock on anything but "purchased" or owned. Ask products() or owned() at every launch.
  • Show the price WispOS gives you (displayPrice), never a price written into the app: it’s formatted for the person, and it’s the price the sheet charges.
  • Sell through WispOS only. The sheet is the only way to sell inside an app, and nothing you sell can be another app or a store of apps.
  • Handle every outcome. "cancelled" is normal: no error message. Handle "pending" even though WispOS doesn’t send it today. Show the error when buy rejects.
  • Keep ids for good. Change a title, description or price in a new build if you like; renaming or removing an id takes it away from people who bought it.

Limits today

  • No money moves. No charge, no payout, no receipt. An up-front price on the app is shown on the install sheet but not charged.
  • One computer. Records live on the computer that bought. restore() re-reads that computer’s records; it doesn’t fetch anything from another one.
  • Subscriptions never lapse. Once bought, a subscription stays owned; no renewal is tracked.
  • "pending" never comes from WispOS yet (it’s for purchases that wait on approval).
  • Paid files aren’t protected. A .wapp is a plain ZIP: anyone who unzips it can read every file, paid content included. Until encrypted paid content comes, unlock things the app makes or draws, not secrets in files.

When real payments open

WispOS sells to the person and pays you; WispOS keeps 15% of each sale and you get the rest. You’ll add a payout account and tax details to your developer account. That’s the plan, not built yet.

Your code doesn’t change: the manifest fields and the wispos.purchases calls stay the same. Build against them now.

Checklist

  • Each item has an id you’ll keep, a clear title and description, a price above 0 in the app’s currency; consumables have quantity, subscriptions have period.
  • wapp validate passes.
  • Prices on screen come from products(); what’s owned is read from WispOS at every launch.
  • Unlock only on "purchased"; "cancelled" is quiet; "pending" and errors are shown.
  • Consumables: consume before giving; buy more at 0.
  • A Restore button.
  • Without window.wispos the app works and says purchases need WispOS.
  • Tested in wapp run (buy, cancel, reload, run out) and in WispOS (buy, reopen, reinstall).
  • Nothing paid is a secret in a file.

Porting a game from an engine

Games aren’t only hand-written web apps. A game made in Unity, Unreal, Godot or another engine comes into WispOS once it’s converted to a web build, and the package is that build. Send us the build and we convert it, or follow the steps here.

  • Unity: Build Settings → WebGL. In Publishing Settings set Compression Format to Disabled (or keep Gzip/Brotli and we unpack it). PlayerPrefs saves work but stay on one device; for saves that follow the player, call wispos.storage from a .jslib plugin with [DllImport("__Internal")].
  • Unreal Engine 5 has no web export, so it comes in through Pixel Streaming: the game runs on a GPU server you operate, and the package carries Epic’s web player (from PixelStreamingInfrastructure) with one wss:// network address for your signalling server. The server and its cost are yours.
  • Unreal Engine 4.23 and earlier (or the community HTML5 fork for 4.24+): package for HTML5; it’s an Emscripten build that runs as a normal web build.
  • Godot 4: Export → Web with Thread Support off. Godot 3’s HTML5 export runs as it is.
  • Others: GameMaker, Construct, Defold, RPG Maker MV/MZ, Bevy and other Rust through wasm-bindgen, C and C++ through Emscripten (raylib, SDL), and the JavaScript engines (Phaser, PixiJS, three.js, Babylon.js): use their web export.

Every build meets these rules:

  • WebAssembly runs; JavaScript eval doesn’t. Rebuild without anything that needs eval.
  • Build single-threaded. WispOS doesn’t turn on cross-origin isolation, so SharedArrayBuffer is off (no -pthread in Emscripten).
  • Turn off compressed builds (Gzip/Brotli files that expect a web server’s Content-Encoding). Files are served as they are.
  • Everything in the package, under the size limits above. A game that talks to a server (multiplayer, Pixel Streaming) declares it like any other network use.

Send us the engine’s web build folder with your manifest, icon and screenshots (or just the build, the name you want and a few lines about the game); we convert what a package can’t carry (file names with spaces, compressed files), write what’s missing and pack it.

Bring an app from another store

Already have your app or game on another store, or on the web? It can come to WispOS. Two ways:

  • We port it. Send it to us the way you’d submit any app: a web build or the engine’s web export, plus your existing store listing (name, description, icon, screenshots) and where it’s listed today. We turn it into a WispOS app, send it back for you to check, and it goes through review like everything else.
  • Turn it into a .wapp yourself. Put your web build in app/, write a manifest.json, add a help page and your screenshots, and you have the folder WispOS installs. Send the folder, or pack it into a .wapp yourself, unsigned.

Either way, it has to follow the rules every app meets: everything it runs inside the package, network only to the addresses it declares, and, for now, free to list (a submission with a price or in-app purchases is held for the reviewer until payments open). A native-only app (Swift, Kotlin, a desktop binary) needs a web build first; game engines usually have one.

Help pages and the listing

  • Help goes in docs/: .html pages (self-contained, same rules as the app) or .md pages that WispOS styles. People reach it from Help on the Apps page; the store lists the titles.
  • Screenshots are of the real app, not mockups: ideally two in a desktop window and two on a phone. Phone shots are upright for a portrait app and on their side for a landscape one; the checks refuse one held the wrong way.
  • Translations of the name, summary, description and screenshot captions go in locales/<lang>.json, for each language after the first in languages.

Trying it

Open app/index.html from a local web server (for example npx serve app) and check it the way people will use it:

  • In a narrow panel around your desktop.width, at your minimums, and stretched across a large window.
  • In the browser’s device mode on an upright phone, with the notch and home indicator showing.
  • In light and in dark (your system appearance, or the devtools’ colour-scheme switch).
  • With window.wispos missing, since your feature detection is what runs there.

Then pack it, try the package with node wapp.mjs run, and install it in WispOS, where window.wispos is real.

Make your .wapp yourself

From an empty folder to a .wapp installed in your own WispOS, with the App Store’s own packer and checker: wapp, one file with no dependencies. A package it passes is one the store’s checks pass. The example is Tally, a counter that remembers its count.

You need Node 18 or newer (node --version in Terminal tells you). Then download wapp.mjs, or in Terminal:

curl -O https://wisp-os.com/wapp.mjs

Run the commands below from the folder wapp.mjs is in. node wapp.mjs on its own lists everything it does.

1. Start the app

node wapp.mjs new tally

That writes a folder named tally with a small app in it, ready to pack. The folder’s name becomes the app’s id; --name "Name" sets the name people see.

tally/
├── manifest.json
├── app/
│   ├── index.html
│   └── app.js
├── docs/
│   └── index.md
└── store/
    └── icon.svg

What’s in each file, to change into your own app:

manifest.json:

{
  "format": "wispos.app/1",
  "id": "tally",
  "name": "Tally",
  "version": "1.0.0",
  "build": 1,
  "kind": "tool",
  "category": "Productivity",
  "summary": "A counter that remembers where you left it.",
  "description": ["Tap to count. The number is kept between launches."],
  "publisher": { "id": "me", "name": "Me" },
  "distribution": { "visibility": "public" },
  "price": { "amount": 0, "currency": "USD" },
  "ageRating": "4+",
  "languages": ["en"],
  "icon": "store/icon.svg",
  "screenshots": [],
  "web": {
    "entry": "app/index.html",
    "window": { "width": 420, "height": 560, "resizable": true,
                "backgrounds": { "dark": "#111318", "light": "#f4f4f6" } },
    "screens": {
      "mobile":  { "orientation": "portrait", "fullscreen": false },
      "desktop": { "orientation": "portrait", "width": 420, "height": 560,
                   "minWidth": 320, "minHeight": 400, "resizable": true }
    }
  },
  "docs": [{ "title": "Using Tally", "path": "docs/index.md" }],
  "permissions": [],
  "privacy": { "collects": [], "tracking": false }
}

app/index.html:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Tally</title>
<style>
  body { margin: 0; height: 100vh; display: grid; place-items: center; font: 16px system-ui;
         background: #f4f4f6; color: #111318; box-sizing: border-box;
         padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
  @media (prefers-color-scheme: dark) { body { background: #111318; color: #f4f4f6; } }
  button { font: 600 clamp(48px, 20vw, 120px) system-ui; min-width: 60vw; padding: 24px;
           border-radius: 24px; border: 0; background: #7b6cff; color: #fff; }
</style>
</head>
<body>
<button id="n">0</button>
<script src="app.js"></script>
</body>
</html>

app/app.js keeps the count in wispos.storage, and still counts in a plain browser:

const button = document.getElementById("n");
const store = window.wispos ? window.wispos.storage : null;
let count = 0;
if (store) store.get("count").then((v) => { count = v || 0; button.textContent = count; });
button.addEventListener("click", () => {
  count += 1;
  button.textContent = count;
  if (store) store.set("count", count);
});

docs/index.md:

# Using Tally

Tap the number to add one. Tally keeps the count between launches.

store/icon.svg:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" fill="#7b6cff"/><text x="256" y="330" font-family="system-ui" font-size="240" font-weight="700" fill="#fff" text-anchor="middle">1</text></svg>

screenshots can stay empty while you try the app on your own Mac; the store needs them before it lists it.

2. Pack it

node wapp.mjs pack tally
✓ tally-1.0.0.wapp: Tally 1.0.0 (build 1), tool, 7 files, 2.6 KB, unsigned
  warning: screenshots: none; the product page will have no preview

It writes the two files every package carries (mimetype, and signature/checksums.json with every file’s SHA-256), zips everything in the order the format requires, and checks the result. A folder that doesn’t make a valid package isn’t packed: it says which rule, for example manifest.json: (root): missing summary. Pack again after every change.

To check a .wapp, yours or one you were sent:

node wapp.mjs validate tally-1.0.0.wapp

And to try it before you install it, node wapp.mjs run tally-1.0.0.wapp opens it in your browser the way WispOS runs it, with a stand-in window.wispos (storage, and test purchases).

3. Install it

In WispOS: More → App Store → Installed → Install from a file…, and pick tally-1.0.0.wapp. WispOS checks the package, shows its install sheet, and installs it. Open Tally from the Apps page, tap a few times, close it and open it again: the count is still there.

4. Change it

Raise build in manifest.json (and version if people will see it), pack again, and install the new file the same way: a higher build replaces the one installed and keeps its storage. With the same build, nothing changes: WispOS treats it as already installed. A lower build is refused.

If WispOS says no

The message after “That file isn’t an app WispOS can install:” names the rule. The ones you’ll most likely meet:

WispOS says What happened Fix
Integrity: app/app.js doesn't match its checksum A file in the package changed after it was packed. Pack it again with node wapp.mjs pack.
Container: app/ is a directory entry The folder was zipped some other way (Finder’s Compress, zip -r). Pack it with node wapp.mjs pack.
a newer build of tally is installed Your build is lower than the installed one. Raise build, or uninstall it from Installed first.

Anything about manifest.json means a field is missing, misspelled or unknown: node wapp.mjs validate says which, and the manifest has every field. You can always send us the folder instead.

Before you ship

  • id chosen for good; build raised since the last release
  • web.screens declared for mobile and desktop, and web.window matches
  • Tested on an upright phone and at desktop widths from your minimum to a full window
  • Nothing scrolls sideways; controls inside the safe area
  • Finger-sized targets on touch, hover on a mouse
  • Pauses when hidden, keeps its state
  • Follows WispOS’s light and dark theme, with both backgrounds
  • Nothing loaded from the internet; every network origin and permission declared, with a reason
  • Privacy label is true
  • For an agent: card.json name matches the id, knows uses real phrases, PROMPT.md says what to do when the files don’t cover it
  • A help page, and a store listing with phone and desktop screenshots

Questions

Can I build with Claude, Codex or Cursor? Yes, and with any other coding tool. Give it the guide from --app-guide, let it make the folder, and check the result with --pack-app or wapp.mjs. The package is the same whichever tool made it.

How is this different from GPTs, Claude artifacts and Gemini Gems? Those live inside one company’s chat: a GPT runs in ChatGPT, a Gem in Gemini, an artifact in Claude. A WispOS app is a plain .wapp file that installs on your computer, opens beside Wisper and on your phone through the phone link, and doesn’t depend on which AI built it.

If I build with one AI, can I switch? Yes. Open the folder with another AI, raise build, pack it, install it over the last one. Storage is kept.

Do agents built this way run on any AI? Building is open to any AI. A store agent runs through Wisper’s brain; see Building an agent for the store for what that needs today.

Ready? Submit your app.