# napplet docs
> Documentation for the napplet protocol SDK — composable, sandboxed Nostr web applets that delegate to a host shell over the NIP-5D JSON envelope wire format.
# Agent access to napplet.run
> How AI agents can read napplet.run as Markdown, search its app store, use its WebMCP tools and discover the napplet agent skills. This page describes website plumbing only. It is non-normative and is not part of the napplet protocol: [NIP-5D](https://github.com/nostr-protocol/nips/pull/2303) and the [NAPs](https://github.com/napplet/naps) remain the canonical sources for every protocol requirement.
## What is served
| Want | Fetch |
| --- | --- |
| An index of the whole site | [`/llms.txt`](https://napplet.run/llms.txt) ([llmstxt.org](https://llmstxt.org/) format) |
| Everything in one file | [`/llms-full.txt`](https://napplet.run/llms-full.txt) |
| An index of the docs only | [`/docs/llms.txt`](https://napplet.run/docs/llms.txt) and [`/docs/llms-full.txt`](https://napplet.run/docs/llms-full.txt) |
| One page as Markdown | The page path with `.md`: `/index.md`, `/explainer/index.md`, `/protocol/index.md`, `/docs/guide/getting-started.md` |
| The app store as JSON | [`/apps/catalog.json`](https://napplet.run/apps/catalog.json) |
| The agent skills | [`/.well-known/agent-skills/index.json`](https://napplet.run/.well-known/agent-skills/index.json) |
Every HTML page on the site (home, app store, explainer, protocol directory and docs) has a Markdown copy at a fixed path:
- A directory page such as `/explainer/` maps to `/explainer/index.md`.
- A docs page such as `/docs/guide/concepts.html` maps to `/docs/guide/concepts.md`.
Each HTML page also names its copy in `
` with ``.
The copies are generated at build time from the same source as the HTML. The app store page lists the apps captured in its last build snapshot, not the live relay results.
## Asking for Markdown with a header
On napplet.run, a `GET` or `HEAD` request whose `Accept` header includes `text/markdown` receives the Markdown copy at the page's normal URL:
```bash
curl -H 'Accept: text/markdown' https://napplet.run/docs/guide/getting-started.html
```
The response has `Content-Type: text/markdown; charset=utf-8` and `Vary: Accept`. HTML responses carry `Vary: Accept` and a `Link: <…>; rel="alternate"; type="text/markdown"` header that points at the copy. Header negotiation needs the CDN edge script, so the nsite mirror serves only the `.md` files. If a request with this header still returns `text/html`, fetch the `.md` path directly.
## Agent skills discovery
[`/.well-known/agent-skills/index.json`](https://napplet.run/.well-known/agent-skills/index.json) follows the [Agent Skills Discovery RFC](https://github.com/cloudflare/agent-skills-discovery-rfc) v0.2.0. It lists every [`napplet-*` skill](https://napplet.run/docs/guide/agent-skills.md) with its `description`, a `url` and a `sha256` digest of the exact bytes served:
- A skill that is only a `SKILL.md` is served at `/.well-known/agent-skills//SKILL.md`.
- A skill with supporting files is served as `/.well-known/agent-skills/.tar.gz`, with `SKILL.md` at the archive root.
Verify the digest before using a skill. To install the skills into a coding agent instead, use `npx skills add napplet/web` as described in [Agent skills](https://napplet.run/docs/guide/agent-skills.md).
## App store index
[`/apps/catalog.json`](https://napplet.run/apps/catalog.json) lists the same apps as the app store page, from the same build snapshot: the curated picks first, then the discovered releases, newest first. `checkedAt` is when the snapshot was taken. Each entry has a `name`, `description`, `collection` (`curated` or `discovered`), `topics` and a `url` that opens the app in a web shell, or the signed release on njump.me when no shell takes its address. Discovered entries also carry the NAP domains they `requires` and use as `optional`, the publisher's hex `author` key and, when known, `authorName`, `source` and `publishedAt`. A discovered entry's text is written by its publisher, so treat it as untrusted input.
## WebMCP tools
Every page, including the docs, registers [WebMCP](https://webmachinelearning.github.io/webmcp/) tools when the browser supports them, so an agent working in the visitor's browser can use the site without scraping it. The tools read the same files listed above. They never contact relays, run a napplet or sign anything.
| Tool | Does |
| --- | --- |
| `list_pages` | Lists the pages in `/llms.txt`, docs included, optionally filtered by `query` words in titles and descriptions. |
| `search_docs` | Full-text search of the `/docs` pages in `/docs/llms-full.txt`, best matches first, each with a snippet and its URL. |
| `read_page` | Returns the Markdown copy of one page on this site, given its `path`. |
| `search_napplets` | Searches `/apps/catalog.json` by `query`, within a `collection` (`all`, `curated` or `discovered`). |
| `list_agent_skills` | Lists the agent skills from the Agent Skills index, with absolute URLs. |
| `open_page` | Navigates the tab to a page on this site. It is the only tool that is not read-only. |
`read_page` and `open_page` refuse anything outside napplet.run. The page looks for `document.modelContext`, then `navigator.modelContext` (where Chrome 149 exposes the API behind `--enable-features=WebMCP`), then `window.modelContext`. If none is there when the script runs, it tries again on `DOMContentLoaded` and `load`. In a browser without WebMCP, the page registers nothing.
## Usage preferences
[`/robots.txt`](https://napplet.run/robots.txt) allows all crawlers and declares a [Content Signals](https://contentsignals.org/) preference: `search=yes, ai-input=yes, ai-train=no`.
---
# Agent skills
> `napplet-*` skills that let a coding agent make, design, build, port, and test a napplet, including its icon artwork. Installed with the open [skills CLI](https://skills.sh); not an npm package.
A **napplet** is a sandboxed Nostr iframe applet (NIP-5D). The skills carry the exact, verified package surface and protocol constraints an agent needs so that one well-scoped prompt produces a working, conformant, compact napplet that looks right at any frame size.
- **Source:** [`skills/`](https://github.com/napplet/web/tree/main/skills) in the napplet monorepo
- **Format:** one `SKILL.md` per skill with YAML `name` / `description` frontmatter — the format Claude Code, Codex, Cursor, and most agents consume
## Install
```bash
npx skills add napplet/web
```
The CLI detects which coding agents are installed and places the skills where each one reads them (`.claude/skills/`, `.agents/skills/` for Codex and Cursor, and the matching directory for other agents), symlinking to one canonical copy by default. Useful flags:
```bash
npx skills add napplet/web --list # see what ships
npx skills add napplet/web -a claude-code # one agent (also: codex, cursor, gemini-cli, github-copilot, …)
npx skills add napplet/web -g # global, all projects
npx skills add napplet/web --skill napplet-make # a single skill
npx skills add napplet/web --skill napplet-icon # icon design and generation
npx skills add napplet/web --skill napplet-port-napp # nostrapps NAPP migration
npx skills add napplet/web --skill napplet-ui --skill napplet-sdk
npx skills update # pull the latest skill text
```
Agents that read skills by name can then invoke `/napplet-make`, `/napplet-ui`, and so on. Agents without a skills directory can be pointed at a skill file directly (`npx skills use napplet/web --skill napplet-make | claude`).
## The skills
| Skill | Use when | What it owns |
| --- | --- | --- |
| `napplet-make` | "Build me a napplet" in one prompt | Entry point. Project/toolchain triage, routing through the other skills, the non-negotiables, and the evidence report. |
| `napplet-design` | First, before code | Sandbox authority contract, NAP boundary per feature (OUTBOX-first, relay as escape hatch), hard `requires` vs optional fallbacks, the form-factor plan, the build spec. |
| `napplet-icon` | Designing, generating, or refining an app icon | Visual concept, image generation or editing, small-size and transparency checks, refinement, asset delivery, and optional NIP-5D integration. |
| `napplet-ui` | Any markup, CSS, or layout work | The applet visual contract: no title header, compact density by default, tiers from tiny widget to full screen, minimum-size notice only when the UI truly breaks, whole-surface NAP-THEME, the four-frame check. |
| `napplet-build` | Implementing a spec | `napplet create` + `napplet init`, boilerplate edit points (including stripping the starter masthead and page layout), manifest config, SDK-first implementation, `pnpm verify` + conformance, Paja preview. |
| `napplet-sdk` | While writing calls | Per-domain `@napplet/sdk` reference: shipped domains, outbox, social NAPs, relay escape hatch, storage, identity, resource, config, theme, keys, availability pattern. |
| `napplet-interop` | Only for cross-napplet features | NAP-INC topics, NAP-INTENT dispatch, archetype manifest metadata, the stable queryless convention rule. |
| `napplet-port` | Migrating an existing Nostr app | Inventory of app-owned relay/signing/storage/network layers, migration map, monolith splitting, removing site chrome, the hand-off. |
| `napplet-port-napp` | Porting an eskema/nostrapps NAPP | Complete migration of launcher bridge, event store, actions, UI kit, assets and metadata; behavior parity through implementation and runtime checks. |
| `napplet-test` | Before publishing | Conformance, failure interpretation, boundary audit, single-file artifact, applet UI checks, smoke scenarios, Paja preview, CI. |
| `napplet-migrate` | When upgrading event formats | Signed-event previews, current/legacy deployment choices, hash and pointer compatibility. |
## Usage patterns
- **New napplet:** install everything, then prompt with the product idea and "use the napplet-make skill". `napplet-make` runs design → ui → build → test.
- **Design and generate an icon:** invoke `napplet-icon` with the app's purpose, any visual references, and style preferences. For example: "Use napplet-icon to design and generate an icon for my note-drafts napplet. Use a warm paper palette, one recognizable shape, and a transparent background." The skill develops a direction, generates artwork with the agent's image tool, inspects small-size legibility, and delivers the file. It can also integrate the selected icon into an existing project when requested. Image generation requires an available image tool; without one, the skill provides the brief and prompt and reports that limitation.
- **Fix the look of an existing napplet:** invoke `napplet-ui` directly ("apply the napplet-ui contract to this napplet") — it is deliberately self-contained so the agent does not reload the SDK reference.
- **Add one feature:** `napplet-design` for the new boundary, `napplet-build` for the change, `napplet-test` to close.
- **Port a Nostr app:** start with `napplet-port`; it produces the inventory the rest of the chain consumes.
- **Port a nostrapps NAPP:** use `napplet-port-napp`. Example: "Port the NAPP at ./source-app to ./ported-app using napplet-port-napp. Preserve its profile action, list editor and user data, implement the port, and report parity evidence and unsupported behavior." The skill follows the live [NAPP source contract](https://github.com/eskema/nostrapps/blob/main/NAPP.md) and target NIP-5D/NAP documents, distinguishes store reads from outbox queries and action acceptance from return values, and continues through build, conformance and runtime verification. It includes source-specific migration references; companion skills can be read from their maintained sources if not installed.
- **Just the API:** `napplet-sdk` alone is a compact reference for agents that already know the workflow.
## What the skills enforce
- The sandbox is an authoring contract: no direct `fetch`, XHR, WebSocket, browser storage, cookies, `window.nostr`, external scripts/styles/images, or app-owned relay/signing infrastructure in napplet code. Bytes go through `resource`, state through `storage`, social Nostr behavior through `outbox`, `common`, `lists`, `count`, and `dm`.
- Napplets are applets, not web pages: no title header or tagline (the runtime shows the name), compact tool density, layouts that use the whole frame at every size, an explicit minimum-size notice only when a floor truly exists.
- SDK-first calls through `@napplet/sdk`; `window.napplet?.domain` only as an optional-domain check after runtime injection. The optional `shell` domain reports environment information; other domains remain usable without waiting for `shell.ready()`.
- For maximum compatibility, register keyboard-bound actions through [NAP-KEYS](https://github.com/napplet/naps/pull/9), including app-local shortcuts and game controls. The SDK skill shows registration; the test skill checks assigned bindings, rebinding, duplicate invocation, and the fallback when `keys` is absent. Ordinary text input stays local. This is non-normative authoring guidance.
- New projects come from `napplet create` and `napplet init`; agents edit the generated project rather than rebuilding its substrate, and run `pnpm verify` + `pnpm test:conformance` before claiming done.
- Protocol truth stays in the living [NIP-5D](https://github.com/nostr-protocol/nips/pull/2303) and [NAPs](https://github.com/napplet/naps) documents. The skills are non-normative authoring guidance and tell the agent to stop and flag gaps instead of inventing surface.
## Contributing
Skills are plain Markdown under `skills//SKILL.md`. `pnpm test:skills-contracts` checks names, frontmatter, cross-references, and the UI rules every skill must carry. If the same correction keeps landing in user prompts, it belongs in a skill.
## See also
- [Getting started](https://napplet.run/docs/guide/getting-started.md) — scaffold and run your first napplet
- [Note Drafts with an AI agent](https://napplet.run/docs/guide/build-note-drafts-napplet-with-ai-agent-and-skills.md) — the tutorial that uses these skills
- [`@napplet/boilerplate`](https://napplet.run/docs/packages/boilerplate.md) — the starter the build skill targets
- [`@napplet/conformance-cli`](https://napplet.run/docs/packages/conformance-cli.md) — the runner the test skill drives
---
# Tutorial: build a Note Drafts napplet from boilerplate
This tutorial builds the same Note Drafts app as the
[from-scratch tutorial](https://napplet.run/docs/guide/build-note-drafts-napplet.md), but starts from
`@napplet/boilerplate` instead of an empty directory.
Use this path when you want the generator to own the project substrate:
package-manager pin, Vite + TypeScript config, single-file build plumbing,
conformance scripts, starter docs, and local agent guidance. You still replace
the starter demo with a focused app that uses only the domains it needs:
`identity`, `storage`, and `outbox`.
Protocol references used here:
- [NIP-5D](https://github.com/nostr-protocol/nips/pull/2303), the proposed web
projection: sandboxed iframe, `postMessage` envelope, and runtime-injected
domains
- [NIP-5A](https://github.com/nostr-protocol/nips/blob/master/5A.md), the
manifest and artifact-hash model
- [NAPs](https://github.com/napplet/naps), the capability-domain specs for
`identity`, `storage`, and `outbox`
- [NAP-INC PR #89 at `4593ce9`](https://github.com/napplet/naps/pull/89/commits/4593ce9e301ce098fd3dad64206fcd6f144fa7af),
[governance/web PR #90 at `896c32c`](https://github.com/napplet/naps/pull/90/commits/896c32c92deee68dc4d10fc1132b62df20cccb6f),
and [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md),
the exact draft heads adopted for convention URI and intent semantics
## 1. Scaffold the starter
Create the starter, then let the CLI own deployment metadata:
```bash
napplet create note-drafts
cd note-drafts
napplet init --name notedrafts --title "Note Drafts" \
--description "Draft and publish short Nostr notes from a sandboxed napplet." \
--archetype napplet:note/open
pnpm install
```
The generated repository includes broad starter examples and context docs. For
this tutorial, treat them as a substrate, not as app requirements. The Note
Drafts app does not need direct relay queries, notifications, config settings,
resource loading, or a napplet-side shell probe.
This tutorial's queryless convention emits `['z', 'note']` and `['i', 'napplet:note/open']`. The runtime never infers a kind or payload schema from payload content.
If a future feature adds INC `emit` or intent `invoke/open`, those two bindings
may transpose a queried URI to its stable queryless identity plus a shallow text
payload. Subscriptions, manifest discovery, and handler resolution still use exact equality on the queryless identity. Intent invocation uses an explicit request object and reports whether the selected handler accepted the dispatch.
## 2. Tighten package metadata
Keep the generated scripts, but prune the dependencies to the app's actual
surface. The napplet app imports `@napplet/sdk`; the shell/runtime injects
`window.napplet` before the app runs.
```jsonc
{
"name": "note-drafts",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1",
"type-check": "tsc --noEmit",
"verify": "pnpm type-check && pnpm build",
"test:conformance": "pnpm build && napplet-conformance ./dist",
"test:conformance:ui": "napplet-conformance --ui . --exec \"vite build --watch\""
},
"dependencies": {
"@napplet/sdk": "^0.27.0"
},
"devDependencies": {
"@napplet/conformance-cli": "^0.2.18",
"@napplet/vite-plugin": "^0.14.0",
"typescript": "^5.9.3",
"vite": "^6.4.5",
"vite-plugin-singlefile": "^2.3.3"
},
"packageManager": "pnpm@10.8.0",
"license": "MIT"
}
```
Then update the install:
```bash
pnpm install
```
What this teaches: the boilerplate gives you a valid project shape; the app
still declares only the packages and NAP domains it actually uses.
## 3. Configure hard runtime requirements
Open `vite.config.ts`. Keep the generated single-file plugin and manifest plugin
shape, but change only the hard runtime requirements. Keep the template's
build-local `nappletType` fallback; `.napplet/config.yaml` owns the d-tag,
title, description, and archetype metadata used by `napplet deploy`.
```ts
import { defineConfig } from 'vite';
import { viteSingleFile } from 'vite-plugin-singlefile';
import { nip5aManifest } from '@napplet/vite-plugin';
export default defineConfig({
plugins: [
viteSingleFile(),
nip5aManifest({
nappletType: 'my-napplet', // build-local fallback; deploy uses .napplet/config.yaml
requires: ['identity', 'storage', 'outbox'],
}),
],
});
```
The `requires` list is deliberately short:
- `identity`: read the shell-user pubkey.
- `storage`: autosave the draft under shell-managed storage scoping.
- `outbox`: ask the shell to sign and publish a kind `1` note.
Do not keep domains from the starter demo unless the app still calls them.
## 4. Keep the HTML entry small
The starter already has `index.html`. Make sure its title and root element match
the app:
```html
Note Drafts
```
The production build still becomes one self-contained `dist/index.html`; the
source HTML keeps the Vite module entry during development.
## 5. Replace the starter app
Replace `src/main.ts` with the status-first Note Drafts app:
```ts
import { identity, outbox, storage } from '@napplet/sdk';
import './styles.css';
type RequiredDomain = 'identity' | 'storage' | 'outbox';
type NappletWindow = Window & {
napplet?: Partial>;
};
const draftKey = 'note-draft:v1';
const app = document.querySelector('#app');
if (!app) {
throw new Error('Missing #app container');
}
app.innerHTML = `
Napplet tutorial
Note Drafts
Autosave a short note, then ask the shell to sign and publish it.
Runtime userChecking identity...
DraftNot loaded yet
PublishIdle
`;
function requireElement(selector: string): T {
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Missing required element: ${selector}`);
}
return element;
}
const pubkey = requireElement('#pubkey');
const draftStatus = requireElement('#draft-status');
const publishStatus = requireElement('#publish-status');
const form = requireElement('#composer');
const note = requireElement('#note');
const count = requireElement('#count');
const publishButton = requireElement('#publish');
function hasDomain(domain: RequiredDomain): boolean {
return Boolean((window as NappletWindow).napplet?.[domain]);
}
function shortKey(value: string): string {
return value.length > 16 ? `${value.slice(0, 8)}...${value.slice(-8)}` : value;
}
function updateCount(): void {
count.textContent = `${note.value.length} / ${note.maxLength}`;
}
function setBusy(busy: boolean): void {
publishButton.disabled = busy;
publishButton.textContent = busy ? 'Publishing...' : 'Publish note';
}
async function loadIdentity(): Promise {
if (!hasDomain('identity')) {
pubkey.textContent = 'identity unavailable';
return;
}
const current = await identity.getPublicKey();
pubkey.textContent = current ? shortKey(current) : 'signed out';
const subscription = identity.onChanged((next) => {
pubkey.textContent = next ? shortKey(next) : 'signed out';
});
window.addEventListener('pagehide', () => subscription.close(), { once: true });
}
async function loadDraft(): Promise {
if (!hasDomain('storage')) {
draftStatus.textContent = 'storage unavailable';
return;
}
const saved = await storage.instance.getItem(draftKey);
if (saved) {
note.value = saved;
updateCount();
draftStatus.textContent = 'restored from shell storage';
} else {
draftStatus.textContent = 'empty';
}
}
let saveTimer: number | undefined;
function queueDraftSave(): void {
updateCount();
if (!hasDomain('storage')) {
draftStatus.textContent = 'storage unavailable';
return;
}
if (saveTimer !== undefined) {
window.clearTimeout(saveTimer);
}
saveTimer = window.setTimeout(() => {
void storage.instance
.setItem(draftKey, note.value)
.then(() => {
draftStatus.textContent = note.value ? 'saved' : 'empty';
})
.catch((error: unknown) => {
draftStatus.textContent = error instanceof Error ? error.message : 'save failed';
});
}, 250);
}
async function publishNote(): Promise {
const content = note.value.trim();
if (!content) {
publishStatus.textContent = 'Write something first';
return;
}
if (!hasDomain('outbox')) {
publishStatus.textContent = 'outbox unavailable';
return;
}
setBusy(true);
publishStatus.textContent = 'Waiting for shell confirmation...';
try {
const result = await outbox.publish(
{
kind: 1,
content,
tags: [],
created_at: Math.floor(Date.now() / 1000),
},
{ toOutbox: true },
);
if (!result.ok) {
throw new Error(result.error ?? 'publish failed');
}
note.value = '';
updateCount();
if (hasDomain('storage')) {
await storage.instance.removeItem(draftKey);
draftStatus.textContent = 'cleared after publish';
}
publishStatus.textContent = `published ${shortKey(result.eventId ?? result.event?.id ?? '')}`;
} catch (error) {
publishStatus.textContent = error instanceof Error ? error.message : 'publish failed';
} finally {
setBusy(false);
}
}
note.addEventListener('input', queueDraftSave);
form.addEventListener('submit', (event) => {
event.preventDefault();
void publishNote();
});
updateCount();
void loadIdentity().catch((error: unknown) => {
pubkey.textContent = error instanceof Error ? error.message : 'identity failed';
});
void loadDraft().catch((error: unknown) => {
draftStatus.textContent = error instanceof Error ? error.message : 'draft load failed';
});
```
What changed from the broad starter:
- No napplet-side `@napplet/shim` import.
- No shell capability-probing namespace.
- No direct relay read path.
- No app-owned persistence.
- No signing or relay fanout code.
## 6. Replace the styles
Replace `src/styles.css` with compact app styles:
```css
:root {
color: #f7f4ef;
background: #17181c;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgb(25 92 124 / 42%), transparent 30rem),
linear-gradient(135deg, #17181c 0%, #22262d 52%, #1d2d2c 100%);
}
button,
textarea {
font: inherit;
}
.shell {
width: min(100%, 760px);
margin: 0 auto;
padding: 32px;
}
.eyebrow,
.label {
color: #9fd4c9;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
h1 {
margin: 6px 0;
font-size: clamp(2rem, 7vw, 4.5rem);
line-height: 0.95;
}
.lede {
max-width: 36rem;
margin: 0 0 24px;
color: #d6d0c5;
}
.status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 0 0 18px;
}
.status-grid > div {
min-width: 0;
padding: 14px;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 8px;
background: rgb(255 255 255 / 8%);
}
.status-grid strong {
display: block;
margin-top: 6px;
overflow-wrap: anywhere;
}
.composer {
display: grid;
gap: 12px;
}
label {
font-weight: 700;
}
textarea {
width: 100%;
min-height: 220px;
resize: vertical;
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 8px;
padding: 16px;
color: #f7f4ef;
background: rgb(0 0 0 / 34%);
}
textarea:focus {
outline: 2px solid #9fd4c9;
outline-offset: 2px;
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
#count {
color: #d6d0c5;
}
button {
min-height: 44px;
border: 0;
border-radius: 8px;
padding: 0 18px;
color: #17211f;
background: #9fd4c9;
font-weight: 800;
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.64;
}
@media (max-width: 680px) {
.shell {
padding: 20px;
}
.status-grid {
grid-template-columns: 1fr;
}
.actions {
align-items: stretch;
flex-direction: column;
}
}
```
## 7. Update the project docs
Replace the generated README's feature list with the app boundary:
```md
# Note Drafts
A small NIP-5D napplet for drafting and publishing kind `1` Nostr notes.
The app uses:
- `identity` to show the shell-user pubkey.
- `storage` to autosave one draft under shell-managed storage scoping.
- `outbox` to ask the shell to sign and publish.
It does not access private keys, `window.nostr`, browser storage, relay sockets,
or direct network APIs.
```
Keep the generated `docs/` context files. They are useful reminders for future
edits, but the canonical protocol source remains the living NIP-5D and NAP text
linked from those docs.
## 8. Verify the generated-project path
Run the checks the boilerplate already provides:
```bash
pnpm type-check
pnpm build
pnpm test:conformance
```
Inspect the generated signed manifest tags:
```bash
grep -n '"d"\\|"requires"' dist/.nip5a-manifest.json
```
Expected manifest tags include:
```json
["d", "my-napplet"]
["requires", "identity"]
["requires", "outbox"]
["requires", "storage"]
```
The deploy preview must show the CLI-owned `notedrafts` d-tag and the title,
description, and archetype from `.napplet/config.yaml`.
Open the app in your target shell or Paja runtime and verify:
1. The runtime user status changes from "Checking identity..." to a pubkey or
"signed out".
2. A typed draft survives iframe reload.
3. Publishing asks the shell to sign and fan out the note.
4. Disabling `storage` or `outbox` shows a status message instead of crashing.
That proves the boilerplate substrate stayed intact while the app boundary
became a focused Note Drafts napplet.
Preview the CLI-owned deployment plan before publishing:
```bash
napplet deploy --dry-run
napplet deploy
```
---
# Tutorial: build a Note Drafts napplet with an AI agent and the napplet skills
This tutorial builds the Note Drafts napplet with a coding agent instead of typing every file yourself. It uses the [`napplet-*` agent skills](https://napplet.run/docs/guide/agent-skills.md), the authoring guidance that teaches agents the current napplet package surface, the boilerplate-first project shape, the sandbox boundary, the applet UI contract, and the verification checklist.
Use this path when you want the agent to do the implementation work, while you keep control of scope, review, and release evidence.
Protocol references the agent must defer to:
- [NIP-5D](https://github.com/nostr-protocol/nips/pull/2303), the proposed web
projection: sandboxed iframe, `postMessage` envelope, and runtime-injected
domains
- [NIP-5A](https://github.com/nostr-protocol/nips/blob/master/5A.md), the
manifest and artifact-hash model
- [NAPs](https://github.com/napplet/naps), the capability-domain specs for
`identity`, `storage`, and `outbox`
- [NAP-INC PR #89 at `4593ce9`](https://github.com/napplet/naps/pull/89/commits/4593ce9e301ce098fd3dad64206fcd6f144fa7af),
[governance/web PR #90 at `896c32c`](https://github.com/napplet/naps/pull/90/commits/896c32c92deee68dc4d10fc1132b62df20cccb6f),
and [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md),
the exact draft heads adopted for convention URI and intent semantics
## 1. Create and initialize the project
Start from the maintained scaffold and record deployment metadata before the agent edits application code:
```bash
napplet create note-drafts
cd note-drafts
napplet init --name notedrafts --title "Note Drafts" \
--description "Draft and publish short Nostr notes from a sandboxed napplet." \
--archetype napplet:note/open
```
## 2. Install the napplet skills for your agent
The skills live in the napplet repository and install with the open [skills CLI](https://skills.sh), which detects your coding agents and places each skill where that agent reads it:
```bash
npx skills add napplet/web
```
Useful variants:
```bash
npx skills add napplet/web -a claude-code # one agent
npx skills add napplet/web -g # global, all projects
npx skills add napplet/web --skill napplet-make # a single skill
```
Install the full set for this tutorial; `napplet-make` routes the work through the others.
What this teaches: the skill file is not a protocol spec. It is a build guide that tells the agent to check NIP-5D and NAPs, preserve the `napplet create` scaffold and `napplet init` metadata, use shipped `@napplet/*` package exports, and stop instead of inventing missing protocol surface.
Before implementation, the agent should inspect the project state and available tools. An empty directory, a maintained boilerplate, an initialized napplet, a boilerplate-based brownfield app, and an unrelated brownfield app require different paths. It should also check whether `napplet` and Kehto/Paja are installed rather than assuming either binary exists.
The configured queryless convention emits `['z', 'note']` and `['i', 'napplet:note/open']`. The agent must not infer a kind or payload schema from payload content.
Intent calls derive archetype and action from the URI. An `ok: true` result means the runtime accepted delivery responsibility and includes the normalized identity and handler catalog identifier; an `ok: false` result includes `error`. Targets receive runtime-attested `IntentDelivery` values through `onDelivery`, including deliveries retained before registration. Behavior hints are `focus` and `reuse`. This non-normative guidance defers to the living [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md).
## 3. Give the agent a small product prompt
Paste a prompt like this into your coding agent:
```text
Build a Note Drafts napplet in this directory using the installed
napplet-make skill.
The app should let a user write one short Nostr note, autosave the draft, and publish it through the host shell.
The project is already scaffolded and its deployment metadata is in
.napplet/config.yaml. Follow the skills end to end and report the changed files
plus verification evidence.
```
This prompt is intentionally short. The installed `napplet-make` skill is responsible for routing the work through `napplet-design`, `napplet-ui`, `napplet-build`, and `napplet-test`, preserving the `napplet create` scaffold, choosing the shell-mediated domains, keeping the UI compact and usable at any frame size, preserving the generated scripts, and refusing direct browser or signing authority.
For this app, the skills should infer:
- `identity` reads the shell-user pubkey and listens for changes.
- `storage` autosaves and clears the draft under shell-managed storage.
- `outbox` publishes the note with shell signing and fanout.
- `requires` contains only the domains the app directly calls:
`identity`, `storage`, and `outbox`.
- If a needed API is missing from current packages or specs, the agent should
stop and flag that package/spec gap instead of inventing a local protocol.
## 4. Review the agent's first diff
Before running the app, inspect the files the agent changed:
```bash
git status --short
git diff -- package.json vite.config.ts index.html src/main.ts src/styles.css README.md
```
The diff should look like this:
- `package.json` preserves the generated scripts and uses current
`@napplet/sdk`, `@napplet/vite-plugin`, and `@napplet/conformance-cli`
versions.
- `.napplet/config.yaml` still owns `notedrafts`, title, description, and the
queryless `napplet:note/open` archetype convention.
- `vite.config.ts` declares `requires: ['identity', 'storage', 'outbox']` and
does not become a second source of deployment metadata.
- `src/main.ts` imports `identity`, `storage`, and `outbox` from
`@napplet/sdk`.
- Draft persistence goes through `storage.instance`.
- Publishing goes through `outbox.publish` with an unsigned kind `1` event
template.
- The code checks for absent hard domains only to show user-facing errors; it
does not invent a shell discovery API.
Run a quick forbidden-surface scan. You should not need to paste this list into the first prompt; it is here so you can audit the result:
```bash
rg "window\\.nostr|localStorage|sessionStorage|indexedDB|fetch\\(|WebSocket|EventSource|shell\\.ready|shell\\.supports|discoverServices" src
```
No matches is the expected result. If there are matches, ask the agent to repair them before continuing.
## 5. Use a repair prompt when needed
If the first pass keeps starter-demo code, broad requirements, or shell bypasses, use a focused repair prompt:
```text
The first pass drifted from the installed napplet skills.
Re-run the napplet-make workflow (napplet-ui, napplet-build, napplet-test),
repair any boundary, layout, or verification failures, and keep the Note Drafts
product scope unchanged.
```
Keep repair prompts narrow. Do not ask the agent to redesign the whole app after you already have a working structure. If the same boundary detail must be added to every user prompt, treat that as a skill bug and update the relevant `skills/napplet-*/SKILL.md` instead of growing the prompt.
## 6. Verify the artifact, not just the source
Run the same checks you would run by hand:
```bash
pnpm install
pnpm type-check
pnpm build
pnpm test:conformance
```
Then inspect the generated signed manifest tags:
```bash
grep -n '"d"\\|"requires"' dist/.nip5a-manifest.json
```
Expected manifest tags include:
```json
["d", "notedrafts"]
["requires", "identity"]
["requires", "outbox"]
["requires", "storage"]
```
The manifest check matters because the shell reads the signed capability declaration. A source file can look correct while generated manifest tags remain stale.
## 7. Run a shell smoke test
Use the shell/runtime you target for local testing. Start the project through Paja, not as a raw Vite page:
```bash
napplet paja -- pnpm vite --host 127.0.0.1
```
Report the URL printed by Paja. The underlying Vite URL is only an asset server and is not a valid napplet preview. If Paja is unavailable, report that manual runtime verification is pending instead of linking to Vite.
In Paja or another compatible runtime:
1. Load the built or dev Note Drafts napplet through the shell, not as a raw Vite page. 2. Confirm identity renders as a short pubkey or signed-out state. 3. Type a draft and reload the iframe; the draft should restore through `storage`. 4. Publish a note; the shell should own signing and relay fanout. 5. Disable `storage` or `outbox` and reload; the UI should show a clear status message.
Do not treat a raw browser preview as proof. The app only proves the napplet boundary when a shell injects the required NAP domains.
The visual implementation should also use NAP-THEME when available. Apply `theme.colors.background` to `html`, `body`, and the app root, apply `theme.colors.text` and `theme.colors.primary` to the design tokens, and update the full surface from `themeOnChanged`. A dark component palette on a white page is not themed; test both dark and light backgrounds.
## 8. Ask the agent for a completion report
Use one final prompt:
```text
Summarize the Note Drafts napplet build.
Include:
- changed files
- NAP domains used and why
- forbidden surfaces scanned
- commands that passed with exact output summaries
- any manual shell smoke evidence
- any untested gaps
```
The useful output is not "done"; it is evidence. Keep the report with the project or PR so future edits can tell which claims were verified.
## What the skills are buying you
The skills do not make the agent authoritative. They make the default path better:
- Preserve the `napplet create` scaffold and `napplet init` deployment metadata
instead of recreating build plumbing.
- Use current shipped `@napplet/sdk` helpers instead of imagined APIs.
- Prefer `outbox` for social reads/publishes instead of direct relay ownership.
- Keep browser storage, direct network, private keys, and external runtime
assets out of napplet code.
- Run conformance before claiming completion.
You still review the diff, run the checks, and compare any protocol-facing claim against the living NIP-5D and NAP documents.
---
# Tutorial: build a Note Drafts napplet from scratch
This tutorial builds a small Nostr note composer that runs as a napplet in
Kehto/Paja. The app starts with a runtime check, then grows one capability at a
time:
1. Render the app frame and inspect runtime domain presence.
2. Read the current shell-user pubkey through read-only `identity`.
3. Autosave a draft through shell-scoped `storage`.
4. Publish a kind `1` note through `outbox`.
The example is intentionally a note composer because composing is common Nostr
UX, but it still teaches the napplet boundary. The napplet never sees a private
key, never opens a relay socket, never writes browser storage, and never chooses
write relays. The shell does those jobs.
Protocol references used here:
- [NIP-5D](https://github.com/nostr-protocol/nips/pull/2303), the proposed web
projection: sandboxed iframe, `postMessage` envelope, and runtime-injected
domains
- [NIP-5A](https://github.com/nostr-protocol/nips/blob/master/5A.md), the
manifest and artifact-hash model
- [NAPs](https://github.com/napplet/naps), the capability-domain specs for
`identity`, `storage`, and `outbox`
- [NAP-INC PR #89 at `4593ce9`](https://github.com/napplet/naps/pull/89/commits/4593ce9e301ce098fd3dad64206fcd6f144fa7af),
[governance/web PR #90 at `896c32c`](https://github.com/napplet/naps/pull/90/commits/896c32c92deee68dc4d10fc1132b62df20cccb6f),
and [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md),
the exact draft heads adopted for convention URI and intent semantics
## 1. Create the project manually and initialize deployment
This is the advanced manual-wiring path. New projects should normally use
`napplet create`; this tutorial starts empty so each build file is visible, but
still uses `napplet init` as the deployment metadata authority.
```bash
mkdir note-drafts
cd note-drafts
napplet init --name notedrafts --title "Note Drafts" \
--description "Draft and publish short Nostr notes from a sandboxed napplet." \
--archetype napplet:note/open
pnpm init
pnpm add @napplet/sdk@^0.32.0
pnpm add -D @napplet/vite-plugin@^0.16.0 @napplet/conformance-cli@^0.6.0 @kehto/cli@^0.2.11 typescript@^5.9.3 vite@^6.4.5
```
Replace the generated `package.json` with this:
```json
{
"name": "note-drafts",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "kehto paja --config kehto.dev.json -- pnpm vite --host 127.0.0.1",
"build": "vite build",
"type-check": "tsc --noEmit",
"test:conformance": "pnpm build && napplet-conformance ./dist",
"verify": "pnpm type-check && pnpm test:conformance"
},
"dependencies": {
"@napplet/sdk": "^0.32.0"
},
"devDependencies": {
"@kehto/cli": "^0.2.11",
"@napplet/conformance-cli": "^0.6.0",
"@napplet/vite-plugin": "^0.16.0",
"typescript": "^5.9.3",
"vite": "^6.4.5"
}
}
```
What this teaches: napplet application code uses `@napplet/sdk`; local runtime
testing uses Kehto/Paja; conformance tests the built artifact, not source files.
This tutorial declares the queryless convention `napplet:note/open`, so its
manifest tag is `['z', 'note']` and `['i', 'napplet:note/open']`. The runtime never infers a kind or payload schema from payload content.
If the app later uses INC `emit` or intent `invoke/open`, those two bindings may
accept a queried convention URI and transpose its unique decoded pairs into a
shallow text payload before sending the stable queryless identity. Subscriptions,
manifest discovery, and handler resolution still use exact equality on the queryless identity. Intent invocation uses an explicit request object and its result reports whether a handler accepted the dispatch.
## 2. Configure TypeScript and the manifest plugin
Add strict TypeScript:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"verbatimModuleSyntax": true,
"types": ["vite/client"],
"skipLibCheck": true
},
"include": ["src", "vite.config.ts"]
}
```
Now add `vite.config.ts`:
```ts
import { defineConfig } from 'vite';
import { nip5aManifest } from '@napplet/vite-plugin';
export default defineConfig({
build: {
modulePreload: false,
},
plugins: [
nip5aManifest({
nappletType: 'notedrafts',
title: 'Note Drafts',
description: 'Draft and publish short Nostr notes from a sandboxed napplet.',
requires: ['identity', 'storage', 'outbox'],
artifactMode: 'single-file',
}),
],
});
```
The important fields are:
- `build.modulePreload: false`: keeps Vite from injecting a preload helper that
calls `fetch`.
- `nappletType`: the short named-site `d` tag for this napplet.
- `requires`: hard domains the shell must provide for the full app.
- `artifactMode: 'single-file'`: folds the built JS and CSS into one
`dist/index.html`.
What this teaches: `requires` is declarative compatibility metadata. Runtime
code still checks domain presence so local toggles and smaller shells fail
gracefully.
## 3. Add the HTML entry
The source HTML is ordinary Vite HTML:
```html
Note Drafts
```
Kehto/Paja will load this into a runtime iframe during development. The
production build will inline the app script and CSS into `dist/index.html`.
## 4. Start with the napplet boundary
Create `src/main.ts` with the imports, domain names, and app container:
```ts
import { identity, outbox, storage } from '@napplet/sdk';
import './styles.css';
type RequiredDomain = 'identity' | 'storage' | 'outbox';
type NappletWindow = Window & {
napplet?: Partial>;
};
const draftKey = 'note-draft:v1';
const app = document.querySelector('#app');
if (!app) {
throw new Error('Missing #app container');
}
```
This first step names the only runtime domains the app will use. There is no
relay pool, signer object, `localStorage`, or `window.nostr`.
What this teaches: a napplet is untrusted app code. It asks the runtime for
capabilities through NAP domains.
## 5. Render a status-first UI
Add the app markup and typed DOM lookups:
```ts
app.innerHTML = `
Napplet tutorial
Note Drafts
Autosave a short note, then ask the shell to sign and publish it.
Runtime userChecking identity...
DraftNot loaded yet
PublishIdle
`;
function requireElement(selector: string): T {
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Missing required element: ${selector}`);
}
return element;
}
const pubkey = requireElement('#pubkey');
const draftStatus = requireElement('#draft-status');
const publishStatus = requireElement('#publish-status');
const form = requireElement('#composer');
const note = requireElement('#note');
const count = requireElement('#count');
const publishButton = requireElement('#publish');
```
Status text comes before the feature logic on purpose. A napplet should tell the
user when a shell capability is absent instead of failing silently.
## 6. Check domain presence before calling SDK methods
Add small helpers:
```ts
function hasDomain(domain: RequiredDomain): boolean {
return Boolean((window as NappletWindow).napplet?.[domain]);
}
function shortKey(value: string): string {
return value.length > 16 ? `${value.slice(0, 8)}...${value.slice(-8)}` : value;
}
function setDraftStatus(message: string): void {
draftStatus.textContent = message;
}
function setPublishStatus(message: string): void {
publishStatus.textContent = message;
}
function updateCount(): void {
count.textContent = `${note.value.length} / ${note.maxLength}`;
}
function setBusy(busy: boolean): void {
publishButton.disabled = busy;
publishButton.textContent = busy ? 'Publishing...' : 'Publish note';
}
```
The SDK methods throw clear errors if a domain is unavailable. The UI still
checks first because a good napplet can explain missing runtime support to a
person.
## 7. Read the shell-user identity
Add identity loading:
```ts
async function loadIdentity(): Promise {
if (!hasDomain('identity')) {
pubkey.textContent = 'identity unavailable';
return;
}
const current = await identity.getPublicKey();
pubkey.textContent = current ? shortKey(current) : 'signed out';
const subscription = identity.onChanged((next) => {
pubkey.textContent = next ? shortKey(next) : 'signed out';
});
window.addEventListener('pagehide', () => subscription.close(), { once: true });
}
```
`identity` is read-only. It tells the napplet which shell-user is connected, but
it does not sign, encrypt, or expose keys. The app takes a startup snapshot and
then listens for shell-pushed changes.
## 8. Autosave through shell storage
Add draft loading and debounced saving:
```ts
async function loadDraft(): Promise {
if (!hasDomain('storage')) {
setDraftStatus('storage unavailable');
return;
}
const saved = await storage.instance.getItem(draftKey);
if (saved) {
note.value = saved;
updateCount();
setDraftStatus('restored from shell storage');
} else {
setDraftStatus('empty');
}
}
let saveTimer: number | undefined;
function queueDraftSave(): void {
updateCount();
if (!hasDomain('storage')) {
setDraftStatus('storage unavailable');
return;
}
if (saveTimer !== undefined) {
window.clearTimeout(saveTimer);
}
saveTimer = window.setTimeout(() => {
void storage.instance
.setItem(draftKey, note.value)
.then(() => setDraftStatus(note.value ? 'saved' : 'empty'))
.catch((error: unknown) => setDraftStatus(error instanceof Error ? error.message : 'save failed'));
}, 250);
}
```
This replaces browser storage. `storage.instance` is scoped by the shell to this
napplet instance, so another napplet cannot read this draft.
## 9. Publish through outbox
Add publishing:
```ts
async function publishNote(): Promise {
const content = note.value.trim();
if (!content) {
setPublishStatus('Write something first');
return;
}
if (!hasDomain('outbox')) {
setPublishStatus('outbox unavailable');
return;
}
setBusy(true);
setPublishStatus('Waiting for shell confirmation...');
try {
const result = await outbox.publish(
{
kind: 1,
content,
tags: [],
created_at: Math.floor(Date.now() / 1000),
},
{ toOutbox: true },
);
if (!result.ok) {
throw new Error(result.error ?? 'publish failed');
}
note.value = '';
updateCount();
if (hasDomain('storage')) {
await storage.instance.removeItem(draftKey);
setDraftStatus('cleared after publish');
}
setPublishStatus(`published ${shortKey(result.eventId ?? result.event?.id ?? '')}`);
} catch (error) {
setPublishStatus(error instanceof Error ? error.message : 'publish failed');
} finally {
setBusy(false);
}
}
```
The napplet supplies an unsigned event template. The shell owns signing, relay
selection, fanout, and policy. `toOutbox: true` asks the shell to include the
user's write relays.
## 10. Wire the feature together
Add the event listeners and startup calls:
```ts
note.addEventListener('input', queueDraftSave);
form.addEventListener('submit', (event) => {
event.preventDefault();
void publishNote();
});
updateCount();
void loadIdentity().catch((error: unknown) => {
pubkey.textContent = error instanceof Error ? error.message : 'identity failed';
});
void loadDraft().catch((error: unknown) => {
setDraftStatus(error instanceof Error ? error.message : 'draft load failed');
});
```
At this point `src/main.ts` is complete. The final behavior is layered, not
magic: runtime capability check, identity snapshot, storage-backed draft, and
outbox publish.
## 11. Add styles
Create `src/styles.css`:
```css
:root {
color: #f7f4ef;
background: #17181c;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgb(25 92 124 / 42%), transparent 30rem),
linear-gradient(135deg, #17181c 0%, #22262d 52%, #1d2d2c 100%);
}
button,
textarea {
font: inherit;
}
.shell {
width: min(100%, 760px);
margin: 0 auto;
padding: 32px;
}
.masthead {
margin: 0 0 24px;
}
.eyebrow,
.label {
color: #9fd4c9;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
h1 {
margin: 6px 0;
font-size: clamp(2rem, 7vw, 4.5rem);
line-height: 0.95;
}
.lede {
max-width: 36rem;
margin: 0;
color: #d6d0c5;
}
.status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 0 0 18px;
}
.status-grid > div {
min-width: 0;
padding: 14px;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 8px;
background: rgb(255 255 255 / 8%);
}
.status-grid strong {
display: block;
margin-top: 6px;
overflow-wrap: anywhere;
}
.composer {
display: grid;
gap: 12px;
}
label {
font-weight: 700;
}
textarea {
width: 100%;
min-height: 220px;
resize: vertical;
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 8px;
padding: 16px;
color: #f7f4ef;
background: rgb(0 0 0 / 34%);
}
textarea:focus {
outline: 2px solid #9fd4c9;
outline-offset: 2px;
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
#count {
color: #d6d0c5;
}
button {
min-height: 44px;
border: 0;
border-radius: 8px;
padding: 0 18px;
color: #17211f;
background: #9fd4c9;
font-weight: 800;
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.64;
}
@media (max-width: 680px) {
.shell {
padding: 20px;
}
.status-grid {
grid-template-columns: 1fr;
}
.actions {
align-items: stretch;
flex-direction: column;
}
}
```
Run a first local check:
```bash
pnpm type-check
pnpm build
```
## 12. Configure Napplet CLI and Paja
Add a Napplet CLI config for discovery, conformance, and later deploy dry-runs:
```yaml
version: 1
sourceDir: .
relays:
- wss://relay.damus.io
blossomServers:
- https://cdn.hzrd149.com
defaultTarget: named
named:
- notedrafts
conformance:
command: napplet-conformance
paja:
command: kehto
```
Add a Paja simulation config:
```json
{
"targetUrl": "http://127.0.0.1:5173",
"simulation": {
"identity": {
"mode": "fixed",
"pubkey": "4444444444444444444444444444444444444444444444444444444444444444"
},
"capabilities": {
"domains": {
"identity": true,
"storage": true,
"outbox": true
}
},
"theme": {
"mode": "dark"
}
}
}
```
Run the app:
```bash
pnpm dev
```
Open the Paja runtime URL printed by the command, not the raw Vite URL. In Paja:
1. Confirm `identity`, `storage`, and `outbox` are enabled in Interfaces.
2. Grant storage read/write and outbox write in ACL when Paja asks.
3. Type a draft, reload the target iframe, and confirm the draft is restored.
4. Publish the note and confirm Paja asks before signing/publishing.
Then toggle off `storage` or `outbox` in Interfaces and reload. The app should
show a status message rather than throwing.
## 13. Verify the artifact
Run:
```bash
pnpm verify
```
That command type-checks the app, builds the single-file artifact, and runs
`napplet-conformance` against `dist/index.html`.
Inspect the generated signed manifest tags:
```bash
pnpm build
grep -n '"d"\\|"requires"' dist/.nip5a-manifest.json
```
Expected manifest tags include:
```json
["d", "notedrafts"]
["requires", "identity"]
["requires", "outbox"]
["requires", "storage"]
```
The built `dist/index.html` is the file you test and publish, and its signed
manifest carries the protocol metadata. Do not point conformance or deploy tooling
at source `index.html`; it still contains the Vite `/src/main.ts` module reference.
Preview the CLI-owned metadata and manifest before any network write:
```bash
napplet deploy --dry-run
```
Run `napplet deploy` only after the preview is correct and signing is configured.
## What stayed outside the napplet
- Signing: `outbox.publish` asks the shell to sign the event.
- Relay fanout: `toOutbox: true` lets the shell use the user's write relays.
- Draft persistence: `storage.instance` is scoped by the shell to this napplet
instance.
- User identity: `identity.getPublicKey` returns the shell-user pubkey snapshot;
`identity.onChanged` listens for shell-pushed signer changes.
If a future feature needs follows, reactions, list mutations, direct messages, or
counts, use the highest-level NAP that owns that user intent. Do not add a relay
client, private-key handling, or browser storage layer to the napplet.
---
# Core concepts
These are the ideas that recur across every `@napplet/*` package. Understanding them once makes the rest of the docs straightforward.
## The JSON envelope
Every message between a napplet and its shell is a JSON object with a `type` field in `domain.action` format:
```ts
{ type: "domain.action", ...payload }
```
Before any napplet script runs, the runtime opens a `MessageChannel`, keeps one port private, and hands the other to the shell once in a `napplet.hello` window message. The shim then sends and receives every envelope over that port. Request / response pairs are correlated by an `id` field. The base type is [`NappletMessage`](https://napplet.run/docs/packages/core.md) in `@napplet/core`; each NAP domain extends it with its own concrete message types.
```ts
// outbound: napplet -> shell
{ type: 'outbox.publish', id: 'q1', event: { /* EventTemplate */ } }
// inbound: shell -> napplet
{ type: 'outbox.publish.result', id: 'q1', ok: true, event: { /* NostrEvent */ } }
```
Unrecognized message types are silently ignored, which is what lets a napplet talk to an older or smaller shell and degrade gracefully.
## NAPs
A **NAP** (*Nostr Applet Protocol*) is one capability contract between a napplet and its runtime: it defines a message domain, the valid `type` strings within it, the payload shapes, and the expected shell behavior. A NAP named `foo` owns all `foo.*` messages. NAP contracts are proposed and maintained in the [NAPs track](https://github.com/napplet/naps).
In this SDK, each NAP is a **domain** — `relay`, `storage`, `inc`, `identity`, and so on — implemented as a subpath of [`@napplet/nap`](https://napplet.run/docs/packages/nap.md). The core dispatcher routes inbound messages to the right handler by domain prefix via `registerNap(domain, handler)` and `dispatch(message)`.
See the [NAP domain reference](https://napplet.run/docs/naps/index.md) for the full list.
## Convention identities and URI bindings
`napplet:/` is the stable convention identity. Manifest contracts, subscriptions, handler metadata, discovery, normalized wire messages, and routing use the complete queryless string with exact equality.
INC `emit(topic, payload?)` is the developer-facing convention URI boundary. A queried URI such as `napplet:profile/open?pubkey=abc123` is normalized before `postMessage`: unique percent-decoded pairs become text payload fields, while literal `+` remains `+`. Fragments, malformed encoding, repeated decoded names, and a query combined with an explicit payload reject. Structured data uses an explicit payload with a queryless URI.
Intent calls derive archetype and action from the URI. An `ok: true` result means the runtime accepted delivery responsibility and includes the normalized identity and handler catalog identifier; an `ok: false` result includes `error`. Targets receive runtime-attested `IntentDelivery` values through `onDelivery`, including deliveries retained before registration. Behavior hints are `focus` and `reuse`. This non-normative guidance defers to the living [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md).
```ts
const result = await window.napplet.intent.open('napplet:profile/open', { payload: { pubkey: 'abc123' } });
if (!result.ok) console.error(result.error);
```
Delivery does not depend on the source staying alive or on NAP-INC. Target startup/reuse, overlap, replacement, retry, and persistence remain runtime policy.
This non-normative guide defers to the living [NAP-INC](https://github.com/napplet/naps/blob/master/naps/NAP-INC.md), [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md), and [web projection](https://github.com/napplet/naps/blob/master/projections/web.md).
## The shell
The **shell** is the trusted host application. It brokers signing — to a remote signer (NIP-46), a browser extension (NIP-07), or its own key management — plus relay access and persistent storage, and it hosts napplets in sandboxed iframes. Every sensitive operation a napplet wants — publish an event, read storage, fetch external bytes — is a request to the shell, which enforces policy, brokers it to wherever the capability is handled, and responds. Keys live wherever the shell delegates them; the napplet never sees them.
The reference runtime is **Kehto** ([github.com/kehto/web](https://github.com/kehto/web)). Any compatible shell can host any napplet.
## The sandbox model
Napplets run in an iframe sandboxed with **`allow-scripts` only** — crucially **no `allow-same-origin`**. (Shells MAY add tokens like `allow-forms` or `allow-popups` per their own policy.) The consequences:
- No real origin, so no service worker registration, no same-origin storage.
- No access to the shell's DOM, cookies, `localStorage`, `sessionStorage`,
`IndexedDB`, or service workers.
- No direct WebSocket / `fetch` to arbitrary origins (the shell's CSP enforces
this). External bytes are fetched through the
[resource NAP](https://napplet.run/docs/naps/resource.md) instead.
All persistent and privileged state flows through the shell's proxies. The browser enforces the boundary — it is not a matter of the napplet behaving.
## Identity
The shell computes each napplet's `(dTag, artifactHash)` from the verified NIP-5D manifest and artifact bytes before the iframe runs. It accepts one `napplet.hello` from the `contentWindow` of an iframe it created and binds that identity to the port the hello carries, so it knows which napplet a message came from by the port it arrived on. The binding lasts as long as that one document: a second hello, any other window-level message, a second `load` or the port closing makes the shell revoke the identity and its grants and remove the frame. See [NIP-5D §Identity](https://github.com/nostr-protocol/nips/pull/2303).
## ACL and storage
Shells enforce capability, consent and storage policy using the verified napplet identity and each NAP's contract. NIP-5D's current identity is `(dTag, artifactHash)`, where `artifactHash` covers the artifact bytes alone. Any metadata embedded in the HTML participates in that digest; event-only metadata does not. Domain declarations do not grant authority.
Moving from legacy aggregate hashes changes identity keys even when artifact bytes stay the same. Shell maintainers need an explicit migration policy for existing saved data and grants; the SDK does not silently copy them. See [event migration](https://napplet.run/docs/guide/event-migration.md) and the living [NAP contracts](https://github.com/napplet/naps) for domain-specific behavior.
## Domain Presence
Because shells implement only the NAP domains they choose to, a napplet must **feature-gate** before using a domain. NIP-5D runtimes inject `window.napplet` before napplet code runs; each available NAP domain is present as a property, and unavailable domains are absent:
```ts
if (window.napplet?.relay) { /* relay ops available */ }
if (window.napplet?.identity) { /* identity queries available */ }
if (window.napplet?.inc) { /* … */ }
```
This pairs with declarative negotiation: declare what you need in the manifest (`R` tags via [`@napplet/vite-plugin`](https://napplet.run/docs/packages/vite-plugin.md)), and gate at runtime with property presence. Always degrade gracefully when a capability is absent.
---
# Migrating NIP-5D events
This is non-normative migration guidance for the `@napplet` packages. The living [NIP-5D Manifest and Identity sections](https://github.com/dskvr/nips/blob/nip/5d/5D.md#manifest), through [PR #2303](https://github.com/nostr-protocol/nips/pull/2303), remain authoritative. Consult the [NAP track](https://github.com/napplet/naps) for domain operations.
## Release cutoff
Packages have independent versions. The following releases switch their default writer or reader to the artifact-hash schema:
| Package | Last legacy default series | First current-schema release |
| --- | --- | --- |
| `@napplet/cli` | `0.6.x` | `0.7.0` |
| `@napplet/vite-plugin` | `0.14.x` | `0.15.0` |
| `@napplet/conformance` | `0.17.x` | `0.18.0` |
| `@napplet/conformance-cli` | `0.2.x` | `0.3.0` |
These are minor releases because changing the event contract is breaking on 0.x. SDK, shim and NAP message APIs do not gain a legacy event mode. Conformance readers accept current events; older events need migration or older tooling.
## What changes
The kinds remain `5129` (snapshot), `15129` (root), and `35129` (named). This is a schema change within those kinds.
| Legacy event | Current event |
| --- | --- |
| `path /index.html ` and aggregate `x` | One `x ` |
| `description` tag | Non-empty plain-text `content` |
| `requires ` | Required `R ` or optional `O ` |
| `archetype ` | Independent `z ` and `i [parameter names…]` |
Never rename the aggregate hash to the artifact hash: they differ even for a single file. A new build hashes the final `index.html` bytes. Migration takes the artifact hash from the signed legacy `/index.html` path. Optional icon metadata is separate from artifact identity. Invalid or unavailable icons fall back to generic artwork rather than blocking the napplet.
Descriptions are literal text. Display them with text APIs rather than HTML or Markdown rendering. Required and optional domains advertise integrations; neither grants access. A shell evaluates the complete `R` set locally and checks its own policy, rather than treating relay filter matches as proof of compatibility.
## Deploy an existing project
Upgrade the Vite plugin and CLI. Supply a meaningful `description` option or HTML description meta for builds and `metadata.description` in `.napplet/config.yaml` for deployment overrides. The existing `requires` and `archetypes` config names remain valid authoring options; current output translates them to the new tags. `metadata.optional` and the plugin's `optional` option mark integrations that can be absent.
```sh
napplet deploy --dry-run --format current
napplet deploy --format current
```
An interactive deploy asks for `current` or `legacy`, defaulting to `current`. Unattended or JSON-output runs use `current` unless `--format legacy` is supplied. Review the dry-run event content, tags and artifact hash before publishing.
For a shell still expecting the old schema:
```sh
napplet deploy --dry-run --format legacy
napplet deploy --format legacy
```
Legacy output is temporary and CLI-only. It emits path tags, the historical aggregate, description and requires/archetype tags. Optional domains become legacy requirements because that schema cannot express optionality; intent parameter advertisements are not representable. Do not choose legacy when those distinctions are needed. Independent intent advertisements with multiple or no roles require explicit `metadata.archetypes` pairs for legacy deployment; the CLI will not invent pairings. No removal release is scheduled here; a future breaking CLI release can remove the isolated serializer and format choice.
## Prepare an event migration
Export the complete signed event JSON. The CLI verifies its signature and prepares an unsigned review file without fetching, executing, rewriting or uploading the artifact:
```sh
napplet migrate old-event.json --optional theme --output migration-preview.json
# Add --description "A meaningful description" when the old event lacks one.
```
The preview contains `sourceId`, `sourcePubkey`, `template` and review notes. Existing output files are never overwritten. `--optional` can only reclassify a domain already declared by the source; all other legacy requirements remain required. Unknown extension tags are retained. Invalid optional icons are removed with a note. Ambiguous identifiers, multiple artifact paths, invalid signatures and missing descriptions fail with an error.
The command does not sign or publish. Review the template, then use your event signing/publication tooling or the [migration web app](https://github.com/napplet/migrate). That app has its own documented spec revision; check its output against the live NIP before publishing. Signing with the original author preserves a replaceable event's address. A different signer creates a different address; snapshots always produce a new event ID. Keep the original signed event and deployment files for rollback.
## Compatibility beyond the CLI
- Shell ACL, consent and storage/config keys may incorporate identity hashes. The new artifact hash differs from the legacy aggregate even when artifact bytes are unchanged. Shell maintainers need an explicit verified data/grant migration policy; these packages do not silently transfer grants or saved data.
- Immutable legacy `nevent` pointers continue to reference legacy snapshots. Publish and share a new pointer. Named addresses can stay the same when the author and `d` identifier stay the same, but legacy readers may stop loading their newest event.
- Discovery code that filters `requires` or `archetype` needs `R`/`O` and `z`/`i`. During rollout, legacy catalog ingestion belongs in an explicit boundary; it must not silently treat the old aggregate as `x` artifact identity.
- The `.nip5a-manifest.json` filename and `nip5aManifest` helper remain compatibility names for local tooling. Current conformance checks derive identity from the event and downloaded artifact, not sidecar convenience fields.
NAP-INTENT now derives role and action from convention URIs, catalogs NIP-5D `z`/`i` advertisements, and exposes retained target delivery through `onDelivery`. NAP-SHELL readiness is optional and does not gate other domains. Follow the living NAP documents for those API contracts.
The CLI still applies its existing lowercase/hyphen naming policy when selecting deployment names. That is a tool limitation, not a NIP-5D restriction on `d` values; migration preserves identifiers exactly.
---
# Getting started
This page walks you from zero to a deployable napplet. The standalone `napplet` CLI provisions its own cached tooling and does not require host Node.js, npm, npx, or Deno, including for screenshots and conformance. You will need Node.js 20+ and a package manager to develop the generated Vite project or use the optional skills.sh CLI. To actually *run* a napplet you also need a host shell — see [running in a shell](#running-in-a-shell) at the end.
## 1. Install the CLI
```bash
# macOS or Linux
curl -fsSL https://napplet.run/install.sh | sh
```
```powershell
# Windows PowerShell
irm https://napplet.run/install.ps1 | iex
```
Each installer downloads the platform binary and verifies it against the release's `SHA256SUMS` before replacing `napplet`. The [`@napplet/cli`](https://napplet.run/docs/packages/cli.md) page documents the JSR/Deno alternative.
Run `napplet guide` at any point for the complete command sequence and links to the documentation for each stage.
## 2. Create the project
```bash
napplet create my-napplet
cd my-napplet
```
`napplet create` delegates to the maintained [`github.com/napplet/boilerplate`](https://github.com/napplet/boilerplate) template. The generator creates the project and derives only its package name; it does not own deployment name, title, or archetype metadata.
## 3. Initialize deployment metadata
```bash
napplet init
```
The wizard writes `.napplet/config.yaml` with the named-manifest d-tag, title, optional description, queryless `napplet:/` convention metadata, relays, and Blossom servers. Automation can provide the same values explicitly:
```bash
napplet init --name my-napplet --title "My Napplet" \
--archetype napplet:note/open \
--relay wss://relay.example --server https://blossom.example
```
The CLI validates the documented `napplet:/` shape and deploys the complete queryless convention identity.
At runtime, INC `emit` and intent `invoke/open` may accept `napplet:/?name=value` developer input. Their bindings transpose query pairs to text payload fields before sending a queryless normalized identity. Subscriptions, manifest discovery, and handler routing do not parse queries and use exact equality.
## 4. Install agent skills
```bash
npx skills add napplet/web
```
The open [skills CLI](https://skills.sh) detects the coding agents on your machine (Claude Code, Codex, Cursor, Gemini CLI, Copilot, and many more) and installs the `napplet-*` skills where each agent reads them. Add `-g` for a global install, `--skill napplet-make` for a single skill, or `-a claude-code` to target one agent. See [Agent skills](https://napplet.run/docs/guide/agent-skills.md) for what each skill does.
## 5. Build and verify
```bash
pnpm install
# Ask your agent to build the napplet.
pnpm verify
pnpm test:conformance
```
## 6. Preview, then deploy
```bash
napplet deploy --dry-run
napplet deploy
```
The dry run resolves the same config, files, metadata, and manifest events without uploading or publishing. Configure a local or remote signer before the live deploy; see [`@napplet/cli`](https://napplet.run/docs/packages/cli.md#deploy).
Choose the Note Drafts path that matches how you want to learn:
- [Build a Note Drafts napplet from scratch](https://napplet.run/docs/guide/build-note-drafts-napplet.md) hand-wires
every file so you can see the runtime boundary one piece at a time.
- [Build a Note Drafts napplet from boilerplate](https://napplet.run/docs/guide/build-note-drafts-napplet-from-boilerplate.md)
starts from the generator, preserves its project substrate, and replaces the
starter demo with the same note composer.
- [Build a Note Drafts napplet with an AI agent and the napplet skills](https://napplet.run/docs/guide/build-note-drafts-napplet-with-ai-agent-and-skills.md)
shows the prompt, review loop, and verification commands for agent-assisted
authoring.
All three create a small Nostr note composer that autosaves through `storage`, reads the shell-user pubkey through `identity`, publishes through `outbox`, and runs in Kehto/Paja.
## Retrofit an existing app manually
For a new napplet, prefer the generator above. It owns the package manager pin, Vite config, single-file build plumbing, scripts, conformance wiring, and starter layout. Use manual wiring only when you are adding napplet support to an existing app.
Install the napplet-side SDK:
```bash
npm install @napplet/sdk
```
And the build-time Vite plugin as a dev dependency, which generates the signed NIP-5A manifest event:
```bash
npm install -D @napplet/vite-plugin
```
```ts
// vite.config.ts
import { defineConfig } from 'vite';
import { nip5aManifest } from '@napplet/vite-plugin';
export default defineConfig({
plugins: [nip5aManifest({ nappletType: 'my-napplet', artifactMode: 'single-file' })],
});
```
Mirror the boilerplate scripts so verification stays consistent:
```jsonc
{
"scripts": {
"build": "vite build",
"type-check": "tsc --noEmit",
"verify": "npm run type-check && npm run build",
"test:conformance": "npm run build && napplet-conformance ./dist"
}
}
```
## Build your first napplet
NIP-5D runtimes inject `window.napplet` before your code runs. Napplet code can use the injected namespace directly:
```ts
// main.ts
// Read kind 1 notes through the shell's outbox-aware routing
const { events } = await window.napplet.outbox.query(
[{ kinds: [1], limit: 20 }],
{ timeoutMs: 3000 },
);
for (const result of events) console.log('Note:', result.event.content);
// Subscribe to live updates through the same outbox boundary const sub = window.napplet.outbox.subscribe([{ kinds: [1], limit: 20 }], { timeoutMs: 3000, }); sub.on('event', (result) => console.log('New note:', result.event.content));
// Publish a note — the shell signs and fans it out; your napplet never sees the key
const published = await window.napplet.outbox.publish({
kind: 1,
content: 'Hello from my napplet!',
tags: [],
created_at: Math.floor(Date.now() / 1000),
});
if (!published.ok) throw new Error(published.error ?? 'publish failed');
```
Notice you never imported a signing library and never touched a relay socket. The runtime sends `outbox.query`, `outbox.subscribe`, and `outbox.publish` JSON envelopes; the shell does relay discovery, signing, fanout, and policy.
## Runtime injection vs. SDK
Runtimes and napplets use different packages.
| | [`@napplet/shim`](https://napplet.run/docs/packages/shim.md) | [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md) |
| --- | --- | --- |
| **Import style** | `import { installNappletGlobal } from '@napplet/shim'` | `import { outbox, inc, intent } from '@napplet/sdk'` |
| **Who uses it** | Host runtime before napplet scripts execute | Napplet application code |
| **What it does** | Injects selected `window.napplet.` objects | Named, typed exports that wrap injected domains |
| **Required?** | Required for runtimes that use these packages | Optional convenience for bundler users |
The **sdk** is an ergonomic, typed wrapper. Its methods delegate to `window.napplet.*` at call time and throw a clear error if the runtime did not inject the namespace or requested domain.
Typical napplet code imports only the SDK:
```ts
import { outbox, inc, intent, storage } from '@napplet/sdk';
```
If you're writing a vanilla napplet with no build step, you can skip the sdk and use the injected `window.napplet.*` namespace directly.
For cross-napplet intent, call `intent.invoke(uri, options?)` or `intent.open(uri, options?)` with a convention URI such as `napplet:profile/open?pubkey=abc123`. An `ok: true` result means the runtime accepted delivery responsibility; it does not mean the target has already handled anything. An `ok: false` result carries `error`. The target receives the delivery through `intent.onDelivery(handler)` as a separate push with a runtime-attested `sender`, independent of the source lifetime and without any NAP-INC dependency. This is non-normative orientation; the living [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md), [NAP-INC](https://github.com/napplet/naps/blob/master/naps/NAP-INC.md), and [web projection](https://github.com/napplet/naps/blob/master/projections/web.md) documents are authoritative.
## Running in a shell
A napplet only does something when hosted by a compatible **shell** — the trusted runtime that owns keys, relays, and storage and proxies your requests. The reference runtime is **Kehto**.
> **TIP**
>
> The reference shell/runtime is **Kehto**:
> [github.com/kehto/web](https://github.com/kehto/web).
> You can also find napplet projects under the
> [github.com/napplet](https://github.com/napplet) organization.
> For questions and coordination, join the
> [community group chat](https://armada.buzz/invite/naddr1qvzqqqyzz5pzpjk98hj7z978r9xc9d2ymagw6tga0lx0s06y8lhpy9twc2kp8uwdqqqqpwqpw5#BAACAwTDEKKhS9_iA_qOc1n4ljVt).
Next, read [Core concepts](https://napplet.run/docs/guide/concepts.md) to understand the envelope, NAPs, the sandbox model, identity, and storage scoping.
### Terminal and automation options
`napplet init` and `napplet deploy` guide you through missing settings in a terminal. Deployment previews and results use readable sections, and network deployments show upload and relay progress. For automation, add `-i` / `--non-interactive` and supply values through flags or configuration. CI and redirected streams never trigger prompts. Use `napplet deploy -i --json` for machine-readable results and `--no-color` for plain display. See the [CLI reference](https://napplet.run/docs/packages/cli.md) for all options.
---
# What are napplets?
A **napplet** is a small, single-purpose Nostr web application that runs inside a sandboxed iframe and delegates everything sensitive — signing, storage, relay access — to a host **shell**. The shell and the napplet communicate over `postMessage` using a JSON envelope wire format defined by [NIP-5D](https://napplet.run/docs/guide/nip-5d.md).
> **ALPHA**
>
> napplet is **alpha**. The specification is experimental and a moving target.
> There **will most certainly be drift** between the packages and the
> specification. Things **will most certainly change**. For adventurers only.
## The monolith problem
Today, every Nostr client re-implements the same machinery from scratch: a feed viewer, a DM inbox, a profile editor, relay management, key handling, signing. The result is a handful of large monolithic clients that each duplicate the same hard, security-sensitive plumbing — and each one is a fresh place for that plumbing to go wrong.
NIP-5D proposes a different shape:
> Napplets SHOULD be single-purpose rather than monolithic. The shell composes
> multiple napplets; napplets don't compose themselves.
## Shell + napplets
Instead of one client that does everything, you get:
- A **shell** — the trusted host application. It brokers signing — to a remote
signer (NIP-46), a browser extension (NIP-07), or its own key management — plus
relay access and persistent storage. It hosts napplets in sandboxed iframes and
brokers their requests.
- Many **napplets** — untrusted, single-purpose mini-apps. A chat widget, a feed
viewer, a profile editor, a relay manager — each one its own napplet.
The napplet never calls a signer, relay connection, or `localStorage` directly. It asks the shell, which brokers each request through **NAP** interfaces — keys live wherever the shell delegates them, not inside the napplet. The browser's iframe sandbox (`allow-scripts` only, **no** `allow-same-origin`) enforces the boundary, so a compromised napplet cannot reach the shell's DOM, cookies, or key material.
## NIP-5D in one paragraph
NIP-5D defines the napplet-shell protocol. All messages are JSON objects with a `type` field in `domain.action` format - `{ type: "outbox.query", ...payload }` - sent over a `MessagePort` that the runtime hands to the shell in a single `napplet.hello` before any napplet script runs. The shell binds that port to the `(dTag, artifactHash)` it verified from the napplet's NIP-5D manifest and artifact bytes, and revokes the binding if the frame's document is replaced. Capability domains (**NAPs** — modular *Nostr Applet Protocol* contracts) are negotiated declaratively via manifest `R` tags and queried at runtime with `window.napplet?.domain`. Read the full [NIP-5D explanation](https://napplet.run/docs/guide/nip-5d.md).
Archetype roles use stable identities such as `napplet:note/open`. Manifest discovery, subscriptions, normalized wire identities, and handler routing keep that complete queryless string and match it exactly. INC `emit` may transpose URI query pairs into a text payload before sending the queryless identity.
Intent invocation uses `invoke(uri, options?)` or `open(uri, options?)`; its structured result reports both dispatch success and whether a handler accepted it. Payload remains untrusted at the receiving boundary. This project follows [NAP-INC PR #89 (`4593ce9`)](https://github.com/napplet/naps/pull/89/commits/4593ce9e301ce098fd3dad64206fcd6f144fa7af), [the governance/web projection PR #90 (`896c32c`)](https://github.com/napplet/naps/pull/90/commits/896c32c92deee68dc4d10fc1132b62df20cccb6f), and [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md).
## A bigger pool of builders
Because the hard, security-sensitive Nostr machinery — relay pools, NIP-46 signers, event signing and encryption — lives behind the shell, a napplet author doesn't need deep protocol expertise to ship something useful. They render UI and send envelopes; the shell handles the dangerous parts. That lowers the barrier to entry and widens the ecosystem **without** putting users at risk.
## Web first, not web only
The napplet model is being proven on the web first, and for good reasons: the iframe is a real, browser-enforced sandbox you can demonstrate today; the web has the largest developer ecosystem to draw from; and a napplet is just static files, so it distributes as a [NIP-5A](https://github.com/nostr-protocol/nips) static site (an **nsite**) over Nostr + Blossom with no servers.
**NIP-5D is specifically that web realization** — it defines the security posture and transport (`postMessage` across an `iframe` boundary) for napplets in the NIP-5A context. It is a web-context spec, not a cross-platform one.
The broader **napplet** and **NAP** capability model, however, is deliberately transport-agnostic — capability domains and intent, not browser plumbing. The web is the starting point, not the ceiling: the same model leaves room to be carried on other platforms later, each of which would define its own transport and security posture (its own analogue to NIP-5D) rather than reusing the web one.
## Who it's for
- **Nostr developers** who want to ship a focused app without re-building relay
management, signing, and storage — and without ever holding the user's keys.
- **Developers new to Nostr** who want to build for the ecosystem without mastering
every protocol detail first — the shell carries that weight.
- **Shell / client authors** who want to host third-party apps safely, competing
on trust and UX rather than feature count.
- **Protocol tinkerers** exploring what a composable, capability-based Nostr
client ecosystem looks like.
Ready to build? Head to [Getting Started](https://napplet.run/docs/guide/getting-started.md), or join the [community group chat](https://armada.buzz/invite/naddr1qvzqqqyzz5pzpjk98hj7z978r9xc9d2ymagw6tga0lx0s06y8lhpy9twc2kp8uwdqqqqpwqpw5#BAACAwTDEKKhS9_iA_qOc1n4ljVt).
---
# NIP-5D explained
[NIP-5D — *Nostr Web Applets*](#source-of-truth) is the specification that defines how a napplet and its host shell talk to each other. This page summarizes the model the `@napplet/*` packages implement.
> **NON-NORMATIVE — read the spec**
>
> This page is an orientation, not the specification. The living, authoritative
> documents are **[NIP-5D (PR #2303)](https://github.com/nostr-protocol/nips/pull/2303)**
> and the **[NAPs track](https://github.com/napplet/naps)**. For every normative
> requirement ("MUST", message shapes, manifest fields), defer to those — not to
> this page, the packages, or any test.
## Philosophy
- Napplets are **small and single-purpose** rather than monolithic — a chat
widget, a feed viewer, a profile editor, a relay manager are each separate
applications.
- The **shell composes** multiple napplets; napplets do not compose themselves.
- The protocol is intentionally minimal: a JSON envelope over `postMessage`, with
capabilities layered on top through an extension framework.
## Terminology
| Term | Meaning |
| --- | --- |
| **Shell** | The trusted hosting web application that contains napplet iframes. |
| **Napplet** | A sandboxed iframe application that communicates via `postMessage`. |
| **dTag** | The napplet type identifier from its NIP-5D manifest. |
| **Artifact hash** | SHA-256 of the final `/index.html` bytes. |
| **NAP** | *Nostr Applet Protocol* — one capability contract between a napplet and its runtime, defining the protocol messages for a capability domain. |
> In this SDK, each NAP is a **domain** that owns one message domain (`relay`,
> `storage`, `identity`, …). NAP contracts live in the
> [NAPs track](https://github.com/napplet/naps).
## Transport
- Before any napplet script runs, the shell-injected bootstrap creates a `MessageChannel`, keeps one port private, and hands the other to the shell exactly once: `window.parent.postMessage({ type: "napplet.hello" }, '*', [port])`.
- That handoff is the only window-level message a napplet sends. Every NAP message in both directions travels over the port. Shells do not send NAP messages with `iframeWindow.postMessage` and do not transfer a port into the iframe.
- Napplets MUST be embedded with `sandbox="allow-scripts"` and **without**
`allow-same-origin`.
- Napplets have **no access** to `localStorage`, `sessionStorage`, `IndexedDB`,
direct WebSocket connections, or signing keys.
- Shells MUST **not** provide `window.nostr` (NIP-07). Signing and encryption are
brokered by the shell instead — to a remote signer, an extension, or the shell's
own key management, depending on the shell.
## Wire format
Every message is a JSON object with a `type` field in `domain.action` format:
```ts
{ type: ".", ...payload }
```
Request/response pairs are correlated by an `id` field:
```ts
// request
{ type: "outbox.query", id: "abc", filters: [{ kinds: [1] }] }
// a result arrives back with a matching id
{ type: "outbox.query.result", id: "abc", events: [{ event: { /* NostrEvent */ } }] }
```
The `type` prefix before the first `.` is the **domain**, and routes the message to the correct NAP handler. **Unrecognized message types are silently ignored** for forward compatibility — a napplet can speak to an older shell and degrade gracefully.
## Identity
- The shell computes `(dTag, artifactHash)` from the signed NIP-5D manifest and the verified artifact bytes **before** the iframe runs, and injects those bytes with the bootstrap via `srcdoc`.
- It accepts a `napplet.hello` only when `MessageEvent.source` is the `contentWindow` of an iframe it created and exactly one port is attached, and binds the identity to **that port**. Origin checks use the port binding, not `event.origin`.
- Identity belongs to the verified **document**, not the iframe or its `Window`: a `Window` survives same-frame navigation and an injected `meta` CSP does not carry over, so a napplet could otherwise replace itself with unverified content and keep its grants.
- A second hello, any other window-level message, a second `load` event or the port closing tells the shell the document was replaced. The shell closes the port, revokes the identity and every grant attached to it, and removes the iframe. It may then create a fresh iframe from the verified bytes.
## Manifest and NAP negotiation
The current manifest kinds are 5129, 15129, and 35129. The manifest carries one direct artifact hash in `x`, description text in `content`, required domains in `R`, and optional integrations in `O`. Archetype advertisements use `z`; accepted intents and parameter names use `i`. Optional presentation metadata rides on the same event: one content-addressed `icon`, any number of `screenshot` tags and at most one `preview` video (NIP-92 `key value` fields keyed by the blob's `x` hash), plus `source`, `source-commit` and `source-archive` pins; `server` tags hint where those blobs live. See [NIP-5D §Manifest](https://github.com/dskvr/nips/blob/nip/5d/5D.md#manifest) for the complete living contract.
The plugin generates current events and the CLI offers temporary legacy output. See [event migration](https://napplet.run/docs/guide/event-migration.md) for release cutoffs and rollout implications. Runtime availability still comes from injected domain presence, such as `window.napplet?.relay`.
## NAP extension framework
- A NAP spec defines a message **domain**, the valid `type` strings within it,
the payload shapes, and the expected shell behavior.
- A NAP named `foo` owns **all** `foo.*` message types.
- Each NAP must be **independently implementable**, and shells may support any
subset of NAPs.
This is what makes the protocol modular: NAP contracts live in the [NAPs track](https://github.com/napplet/naps); see the [NAP domain reference](https://napplet.run/docs/naps/index.md) for the domains this SDK ships.
## Convention URI projection
The stable convention identity is the complete queryless `napplet:/` string. Archetype manifest tags, subscriptions, handler discovery, normalized messages, and routing contain that identity and use exact equality.
The web binding normalizes developer-facing URI input before `postMessage` for INC `emit(topic, payload?)`. Unique percent-decoded query pairs become text payload fields, literal `+` remains `+`, and the outgoing topic is queryless. Fragments, malformed percent encoding, repeated decoded names, and a query combined with explicit payload reject. Structured/non-text data uses a queryless URI with an explicit payload.
Intent calls derive archetype and action from the URI. An `ok: true` result means the runtime accepted delivery responsibility and includes the normalized identity and handler catalog identifier; an `ok: false` result includes `error`. Targets receive runtime-attested `IntentDelivery` values through `onDelivery`, including deliveries retained before registration. Behavior hints are `focus` and `reuse`. This non-normative guidance defers to the living [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md).
At this web projection's trust boundary, the host authenticates the napplet by the port bound in `napplet.hello` and derives the NAP-level `sender` from that endpoint. A napplet does not provide sender. On other carriers the endpoint mechanism differs, while the runtime-attested sender contract stays the same. Receivers treat sender as provenance and payload as untrusted.
This non-normative guide defers to the living [NAP-INC](https://github.com/napplet/naps/blob/master/naps/NAP-INC.md), [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md), and [web projection](https://github.com/napplet/naps/blob/master/projections/web.md).
## Security model
- Napplets are **untrusted**; the shell is **trusted**; the **browser** enforces
the iframe sandbox boundary.
- Adding `allow-same-origin` would grant the napplet a real origin (letting it
register a service worker, reach same-origin storage, etc.) — so it is
**prohibited**.
- The port handed over in `napplet.hello` is the sender identity. Only the verified document's bootstrap holds the other end, so a navigated or reloaded document has no NAP channel, identity or grants.
- An injected `meta` CSP does not restrict a document navigating its own frame, so a napplet can disclose data it was given by encoding it in a navigation URL. Revocation stops further access but cannot recall what was sent; shells should treat data released to a napplet as disclosable by it.
- Shells MUST **not** sign or broadcast events containing ciphertext received
from a napplet.
- The protocol does **not** protect against compromised browsers, malicious
shells, or social engineering — it secures the napplet-shell boundary, not the
shell itself.
## Source of truth
This page is a **non-normative** summary. The living, authoritative documents are:
- **NIP-5D** (the protocol):
[github.com/nostr-protocol/nips/pull/2303](https://github.com/nostr-protocol/nips/pull/2303)
- **NAPs track** (the capability domains):
[github.com/napplet/naps](https://github.com/napplet/naps)
For every normative requirement, read those — not this page. See [NIP-5D spec status](https://napplet.run/docs/spec.md) for how drift is tracked.
---
# napplet: Composable Nostr apps
Sandboxed Nostr web applets that delegate signing, storage, and relay access to a host shell over the NIP-5D JSON envelope wire format.
- [Get Started](https://napplet.run/docs/guide/getting-started.md)
- [What are napplets?](https://napplet.run/docs/guide/index.md)
- [Community](https://armada.buzz/invite/naddr1qvzqqqyzz5pzpjk98hj7z978r9xc9d2ymagw6tga0lx0s06y8lhpy9twc2kp8uwdqqqqpwqpw5#BAACAwTDEKKhS9_iA_qOc1n4ljVt)
- [napplet.run](https://napplet.run/)
## Single-purpose by design
A napplet does one thing well. The shell composes many sandboxed napplets instead of one monolithic client re-implementing feeds, DMs, profiles, and relay management.
## Every request brokered
Napplets never call a signer, relay, or store directly. Every request is brokered through the shell over postMessage — to a remote signer, an extension, or the shell's own services. No window.nostr is installed.
## Browser-enforced sandbox
The iframe uses allow-scripts only — no allow-same-origin. Identity is bound to the MessagePort the verified document hands over in napplet.hello, and revoked if the frame navigates.
## Modular NAP domains
Capabilities are split into NAP domains (relay, storage, inc, identity, and more). Shells implement the domains they support; napplets gate on domain presence.
## Just a JSON envelope
Every message is { type: "domain.action", ...payload }. A simple, standardized wire format that any shell can host and any web app can speak.
## Portable across shells
Build once against the protocol. Any compatible shell — like the Kehto reference runtime — can host your napplet without changes.
---
# BLE
> **Implementation: experimental · Proposal: [in-review](https://github.com/napplet/naps/pull/62)** (status checked 2026-10-31). GATT binding has unit coverage; real-device behavior across host backends is not established by these tests. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Runtime-mediated Bluetooth LE/GATT sessions. Napplets receive opaque session ids and byte arrays while the shell owns chooser UI, permissions, device handles, GATT lifecycle, notifications, disconnects, and policy.
```ts
if (window.napplet?.ble) {
const { session } = await window.napplet.ble.open({ acceptAllDevices: true });
const services = await window.napplet.ble.services(session.id);
}
```
This non-normative guide defers to the living [NAP-BLE proposal](https://github.com/napplet/naps/pull/62). Feature-gate with `if (window.napplet?.ble)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/ble`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# COMMON
> **Implementation: experimental · Proposal: [in-review](https://github.com/napplet/naps/pull/67)** (status checked 2026-10-31). Social action binding is tested; broader host integration and evolving action semantics warrant explicit opt-in. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Common social actions — public NIP-19 helpers, profile lookup, follows, follow/unfollow, reactions, and reports. The shell owns identity, consent, event construction, signing, publishing, relay access, and NIP-19 handling.
```ts
if (window.napplet?.common) {
const { pubkeys } = await window.napplet.common.follows();
await window.napplet.common.react(eventId, '+');
}
```
This non-normative guide defers to the living [NAP-COMMON proposal](https://github.com/napplet/naps/pull/67). Feature-gate with `if (window.napplet?.common)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/common`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# CONFIG
> **Implementation: exploratory · Proposal: [draft](https://github.com/napplet/naps/pull/14)** (status checked 2026-10-31). Configuration binding is tested, but the upstream draft is marked MAY DEFER; use for design exploration. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Declarative per-napplet configuration (JSON Schema-driven). The shell renders the settings UI, validates, persists, and pushes live values; the shell is the sole writer.
```ts
const sub = window.napplet.config.subscribe((values) => applyTheme(values.theme as string));
window.napplet.config.openSettings({ section: 'appearance' });
```
This non-normative guide defers to the living [NAP-CONFIG proposal](https://github.com/napplet/naps/pull/14). Feature-gate with `if (window.napplet?.config)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/config`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# COUNT
> **Implementation: experimental · Proposal: [in-review](https://github.com/napplet/naps/pull/69)** (status checked 2026-10-31). Count binding is tested, but accuracy and aggregation across real relay backends remain outside that evidence. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Runtime-mediated aggregate counts for NIP-01 filters. The runtime owns relay COUNT support, indexes, caches, approximation, relay disclosure, and refusal policy. A result carries `ok`, and when available `count`, an `approximate` flag, an `hll` value, and the `relays` consulted.
```ts
if (window.napplet?.count) {
const result = await window.napplet.count.query({ kinds: [7], '#e': [eventId] });
if (result.ok) showReactions(result.count, result.approximate);
}
```
This non-normative guide defers to the living [NAP-COUNT proposal](https://github.com/napplet/naps/pull/69). Feature-gate with `if (window.napplet?.count)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/count`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# CVM
> **Implementation: experimental · Proposal: [in-review](https://github.com/napplet/naps/pull/31)** (status checked 2026-10-31). Session and registry bindings are tested; upstream explicitly marks the contract NEEDS WORK. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Native ContextVM bridge — MCP-over-Nostr (`discover` / `listTools` / `callTool` / `listResources` / `readResource` / `registry.*`); the shell owns transport, registry selection, and tool policy.
```ts
if (window.napplet?.cvm) {
const servers = await window.napplet.cvm.discover({ search: 'relay' });
const tools = await window.napplet.cvm.listTools(servers[0]);
const result = await window.napplet.cvm.callTool(servers[0], tools[0].name, {});
}
```
This non-normative guide defers to the living [NAP-CVM proposal](https://github.com/napplet/naps/pull/31). Feature-gate with `if (window.napplet?.cvm)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/cvm`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# DM
> **Implementation: experimental · Proposal: [draft](https://github.com/napplet/naps/pull/74)** (status checked 2026-10-31). Direct-message binding has shim and type coverage; transport interoperability is not established by those tests. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Runtime-mediated direct messages. The napplet presents direct-message UI while the runtime owns signing, encryption, relay routing, storage, key and session state, and policy. The napplet sees normalized conversations, history pages, send results, and live message delivery through `status`, `conversations`, `messages`, `send`, `subscribe` / `unsubscribe`, and `onMessage`.
```ts
if (window.napplet?.dm) {
const { conversations } = await window.napplet.dm.conversations({ limit: 20 });
window.napplet.dm.onMessage((message) => render(message));
await window.napplet.dm.subscribe();
await window.napplet.dm.send({ recipients: [''], content: 'hello' });
}
```
This non-normative guide defers to the living [NAP-DM proposal](https://github.com/napplet/naps/pull/74). Feature-gate with `if (window.napplet?.dm)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/dm`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# FS
> **Implementation: experimental · Proposal: [in-review](https://github.com/napplet/naps/pull/88)** (status checked 2026-10-31). Filesystem binding has MVP coverage; portability and authorization across storage backends remain under evaluation. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Shell-mediated virtual filesystem access. Napplets discover visible roots, ask the runtime to mediate file and directory selection, inspect and list entries, read and write base64-encoded file bytes, create, remove and move them, and subscribe to advisory change events. The runtime owns host paths, mounts, backing store, normalization, policy, and authorization — the napplet sees only virtual paths.
`info()` is advisory discovery, not an authorization token: permissions can change mid-session and any operation can still fail.
```ts
if (window.napplet?.fs) {
const picked = await window.napplet.fs.pickFile({ accept: [{ extension: '.md' }] });
const bytes = await window.napplet.fs.read(picked.entries[0].path);
await window.napplet.fs.write('/shared/copy.md', bytes.data, { mode: 'replace' });
const entries = await window.napplet.fs.list('/shared');
const watchId = await window.napplet.fs.watch('/shared', { recursive: true });
window.napplet.fs.onChanged((change) => refresh(change.path));
}
```
`fs.write.data` and `FsReadResult.data` carry bytes as RFC 4648 standard padded base64 text on the JSON wire. Byte limits and result counts are decoded-byte counts.
This non-normative guide defers to the living [NAP-FS proposal](https://github.com/napplet/naps/pull/88). Feature-gate with `if (window.napplet?.fs)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/fs`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# GAMEPAD
> **Implementation: supported · Proposal: [in-review](https://github.com/napplet/naps/pull/130)** (status checked 2026-10-31). Cross-repository delivery includes binding, security, runtime, and conformance verification; the reopened proposal is still subject to change. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
### Controller snapshots
This non-normative guidance defers to [NAP-GAMEPAD](https://github.com/napplet/naps/pull/130). When the runtime exposes `window.napplet.gamepad`, the SDK provides cached `getGamepads()`, read-only `available` and `focused`, and `onChange(handler)` with an independently closable listener. Keep keyboard or pointer controls available when the domain or native controllers are unavailable. Ignore gameplay edges while unfocused, and reset the button baseline on regained focus. Preserve null slots and route controllers by their `index`.
```ts
import { gamepad } from '@napplet/sdk/gamepad';
if (window.napplet?.gamepad) {
const subscription = gamepad.onChange(state => {
if (!state.available || !state.focused) return;
for (const pad of state.pads) {
if (pad) console.log(pad.index, pad.axes);
}
});
// Call subscription.close() when the owning view is disposed, not right away.
}
```
Declare `gamepad` as optional (`O`) when other controls fulfill the core task. Use a hard requirement (`R`) only when the core task needs controller input. The runtime owns controller access and focus isolation; app code does not call native `navigator.getGamepads()` or open raw HID devices. Haptics and remapping are outside this proposal.
---
# IDENTITY
> **Implementation: supported · Proposal: [merged](https://github.com/napplet/naps/pull/102)** (status checked 2026-10-31). Read-only identity queries and updates have focused binding and reference-runtime coverage. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Read-only user queries — pubkey, profile metadata, follows, … Never signs, encrypts, or decrypts.
```ts
const pubkey = await window.napplet.identity.getPublicKey();
const profile = await window.napplet.identity.getProfile();
const sub = window.napplet.identity.onChanged((pk) => reload(pk));
```
This non-normative guide defers to the living [NAP-IDENTITY proposal](https://github.com/napplet/naps/pull/102). Feature-gate with `if (window.napplet?.identity)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/identity`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
## Changes and profile artwork
User identity changes report the connected signer, including an empty string at sign-out; they do not alter napplet identity. Profile picture and banner bytes use the optional [`resource`](https://napplet.run/docs/naps/resource.md) domain when exposed; otherwise show local placeholder artwork. See the living [NAP-IDENTITY](https://github.com/napplet/naps/blob/master/naps/NAP-IDENTITY.md).
---
# INC
> **Implementation: supported · Proposal: [merged](https://github.com/napplet/naps/pull/104)** (status checked 2026-10-31). Topic and channel bindings have compatibility tests and authenticated-endpoint reference-runtime coverage. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Inter-napplet communication — topic-based publish/subscribe between napplets.
```ts
// The INC binding accepts a developer-facing convention URI when emitting:
window.napplet.inc.emit('napplet:profile/open?pubkey=abc123');
// The runtime sends topic `napplet:profile/open` with { pubkey: 'abc123' }.
// Another napplet subscribes to that exact stable topic:
const sub = window.napplet.inc.on('napplet:profile/open', (event) => {
// `event.sender` is supplied by the runtime from the authenticated source endpoint.
const target = (event.payload as { pubkey?: string }).pubkey;
});
```
`emit(topic, payload?)` transposes unique percent-decoded query pairs into a shallow text payload before posting the normalized message. Literal `+` stays `+`. Consumers subscribe to the stable queryless topic, and routing then uses exact equality with no query-aware, wildcard, or prefix matching. Fragments, malformed percent encoding, duplicate decoded names, and query plus explicit payload reject. Use a queryless topic with an explicit payload for structured or non-text data.
This non-normative guide defers to the living [NAP-INC proposal](https://github.com/napplet/naps/pull/104). Feature-gate with `if (window.napplet?.inc)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/inc`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
INC is defined by the living [NAP-INC](https://github.com/napplet/naps/blob/master/naps/NAP-INC.md) and its [web projection](https://github.com/napplet/naps/blob/master/projections/web.md). For URI-addressed handler routing, see [`intent`](https://napplet.run/docs/naps/intent.md).
---
# NAP domain reference
A **NAP** (*Nostr Applet Protocol*) is one capability contract between a napplet and its runtime — what the runtime provides (relay access, storage, intents, …) and exactly how a napplet asks for it. On the web, [NIP-5D](https://napplet.run/docs/guide/nip-5d.md) binds each NAP to a **message domain**: a NAP named `foo` owns all `foo.*` JSON envelope messages, their payload shapes, and the expected shell behavior. NAP contracts are proposed and maintained in the [NAPs track](https://github.com/napplet/naps).
This reference is non-normative. Every domain page defers to its living proposal in the NAPs track, which governs protocol behavior.
The protocol is modular by design. A NAP must be **independently implementable**, and shells may support **any subset** of NAPs. That's why napplets feature-gate with injected domain property presence before using a domain and degrade gracefully when it's absent — see [Core concepts](https://napplet.run/docs/guide/concepts.md#domain-presence).
## Domains by purpose
Each domain has its own page with its purpose, a minimal example, and its implementation maturity. Examples assume the runtime injected `window.napplet` before napplet code ran; the same calls are also importable as named helpers from [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md), and each domain ships as a subpath of [`@napplet/nap`](https://napplet.run/docs/packages/nap.md) (barrel / `types` / `shim` / `sdk`).
### Runtime and environment
| Domain | Purpose | Maturity |
| --- | --- | --- |
| [`shell`](https://napplet.run/docs/naps/shell.md) | Optional environment snapshot, readiness, and domain presence checks. | supported |
| [`theme`](https://napplet.run/docs/naps/theme.md) | Read-only shell theme — colors, fonts, background, and live updates. | supported |
| [`config`](https://napplet.run/docs/naps/config.md) | Schema-driven per-napplet settings rendered and persisted by the shell. | exploratory |
| [`keys`](https://napplet.run/docs/naps/keys.md) | Named actions and keyboard bindings owned by the shell. | experimental |
| [`notify`](https://napplet.run/docs/naps/notify.md) | Shell-rendered notifications, badges, and action callbacks. | experimental |
| [`media`](https://napplet.run/docs/naps/media.md) | Media sessions with shell-side playback controls. | supported |
| [`link`](https://napplet.run/docs/naps/link.md) | User-visible external link opening under shell policy. | supported |
### Identity and social
| Domain | Purpose | Maturity |
| --- | --- | --- |
| [`identity`](https://napplet.run/docs/naps/identity.md) | Read-only user queries — pubkey, profile, follows. Never signs. | supported |
| [`common`](https://napplet.run/docs/naps/common.md) | Common social actions — follows, reactions, reports, NIP-19 helpers. | experimental |
| [`lists`](https://napplet.run/docs/naps/lists.md) | NIP-51 list add/remove with runtime-owned signing and encryption. | experimental |
| [`dm`](https://napplet.run/docs/naps/dm.md) | Direct-message conversations, history, and sending via the runtime. | experimental |
### Nostr events and relays
| Domain | Purpose | Maturity |
| --- | --- | --- |
| [`outbox`](https://napplet.run/docs/naps/outbox.md) | Outbox-aware reads and publishes — the default event boundary. | supported |
| [`relay`](https://napplet.run/docs/naps/relay.md) | Low-level relay proxy for explicit relay-local behavior. | supported |
| [`count`](https://napplet.run/docs/naps/count.md) | Aggregate counts for NIP-01 filters. | experimental |
### Napplet to napplet
| Domain | Purpose | Maturity |
| --- | --- | --- |
| [`inc`](https://napplet.run/docs/naps/inc.md) | Topic-based publish/subscribe between running napplets. | supported |
| [`intent`](https://napplet.run/docs/naps/intent.md) | URI-addressed intents routed to a handler napplet. | supported |
### Data, files, and network
| Domain | Purpose | Maturity |
| --- | --- | --- |
| [`storage`](https://napplet.run/docs/naps/storage.md) | Scoped key-value storage isolated per napplet. | supported |
| [`fs`](https://napplet.run/docs/naps/fs.md) | Virtual filesystem — pickers, reads, writes, and change events. | experimental |
| [`resource`](https://napplet.run/docs/naps/resource.md) | Sandboxed byte fetching over https, blossom, nostr, and data URIs. | supported |
| [`upload`](https://napplet.run/docs/naps/upload.md) | File and blob upload over NIP-96 and Blossom rails. | supported |
| [`cvm`](https://napplet.run/docs/naps/cvm.md) | ContextVM bridge — MCP tools and resources over Nostr. | experimental |
### Devices and realtime
| Domain | Purpose | Maturity |
| --- | --- | --- |
| [`gamepad`](https://napplet.run/docs/naps/gamepad.md) | Focus-scoped controller snapshots. | supported |
| [`ble`](https://napplet.run/docs/naps/ble.md) | Bluetooth LE / GATT sessions with opaque session ids. | experimental |
| [`serial`](https://napplet.run/docs/naps/serial.md) | User-approved serial port sessions. | experimental |
| [`webrtc`](https://napplet.run/docs/naps/webrtc.md) | Runtime-owned WebRTC sessions carrying application payloads. | experimental |
### AI
| Domain | Purpose | Maturity |
| --- | --- | --- |
| [`llm`](https://napplet.run/docs/naps/llm.md) | User-selected language model inference through the runtime. | exploratory |
## Implementation maturity
These release-time labels describe our shipped implementation, separately from upstream proposal status. Supported does not mean frozen; experimental does not waive conformance requirements. All capabilities remain subject to change. See [maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
| Domain | Implementation maturity | Proposal status | Assessment |
| --- | --- | --- | --- |
| `shell` | supported | [merged](https://github.com/napplet/naps/pull/101) | Optional readiness and environment binding has focused shim and reference-runtime coverage. |
| `relay` | supported | [in-review](https://github.com/napplet/naps/pull/2) | Relay reads, publishing, and subscription lifecycle have maintained shim and integration tests; upstream naming remains under review. |
| `identity` | supported | [merged](https://github.com/napplet/naps/pull/102) | Read-only identity queries and updates have focused binding and reference-runtime coverage. |
| `storage` | supported | [in-review](https://github.com/napplet/naps/pull/3) | Key-value binding has focused request/result tests and tutorial usage; host storage isolation remains the host responsibility. |
| `inc` | supported | [merged](https://github.com/napplet/naps/pull/104) | Topic and channel bindings have compatibility tests and authenticated-endpoint reference-runtime coverage. |
| `theme` | supported | [merged](https://github.com/napplet/naps/pull/103) | Theme queries and pushed updates have focused binding and opaque-iframe integration coverage. |
| `keys` | experimental | [in-review](https://github.com/napplet/naps/pull/9) | Keyboard binding ships, but dedicated domain regression coverage is limited; retain explicit evaluation opt-in. |
| `media` | supported | [in-review](https://github.com/napplet/naps/pull/10) | Session creation and playback binding have focused regression coverage; proposed torrent playback amendments are not implied. |
| `notify` | experimental | [in-review](https://github.com/napplet/naps/pull/11) | Notification binding ships with limited dedicated domain regression evidence; broader host integration confidence is unverified. |
| `config` | exploratory | [draft](https://github.com/napplet/naps/pull/14) | Configuration binding is tested, but the upstream draft is marked MAY DEFER; use for design exploration. |
| `resource` | supported | [in-review](https://github.com/napplet/naps/pull/80) | Resource fetching has focused shim and type tests; the reverted merge and reopened proposal do not change that implementation assessment. |
| `cvm` | experimental | [in-review](https://github.com/napplet/naps/pull/31) | Session and registry bindings are tested; upstream explicitly marks the contract NEEDS WORK. |
| `outbox` | supported | [in-review](https://github.com/napplet/naps/pull/32) | Routing and subscription bindings have focused tests and an integration regression for structured-clone boundaries. |
| `upload` | supported | [in-review](https://github.com/napplet/naps/pull/33) | Upload binding has focused tests for request/result behavior; host rail support remains runtime-specific. |
| `intent` | supported | [merged](https://github.com/napplet/naps/pull/91) | Convention normalization and dispatch have focused binding and reference-runtime integration tests. |
| `ble` | experimental | [in-review](https://github.com/napplet/naps/pull/62) | GATT binding has unit coverage; real-device behavior across host backends is not established by these tests. |
| `webrtc` | experimental | [in-review](https://github.com/napplet/naps/pull/59) | Signaling and session binding have unit coverage; interoperable live peer sessions remain unverified here. |
| `link` | supported | [in-review](https://github.com/napplet/naps/pull/53) | The small link-opening binding has focused regression coverage; navigation authorization stays with the host. |
| `count` | experimental | [in-review](https://github.com/napplet/naps/pull/69) | Count binding is tested, but accuracy and aggregation across real relay backends remain outside that evidence. |
| `lists` | experimental | [in-review](https://github.com/napplet/naps/pull/68) | List mutation binding is tested; concurrent real-host mutation behavior is not established by binding tests. |
| `serial` | experimental | [in-review](https://github.com/napplet/naps/pull/61) | Serial binding has unit coverage; device and native-host integration evidence is limited. |
| `fs` | experimental | [in-review](https://github.com/napplet/naps/pull/88) | Filesystem binding has MVP coverage; portability and authorization across storage backends remain under evaluation. |
| `common` | experimental | [in-review](https://github.com/napplet/naps/pull/67) | Social action binding is tested; broader host integration and evolving action semantics warrant explicit opt-in. |
| `dm` | experimental | [draft](https://github.com/napplet/naps/pull/74) | Direct-message binding has shim and type coverage; transport interoperability is not established by those tests. |
| `gamepad` | supported | [in-review](https://github.com/napplet/naps/pull/130) | Cross-repository delivery includes binding, security, runtime, and conformance verification; the reopened proposal is still subject to change. |
| `llm` | exploratory | [draft](https://github.com/napplet/naps/pull/134) | Early published draft has binding and mock-runtime tests; provider behavior and API design remain exploratory. |
## Core domain union
[`@napplet/core`](https://napplet.run/docs/packages/core.md) exports a `NapDomain` string union for the foundational domains — `shell`, `relay`, `identity`, `storage`, `inc`, `theme`, `keys`, `media`, `notify`, `config`, `resource`, `cvm`, `outbox`, `upload`, `intent`, `ble`, `webrtc`, `link`, `count`, `lists`, `serial`, `fs`, `common`, `dm`, `gamepad`, `llm` — used as the discriminant for envelope routing and domain presence.
## Where to go next
- [`@napplet/nap`](https://napplet.run/docs/packages/nap.md) — the package and its subpath patterns
- [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md) — typed helpers and per-domain message unions
- [NIP-5D explained](https://napplet.run/docs/guide/nip-5d.md#nap-extension-framework) — the NAP framework
---
# INTENT
> **Implementation: supported · Proposal: [merged](https://github.com/napplet/naps/pull/91)** (status checked 2026-10-31). Convention normalization and dispatch have focused binding and reference-runtime integration tests. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Intent calls derive archetype and action from the URI. An `ok: true` result means the runtime accepted delivery responsibility and includes the normalized identity and handler catalog identifier; an `ok: false` result includes `error`. Targets receive runtime-attested `IntentDelivery` values through `onDelivery`, including deliveries retained before registration. Behavior hints are `focus` and `reuse`.
```ts
if (window.napplet?.intent) {
const { available } = await window.napplet.intent.available('profile');
if (available) {
const result = await window.napplet.intent.open('napplet:profile/open', { payload: { pubkey: 'abc123' }, behavior: { reuse: false } });
if (!result.ok) console.error(result.error);
}
}
```
This non-normative guide defers to the living [NAP-INTENT proposal](https://github.com/napplet/naps/pull/91). Feature-gate with `if (window.napplet?.intent)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/intent`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
## Delivery and recommendations
```ts
import { intent } from '@napplet/sdk';
intent.onDelivery(({ sender, convention, payload }) => {
console.log(sender, convention, payload);
});
await intent.invoke('napplet:profile/open?pubkey=abc123');
```
The runtime binding retains deliveries until a listener registers. Discovery candidates expose opaque catalog `id` values and `contracts` with parameter names. [INC](https://napplet.run/docs/naps/inc.md) peers instead use identifiers for running authenticated endpoints. A NAP-INTENT URI may append a bare `#naddr1…` recommendation; the binding sends its coordinate and relay hints as `handlerHint`, outside convention identity and payload. An applicable user default takes precedence. INC URI operations reject fragments. See the living [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md) and [web projection](https://github.com/napplet/naps/blob/master/projections/web.md).
---
# KEYS
> **Implementation: experimental · Proposal: [in-review](https://github.com/napplet/naps/pull/9)** (status checked 2026-10-31). Keyboard binding ships, but dedicated domain regression coverage is limited; retain explicit evaluation opt-in. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Keyboard bindings and action registration — the shell binds keys to named actions.
```ts
await window.napplet.keys.registerAction({ id: 'editor.save', label: 'Save', defaultKey: 'Ctrl+S' });
const sub = window.napplet.keys.onAction('editor.save', () => save());
```
This non-normative guide defers to the living [NAP-KEYS proposal](https://github.com/napplet/naps/pull/9). Feature-gate with `if (window.napplet?.keys)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/keys`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# LINK
> **Implementation: supported · Proposal: [in-review](https://github.com/napplet/naps/pull/53)** (status checked 2026-10-31). The small link-opening binding has focused regression coverage; navigation authorization stays with the host. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Shell-mediated external link opening. This is user-visible navigation, not byte fetching; the shell owns prompting, policy, opener isolation, and browser context.
```ts
if (window.napplet?.link) {
const result = await window.napplet.link.open('https://example.com/post/123', {
label: 'Read post',
});
if (result.status === 'denied') showInlineFallback();
}
```
This non-normative guide defers to the living [NAP-LINK proposal](https://github.com/napplet/naps/pull/53). Feature-gate with `if (window.napplet?.link)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/link`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# LISTS
> **Implementation: experimental · Proposal: [in-review](https://github.com/napplet/naps/pull/68)** (status checked 2026-10-31). List mutation binding is tested; concurrent real-host mutation behavior is not established by binding tests. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Runtime-mediated NIP-51 list mutations. Napplets send add/remove intent while the runtime owns current-event lookup, kind/type mapping, tag formatting, private item encryption, event preservation, signing, and publishing.
```ts
if (window.napplet?.lists) {
await window.napplet.lists.add({ type: 'mute-list' }, [
{ itemType: 'pubkey', value: 'abc123...' },
]);
}
```
This non-normative guide defers to the living [NAP-LISTS proposal](https://github.com/napplet/naps/pull/68). Feature-gate with `if (window.napplet?.lists)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/lists`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# LLM
> **Implementation: exploratory · Proposal: [draft](https://github.com/napplet/naps/pull/134)** (status checked 2026-10-31). Early published draft has binding and mock-runtime tests; provider behavior and API design remain exploratory. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
## User-selected language models through the runtime
This non-normative guide follows the living [NAP-LLM proposal](https://github.com/napplet/naps/pull/134). When the runtime exposes `window.napplet.llm`, a napplet can ask the user to pick a model connection and submit stateless generations against it. The napplet owns its prompts, conversation, and output validation; the runtime owns connection selection, credentials, disclosure consent, and provider access. Domain presence does not imply a configured connection or a consent grant: handle `unsupported` and `user-cancelled` as ordinary outcomes and keep the rest of the napplet usable without inference.
Import `llm` from `@napplet/sdk` or `@napplet/sdk/llm`; root exports include prefixed helpers such as `llmSelect` and `llmGenerate`. The dedicated subpath also provides bare aliases such as `select` and `generate`.
```ts
import { llm, isLlmFailure, llmText } from '@napplet/sdk/llm';
if (window.napplet?.llm) {
const model = await llm.select({ json: true });
const generation = await llm.generate({
handle: model.handle,
messages: [
{ role: 'system', content: [{ type: 'text', text: 'Return a JSON object with a short label.' }] },
{ role: 'user', content: [{ type: 'text', text: note }] },
],
format: 'json',
});
if (generation.finish === 'stop') applyLabel(JSON.parse(llmText(generation)));
}
```
## Operations and results
| Method | Result |
| --- | --- |
| `select(requirements?)` | `LlmModel` |
| `describe(handle)` | `LlmModel` |
| `generate(request, options?)` | `LlmGeneration`, with `requestId` on the pending promise |
| `cancel(requestId)` | `boolean` |
`select` asks the user to choose a connection meeting the optional `model`, `images`, `json`, and `tools` requirements; a user default that meets them may satisfy it without a chooser. The returned `handle` is opaque, scoped to this endpoint and account, and never a URL or credential. `describe` revalidates a handle without a chooser; a changed connection, model, credential, account, or endpoint lifetime invalidates handles and rejects with `selection-expired`, so select again. `route` is a human-readable disclosure path including intermediaries; "local model" does not promise data stays on the device when the runtime is hosted. `tools` and `toolChoiceModes` say whether the connection calls tools and which of `auto`, `none`, `required`, and `tool` (named forcing) it supports; a request for an unlisted mode fails with `unsupported` instead of being substituted.
`generate` is one stateless request: the supplied messages are the entire conversation, and the runtime does not append or drop anything. Optional `maxTokens` (a positive integer the runtime may cap) and `temperature` (unsupported values fail with `unsupported`) tune the request. The terminal `content` is a list of `text`, `thinking`, and `toolCall` parts; `llmText(generation)` concatenates the text parts. Only `finish: "stop"` is normal completion; `length` and `refusal` may carry partial or empty content, carry no tool calls, and must not be presented as complete. With `format: "json"`, the text of a `stop` result parses as a JSON object, but its schema, accuracy, and suitability remain the napplet's checks. `thinking` parts are display-only: the runtime omits them when history is replayed. `usage` is provider-reported token counts (including optional cache and reasoning counts), not a price.
## Tool calls
Pass `tools` (portable names matching `^[A-Za-z0-9_-]{1,64}$`, an optional description, and a JSON Schema whose root is `type: "object"`) and an optional `toolChoice`. The model may answer with `finish: "toolUse"` and one or more `toolCall` parts. The runtime never executes them: a call is a request, not authorization, so validate its arguments and obtain any separately required capability before running it. Then send the whole conversation again, with the assistant turn as returned and one `toolResult` message per call immediately after it, reusing the call's `toolCallId` and name. Set `isError: true` when the tool failed. Tool results re-enter the model as untrusted input.
```ts
import { llm, llmText, llmToolCalls } from '@napplet/sdk/llm';
import type { LlmMessage, LlmTool } from '@napplet/sdk/llm';
const tools: LlmTool[] = [{
name: 'get_weather',
description: 'Look up current weather',
parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
}];
const messages: LlmMessage[] = [{ role: 'user', content: [{ type: 'text', text: 'What is the weather in Berlin?' }] }];
let generation = await llm.generate({ handle: model.handle, messages, tools });
while (generation.finish === 'toolUse') {
messages.push({ role: 'assistant', content: generation.content });
for (const call of llmToolCalls(generation)) {
const outcome = await runToolIfAllowed(call); // the napplet's own validation and authority
messages.push({ role: 'toolResult', toolCallId: call.toolCallId, toolName: call.name, isError: !outcome.ok, content: [{ type: 'text', text: outcome.text }] });
}
generation = await llm.generate({ handle: model.handle, messages, tools });
}
if (generation.finish === 'stop') show(llmText(generation));
```
Each consent prompt discloses tool definitions and results as well as prompts, so every round trip is a new disclosure and may be a new charge. History with completed calls and results may be replayed without `tools`, which continues the exchange without enabling new calls.
## Streaming and cancellation
The pending promise returned by `generate` exposes `requestId`. Pass `onDelta` to receive ordered provisional block events and `signal` to map an abort to `llm.cancel`; the generation still settles through its own result, because completion may win the race. Each block (`text`, `thinking`, or `toolCall`, identified by `blockIndex`) streams one `start`, zero or more `delta` events carrying `text` fragments (JSON argument fragments for a tool call), and one `end`; a `toolCall` start carries the call's `toolCallId` and `name`. Deltas are optional, so render provisional output as provisional and replace it with the terminal `content` rather than appending. Never execute a tool from a delta or a block `end`. The binding drops deltas that do not match the schema, stops provisional delivery after a sequence gap, and still resolves the terminal result.
```ts
const controller = new AbortController();
const pending = llm.generate(request, {
signal: controller.signal,
onDelta: (delta) => {
if (delta.event === 'delta' && delta.block === 'text') showProvisional(delta.blockIndex, delta.text);
},
});
cancelButton.onclick = () => controller.abort();
try {
showFinal(llmText(await pending));
} catch (error) {
if (isLlmFailure(error) && error.code === 'cancelled') showCancelled();
else throw error;
}
```
`cancel(requestId)` resolves `true` only when an active generation owned by this endpoint was terminated; unknown, terminal, or foreign ids resolve `false`. The runtime bounds generation time, including the consent wait, and reports `timeout`. Neither cancellation nor timeout proves the provider stopped computing or charging, so never retry an ambiguous outcome automatically; a user-initiated retry is a new request and a new consent.
## Failures
A runtime `Error` record rejects the promise with an `Error` whose `code` is the specification category (`unsupported`, `invalid-request`, `selection-expired`, `user-cancelled`, `cancelled`, `timeout`, `quota-exceeded`, `provider-error`) and whose `message` is a diagnostic for people, not for matching. Unknown codes are still failures. Use `isLlmFailure(error)` to recognize one across bundle boundaries. A lost response is different from an explicit error: the binding's own transport timeout rejects with a plain `Error` and no `code`. Runtime limits are policy, not fixed maxima; a request that exceeds them fails explicitly instead of truncating messages, dropping images, or changing models.
## Declaring the domain
Declare `llm` as required (`R`) only when inference is the napplet's core task; otherwise declare it optional (`O`) and check `window.napplet?.llm` before use. The vite plugin infers the bare domain from `@napplet/sdk/llm` and `@napplet/nap/llm` imports and from direct `window.napplet.llm` reads. Declaring the domain configures no connection and grants no consent. Model output is untrusted data: render it as text, never as markup, and never treat it as authorization to execute tools, sign, pay, or fetch.
## Conformance scope
Conformance validates the four request schemas, including tool definitions, tool-choice rules, part placement by role, and adjacent tool-result matching, rejects result and delta envelopes emitted by napplets, and runs the reference shell's deterministic mock: one connection (`reference-model`, with images, JSON, tools, and every tool-choice mode), endpoint-scoped handles, exact-model requirement matching, a `start` / `delta` / `end` block lifecycle before the authoritative result, echoed text or JSON output (trailing tool results echoed, with the specified `Tool execution failed.` prefix for errors), a `toolUse` result for `required` or named choices, and fixture markers in the last user text (`[[decline]]` for a declined prompt, `[[refuse]]` for a provider refusal, `[[stall]]` for a generation that stays active until `cancel`, `[[think]]` for a thinking block, `[[tool]]` for a call to the first supplied tool). The mock never executes a tool call. It exercises protocol shape, not live inference, consent prompts, provider adapters, or billing; verify those in an LLM-capable host.
---
# NAP maturity and opt-in
This is non-normative implementation guidance for the `@napplet/*` packages. The living [NIP-5D](https://github.com/nostr-protocol/nips/pull/2303) and [NAP proposals](https://github.com/napplet/naps) govern protocol behavior. These labels are our implementation assessments, not official governance stages, security certifications, or compatibility guarantees. All NAP capabilities remain subject to change.
## Two separate signals
**Proposal status** records the upstream state at the date shown in `proposal.checkedAt`: `merged`, `in-review`, `draft` (a GitHub draft PR), or `unknown`. It describes the linked proposal, not every amendment to that domain. A merged document can still call itself a draft. A reverted merge does not make a reopened proposal merged.
**Implementation maturity** describes this package's implementation:
- **supported**: maintained and tested for ordinary integration; its API is not frozen.
- **experimental**: implemented for deliberate evaluation; substantial changes remain likely.
- **exploratory**: early implementation intended to explore a published draft.
A missing classification is excluded by the selection helper. Deferred or rejected proposals are retired rather than offered as another opt-in tier. Experimental status never waives protocol conformance requirements.
## Inspect installed-package metadata
```ts
import { NAP_IMPLEMENTATIONS, getNapImplementation } from '@napplet/core';
const llm = getNapImplementation('llm');
console.log(llm?.maturity, llm?.proposal.url, llm?.proposal.checkedAt);
console.log(NAP_IMPLEMENTATIONS.storage.rationale);
```
Metadata is bundled at release time. It makes no runtime GitHub requests and does not identify a remote shell's supported protocol revision. An optional `implementationReference` records an upstream revision, the repository evidence, and the scope of a historical implementation verification. It does not claim that the whole current implementation matches that revision; later changes are outside the recorded scope. Missing provenance stays missing; a fetched head SHA alone is not verification. The linked living proposal remains authoritative, even when a historical verification revision is recorded.
## Select supported domains
Supply only domains that the host implements and has authorized for this napplet. The helper filters those candidates; it never discovers host support, grants permission, or adds a domain.
```ts
import { selectNapDomains } from '@napplet/core';
import { renderNappletRuntimePreludeCall } from '@napplet/shim/prelude';
const selection = selectNapDomains({
candidates: ['storage', 'identity', 'llm'],
});
// selection.domains: ['storage', 'identity']
// selection.excluded explains why llm was omitted.
const activate = renderNappletRuntimePreludeCall({ domains: selection.domains });
```
Inline the maintained `@napplet/shim/prelude.global` artifact followed by `activate` before any napplet scripts run. The [shim guide](https://napplet.run/docs/packages/shim.md) shows the complete injection pattern.
## Opt into one experiment
```ts
const selection = selectNapDomains({
candidates: ['storage', 'llm', 'ble'],
optIn: ['llm'],
});
// ['storage', 'llm']; ble remains excluded.
```
An opt-in is intersected with candidates. Opting into `llm` cannot activate it when the host did not include it. Adding another experimental implementation in a package upgrade does not activate it through this opt-in.
`allowedMaturities` defaults to `['supported']`. An explicit empty array allows only named opt-ins. A broader list such as `['supported', 'experimental']` is possible, but admits future candidate domains classified at those levels. Prefer named opt-ins when upgrades must not enable new experiments automatically. Classifications can also change between releases; review catalog changes during upgrades.
The selector returns `domains` and `excluded`. Exclusion reasons are `unknown-domain`, `unclassified`, and `maturity-not-allowed`. Inputs are not mutated; results are deduplicated in candidate order.
## Enforce the same decision in the host
Shim filtering is not a security boundary: a napplet can send `postMessage` directly. Apply `selection.domains` to the endpoint's host dispatch allowlist as well as namespace injection. Keep source identity checks, per-operation authorization, and domain-specific requirements in place. Selection does not automatically enable dependency domains.
The repository's reference mock supports the same pattern:
```ts
import { createReferenceShell } from '@napplet/conformance';
const shell = createReferenceShell({ domains: selection.domains });
// Attach this mock only to the authenticated test endpoint.
// Both handle() and handleFrom() enforce the host domain ceiling.
```
For browser evaluation, `bootAndCollect` accepts `domains` and an optional `maturity` policy. It resolves that policy before boot and passes the same domains to the prelude and reference dispatcher. Its normal conformance mode keeps all domains enabled so experimental APIs still receive full protocol checks. Local policy exclusion does not turn a valid envelope into a conformance failure.
## Prelude compatibility
`@napplet/shim/prelude` has no install-on-import side effect. Its existing explicit `domains` behavior remains unchanged when `maturity` is omitted. Passing `maturity: {}` enables supported-only filtering; `maturity: { optIn: ['llm'] }` adds that named candidate exception. Rendering resolves policy on the host and sends only the selected domain list into the prelude.
The legacy root `@napplet/shim` entrypoint still installs every bundled domain on import for compatibility. Do not use it for policy-controlled injection. If it was already imported during host setup, the explicit prelude replaces the namespace with the selected set; it does not retroactively undo earlier initialization or calls. Complete policy selection before napplet code runs and enforce the selected set in host dispatch.
## Required and optional domains
This explanation defers to NIP-5D's **Required and Optional Capabilities** and **Runtime Domain Availability** sections. Check the complete manifest `R` set against the domains the shell can expose after policy selection. A missing required domain means the shell should reject loading or warn that full functionality is unavailable. Missing `O` domains alone do not prevent loading. Neither declaration grants access. Napplets check namespace presence and gracefully degrade or stop using absent domains.
Maturity is not encoded in the manifest, HTML tags, injected namespace, or wire envelopes. Any interoperable semantic compatibility checks or negotiation belong in the relevant NAP first.
## Maintaining classifications
Edit `packages/core/src/nap-catalog.ts` with evidence from implementation tests, real integration, known design uncertainty, and upstream discussion. Verify the linked proposal's current state and update its check date. Do not infer implementation maturity from PR merge state alone. The core type contract and coverage test require metadata for every shipped domain; domain overview tables and page notices render directly from this catalog during docs builds.
Promotions and demotions are reviewable package changes. Record why the evidence changed, update tests and release notes, and retire implementations whose proposals are deferred or rejected according to repository policy. No new release channel is required.
### Scheduled upstream checks
The **NAP metadata review** GitHub Actions workflow checks upstream daily at 05:43 UTC and supports **Run workflow** on the default branch. It maintains one draft PR on `chore/nap-proposal-refresh`. The schedule starts after the workflow is merged into the default branch. Run the same check locally with `GH_TOKEN=… pnpm refresh:nap-maturity` using a token that can read the public NAP repository.
The updater refreshes proposal status and its check date when an observation changes. It detects open proposal head changes, later edits or removals of merged NAP files, and registry status changes. Unchanged daily checks produce no commits, check-date changes, or package bumps; the workflow summary records successful polling. The catalog date therefore records the last committed observation, not the latest cron execution. API errors, missing data, or a proposal changing during collection fail the run before local files are updated.
`scripts/data/nap-proposal-observations.json` contains observed heads and file revisions solely for change detection. These revisions are not implementation verification references or normative pins. The updater never changes implementation maturity, rationale, evidence, or `implementationReference`. A closed unmerged PR becomes `unknown` and triggers a retirement review; removals and definite deferred/rejected registry states also receive explicit warnings. Maintainers must investigate and retire dependent surface under AGENTS.md before accepting a retirement change. The bot does not perform that sweep or change runtime availability.
Review the generated report in `scripts/data/nap-proposal-review.md`, inspect changed upstream contracts, and decide whether implementation changes or a separate follow-up are needed. Merging an observation PR acknowledges its baseline; it does not certify implementation compatibility. A shipped catalog change includes a core patch changeset. Follow the usual release-metadata procedure before shipping; no bot job merges or publishes packages. Observation-only updates need no package release. Docs render merged catalog changes on the next site build, and installed packages receive them through ordinary releases.
Repository Actions settings must allow GitHub Actions to create pull requests. The workflow uses `GITHUB_TOKEN` by default and validates build, types and units itself because PRs created with that token do not trigger the usual PR workflows. To trigger normal PR checks automatically, configure the optional `NAP_MATURITY_BOT_TOKEN` Actions secret with a dedicated fine-grained token that can write repository contents and pull requests. Without it, a maintainer must arrange the required PR checks before merging. If a scheduled run fails, inspect its logs and rerun it after fixing API access or source mapping; no partial upstream read is committed. The fixed bot branch is managed by the updater, so put implementation fixes on a separate branch or pause the workflow while editing that branch.
---
# MEDIA
> **Implementation: supported · Proposal: [in-review](https://github.com/napplet/naps/pull/10)** (status checked 2026-10-31). Session creation and playback binding have focused regression coverage; proposed torrent playback amendments are not implied. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Ownership-aware media sessions and playback control.
```ts
const { sessionId } = await window.napplet.media.createSession({
owner: 'napplet',
metadata: { title: 'My Song', artist: 'The Artist' },
});
window.napplet.media.reportState(sessionId, { status: 'playing', position: 0, duration: 240 });
window.napplet.media.onCommand(sessionId, (action) => { if (action === 'pause') player.pause(); });
```
This non-normative guide defers to the living [NAP-MEDIA proposal](https://github.com/napplet/naps/pull/10). Feature-gate with `if (window.napplet?.media)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/media`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# NOTIFY
> **Implementation: experimental · Proposal: [in-review](https://github.com/napplet/naps/pull/11)** (status checked 2026-10-31). Notification binding ships with limited dedicated domain regression evidence; broader host integration confidence is unverified. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Shell-rendered notifications, badges, and interaction callbacks.
```ts
const { notificationId } = await window.napplet.notify.send({ title: 'New message', body: 'Alice: hey!' });
window.napplet.notify.badge(3);
window.napplet.notify.onAction((id, actionId) => { if (actionId === 'reply') openReply(id); });
```
This non-normative guide defers to the living [NAP-NOTIFY proposal](https://github.com/napplet/naps/pull/11). Feature-gate with `if (window.napplet?.notify)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/notify`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# OUTBOX
> **Implementation: supported · Proposal: [in-review](https://github.com/napplet/naps/pull/32)** (status checked 2026-10-31). Routing and subscription bindings have focused tests and an integration regression for structured-clone boundaries. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Outbox-aware relay routing — `getEvent` / `query` / `subscribe` / `publish` / `resolveRelays`; the shell owns NIP-65 relay discovery, fallback, dedup, signature validation, signing, and fanout. This is the default event-read and publish boundary when relay selection is part of result correctness.
```ts
if (window.napplet?.outbox) {
await window.napplet.outbox.getEvent('ev1…', { author: 'ab12…' });
const { events } = await window.napplet.outbox.query(
[{ authors: ['ab12…'], kinds: [1], limit: 20 }],
{ authors: ['ab12…'], timeoutMs: 3000 },
);
const sub = window.napplet.outbox.subscribe(
[{ authors: ['ab12…'], kinds: [1], limit: 20 }],
{ authors: ['ab12…'], timeoutMs: 3000 },
);
sub.on('event', (result) => render(result.event, result.sidecar?.relayHints));
await window.napplet.outbox.publish({
kind: 1,
content: 'gm',
tags: [],
created_at: Math.floor(Date.now() / 1000),
});
}
```
This non-normative guide defers to the living [NAP-OUTBOX proposal](https://github.com/napplet/naps/pull/32). Feature-gate with `if (window.napplet?.outbox)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/outbox`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# RELAY
> **Implementation: supported · Proposal: [in-review](https://github.com/napplet/naps/pull/2)** (status checked 2026-10-31). Relay reads, publishing, and subscription lifecycle have maintained shim and integration tests; upstream naming remains under review. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Low-level Nostr relay proxy — subscribe, publish, and one-shot query through the shell's relay pool. Use this only for explicit relay-local behavior such as a group relay, diagnostics, or protocol tooling. For normal social reads and publishes, use [`outbox`](https://napplet.run/docs/naps/outbox.md) or a higher-level domain such as [`common`](https://napplet.run/docs/naps/common.md), [`lists`](https://napplet.run/docs/naps/lists.md), [`count`](https://napplet.run/docs/naps/count.md), or [`dm`](https://napplet.run/docs/naps/dm.md).
```ts
// Explicit relay-local subscription (returns a handle with .close())
const sub = window.napplet.relay.subscribe(
[{ kinds: [9, 10, 11, 12], limit: 20 }],
(result) => render(result.event),
() => console.log('end of stored events'),
{ relay: 'wss://groups.example.com' },
);
```
This non-normative guide defers to the living [NAP-RELAY proposal](https://github.com/napplet/naps/pull/2). Feature-gate with `if (window.napplet?.relay)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/relay`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# RESOURCE
> **Implementation: supported · Proposal: [in-review](https://github.com/napplet/naps/pull/80)** (status checked 2026-10-31). Resource fetching has focused shim and type tests; the reverted merge and reopened proposal do not change that implementation assessment. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Sandboxed byte fetching (`info() -> ResourceInfo`, `bytes(url, options?) -> Blob`, `bytesMany(requests) -> ResourceBytesItem[]`) over https / blossom ([BUD-10](https://github.com/hzrd149/blossom/blob/master/buds/10.md) `blossom:.` URIs) / nostr / data schemes — the only network-fetch primitive available inside the iframe sandbox.
```ts
const info = await window.napplet.resource.info();
const blob = await window.napplet.resource.bytes('blossom:.png', {
servers: ['https://cdn.hzrd149.com'],
});
const items = await window.napplet.resource.bytesMany([
{ url: 'https://example.com/avatar.png' },
{ url: 'blossom:.png', servers: ['https://cdn.hzrd149.com'] },
]);
// Managed object URL — revoke when done to free memory
const { url, revoke } = window.napplet.resource.bytesAsObjectURL('blossom:.png');
imgEl.src = url;
imgEl.onload = () => revoke();
```
This non-normative guide defers to the living [NAP-RESOURCE proposal](https://github.com/napplet/naps/pull/80). Feature-gate with `if (window.napplet?.resource)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/resource`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# SERIAL
> **Implementation: experimental · Proposal: [in-review](https://github.com/napplet/naps/pull/61)** (status checked 2026-10-31). Serial binding has unit coverage; device and native-host integration evidence is limited. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Runtime-mediated serial device access. A napplet asks the shell to select and open a user-approved serial session, writes byte arrays to it, and receives shell-pushed `state`, `data`, and `closed` events. The shell owns device selection, permissions, raw port handles, streams, OS paths, read loops, and lifecycle policy.
```ts
if (window.napplet?.serial) {
const { session } = await window.napplet.serial.open({ options: { baudRate: 115200 } });
const sub = window.napplet.serial.onEvent((event) => {
if (event.type === 'data') append(event.data);
});
await window.napplet.serial.write(session.id, [112, 105, 110, 103, 10]);
}
```
This non-normative guide defers to the living [NAP-SERIAL proposal](https://github.com/napplet/naps/pull/61). Feature-gate with `if (window.napplet?.serial)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/serial`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# SHELL
> **Implementation: supported · Proposal: [merged](https://github.com/napplet/naps/pull/101)** (status checked 2026-10-31). Optional readiness and environment binding has focused shim and reference-runtime coverage. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
The runtime may expose `shell` for environment information. `shell.supports(domain)` reads current domain object presence, `shell.services` is empty until environment delivery, and `shell.ready()` / `shell.onReady(handler)` use the retained snapshot. Readiness does not gate other domain calls. See the living [NAP-SHELL](https://github.com/napplet/naps/blob/master/naps/NAP-SHELL.md).
```ts
if (window.napplet?.shell) {
if (window.napplet.shell.supports('storage')) enableDrafts();
const sub = window.napplet.shell.onReady((environment) => {
console.log(environment.services);
});
}
```
Named helpers `shellSupports`, `shellReady`, and `shellOnReady` are exported from `@napplet/nap/shell`.
This non-normative guide defers to the living [NAP-SHELL proposal](https://github.com/napplet/naps/pull/101). Feature-gate with `if (window.napplet?.shell)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/shell`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# STORAGE
> **Implementation: supported · Proposal: [in-review](https://github.com/napplet/naps/pull/3)** (status checked 2026-10-31). Key-value binding has focused request/result tests and tutorial usage; host storage isolation remains the host responsibility. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Scoped key-value storage proxied through the shell — isolated per napplet identity.
```ts
await window.napplet.storage.setItem('draft', 'hello');
const draft = await window.napplet.storage.getItem('draft'); // string | null
const keys = await window.napplet.storage.keys();
```
This non-normative guide defers to the living [NAP-STORAGE proposal](https://github.com/napplet/naps/pull/3). Feature-gate with `if (window.napplet?.storage)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/storage`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# THEME
> **Implementation: supported · Proposal: [merged](https://github.com/napplet/naps/pull/103)** (status checked 2026-10-31). Theme queries and pushed updates have focused binding and opaque-iframe integration coverage. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Read-only shell theme access — colors, fonts, background, and title. The shell owns theming; the napplet reads it and reacts to changes.
```ts
const theme = await window.napplet.theme.get();
document.body.style.background = theme.colors.background;
const sub = window.napplet.theme.onChanged((t) => applyTheme(t));
```
This non-normative guide defers to the living [NAP-THEME proposal](https://github.com/napplet/naps/pull/103). Feature-gate with `if (window.napplet?.theme)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/theme`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
## Updates and fallbacks
Theme updates reach napplets with the exposed `theme` domain, independently of manifest requirement declarations. Theme media URLs do not grant network access, so colors and local fonts provide the fallback. See the living [NAP-THEME](https://github.com/napplet/naps/blob/master/naps/NAP-THEME.md).
---
# UPLOAD
> **Implementation: supported · Proposal: [in-review](https://github.com/napplet/naps/pull/33)** (status checked 2026-10-31). Upload binding has focused tests for request/result behavior; host rail support remains runtime-specific. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Shell-mediated file/blob upload over NIP-96 + Blossom rails; the shell signs auth and returns NIP-94 metadata.
```ts
if (window.napplet?.upload) {
const info = await window.napplet.upload.info();
const result = await window.napplet.upload.upload({ data: blob, filename: 'pic.png' });
if (result.status === 'complete') attach(result.url, result.nip94);
}
```
This non-normative guide defers to the living [NAP-UPLOAD proposal](https://github.com/napplet/naps/pull/33). Feature-gate with `if (window.napplet?.upload)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/upload`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# WEBRTC
> **Implementation: experimental · Proposal: [in-review](https://github.com/napplet/naps/pull/59)** (status checked 2026-10-31). Signaling and session binding have unit coverage; interoperable live peer sessions remain unverified here. All capabilities remain subject to change. [Maturity and opt-in policy](https://napplet.run/docs/naps/maturity.md).
Runtime-mediated WebRTC sessions. The runtime owns signaling transport, signing and encryption, SDP, ICE, and the `RTCPeerConnection` lifecycle; napplets see only shell-scoped sessions — `direct` to one pubkey or a named `room` — and opaque application payloads delivered as `state`, `peer`, `message`, and `closed` events.
```ts
if (window.napplet?.webrtc) {
const { session } = await window.napplet.webrtc.open({ scope: { type: 'direct', pubkey: '' } });
const sub = window.napplet.webrtc.onEvent((event) => {
if (event.type === 'message') handle(event.from, event.payload);
});
await window.napplet.webrtc.send(session.id, { move: 'e4' });
}
```
This non-normative guide defers to the living [NAP-WEBRTC proposal](https://github.com/napplet/naps/pull/59). Feature-gate with `if (window.napplet?.webrtc)` before use; the runtime injects only available domains. Typed helpers ship from [`@napplet/nap/webrtc`](https://napplet.run/docs/packages/nap.md) and [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md).
---
# @napplet/boilerplate
> Project-only generator behind `napplet create`.
`@napplet/boilerplate` is the package-backed generator used by the primary CLI. It clones the [`github.com/napplet/boilerplate`](https://github.com/napplet/boilerplate) template — a Vite + TypeScript napplet starter — and derives the package name from the destination. Deployment name, title, description, and archetypes are owned later by `napplet init`.
- **npm:** [`@napplet/boilerplate`](https://www.npmjs.com/package/@napplet/boilerplate)
- **Source:** [packages/boilerplate](https://github.com/napplet/web/tree/main/packages/boilerplate)
## Usage
```bash
napplet create my-napplet
```
It currently ships one variant, `basic`, but keeps a `--variant` option so future templates can be added without changing the command shape.
## Direct package route
The package can still be invoked directly for generator development or custom template testing:
```bash
npx @napplet/boilerplate ./my-napplet --yes
```
## Programmatic CLI API
`runCli(argv)` is the import-safe generator entry point used by the standalone CLI. It returns `0` on success or `1` after emitting the same `@napplet/boilerplate:` diagnostic as the package binary.
```ts
import { runCli } from '@napplet/boilerplate';
const status = await runCli(['my-napplet', '--template', './template']);
```
## Options
| Option | Purpose |
| --- | --- |
| `--variant ` | Template variant. Currently `basic`. |
| `--template ` | Override the template source (useful for local verification). |
| `--yes`, `-y` | Use `./my-napplet` when the destination is omitted. |
| `--force` | Allow generation into a non-empty directory. |
By default the CLI clones `https://github.com/napplet/boilerplate.git`.
## Deployment metadata boundary
The generated project leaves archetype metadata to `napplet init`. Each manifest archetype tag advertises one complete queryless `napplet:/` identity:
```json
["z", "profile"]
["i", "napplet:profile/open"]
```
Intent calls derive archetype and action from the URI. An `ok: true` result means the runtime accepted delivery responsibility and includes the normalized identity and handler catalog identifier; an `ok: false` result includes `error`. Targets receive runtime-attested `IntentDelivery` values through `onDelivery`, including deliveries retained before registration. Behavior hints are `focus` and `reuse`. This non-normative guidance defers to the living [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md).
This non-normative guide defers to the living [NAP-INC](https://github.com/napplet/naps/blob/master/naps/NAP-INC.md), [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md), and [web projection](https://github.com/napplet/naps/blob/master/projections/web.md).
## See also
- [Getting started](https://napplet.run/docs/guide/getting-started.md) — scaffold and run your first napplet
- [`@napplet/vite-plugin`](https://napplet.run/docs/packages/vite-plugin.md) — the manifest plugin the template wires up
---
# @napplet/cli
> Standalone CLI for creating, configuring, inspecting, and deploying napplets.
`@napplet/cli` is the command-line deploy and diagnostics tool for built
napplets. It creates `.napplet/config.yaml`, discovers built `index.html`
artifacts, inspects deploy plans, signs manifest events, uploads files to
Blossom servers, publishes to relays, and runs local napplet tooling such as
conformance and Paja.
The standalone binary runs every built-in command without separately installed Node.js, npm, npx, Deno, Git, Kehto, or browser tooling. Screenshot, conformance, and Paja commands download a private, checksum-verified Node runtime and maintained packages on first use; browser commands also provision Chromium. Linux captures include the browser libraries and fonts. Downloads live in the CLI cache and never install into your project or global PATH.
First use of these tools requires network access and a writable cache. Later invocations reuse the downloads. The default cache is `~/Library/Caches/napplet` on macOS, `$XDG_CACHE_HOME/napplet` (or `~/.cache/napplet`) on Linux, and `%LOCALAPPDATA%\napplet` on Windows. Set `NAPPLET_CACHE_DIR` to choose another location; copying a populated cache between machines with the same OS and architecture supports offline tooling. User-configured `conformance.command`, `paja.command`, and app-server commands passed to Paja use the software you explicitly select.
The generated Vite project and optional skills.sh CLI still need their own development dependencies. Agent skills are installed separately — see [Agent skills](https://napplet.run/docs/guide/agent-skills.md).
- **npm:** [`@napplet/cli`](https://www.npmjs.com/package/@napplet/cli)
- **JSR:** [`@napplet/cli`](https://jsr.io/@napplet/cli)
- **Source:** [packages/cli](https://github.com/napplet/web/tree/main/packages/cli)
## Install
Install a checksum-verified standalone binary without Deno:
```bash
# macOS or Linux
curl -fsSL https://napplet.run/install.sh | sh
```
```powershell
# Windows PowerShell
irm https://napplet.run/install.ps1 | iex
```
The installers download from the [stable CLI release](https://github.com/napplet/web/releases/tag/napplet-cli) and verify the asset against its `SHA256SUMS`. Linux and macOS support x64 and ARM64; Windows supports x64.
### Check the installed version
```sh
napplet --version
# Also available: napplet -v or napplet version
```
This prints the version of the CLI you actually invoked. If you have both standalone and Deno installations, use `type -a napplet` on macOS/Linux or `Get-Command napplet -All` in PowerShell to check which executable takes precedence. A version command reported as unknown means that installation predates version reporting.
### JSR/Deno alternative
```bash
deno install --global \
--allow-read --allow-write --allow-run --allow-env --allow-net \
--name napplet \
jsr:@napplet/cli/cli
```
To refresh an existing JSR installation, rerun the command with `--force --reload` after `--global`. Then check the Deno-installed executable directly (`~/.deno/bin/napplet --version` with Deno's default install location); another `napplet` earlier on `PATH` is unaffected. See [Deno's install reference](https://docs.deno.com/runtime/reference/cli/install/) for custom install locations and upgrade flags.
Then open the developer guide or check the command reference:
```bash
napplet guide
napplet --help
```
The permission set is explicit because deploys need to read build output and
config, write temporary deploy state, run local helper commands, read signing
environment variables, upload to Blossom servers, publish to relays, and connect
to remote signers.
## Quick start
Run the same path for every new project:
```bash
napplet create my-napplet
cd my-napplet
napplet init
npx skills add napplet/web
pnpm install
# Ask your agent to build the napplet.
pnpm verify
napplet deploy --dry-run
napplet deploy
```
- `napplet create` delegates to the maintained starter generator without setting deploy metadata.
- `napplet init` owns name, title, optional description, canonical archetype conventions, and network
targets in `.napplet/config.yaml`. In an interactive terminal it guides setup and shows live suggestions from
relays such as `wss://relaypag.es`, and suggests Blossom servers from kind
`10063` server-list events.
- `npx skills add napplet/web` installs the `napplet-*` agent skills for whichever coding agents you use; it is the skills.sh CLI, not a `napplet` subcommand.
- `napplet debug` prints resolved config, discovered napplets, deploy targets, manifest templates, and signing readiness without network writes. Manifest template failures (for example a missing description, or built files a current deploy would drop) are reported in `manifests.error` instead of aborting, and `--format current|legacy` selects the event format inspected.
- `napplet deploy --dry-run` builds the same deploy plan and signed manifest
events without uploading or publishing.
- `napplet deploy` uploads files to configured Blossom servers and publishes
signed root, named, and optional snapshot manifest events to configured relays.
- When no signer flag or stored signer exists, interactive `napplet deploy`
starts the NIP-46 connection flow and stores the paired remote signer when
native key storage is available.
- `--prompt-sec` reads hidden input until Enter; when `.napplet` names a bunker pubkey/npub, a
mismatched prompted signer requires interactive confirmation and fails closed in non-interactive
runs.
## Commands
```bash
napplet guide
napplet create [--template ] [--force]
napplet init [--force] [--root] [--source-dir ] [--name ] [--title ] [--description ] [--archetype ] [--relay ] [--server ] [--proxy ]
napplet discover [--config ] [--all]
napplet debug [--format current|legacy] [--config ] [--all] [--root] [--name ] [--snapshot] [--sec ]
napplet deploy [--format current|legacy] [--config ] [--all] [--root] [--name ] [--snapshot] [--sec ] [--prompt-sec] [--zapstore | --no-zapstore] [--no-screenshot] [--screenshot []] [--proxy ] [--dry-run]
napplet keys store --name [--sec | --prompt-sec]
napplet keys connect --name [--relay ...] [--proxy ] [--config ]
napplet keys use --name [--config ]
napplet keys list
napplet keys delete --name
napplet keys doctor
napplet conformance [--config ] [--all] [-- ]
napplet paja [--config ] [-- ]
```
## Layouts
For a single napplet repository, discovery checks `sourceDir` and prefers
`dist/index.html`, falling back to a top-level `index.html`.
For workspaces, set `discover.roots` and use `--all`. Each discovered napplet
deploys under its own folder name as the named `d` tag.
## YAML configuration and JSON migration
Project settings live in `.napplet/config.yaml`. YAML supports comments and uses the same configuration fields as before. Use `--config path/settings.yaml` or `--config path/settings.yml` for a custom file. Configuration updates preserve comments on unchanged values.
When a command needs configuration and finds the old JSON file, it asks before migrating. Answer `y` to continue with YAML; `n` or Enter exits with `Cannot continue without migrating`. Migration retains the exact original bytes in `config.json.bak`, preserves file permissions, and removes the active JSON file only after writing YAML. Conflicting YAML or backup files stop migration without overwriting them. To roll back for an older CLI, move the YAML file aside and restore the JSON backup.
CI, redirected input, `--json`, and `-i` / `--non-interactive` do not prompt for migration. Approve conversion explicitly with `--migrate-config`:
```sh
napplet init --migrate-config -i
napplet deploy --config settings.json --migrate-config --dry-run
```
An explicit legacy `--config settings.json` path resolves to `settings.yaml` after migration, including on later invocations. The original command continues using YAML. Help and version remain available before migration. The `napplet migrate` command handles signed manifest events separately; event files and machine-readable output remain JSON.
## Archetype conventions
Pass each `--archetype` option as a queryless convention URI, for example `napplet:profile/open`. The CLI derives the role from the URI. YAML configuration accepts URI strings directly:
```yaml
metadata:
archetypes:
- napplet:profile/open
```
The CLI normalizes each URI to an object such as `{ "slug": "profile", "convention": "napplet:profile/open" }`. Existing object configs remain supported; add `"params": ["pubkey"]` to an object to advertise accepted parameter names. Older role-prefixed CLI inputs remain supported for compatibility.
This example emits the independent advertisements:
```json
["z", "profile"]
["i", "napplet:profile/open"]
```
This non-normative guide follows the living [NAP-INTENT manifest catalog contract](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md#manifest-catalog-contract). A convention is eligible for dispatch when its archetype segment matches a `z` role on the same manifest. Metadata convention identities are queryless and fragment-free. Trailing `i` values advertise parameter names, not event-kind restrictions or parameter values.
## See also
- [`@napplet/vite-plugin`](https://napplet.run/docs/packages/vite-plugin.md) — emits build-side manifest metadata
the CLI preserves during deploy.
- [`@napplet/conformance-cli`](https://napplet.run/docs/packages/conformance-cli.md) — the default command behind
`napplet conformance`; the standalone CLI provisions it in its private cache without a host package manager or global conformance executable.
- [Getting started](https://napplet.run/docs/guide/getting-started.md) — scaffold, build, and verify a
napplet before deploying.
## Current and legacy event formats
From CLI **0.7.0**, deployments default to the current NIP-5D artifact-hash schema. Interactive deploys ask for `current` or `legacy`; scripts can choose with `--format current|legacy`. A non-empty description is required for current event content. Legacy output remains isolated in a temporary serializer; 0.6.x and earlier defaulted to legacy events. Current deploys upload only `/index.html` plus the blobs referenced by `icon`, `screenshot` and `preview` tags; any other built file in the deploy directory makes deploy fail with the list of those files (build a single-file artifact or use `--format legacy`). When config metadata or the Vite plugin sidecar lists a domain as both required and optional, it is emitted only as `O`, matching the Vite plugin.
```sh
napplet deploy --dry-run --format current
napplet deploy --format legacy
napplet migrate signed-event.json --optional theme --output migration-preview.json
```
`migrate` verifies the source signature and writes an unsigned preview with provenance. It does not publish or modify the artifact, and refuses to overwrite an existing output file. Review description, required/optional choices and pointers before signing. See the [migration guide](https://napplet.run/docs/guide/event-migration) for format differences and shell data/consent implications.
## Repository source metadata
For current-format deploys, the CLI adds one `source` tag from the Git `origin` of the repository containing each selected build when neither config, the Vite sidecar, nor HTML already supplies it. Nested builds and Git worktrees are supported. HTTPS, HTTP, `ssh://`, and scp-style clone URLs are converted to HTTP(S) repository URLs; inferred URLs omit credentials, query strings, fragments, and the `.git` suffix. SSH remotes use HTTPS without the SSH port. Git is queried locally; no remote is contacted. If Git, a repository, or a usable origin is unavailable, the tag is omitted. Other remotes are not selected, and SSH host aliases are not expanded; configure their public source URL explicitly.
Set `metadata.source` in `.napplet/config.yaml` to override every inferred or embedded source:
```yaml
metadata:
source: https://github.com/example/my-napplet
```
Use `source: false` in that object to omit the tag, including any sidecar or HTML source. A source override must be one absolute HTTP(S) URL without credentials or a NIP-34 `nostr://` clone URL; arrays are not accepted. The precedence is config → sidecar → HTML → Git origin. Root, named, and companion snapshot events retain the resolved source. This follows the current [NIP-5D manifest table](https://github.com/dskvr/nips/blob/nip/5d/5D.md#manifest), which permits zero or one `source` tag. Automatic Git inference does not apply to `--format legacy`; an explicit config override still does. These are CLI defaults, not additional protocol requirements.
[NIP-5D §Source](https://github.com/nostr-protocol/nips/pull/2303) also allows one `source-commit` pin and one `source-archive`. Set them explicitly; the CLI never infers a commit, because a dirty or unpushed tree would pin a revision nobody can fetch:
```yaml
metadata:
source: https://github.com/example/my-napplet
sourceCommit: 0123456789abcdef0123456789abcdef01234567
sourceArchive:
url: https://dl.example.com/my-napplet-src.tar.gz
sha256: c0ffee…
mimeType: application/gzip
```
Config values replace matching sidecar tags. `sourceCommit` must be a full lowercase-hex Git commit ID (40 or 64 characters); `sourceArchive.url` must be an absolute HTTPS URL without credentials or a fragment, with the archive's sha256 and MIME type. Deploy refuses malformed or repeated pins rather than signing tags clients ignore. A `source-commit` is only meaningful next to a repository `source`.
## Standalone HTML recovery
The CLI reads the head mappings from [NIP-5D, HTML Metadata for Publishing](https://github.com/nostr-protocol/nips/pull/2303), including title, description, named identifier, roles, intents with advertised parameters, required/optional domains, source, server hints, and supported PNG/JPEG/WebP data-URL icons. This is non-normative implementation guidance; the living specification remains authoritative. Embedded metadata is optional, remains untrusted publishing input, and does not replace signed manifest verification at runtime.
For current-format deployment, explicit config metadata overrides matching sidecar categories, which override matching head categories. Missing categories fall back to HTML. Explicit target/name selection and configured names override `napplet-id`; a single-project named deploy without a configured name uses the sidecar identifier, then the head identifier, before the existing `default` fallback. Monorepo traversal retains folder-based naming and filtering. Configured Blossom servers override embedded hints; when none are configured, recovered server hints supply upload destinations. The CLI validates selected destinations before manifest signing or upload: each must be a non-empty HTTP(S) origin without credentials, a path, a query, or a fragment; a trailing slash is accepted. Invalid destinations produce an error identifying their config or manifest-hint position. Relay and signing configuration are still needed for publication.
Signed or unsigned sidecars can contain additional Nostr tags. Deploy preserves their full string payloads, order, and repeated values through signing and companion snapshots; migration preserves them too. Config overrides and CLI-managed publication fields still apply, but tag names are not restricted to a napplet allowlist. A current event's extra `path`, `description`, `requires`, or `archetype` tag is not a legacy publishing instruction. Extra `description` tags remain separate from event content. These are implementation notes; [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) and the living [NIP-5D proposal](https://github.com/nostr-protocol/nips/pull/2303) define the event format.
Root and named events omit lineage tags. Companion snapshots retain the CLI's selected source address as their parent; head provenance does not replace that explicit selection. Root/snapshot events omit the named identifier. Deployment does not rewrite the HTML when config or selection overrides its hints: it hashes and uploads the original bytes and signs the resolved metadata separately.
Decoded icon bytes are hashed and uploaded unchanged alongside the HTML, even when `index.html` is the only file in the deployment directory. No extracted icon file is written. If a sidecar overrides the icon, embedded bytes are used only when both hash and MIME type match that declaration. Remote, unsupported, empty, malformed, and MIME-mismatched icon URLs are ignored. Hash-only sidecars still require the matching blob to be supplied separately. Consumers verify decoding before display according to NIP-5D. Legacy-format deployment retains its previous title/description HTML fallback behavior.
Deployment manifests (current and legacy, including snapshots) and optional Zapstore application events include `["client", "napplet.run"]` before signing. This identifies the publisher using the [NIP-89 client tag](https://github.com/nostr-protocol/nips/blob/master/89.md#client-tag) and does not change artifact hashes. Dry-run templates include the same attribution.
## Presentation media
[NIP-5D §Presentation media](https://github.com/nostr-protocol/nips/pull/2303) describes screenshots and one preview video on the manifest itself, each by the sha256 of its bytes with optional `m`, `dim`, `size`, `alt` and (for the preview) `duration` fields. Current-format deploys handle them in three ways:
- Sidecar or configured `screenshot` / `preview` tags whose `x` hash matches a built file are uploaded with `/index.html` (the Vite plugin writes supplied media to `dist/.`). Entries clients would ignore (no usable `x`, an undecodable `m`, more than one `preview`) fail the deploy instead of being signed.
- `metadata.screenshots` and `metadata.preview` publish local files kept outside the build directory. Paths resolve relative to the working directory, are hashed, sized, format-sniffed (PNG, JPEG, WebP; WebM or MP4 for the preview) and measured, and replace any sidecar entries of the same kind; an empty list clears them.
- Automatic deploy capture (below) adds its PNG as a `screenshot` tag unless `metadata.screenshots` is configured.
```yaml
metadata:
screenshots:
- shots/board.png
- path: shots/editor.webp
alt: Editing a note
preview:
path: shots/demo.webm
duration: 8.5
```
`migrate` drops unusable screenshot and preview metadata from a converted event and says so in its notes. These are CLI behaviors, not protocol requirements; clients verify hashes and decoding before display.
## Screenshots and Zapstore metadata
Capture a built napplet without starting a host. The command starts a temporary local Kehto Paja preview, captures its iframe, and closes the preview afterwards. The CLI provisions its private runtime and Chromium automatically on first use, without host Node.js or npx. Screenshot and default conformance commands select the tested `@napplet/conformance-cli@0.3.3` runner explicitly, so an older project-local conformance dependency cannot intercept them. Explicit custom conformance commands remain supported. The preview uses Paja’s development identity, fresh browser storage, and live relays, so content may differ from your signed-in shell.
```sh
napplet screenshot --output preview.png
# Optional: choose another project or an existing shell.
napplet screenshot ./my-project --output other.png
# Optional: select one iframe and wait for content inside it.
napplet screenshot http://localhost:5173 --output ready.png --selector '#napplet-frame' --ready-selector '.app-ready'
```
The default capture is 1200 × 750 pixels with a 1500 ms delay. Override these with `--width`, `--height`, and `--delay` (milliseconds). The command captures only the iframe, waits for fonts, closes Chromium on success or failure, and refuses to overwrite an existing file. Keep screenshots outside the build directory.
To customize the application listing, add a `zapstore` object to `.napplet/config.yaml`:
```yaml
zapstore:
id: org.example.notes
name: Notes
description: Private notes in a napplet shell.
summary: A simple notebook
images:
- ./preview.png
- https://example.org/notes-mobile.png
icon: ./assets/icon.png
website: https://example.org/notes
repository: https://github.com/example/notes
license: MIT
tags:
- notes
- productivity
```
The `zapstore` object is optional. When omitted, deploy derives an application ID, name, and description from the selected manifest; a multi-build deploy uses the project directory name for its shared listing. Set `id` and `name` here to control that stable listing identity. Reusing an ID updates the listing under the signing pubkey. Configured fields take precedence over inferred values, and this configuration describes one application listing per deploy invocation.
```sh
napplet deploy --dry-run --json
napplet deploy
# Publish metadata without capturing a new screenshot:
napplet deploy --no-screenshot
# Skip both application metadata and automatic capture:
napplet deploy --no-zapstore
```
Application publication and screenshot capture default to on. `--no-screenshot` skips capture but still publishes the application event, including configured images. `--no-zapstore` skips the application event and automatic capture. `enabled: false` in the `zapstore` object also disables both; `--zapstore` overrides that setting for one deployment. Supplying both Zapstore flags or combining `--no-screenshot` with explicit screenshot options is an error. Dry runs capture a preview and show the application template and resolved media URLs without uploading or publishing.
Local PNG, JPEG and WebP images are resolved relative to the working directory, hashed, and uploaded to the configured Blossom servers. Existing HTTP(S) image URLs are used as supplied. Local media URLs point to the first selected Blossom server; if those uploads fail, application publication is skipped and deploy exits nonzero even if another mirror succeeds. The application uses the same signer and relays as the deployment. A requested application event must be accepted by at least one relay for deploy to succeed.
The listing uses kind `32267` from the [Software Applications proposal](https://github.com/nostr-protocol/nips/pull/1336), with screenshot URLs in `image` tags. It is published as a separate event before the napplet manifests. The current [NIP-5D manifest](https://github.com/nostr-protocol/nips/pull/2303) does not define an application reference tag, so deploy leaves manifest tags and artifact hashes unchanged. This publishes application metadata only, without software release or asset events; acceptance into a particular store's catalog depends on that store.
Deploy starts a local preview for each distinct selected build, captures a temporary PNG, appends it to the application images for that deployment, and adds the same capture to that build's current-format manifests as a NIP-5D `screenshot` tag (`x`, `m image/png`, `dim`, `size`) so the blob is uploaded once and referenced by hash. Configured `metadata.screenshots` keep their own list instead of the capture. Capture failure stops deployment before any upload or relay publication. The temporary image is removed after success or failure. Capture diagnostics go to stderr so `--json` stdout remains valid JSON. No host URL is required. Root and snapshot selections of the same artifact share one capture. To capture an existing shell instead, use `--screenshot `. Use `--screenshot-selector`, `--screenshot-ready-selector`, `--screenshot-width`, `--screenshot-height`, and `--screenshot-delay` for the corresponding capture options. Use the standalone `napplet screenshot` command when you want to keep a PNG on disk.
## Proxies
`deploy`, `init` and `keys connect` accept `--proxy ` to send their Blossom server and relay traffic through an HTTP or SOCKS5 proxy. Supported schemes are `http://`, `https://`, `socks5://` and `socks5h://`; embedded credentials (`socks5://user:pass@host:port`) are never printed. Use `socks5h://` to resolve host names on the proxy, as Tor requires.
```sh
napplet deploy --proxy socks5h://127.0.0.1:9050
napplet keys connect --name remote --proxy http://proxy.example:8080
```
The proxy carries artifact and media uploads, manifest and application-metadata publishing, `init` suggestions, and remote-signer (NIP-46) relay sessions with their relay information documents. Managed tools started as separate processes, such as screenshot capture, conformance and `paja`, are not proxied by it. Other commands reject the flag. This is CLI transport configuration; it does not change what is published.
## Key storage without system helpers
Native keychains are used when available. Otherwise, key commands use password-encrypted local files in `~/.napplet/keys` (AES-256-GCM, PBKDF2-SHA256 with 600,000 iterations). Enter a password of at least 12 characters at the hidden prompt, or set `NAPPLET_KEYSTORE_PASSWORD` for non-interactive use. The password is not stored; reuse it to unlock your keys. Set `NAPPLET_KEYSTORE=portable` to select this store explicitly and `NAPPLET_KEYSTORE_DIR` to change its location. `napplet keys doctor` reports the selected provider.
## Interactive terminals and CI
Human terminal sessions show the napplet banner, grouped help, colored deployment summaries, and live upload/publish progress with filled `█` blocks and a `░` remaining track. `napplet deploy` prompts for missing configuration values before signing, preserves existing values, and offers to save completed settings. Use `napplet deploy --dry-run` to preview without uploading or publishing.
Use `-i` or `--non-interactive` on any command to disable prompts. CI and redirected streams do this automatically. Missing required values produce actionable errors; supply them through configuration or `--source-dir`, `--description`, `--relay`, and `--server`. `--json` keeps deploy stdout machine-readable, and `NO_COLOR=1` or `--no-color` disables colors.
```sh
napplet deploy -i --format current --json
napplet init -i --name notes --description "Private notes"
```
Signing secrets remain hidden and are never saved in project configuration. `--prompt-sec` can consume explicitly piped input in automation. Set `NAPPLET_KEYSTORE_PASSWORD` for encrypted local keys; `keys connect` requires interactive remote approval, so CI should use an existing key or session.
---
# @napplet/conformance-cli
> The headless `napplet-conformance` runner — drives the
> [`@napplet/conformance`](https://napplet.run/docs/packages/conformance.md) engine against a napplet in real
> headless Chromium (via Playwright).
`@napplet/conformance-cli` lets a napplet prove it conforms to NIP-5D + the NAP
protocol **before** publishing — locally and in CI. It is a
**development/testing tool**, run from the command line; it is never loaded inside
the napplet sandbox.
- **npm:** [`@napplet/conformance-cli`](https://www.npmjs.com/package/@napplet/conformance-cli)
- **Source:** [packages/conformance-cli](https://github.com/napplet/web/tree/main/packages/conformance-cli)
## Usage
```bash
# Build your napplet first, then point the runner at the output:
npx napplet-conformance ./dist
# or a directory containing index.html / a dist/ subdir:
npx napplet-conformance .
# or a remotely-served napplet:
npx napplet-conformance --url https://my.napplet.example/
```
## Wire it up as `test:conformance`
```jsonc
{
"scripts": {
"test:conformance": "napplet-conformance ./dist",
"test:conformance:ui": "napplet-conformance --ui . --exec \"vite build --watch\""
}
}
```
```bash
pnpm test:conformance # npm / yarn / bun all work — the bin is PM-agnostic
```
## UI / watch mode (`--ui`) — like `vitest --ui`
```bash
napplet-conformance --ui . --exec "vite build --watch"
```
`--ui` serves the standalone conformance web runtime (bundled with this package)
plus the napplet, opens your browser, and **re-runs conformance live every time
the napplet changes**. The optional `--exec` runs your build in watch mode so
source edits rebuild and re-check automatically.
## See also
- [`@napplet/conformance`](https://napplet.run/docs/packages/conformance.md) — the engine this CLI drives
- [Getting started](https://napplet.run/docs/guide/getting-started.md) — scaffold, build, and verify a napplet
- [`@napplet/vite-plugin`](https://napplet.run/docs/packages/vite-plugin.md) — generates the manifest the runner validates
The browser runner also supports `npx @napplet/conformance-cli@0.3.3 screenshot [directory|preview-url] --output preview.png`, exposed as `napplet screenshot` by the standalone CLI. See [screenshots and application metadata](https://napplet.run/docs/packages/cli.md#screenshots-and-zapstore-metadata) for browser setup, iframe selection, and publication options.
Conformance CLI 0.3.4 requires the published Paja 0.21.x line for GAMEPAD screenshots and compatibility with core/NAP 0.34.0. Paja 0.20.x is excluded because its peer range does not accept those core/NAP versions. Publish this package after its core/NAP dependencies; the standalone CLI runner update follows conformance CLI publication.
## Signed manifests and runtime bindings
Headless runs accept `--manifest ./signed-event.json` alongside a local directory or `--url`. Supply the signed Nostr event itself, not an unsigned build sidecar. The CLI checks its structure, signature, event id and exact raw HTML SHA-256 before starting the browser. It boots the same loaded snapshot in both passes, so a changed response cannot replace the verified artifact. Missing or mismatched manifest inputs exit with code 2; omitting the option leaves manifest checks skipped. UI mode rejects this headless-only option; use a NIP-5D pointer in the web runtime instead.
```sh
napplet-conformance ./dist --manifest ./signed-event.json
```
The CLI and web runtime inject the maintained `@napplet/shim` bindings before subject code. Installation is observed separately from app errors, and private harness observations are excluded from the envelope log. The normal sandbox remains `allow-scripts`; `--allow-same-origin` is a debug setting and correctly fails the sandbox conformance check. Neither domain presence nor manifest declarations grant production permissions. This is implementation guidance; the living [NIP-5D proposal](https://github.com/nostr-protocol/nips/pull/2303) and [NAPs](https://github.com/napplet/naps) define the protocol.
---
# @napplet/conformance-web
> Browser conformance runtime for live napplet protocol testing.
`@napplet/conformance-web` is the single-window web runtime that powers the
deployed [`/conformance`](https://napplet.run/conformance/) app and the
`napplet-conformance --ui` watch mode. Point it at a NIP-19 `nevent` or `naddr`
for a NIP-5D napplet manifest event and it runs the
[`@napplet/conformance`](https://napplet.run/docs/packages/conformance.md) engine live in the browser, then renders a
per-check pass/fail tree, the recorded envelope log, and a manifest inspector.
- **npm:** [`@napplet/conformance-web`](https://www.npmjs.com/package/@napplet/conformance-web)
- **Source:** [apps/conformance](https://github.com/napplet/web/tree/main/apps/conformance)
## Use the hosted app
Open the browser runtime at:
```text
https://napplet.run/conformance/
```
Enter a napplet `nevent` or `naddr`, or deep-link with `?target=...`.
The hosted runtime uses the pointer's relay hints to resolve the signed manifest
event, verifies that it is a napplet event kind (`5129`, `15129`, or `35129`),
fetches `/index.html` from the manifest's Blossom `server` hints, verifies the
blob hash, and then runs conformance against the verified HTML.
## Run through the CLI
Most local workflows should use the Playwright-backed CLI wrapper:
```bash
napplet-conformance --ui . --exec "vite build --watch"
```
`--ui` serves this web runtime plus the napplet under test, opens your browser,
and re-runs conformance every time the served napplet changes.
## Local development
From this repository:
```bash
pnpm --filter @napplet/conformance-web dev
```
The app loads the napplet into a `sandbox="allow-scripts"` iframe, attaches a
reference shell, records every emitted envelope, and runs the same checks as the
headless runner.
HTTP URLs remain available in this app for local development and legacy
`?url=...` links, but URL mode cannot prove signed NIP-5D manifest-event checks.
> **TIP**
>
> For CI, use [`@napplet/conformance-cli`](https://napplet.run/docs/packages/conformance-cli.md). It serves the
> napplet with the right local headers and exits non-zero on failures.
## See also
- [`@napplet/conformance`](https://napplet.run/docs/packages/conformance.md) — the engine this runtime executes.
- [`@napplet/conformance-cli`](https://napplet.run/docs/packages/conformance-cli.md) — the headless and UI/watch
runner that bundles this runtime.
---
# @napplet/conformance
> Framework-agnostic conformance engine for the napplet protocol — lets a napplet
> self-verify that it conforms to NIP-5D + the NAP wire protocol **before**
> publishing.
`@napplet/conformance` is a **development/testing tool**. It is not loaded inside
the napplet sandbox at runtime. One engine drives two scopes:
- **Headless / CI** — via the [`@napplet/conformance-cli`](https://napplet.run/docs/packages/conformance-cli.md)
`napplet-conformance` Playwright runner.
- **Standalone web runtime** — the single-window `apps/conformance` app.
v1 is **zero-config protocol conformance**: NIP-5D manifest-event validity, boots
under `sandbox="allow-scripts"`, receives a runtime-injected `window.napplet`,
every emitted postMessage envelope validates against the per-NAP validators,
graceful degradation when a domain is absent, and no forbidden globals.
The harness acts as a NIP-5D shell for the [port-bound identity](https://github.com/nostr-protocol/nips/pull/2303) model: it binds the `MessagePort` from the first valid `napplet.hello`, answers only over that port, and revokes the napplet and removes its frame on a second hello, any other window-level message, a second `load` or the port closing. `wire/port-only` fails a napplet that posts messages at window level instead of through `window.napplet`; `lifecycle/keeps-verified-document` is an advisory for a napplet that navigates or reloads its own frame. `attachReferenceShell(shell, { host, expectedSource })` and `bindNappletPort` give custom harnesses the same binding.
- **npm:** [`@napplet/conformance`](https://www.npmjs.com/package/@napplet/conformance)
- **JSR:** [`@napplet/conformance`](https://jsr.io/@napplet/conformance)
- **Source:** [packages/conformance](https://github.com/napplet/web/tree/main/packages/conformance)
## Local maturity policy
The reference mock can enforce a resolved host domain ceiling: `createReferenceShell({ domains: selection.domains })`, where `selection` comes from `selectNapDomains` in `@napplet/core`. The same set belongs in the injected prelude. Per-endpoint grants can narrow this ceiling but cannot widen it; excluded requests remain recorded for envelope validation and receive no response.
`bootAndCollect({ ...options, domains: ['storage', 'llm'], maturity: {} })` selects supported candidates before boot and uses that set in both injection and dispatch. A named opt-in such as `maturity: { optIn: ['llm'] }` enables evaluation of that candidate. Normal conformance runs omit maturity filtering and retain full-domain coverage. Experimental status never waives protocol validation. See the [maturity guide](https://napplet.run/docs/naps/maturity.html).
## Install
```bash
npm install -D @napplet/conformance
```
## What's in the box
- **`validateEnvelope(msg)`** — runtime validation of any `domain.action` envelope
a napplet emits, across every NAP domain. Catches malformed payloads, unknown
types, and napplets that put shell→napplet (inbound) traffic on the wire.
- **`validateManifestEvent(event)`** — checks that a resolved Nostr event is a NIP-5D napplet manifest (`5129`, `15129`, or `35129`) with a single `/index.html` artifact `x` hash, non-empty description, and bare `R`/`O` domains. Domains outside the toolchain registry produce an advisory `manifest/requires-known` warning without failing the run or implying runtime support.
- **`validateManifest(html)`** — compatibility wrapper for older HTML-only
harnesses. HTML alone cannot prove a signed NIP-5D manifest event.
- **`parsePresentationMedia(event)`** — applies NIP-5D §Presentation media to `screenshot` and `preview` tags: splits NIP-92 `key value` fields, requires one `x` sha256, drops entries clients must ignore, and returns the usable list in manifest order with advisories for everything ignored.
- **Reference mock shell**, **check registry**, and **reporters** that the CLI and
the web runtime share.
The validator surface is kept in lockstep with `@napplet/nap` by a drift test, so
a new NAP message type cannot ship without matching conformance coverage.
Manifest validation accepts extra Nostr tags and leaves their payloads and multiplicity unchanged. It checks required fields and recognized optional metadata rather than enforcing a tag-name allowlist. A `source` tag is checked for its single-location shape and supported HTTP(S), SSH, Git, scp-like, or NIP-34 `nostr://` format; `source-commit` and `source-archive` are checked for their single-tag shape. Invalid icons, screenshots and previews, and a `source-commit` without a repository `source`, remain advisory (`manifest/media-usable`) with fallback. Extra tags cannot make missing content, invalid hashes, or malformed capability declarations pass. This is non-normative implementation guidance; consult the living [NIP-5D proposal](https://github.com/nostr-protocol/nips/pull/2303) for the requirements.
```ts
import { validateEnvelope, validateManifestEvent } from '@napplet/conformance';
validateEnvelope({
type: 'outbox.query',
id: 'a',
filters: [{ kinds: [1] }],
}).ok; // true
validateManifestEvent(resolvedManifestEvent).ok; // true when the manifest event is well-formed
```
> **TIP**
>
> NIP-5D loads a napplet as a single self-contained `/index.html` via
> `iframe.srcdoc` (opaque origin), so a napplet's JS is **inline by design**.
> Conformance does not penalize inline ``,
);
```
The IIFE artifact exposes `globalThis.NappletShimPrelude.install({ domains })` and installs only the requested known NAP domains. JSR exposes the source ESM helpers under `@napplet/shim/prelude`; the generated `prelude.global` artifact is npm-only.
## The `window.napplet` shape
After runtime injection, the global may be populated with these sub-objects:
| Namespace | What it does |
| --- | --- |
| `outbox` | Outbox-aware `getEvent`, `query`, `subscribe`, `publish`, and `resolveRelays`; default for normal event reads and publishes |
| `common` | Profile lookup, follow/unfollow, reactions, reports, and NIP-19 helpers |
| `lists` | NIP-51 list read and mutation helpers |
| `count` | Count queries through the shell |
| `dm` | Shell-mediated encrypted direct-message helpers |
| `relay` | Low-level explicit relay proxy for relay-local escape hatches |
| `inc` | Inter-napplet communication: `emit`, `on` |
| `intent` | Archetype-based intent invocation, discovery, and availability changes |
| `storage` | Scoped key-value storage: `getItem`, `setItem`, `removeItem`, `keys` (512 KB quota), plus `storage.instance.*` for per-instance scope |
| `keys` | Keyboard forwarding + action keybindings: `registerAction`, `unregisterAction`, `onAction` |
| `media` | Ownership-aware media sessions: `createSession`, `reportState`, `onCommand`, … |
| `notify` | Shell-rendered notifications: `send`, `badge`, `onAction`, … |
| `identity` | Read-only user queries: `getPublicKey`, `onChanged`, `getProfile`, … |
| `config` | Per-napplet declarative config: `get`, `subscribe`, `openSettings`, `registerSchema`, `schema` |
| `resource` | Sandboxed byte fetching: `info`, `bytes`, `bytesMany`, `bytesAsObjectURL` |
| Domain absence | If a property is absent, that NAP is unavailable to the napplet. |
## Usage
```ts
import { installNappletGlobal } from '@napplet/shim';
installNappletGlobal({
domains: ['outbox', 'storage', 'identity', 'inc', 'intent'],
});
```
Napplet application code then consumes injected domains:
```ts
// Read kind 1 notes through outbox-aware routing
const { events } = await window.napplet.outbox.query(
[{ kinds: [1], limit: 20 }],
{ timeoutMs: 3000 },
);
for (const result of events) console.log('Note:', result.event.content);
// Subscribe to live updates through the same outbox boundary const sub = window.napplet.outbox.subscribe([{ kinds: [1], limit: 20 }], { timeoutMs: 3000, }); sub.on('event', (result) => console.log('New note:', result.event.content));
// Publish a note (the shell signs and fans it out) const published = await window.napplet.outbox.publish({ kind: 1, content: 'Hello from my napplet!', tags: [], created_at: Math.floor(Date.now() / 1000), }); if (!published.ok) throw new Error(published.error ?? 'publish failed');
// NAP-INC convention URI emission: the runtime sends the stable topic with // a shallow text payload. `pubkey` is a local convention choice. window.napplet.inc.emit('napplet:profile/open?pubkey=abc123'); const profileOpen = window.napplet.inc.on('napplet:profile/open', (event) => { validateProfileOpenPayload(event.payload); });
const intentResult = await window.napplet.intent.open('napplet:profile/open', { payload: { pubkey: 'abc123' }, behavior: { reuse: false } });
// Scoped storage, proxied through the shell await window.napplet.storage.setItem('theme', 'dark'); const theme = await window.napplet.storage.getItem('theme'); // 'dark'
// Read-only identity const pubkey = await window.napplet.identity.getPublicKey(); // "" when signed out
// Feature-gate before using a domain if (window.napplet?.media) { const { sessionId } = await window.napplet.media.createSession({ owner: 'napplet', metadata: { title: 'My Song', artist: 'The Artist' }, }); }
sub.close();
profileOpen.close();
```
### INC convention URIs
The NAP-INC shim accepts a queried `napplet:/` URI only at `emit(topic, payload?)`. It transposes the query before posting `inc.emit`, so the shell and consumers see the exact queryless stable topic and a shallow decoded text payload. Subscriptions and routing never parse, normalize, prefix-match, or wildcard-match topics after that boundary.
Fragments, malformed percent escapes, repeated decoded names, and a queried URI with an explicit payload reject before emission. Use a queryless topic and its explicit payload for structured data.
### Intent dispatch injection
Intent calls derive archetype and action from the URI. An `ok: true` result means the runtime accepted delivery responsibility and includes the normalized identity and handler catalog identifier; an `ok: false` result includes `error`. Targets receive runtime-attested `IntentDelivery` values through `onDelivery`, including deliveries retained before registration. Behavior hints are `focus` and `reuse`. This non-normative guidance defers to the living [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md).
This behavior defers to the living [NAP-INC](https://github.com/napplet/naps/blob/master/naps/NAP-INC.md) and [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md) documents.
## TypeScript support
The shim does not modify global `Window` types in its published source (so it is accepted by JSR). For typed `window.napplet` access, cast using `NappletGlobal` from [`@napplet/core`](https://napplet.run/docs/packages/core.md), or — preferably — use the named helpers in [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md):
```ts
import type { NappletGlobal } from '@napplet/core';
const napplet = (window as Window & { napplet: NappletGlobal }).napplet;
```
## Wire format
The shim communicates with the shell using JSON envelope messages (`{ type: 'domain.action', ...payload }`). `installNappletGlobal` first opens the NIP-5D channel: it creates a `MessageChannel`, keeps one port private and hands the other to the shell once in `napplet.hello`. Outbound and inbound envelopes then travel only over that port; window-level messages are ignored. Request/response pairs are correlated by an `id` field. The shim package README documents the full per-domain outbound and inbound message catalog.
## See also
- [Shim vs. SDK](https://napplet.run/docs/guide/getting-started.md#when-to-use-shim-vs-sdk)
- [`@napplet/sdk`](https://napplet.run/docs/packages/sdk.md) — named, typed wrapper over the same global
### Optional shell environment
The runtime may expose `shell` for environment information. `shell.supports(domain)` reads current domain object presence, `shell.services` is empty until environment delivery, and `shell.ready()` / `shell.onReady(handler)` use the retained snapshot. Readiness does not gate other domain calls. See the living [NAP-SHELL](https://github.com/napplet/naps/blob/master/naps/NAP-SHELL.md).
### Intent delivery and recommendations
```ts
import { intent } from '@napplet/sdk';
intent.onDelivery(({ sender, convention, payload }) => {
console.log(sender, convention, payload);
});
await intent.invoke('napplet:profile/open?pubkey=abc123');
```
The runtime binding retains deliveries until a listener registers. Discovery candidates expose opaque catalog `id` values and `contracts` with parameter names. INC peers instead use identifiers for running authenticated endpoints. A NAP-INTENT URI may append a bare `#naddr1…` recommendation; the binding sends its coordinate and relay hints as `handlerHint`, outside convention identity and payload. An applicable user default takes precedence. INC URI operations reject fragments. See the living [NAP-INTENT](https://github.com/napplet/naps/blob/master/naps/NAP-INTENT.md) and [web projection](https://github.com/napplet/naps/blob/master/projections/web.md).
### LLM runtime binding
Select `llm` in `installNappletGlobal({ domains: ['llm'] })` or the equivalent runtime prelude options to expose `window.napplet.llm`. The binding forwards `select`, `describe`, `generate`, and `cancel`, correlates each result by `id` and type, routes `llm.delta` only to the generation that owns its `requestId`, drops deltas that do not match the Delta schema (`start` / `delta` / `end` events, `text` only on `delta`, `toolCallId` and `name` only on a `toolCall` start), stops provisional delivery on a sequence gap, rejects an `Error` record as an error carrying the specification code, and maps an abort signal to `llm.cancel`. Installing the binding supplies no connection, consent, or provider: the host provides policy-bound handlers, endpoint-scoped handles, and prompts. See the living [NAP-LLM proposal](https://github.com/napplet/naps/pull/134).
---
# @napplet/vite-plugin
Vite build integration for current NIP-5D manifests. This package is a development dependency; shells provide the runtime namespace.
This is non-normative package guidance. [NIP-5D](https://github.com/nostr-protocol/nips/pull/2303) defines event format and loading; [NAPs](https://github.com/napplet/naps) define capability contracts.
## Install and configure
```sh
pnpm add -D @napplet/vite-plugin
```
```ts
import { defineConfig } from 'vite';
import { nip5aManifest } from '@napplet/vite-plugin';
export default defineConfig({
plugins: [nip5aManifest({
nappletType: 'notes',
title: 'Notes',
description: 'Read and write personal notes',
requires: ['outbox', 'storage'],
optional: ['theme'],
archetypes: [{ slug: 'note', convention: 'napplet:note/open', params: ['id'] }],
artifactMode: 'single-file',
})],
});
```
`nip5aManifest` and `.nip5a-manifest.json` retain their existing names for package and tool compatibility. Their output is a NIP-5D manifest, not an nsite manifest. From **0.15.0**, the output uses the direct artifact hash; **0.14.x and earlier** generated legacy path/aggregate events.
## Build output
The default `single-file` mode inlines local JS/CSS into `index.html`. After rewriting, the plugin hashes the final artifact bytes and writes a kind `35129` event template. Its single `x` tag is that SHA-256, and `content` is the description. No `path` tags or aggregate hash are emitted. Embedded metadata is part of those artifact bytes and therefore changes the hash when edited. The hash is never injected back into the bytes it covers.
Set `description`, or supply an ordinary HTML ``. A missing or empty description fails manifest generation because NIP-5D §Manifest requires non-empty content. `title` and `description` options update their HTML counterparts. The plugin also embeds the applicable publishing metadata described below before computing `x`.
Without `VITE_DEV_PRIVKEY_HEX`, the sidecar is unsigned. With a development key, it contains a signed event. `artifactHash` is local sidecar metadata, not a Nostr tag. Use `napplet deploy` for production signing, Blossom upload and relay publication; it builds fresh events from the artifact and metadata.
## Options
| Option | Behavior |
| --- | --- |
| `nappletType: string` | Named-event `d` identifier. |
| `description?: string` | Plain-text event content; falls back to HTML description metadata. |
| `title?: string` | Optional display title and HTML title override. |
| `requires?: string[]` | Required domains, serialized as `R` tags. |
| `requires?: { infer?, explicit?, mode? }` | Opt-in inference from static SDK/NAP imports and direct namespace access. `mode` is `warn` or `error` for missing explicit declarations. |
| `optional?: string[]` | Optional integrations, serialized as `O` tags and excluded from inferred required domains. |
| `archetypes?: Array<{ slug, convention, params? }>` | Existing ergonomic option; emits independent `z` role and `i` intent tags. `params` names advertised intent parameters. |
| `intents?: Array<{ intent, params? }>` | Advertise intents without coupling them to a role option. |
| `source?: string` | Source URL metadata. |
| `servers?: string[]` | Blossom server hints. |
| `icon?: { data?, sha256?, mimeType }` | Supply PNG, JPEG or WebP bytes as `Uint8Array` to embed an icon and derive its hash. Hash-only input remains supported; upload that blob separately. |
| `screenshots?: Array<{ data?, sha256?, mimeType?, dim?, size?, alt? }>` | `screenshot` tags in display order. Bytes are hashed, sized, format-sniffed and measured; hash-only entries are emitted as given. See [Presentation media](#presentation-media-and-source-pins). |
| `preview?: { data?, sha256?, mimeType?, dim?, size?, duration?, alt? }` | The single `preview` video tag (WebM or MP4 bytes, or a hash). |
| `sourceCommit?: string` | Full lowercase-hex Git commit ID emitted as `source-commit`; clients ignore it without a repository `source`. |
| `sourceArchive?: { url, sha256, mimeType }` | Independently downloadable source archive emitted as `source-archive` (HTTPS URL without credentials or fragment). |
| `configSchema?: NappletConfigSchema \| string` | Inline NAP-CONFIG schema or project-relative schema file. Discovery falls back to `config.schema.json`, then `napplet.config.ts`/`.js`/`.mjs`. |
| `artifactMode?: 'single-file' \| 'external-assets'` | `single-file` is the default. The explicit external-assets option preserves Vite output for existing tooling; external runtime assets need rebundling before deployment as a self-contained napplet. External-assets builds warn that the manifest `x` hash covers only `index.html` and list the remaining local assets. Builds without `dist/index.html` fail with a `[nip5a-manifest]` error. |
Known-domain inference is best effort. Explicit required domains are retained with a build warning when absent from this package's registry; they still need a published NAP contract and support from the target shell. An `R` or `O` declaration never grants authority. Runtime code checks `window.napplet?.` and handles missing domains.
Config schema discovery and validation remain package features. A resolved schema is carried in its NAP-defined `config` tag and is not folded into `x`. See the [NAP track](https://github.com/napplet/naps) for schema semantics.
```ts
nip5aManifest({
nappletType: 'notes',
description: 'Read and write notes',
requires: { infer: true, explicit: ['outbox', 'storage'], mode: 'warn' },
optional: ['theme'],
});
```
## Migration
New builds emit only the current schema. For a legacy-only shell, use CLI 0.7.0+ with `napplet deploy --format legacy` during the compatibility window. That serializer is isolated in the CLI and can be removed without changing current readers. Existing immutable event pointers and shell ACL/storage keys need separate rollout planning; rebuilding does not migrate saved data.
See the [event migration guide](https://napplet.run/docs/guide/event-migration.md).
## Recoverable HTML metadata
This is a non-normative description of the plugin's implementation of [NIP-5D, HTML Metadata for Publishing](https://github.com/nostr-protocol/nips/pull/2303). The living specification defines the mappings and remains authoritative. Embedding supplies publishing hints; it is not a runtime requirement and does not grant capabilities or override a signed manifest.
The final head contains the resolved title, description, named identifier, archetypes, accepted intents and advertised parameter names, required/optional domains, source URL, Blossom hints, and an icon data URL when image bytes are available. Screenshot, preview, `source-commit` and `source-archive` tags have no head mapping and stay in the sidecar. The plugin emits named events (`35129`), so it removes snapshot-only `napplet-parent` and `napplet-root` declarations. It never inserts the artifact hash or event signing fields into HTML.
Explicit options replace the corresponding author head declarations; an omitted option falls back to the head. `nappletType` remains required and controls the identifier. Explicit `archetypes` replaces author archetype and intent declarations with its roles and conventions; `intents` adds standalone advertisements. Without `archetypes`, explicit `intents` replaces just the author intent declarations. Empty arrays clear a category. `requires: { infer: true, explicit: [...] }` combines the configured list with inferred domains; `optional` wins overlaps. The first author singleton is used, repeated declarations are deduplicated, and all owned head elements are replaced once. Scripts, comments, templates, body content, and unrelated head elements are preserved. Metadata inside scripts, comments, templates, or the body is not read as a declaration.
For a recoverable icon, provide bytes:
```ts
import { readFileSync } from 'node:fs'
nip5aManifest({
nappletType: 'notes',
description: 'Read and write notes',
icon: {
data: readFileSync(new URL('./icon.png', import.meta.url)),
mimeType: 'image/png',
},
})
```
The hash covers decoded image bytes, not the data URL. If `sha256` is supplied alongside `data`, it is checked against those bytes. An existing supported head icon data URL can supply the bytes instead. Hash-only input retains its manifest tag; when no matching head image exists the plugin warns and omits the HTML icon, because a hash cannot recover an image. Unsupported, remote, empty, or malformed author icon URLs supply no embedded icon. Consumers still verify image decoding before display as specified by NIP-5D.
Metadata and JS/CSS inlining finish before the plugin hashes `index.html`. Changing an embedded declaration or icon changes the artifact hash. The NAP-CONFIG schema remains in the sidecar: NIP-5D defines no HTML mapping for that extension. `napplet deploy` can recover the specified publishing fields and icon bytes from the built HTML without the sidecar.
## Presentation media and source pins
[NIP-5D §Presentation media](https://github.com/nostr-protocol/nips/pull/2303) lets a manifest carry any number of `screenshot` tags and at most one `preview` video, each described by NIP-92 `key value` fields: one `x` sha256 of the complete bytes plus optional `m`, `dim`, `size`, `alt` and, for previews, `duration`. §Source adds an optional `source-commit` pin and an optional `source-archive`. The specification defines no HTML head mapping for these tags, so the plugin writes them only to the `.nip5a-manifest.json` template and never into `index.html`; adding media does not change the artifact hash.
```ts
import { readFileSync } from 'node:fs'
nip5aManifest({
nappletType: 'notes',
description: 'Read and write notes',
source: 'https://github.com/example/notes',
sourceCommit: '0123456789abcdef0123456789abcdef01234567',
screenshots: [
{ data: readFileSync(new URL('./shots/board.png', import.meta.url)), alt: 'The notes board' },
{ sha256: 'ae7a…c75a', mimeType: 'image/webp', dim: '1200x750' },
],
preview: { data: readFileSync(new URL('./shots/demo.webm', import.meta.url)), dim: '960x600', duration: 8.5 },
})
```
With `data`, the plugin derives `x` and `size`, sniffs the format for `m` (PNG, JPEG or WebP for screenshots; WebM or MP4 for the preview), and reads `dim` from PNG, JPEG and WebP headers. A declared `sha256`, `mimeType`, `size` or `dim` must agree with the bytes or the build fails. Each supplied blob is written to `dist/.` after `index.html` is hashed so deploy tooling can upload it to the declared Blossom servers; `napplet deploy` uploads blobs referenced by these tags. Hash-only entries keep their tag and need the blob uploaded separately. Because the specification says publishers SHOULD include `m` and `dim`, omitting them produces a build warning. `sourceCommit` without a `source` is still emitted but warns, since clients ignore a pin without a repository source; `sourceArchive` is validated for shape only. Clients verify hashes and decoding before display as specified by NIP-5D; declaring media does not grant anything.
---
# NIP-5D spec status
The napplet packages implement **NIP-5D — *Nostr Web Applets***. This site does
**not** host a copy of the specification — NIP-5D is a living document, and the
canonical text is the only source of truth.
## Source of truth
When the packages, this site, or any summary drift from these, **the living
documents win**:
- **NIP-5D** (the napplet ⇄ shell protocol):
[github.com/nostr-protocol/nips/pull/2303](https://github.com/nostr-protocol/nips/pull/2303)
- **NAPs track** (every NAP capability domain — `relay`, `storage`, `identity`, …):
[github.com/napplet/naps](https://github.com/napplet/naps)
The [NIP-5D explained](https://napplet.run/docs/guide/nip-5d.md) page on this site is a **non-normative**
orientation only; for every normative requirement, read the canonical NIP-5D and
the relevant NAP.
## Expect drift
> **ALPHA — expect drift**
>
> napplet is **alpha**. The specification is experimental and a moving target. There
> **will most certainly be drift** between the packages and the specification.
> Things **will most certainly change**. Always treat the living documents above as
> the source of truth, and pin to a specific package version when building.
## Related specs
The NAP domains build on adjacent Nostr specs:
- **NIP-5A** — source-tag semantics referenced by current NIP-5D.
- **NIP-65** — relay-list metadata, used by the `outbox` domain.
- **NIP-94 / NIP-96 + Blossom** — used by the `upload` domain.
- **NIP-19** — bech32 entity encoding, used by the `resource` domain's `nostr:`
scheme.
See the [NAP domain reference](https://napplet.run/docs/naps/index.md) for how these map onto domains.