# Popsule: full capsule spec
Everything an AI needs to write a Popsule capsule: the rules (POPSULE.md), the exact types of
window.popsule (popsule.d.ts) and a finished example capsule. The one-page version is
https://popsule.app/kit.txt.
================ POPSULE.md ================
# Building a Popsule capsule
A capsule is a tiny personal app that runs inside the Popsule app on one person's phone. You write it
as one HTML file. Popsule runs it offline in a sealed sandbox and gives it native features (widgets,
reminders, voice actions and more) through `window.popsule`.
Read this file in order. Then read `popsule.d.ts` for exact types. Popsule enforces every rule here:
a capsule that breaks one fails validation with an error that says how to fix it.
## 1. Plan first
Before writing code, fill in this plan from the person's request. Use the "use when" column of the
feature catalog (section 3) to decide which features to include. Most good capsules use 2 to 4.
```
does: one plain sentence a non-developer understands ("Splits a bill between friends, with tip")
remembers: what it saves between opens (memory), or "nothing"
shares: shared data it reads or adds to (data), or "nothing"
glance: what a widget should show, or "no widget"
reminds: when it should remind them, or "never"
quick: one action worth saying out loud or tapping without opening, or "none"
receives: photos, links, text or files it accepts from other apps, or "nothing"
lifespan: "once" if it does one job and is done, otherwise default
```
If a remote tool `plan_capsule` is available, call it with the request; it returns this checklist
with suggestions filled in.
The person can always open a capsule's source in Popsule, read it and change it. Write code a
curious person could follow: clear names, short functions, a comment above anything clever.
## 2. The file
- ONE self-contained `.html` file, under 1 MB as written (3 MB after libraries are packed in).
A `
` and a manifest block are required.
- Inline `
```
| Field | Required | Meaning |
|---|---|---|
| `spec` | yes | Always `1`. |
| `name` | yes | Shown in the library. 2 to 4 words. |
| `does` | yes | One plain sentence, shown on the capsule's label as "Does". |
| `capabilities` | yes | Every feature the capsule uses (section 3). Using one you did not list fails. |
| `data` | with `data` | `{ "reads": [...], "writes": [...] }` collection names. |
| `widget` | with `widget` | Section 4. |
| `actions` | with `actions` | Section 5. |
| `shareIn` | with `share-in` | `{ "accepts": ["image","link","text","file"], "label": "Add receipt" }` |
| `libraries` | no | `[{ "url": "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" }]`. Packed in at push; exact versions only. |
| `dataVersion` | no | Whole number, default `1`. Raise it when an update changes the shape of saved data (section 6). |
| `lifespan` | no | `"once"` for run-once capsules. Leave out otherwise. |
| `resets` | no | `[{ "kv": "glasses", "to": 0, "every": "day" }]`. Popsule resets saved values at local midnight (`"day"`) or Monday (`"week"`), even while the capsule is closed. |
Popsule writes the rest of the label ("Can use", "Internet: Never", "Goes away") from these fields.
The person approves each capability once, in plain words, before the capsule first opens.
## 3. Feature catalog
| Capability | API | Use when | Person sees |
|---|---|---|---|
| `kv` | `popsule.kv` | Anything should be remembered between opens. Almost always. | "Remember what you enter" |
| `data` | `popsule.data` | It tracks tasks, expenses, notes, contacts or events another capsule could use too. | "Use your lists" / "Add to your expenses" |
| `widget` | manifest `widget`, `popsule.widget` | The person will check a number, a short list or a date often. | "Show a widget" |
| `remind` | `popsule.remind` | The request says daily, weekly, "remind me", a time or a date. | "Send you reminders" |
| `notify` | `popsule.notify` | It must tell them something right now while open (a timer ended). | "Show you notifications" |
| `actions` | manifest `actions` | There is one obvious quick thing to do without opening ("add an item", "log a glass"). | "Work with your phone's assistant" |
| `share-in` | manifest `shareIn`, `popsule.launch` | It works on photos, links, text or files that come from other apps. | "Accept things you share" |
| `live` | `popsule.live` | Something counts down or runs while the phone is locked (cooking, workout, parking). | "Show on your lock screen" |
| `sticker` | `popsule.launch` | It belongs to a place or object at home (fridge, plants, laundry). | "Open from a sticker" |
| `files` | `popsule.files` | It reads a file the person picks, or gives them a file to save. | "Open and save files you choose" |
| `ai` | `popsule.ai` | It needs to summarise, sort, rewrite or answer in words. | "Send text you enter to your AI (leaves your phone)" |
`ai` is the only way anything leaves the phone. A capsule with `ai` shows "Internet: Only to your AI"
on its label, and the person approves it. Send only text the person entered or shared into this
capsule. Everything else stays offline.
Built-in collections for `data` (reuse these before inventing new ones):
`tasks` `{ text, done, list?, due? }` · `expenses` `{ amount, currency, date, note?, category? }` ·
`notes` `{ title?, text }` · `contacts` `{ name, phone?, email? }` · `events` `{ title, start, end? }`.
Amounts are in minor units (cents). Dates are local `YYYY-MM-DD`; times are ISO 8601.
Not every phone supports every feature. Call `popsule.supports("live")` before relying on one;
unsupported features are skipped quietly, never an error.
## 4. Widgets
A widget is drawn natively by Popsule. Capsules never send HTML to a widget. You describe it with
**blocks**, and Popsule draws them in its own style.
### Blocks
| Block | Fields | Notes |
|---|---|---|
| `title` | `text` | Usually first. |
| `value` | `text`, `accent?` | A big number or short phrase ("4 of 7", "$32.00"). |
| `detail` | `text` | One muted line. |
| `bar` / `ring` | `progress` 0 to 1 | |
| `segments` | `done`, `total` (max 14) | Habits, steps. |
| `sparkline` | `points` (max 30 numbers) | |
| `rows` | `items` `[{ id, text, done? }]` (max 5) | Checklists. Tappable when bound (below). |
| `countdown` | `until` (ISO time), `label?` | Ticks by itself; no updates needed. |
| `icon` | `name` | From Popsule's line icon set (see `popsule.d.ts`). |
| `image` | `src` (data URL, max 64 KB) | Small picture. |
| `button` | `id`, `text` | Tappable when bound. |
Small widgets show up to 3 blocks, medium and large up to 4. Popsule trims the rest.
### Two ways to fill a widget
**Snapshot.** The capsule sends the current blocks while it is open:
```js
popsule.widget.update({ blocks: [
{ type: "title", text: "Habits today" },
{ type: "value", text: "4 of 7", accent: true },
{ type: "segments", done: 4, total: 7 }
]});
```
**Bound.** The manifest describes the blocks and where their data lives. Popsule fills the widget from
saved data at any time, even when the capsule is closed, and applies taps itself. **Taps only work on
bound widgets**, because the capsule is not running when someone taps.
```json
"widget": {
"sizes": ["small", "medium", "lock"],
"suggest": { "accent": "data", "surface": "paper", "density": "roomy" },
"blocks": [
{ "type": "title", "text": "Groceries" },
{ "type": "rows", "bind": { "kv": "items", "text": "name", "done": "done", "limit": 5 },
"tap": "toggle" }
]
}
```
- `bind.kv` reads an array or number saved with `popsule.kv`; `bind.collection` with optional
`where` reads shared data.
- `tap: "toggle"` flips `done` on the tapped row. A `button` can use `"tap": { "kv": "glasses", "add": 1 }`.
- `value` can bind a number with a format: `{ "type": "value", "bind": { "kv": "glasses" }, "format": "{n} of 8" }`.
### Look
`suggest` is the capsule's suggestion. The person can change it from the widget's edit menu.
- `accent`: `"data"` (blue), `"file"` (green), `"time"` (orange), `"seal"` (purple), `"ink"`.
- `surface`: `"paper"`, `"tint"`, `"bold"`.
- `density`: `"roomy"`, `"compact"`.
Pick the accent by meaning: blue for counts of the person's things, orange for time and deadlines,
green for files and done-once jobs, purple for things kept or locked. Popsule checks contrast.
Adding a widget to the home screen keeps (seals) the capsule, so it never goes away under the widget.
## 5. Quick actions, reminders, live and launch
**Actions** (voice, shortcuts, the Action button). Declared, and applied by Popsule without opening
the capsule, or opening it. Phones limit custom voice phrases: on iPhone people say a shared phrase
such as "Add milk to Grocery List" and pick the capsule by name; Android follows your phrase more
closely. Write `phrase` as a short, clear label and never depend on the exact words.
```json
"actions": [
{ "id": "add-item", "phrase": "Add {item} to my grocery list", "input": { "item": "text" },
"apply": { "kv": "items", "push": { "name": "{item}", "done": false } } },
{ "id": "open-list", "phrase": "Show my grocery list", "open": true }
]
```
**Reminders** are scheduled by Popsule and fire while the capsule is closed. Buttons on them apply a
change the same way actions do. Limits: up to 8 reminders per capsule, each firing at most 6 times a
day. Phones cap how many notifications one app can queue, so Popsule keeps only the next few of each
scheduled and tops them up; never rely on a reminder far in the future being queued already.
```js
popsule.remind.set({
id: "shopping", title: "Shopping day", body: "3 things on your list",
every: { weekly: { day: "sat", time: "10:00" } },
buttons: [{ id: "done", text: "Done", apply: { kv: "lastShop", set: "{today}" } }]
});
```
**Live** (lock screen while running): `popsule.live.start({ id, title, until })`, then `update`, `end`.
**Launch context.** `popsule.launch` says why the capsule opened: `{ via: "library" | "widget" |
"action" | "share" | "sticker" | "reminder", action?, share?, reminder? }`. For `share`,
`share` is `{ type, text?, url?, dataUrl?, name? }`.
**Run-once.** With `"lifespan": "once"`, call `popsule.done({ summary: "4 rows fixed" })` when the job is
finished. Popsule shows the summary and the capsule goes away.
### How long a capsule lives
- A capsule goes away after 7 days without use. **Use** means opening it, tapping its widget, pressing
a reminder button or running one of its actions. Each resets the clock.
- Adding its widget to a home or lock screen keeps it (seals it) while the widget is there.
- A day before an unsealed capsule with reminders or actions goes away, Popsule asks whether to keep it.
- When a capsule goes away, its reminders, actions, widget and private memory go with it. Records it
added to shared data stay, because they belong to the person.
## 6. Saved data across updates and devices
- **Merging.** The same capsule can be used on several devices, sometimes offline. For counters and
lists, use `popsule.kv.add(key, n)` and `popsule.kv.push(key, item)` (and `add` / `push` in
`apply`). Popsule merges these so no tap is lost. `popsule.kv.set` replaces the whole value; when two
devices set the same key, the latest change wins.
- **Updates.** When an assistant updates a capsule (`update_capsule`), the old saved data stays. If the
new version stores data in a different shape, raise `dataVersion` in the manifest. On the first open
after the update, `popsule.previousDataVersion` holds the old number: convert the old data, then
carry on. Always read saved data defensively; old fields may be missing.
## 7. Calling the API
Every `popsule.*` call returns a Promise and may be called at startup (calls wait for the connection).
Errors say what to change, for example:
- `kv.get denied: "kv" is not in manifest.capabilities`
- `widget: block 3 "rows" needs "bind" to use "tap"`
- `data.add: "expenses.amount" must be a whole number of cents`
- `fetch blocked: capsules can't go online. Use popsule.ai for text, or ask the person to share the content in`
- `
```
`test(name, fn)` and `expect(value).toBe(x) / .toEqual(x) / .toBeCloseTo(x, digits)` are provided.
Capsules that calculate money, dates or amounts without tests fail validation.
2. Run `popsule check capsule.html` (or the `validate` tool) and fix every error.
3. If a `preview` tool is available, look at the capsule and its widget in both looks.
4. Push it with `popsule push` or `push_capsule`. The person approves it on their phone before it runs,
and can always view and edit its source.
## 9. Complete example
```html
Water Tracker
Glasses today
0
```
================ popsule.d.ts ================
// Popsule capsule SDK, spec 1.
// Available as `window.popsule` inside every capsule. Injected by Popsule; do not import it.
// Every call returns a Promise. Rejections are Errors whose message says what to change.
// Each namespace requires its capability in manifest.capabilities (see POPSULE.md, section 3).
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue };
export type Capability =
| "kv" | "data" | "widget" | "remind" | "notify" | "actions"
| "share-in" | "live" | "sticker" | "files" | "ai";
export type Accent = "data" | "file" | "time" | "seal" | "ink";
export type Collection = "tasks" | "expenses" | "notes" | "contacts" | "events" | (string & {});
// ---------- manifest ----------
/** A change Popsule applies by itself, without opening the capsule. `{item}`, `{today}`, `{now}` are filled in. */
export type Apply =
| { kv: string; set: JSONValue }
| { kv: string; add: number }
| { kv: string; push: JSONValue }
| { collection: Collection; add: { [field: string]: JSONValue } };
export type Bind =
| { kv: string; text?: string; done?: string; limit?: number }
| { collection: Collection; where?: { [field: string]: JSONValue }; text?: string; done?: string; limit?: number };
export type ManifestBlock =
| { type: "title"; text: string }
| { type: "value"; text?: string; bind?: Bind; format?: string; accent?: boolean }
| { type: "detail"; text?: string; bind?: Bind; format?: string }
| { type: "bar" | "ring"; bind: Bind; max: number }
| { type: "segments"; bind: Bind; total: number }
| { type: "rows"; bind: Bind; tap?: "toggle" }
| { type: "countdown"; until?: string; bind?: Bind; label?: string }
| { type: "icon"; name: IconName }
| { type: "button"; id: string; text: string; tap: Apply };
export interface PopsuleManifest {
spec: 1;
/** 2 to 4 words, shown in the library. */
name: string;
/** One plain sentence, shown on the label as "Does". */
does: string;
capabilities: Capability[];
data?: { reads?: Collection[]; writes?: Collection[] };
widget?: {
sizes: Array<"small" | "medium" | "large" | "lock">;
suggest?: { accent?: Accent; surface?: "paper" | "tint" | "bold"; density?: "roomy" | "compact" };
/** Bound widget. Leave out to fill the widget only with popsule.widget.update(). */
blocks?: ManifestBlock[];
};
actions?: Array<{
id: string;
/** What the person says or sees in shortcuts. Use {name} for inputs. */
phrase: string;
input?: { [name: string]: "text" | "number" };
/** Applied without opening the capsule. */
apply?: Apply;
/** Open the capsule instead; popsule.launch.action is the id. */
open?: true;
}>;
shareIn?: { accepts: Array<"image" | "link" | "text" | "file">; label: string };
resets?: Array<{ kv: string; to: JSONValue; every: "day" | "week" }>;
/** Exact-version cdnjs or jsdelivr URLs. Popsule packs them into the capsule at push; nothing loads at runtime. */
libraries?: Array<{ url: string }>;
/** Raise when an update changes the shape of saved data. Default 1. */
dataVersion?: number;
lifespan?: "once";
}
// ---------- runtime ----------
export interface PopsuleKV {
get(key: string): Promise;
/** Max 256 KB per value, 1000 keys. Replaces the value; across devices the latest set wins. */
set(key: string, value: JSONValue): Promise;
/** Adds to a number. Merged across devices without losing any additions. Resolves to the new value. */
add(key: string, amount: number): Promise;
/** Appends to a list. Merged across devices. Resolves to the new length. */
push(key: string, item: JSONValue): Promise;
delete(key: string): Promise;
keys(): Promise;
}
export interface DataRecord { id: string; createdBy: string; createdAt: string; updatedAt: string; [field: string]: JSONValue }
export interface PopsuleData {
/** Popsule shows a picker; the capsule gets only what the person chooses. Needs the collection in data.reads. */
pick(collection: Collection, options?: { multiple?: boolean }): Promise;
query(collection: Collection, options?: { where?: { [field: string]: JSONValue }; orderBy?: string; limit?: number }): Promise;
add(collection: Collection, record: { [field: string]: JSONValue }): Promise;
/** Only records this capsule created, unless the person allowed editing others. */
update(collection: Collection, id: string, patch: { [field: string]: JSONValue }): Promise;
remove(collection: Collection, id: string): Promise;
/** Called with fresh results whenever the collection changes, including after sync. Returns a stop function. */
watch(collection: Collection, callback: (records: DataRecord[]) => void): Promise<() => void>;
}
export type WidgetBlock =
| { type: "title"; text: string }
| { type: "value"; text: string; accent?: boolean }
| { type: "detail"; text: string }
| { type: "bar" | "ring"; progress: number }
| { type: "segments"; done: number; total: number }
| { type: "sparkline"; points: number[] }
| { type: "rows"; items: Array<{ id: string; text: string; done?: boolean }> }
| { type: "countdown"; until: string; label?: string }
| { type: "icon"; name: IconName }
| { type: "image"; src: string };
export interface PopsuleWidget {
/** Snapshot: replaces what the widget shows. Not tappable; use a bound widget in the manifest for taps. */
update(content: { blocks: WidgetBlock[] }): Promise;
}
export type Repeat =
| { daily: string | string[] } // "08:00" or ["10:00", "16:00"]
| { weekly: { day: "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"; time: string } }
| { monthly: { date: number; time: string } };
export interface PopsuleRemind {
set(reminder: {
id: string; title: string; body?: string;
/** One-off, ISO 8601 local time. */
at?: string;
every?: Repeat;
buttons?: Array<{ id: string; text: string; apply?: Apply; open?: true }>;
}): Promise;
cancel(id: string): Promise;
list(): Promise>;
}
export interface PopsuleLive {
start(activity: { id: string; title: string; until?: string; progress?: number; detail?: string }): Promise;
update(id: string, changes: { title?: string; until?: string; progress?: number; detail?: string }): Promise;
end(id: string): Promise;
}
export interface PopsuleFiles {
/** Person picks a file. Text files arrive as text, everything else as a data URL. Max 10 MB. */
pick(options?: { accept?: string[] }): Promise<{ name: string; type: string; text?: string; dataUrl?: string } | null>;
/** Person chooses where to save it. */
save(file: { name: string; type: string; text?: string; dataUrl?: string }): Promise;
}
export interface PopsuleAI {
/** Text goes to the AI the person chose in Popsule. Never include data they did not enter or share. */
complete(request: { prompt: string; system?: string; maxWords?: number }): Promise;
}
export interface LaunchContext {
via: "library" | "widget" | "action" | "share" | "sticker" | "reminder";
action?: { id: string; input?: { [name: string]: string | number } };
share?: { type: "image" | "link" | "text" | "file"; text?: string; url?: string; dataUrl?: string; name?: string };
reminder?: { id: string; button?: string };
}
export interface Popsule {
readonly kv: PopsuleKV;
readonly data: PopsuleData;
readonly widget: PopsuleWidget;
readonly remind: PopsuleRemind;
readonly live: PopsuleLive;
readonly files: PopsuleFiles;
readonly ai: PopsuleAI;
/** Why the capsule opened this time. */
readonly launch: LaunchContext;
/** On the first open after an update that raised dataVersion: the old version. Otherwise null. */
readonly previousDataVersion: number | null;
/** "paper" or "charcoal". prefers-color-scheme already matches it. */
readonly theme: "paper" | "charcoal";
/** Requires "notify". Shows a notification now. */
notify(title: string, body?: string): Promise;
/** False when this phone can't do a feature; the capsule should carry on without it. */
supports(capability: Capability): boolean;
/** Run-once capsules: report what was done; the capsule then goes away. */
done(result: { summary: string }): Promise;
}
/** Popsule line icons for widget icon blocks. */
export type IconName =
| "cart" | "check" | "clock" | "calendar" | "drop" | "flame" | "heart" | "home" | "leaf" | "book"
| "cup" | "pill" | "plant" | "run" | "bike" | "car" | "plane" | "bag" | "coin" | "receipt"
| "star" | "bell" | "note" | "list" | "timer" | "moon" | "sun" | "gift" | "music" | "pin";
// ---------- test blocks (