Uniques & the store

A unique is a static web app plus a manifest plus a guide. The engine serves it, the shell gives it a companion, chat and components, and LIP carries what you do to Claude and what Claude decides back.

What a unique is

The engine knows nothing about graphs. A unique is a repository with a manifest, a guide and a static UI; the engine serves it, gives it the companion and the protocol, and that is all. Uniques are plain HTML, ES-module JavaScript and CSS: no build step, no CDN, no server-side code. Because the host never executes unique code (it only serves static files and routes JSON envelopes), third-party uniques are safe to install straight from a git URL.

Defaults vs the store

defaults.json in the engine repo is the list of uniques every installation gets. They are installed from git into ~/.lets/uniques the first time the engine runs or the first time you open one; node engine/bin/lets.mjs defaults does it by hand, and LETS_NO_DEFAULTS=1 opts out. Today the defaults are graph and den. Defaults are trusted by the list and still validated before they are served. More defaults will be added there as they mature.

Everything else comes from a store: a git repository whose index.json lists uniques by git URL. Install with /lets store <name>: Claude syncs, searches, shows you the inspection (permissions, problems, verified flag), and installs only after you say yes. The first store-only uniques by the maintainers are cocode, a code window you talk through (/lets store cocode), and mors, a Morse key you talk through (/lets store mors).

UniqueRepoDefaultIn lets-store
graph: talk in diagramslets-unique-graphyesyes, verified
den: Doto's home new in 0.6.0lets-unique-denyesyes, verified
cocode: talk in code newlets-unique-cocodenoyes, verified
mors: talk in Morse newlets-unique-morsnoyes, verified
pulse: a dial you talk through (template)lets-unique-templatenoyes, verified

New candidates grow in the store first. Once a unique is solid, propose it for defaults.json. See the uniques page for cards.

Authoring a unique

The folder

my-unique/
  unique.json        # manifest (required)
  GUIDE.md           # instructions for Claude (strongly recommended)
  icon.svg           # 64×64
  schemas/*.json     # optional JSON Schemas for your events/commands
  ui/index.html      # entry (declared in the manifest)
  ui/app.js
  ui/style.css

Rules: name is lowercase kebab (^[a-z][a-z0-9-]{1,31}$); entry, guide and icon stay inside the folder; your domains are yours (lip, session, entity, chat, component, media, ui are reserved); every event and command you declare starts with one of your domains.

The manifest

The full format is in the protocol, §6. Keep summary lines short: they are shown to Claude on open. Declare capabilities honestly (the shell only enables paste/upload when media.image or clipboard is listed) and permissions (fs.write: ["uploads"] is the only write a unique can ask for). Declare permissions even when empty: "fs.write": [] means "writes nothing".

The optional session block new in 0.4.0 names which of your types are the export ("Send to Claude"), the snapshot (reply to a state query) and the restore command, so the engine can save and resume your windows. Without it the host assumes <domain>.export, <domain>.snapshot and <domain>.load.

"session": { "export": "graph.export", "snapshot": "graph.snapshot", "restore": "graph.load" }

The UI

Inside a unique the host serves the SDK at /sdk/…, so a unique's page imports it like this:

<body data-unique="my-unique">
<link rel="stylesheet" href="/sdk/lets-shell.css">
<script type="module" src="./app.js"></script>
import { Lets } from '/sdk/lets-client.js';
import { Shell } from '/sdk/lets-shell.js';

const lets = Lets.connect({ unique: 'my-unique', version: '0.1.0', caps: ['entity', 'chat', 'components'] });
const shell = Shell.mount(lets, {
  root: document.body,
  context: () => ({ /* what the companion should know when summoned/clicked */ }),
  quickSuggestions: (ctx) => [{ id: 'help', label: 'What can I do here?' }],
});

lets.on('my-unique.thing.set', (data) => { /* a command from Claude */ });
button.onclick = () => lets.emit('my-unique.thing.changed', { value });

The shell handles entity.*, chat.*, component.* and ui.* for you; never re-implement them. Use shell.onMedia(cb) to place pasted images, shell.onInline(cb) to place inline components on your canvas, and shell.toast() for feedback. For the theme, use the CSS variables in lets-shell.css (--lets-bg, --lets-accent, …) so your unique matches the companion.

The guide

GUIDE.md is read by Claude once per open. It is the most important file you write. Write it for a smart colleague:

Keep it under about 1,200 words.

Start from the template

lets-unique-template is pulse: a full-screen dark-violet mood dial. You drag a ring to set a value from 0 to 100 and pick one of three mode chips (calm / focused / wild); the companion reacts, and Claude can move the dial itself. It demonstrates the whole contract in a handful of files. Template 0.2.0 makes the dial keyboard-accessible, demonstrates Send to Claude and resume with a session block, and adds validate scaffolding.

  1. On GitHub, "Use this template" and name the new repo lets-unique-<name>.
  2. Clone it and rename pulse everywhere: manifest name, domains, event and command types, data-unique, Lets.connect.
  3. Build. Bump version whenever you change events or commands.

The reference implementation is graph: its CONTRACT.md, GUIDE.md and ui/graph.js show every part of the contract in use.

Develop & validate

Uniques are served by the lets host, never opened from disk. Put yours where the host looks, then run the host standalone from a lets checkout:

# option a: any folder (point the registry at the PARENT of your unique folders)
LETS_UNIQUES_DIRS=/path/to/parent node engine/bin/lets.mjs serve --open my-unique --verbose
# option b: project-local
mkdir -p .lets/uniques && ln -s /path/to/my-unique .lets/uniques/my-unique
# option c: user-wide (where defaults and store installs live)
cp -r my-unique ~/.lets/uniques/

# validate the manifest
node engine/bin/lets.mjs validate ./my-unique

--verbose prints every up-event. Use the companion's chat to simulate Claude, or run the real thing: claude --plugin-dir /path/to/lets then /lets:lets my-unique. The quality bar is zero console errors in Chromium and Edge.

Publish to a store

  1. Push your unique to GitHub as its own repo (or a folder in one).
  2. Validate it with the engine: node <lets>/engine/bin/lets.mjs validate <your-unique-dir>.
  3. Fork lets-store, add an entry to index.json (keep entries sorted by name) and update the top-level updated date.
  4. Run the store validator: npm run validate.
  5. Open a pull request. The checklist in the store's CONTRIBUTING.md must be satisfied; CI runs the validator on every push and PR.

New entries land unverified. A maintainer reviews the manifest and flips the flag in a follow-up commit.

Index entry fields

FieldMeaning
nameUnique id, ^[a-z][a-z0-9-]{1,31}$; must match the manifest name. Unique within the index.
titleHuman title shown in listings.
descriptionOne line: what the human does with it.
repoGit URL the host clones from. Must be https://…, git@… or ssh://….
pathFolder inside the repo that holds unique.json (empty string = repo root).
refBranch or tag to track (default main).
shaOptional commit pin: the host clones exactly this commit instead of moving with ref.
manifestSha256Optional SHA-256 of unique.json, so the host can detect a manifest that changed since review.
verifiedtrue = reviewed by a store maintainer (below).
tagsFree-form discovery tags.
authorGitHub handle of the unique's author.

What "verified" means

An entry marked "verified": true has had its manifest reviewed by a store maintainer, who checked that:

verified is a review mark, not a cryptographic guarantee. You still see the manifest and its permissions when you install. Pinning sha (and ideally manifestSha256) freezes the reviewed content, so a later push to the branch cannot silently change what was reviewed.

Security model

Design for the future

Think in acts, not widgets. Your events should say what the human meant (timeline.span.set, not mouse.drag). Your commands should be idempotent and animatable. If your medium has no pointer (voice, gesture, AR), you still emit entity.summon, component.action and media.*; the shell for that medium decides how those feel.