Build E2E encrypted apps that run inside noone. chats -- 1:1 and groups.
Apps are sandboxed modules that run inside a chat conversation. They can send and receive JSON messages through the same E2E encrypted channel used for text messages. Apps have:
Apps do not have access to: private keys, the vault, other contacts, the relay, or any data outside the current conversation.
class NooneAppSDK {
appId: string // Your app's unique identifier
container: HTMLElement // DOM element to render into
contact: object // { label, nostrPkHex } or { label, groupId }
t: function // Translation: t('key') -> localized string
// Messaging
sendMessage(data: object): Promise // Send JSON to all participants
onMessage(callback): unsubscribe // Receive messages; returns cleanup function
loadHistory(): Promise<array> // Load past app messages for state replay
// Group context (v2)
isGroup: boolean // true in group chats
participants: array // [{ label, nostrPkHex, isMe }]
myNostrPkHex: string // Your own Nostr pubkey
// P2P status (v2)
isP2PConnected: boolean // DataChannel open with at least one peer
onP2PStatusChange(cb): unsubscribe // Notified on P2P status changes
// Helpers
getMe(): object // Your participant entry
getOthers(): array // Non-self participants
getRole(nostrPkHex, maxPlayers=2): 'player' | 'spectator'
// Edge-worker client (v3) — connect to a Cloudflare Worker (or any
// server-side noone-p2p-v1 peer) with full E2E encryption.
connectEdgePeer(wsUrl: string): EdgePeer
}
Apps can talk to a Cloudflare Worker (or any server-side endpoint) over a fully end-to-end encrypted channel — no plaintext app data ever crosses the wire. The same envelope shape and key derivation as Noone's classical browser-to-browser P2P mode is used; only the post-quantum ML-KEM-768 layer is omitted because Workers can't yet ship that bundle.
// Open an encrypted channel to your worker
const peer = sdk.connectEdgePeer('wss://your-app.example.com/ws');
peer.onOpen(() => {
// Channel verified — both sides hold the same ChaCha20-Poly1305 key
peer.send({ type: 'hello', data: 42 });
});
peer.onMessage((msg) => { /* msg is the decrypted JSON object */ });
peer.onError((err) => { /* handshake or decrypt failure */ });
peer.onClose((ev) => { /* WebSocket closed */ });
// Later — graceful shutdown
peer.close();
1. server → { type: 'hello', x25519PkHex, protocol: 'noone-p2p-v1' } (plaintext, only public material)
2. client → { type: 'hello-ack', x25519PkHex, schema: 'v2-noone' } (plaintext, our pubkey + schema)
3. ECDH(client_sk, server_pk) → HKDF-SHA256(info) → ChaCha20-Poly1305 key
info = 'noone-p2p-v2' if hello-ack.schema === 'v2-noone'
= 'nostrcomm-p2p-v1' otherwise (legacy clients, absent field)
4. server → encrypted { type: 'ready' } (channel verified end-to-end)
5. all subsequent frames: { ct: base64, n: nonce_hex, kem_ct: '' } (one frame per message)
The two plaintext frames carry only X25519 public keys — this is the standard threat model of an authenticated Diffie-Hellman handshake. Each worker connection generates a fresh ephemeral keypair, so there is no persistent server identity for an attacker to compromise.
Use the shared acceptNoonePeer helper at
workers/_shared/noone-server-peer.js:
import { acceptNoonePeer } from '../../_shared/noone-server-peer.js';
export class MyBot {
async fetch(request) {
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
server.accept();
const peer = acceptNoonePeer(server, {
onReady: () => peer.send({ type: 'welcome' }),
onMessage: (data) => peer.send({ type: 'echo', data }),
onError: (e) => console.warn(e),
});
return new Response(null, { status: 101, webSocket: client });
}
}
pong.noone.chat and chess.noone.chat) speak this
protocol — there is no plaintext fallback.
All app messages must be JSON objects with an _app field:
{
"_app": "your-app-id",
"type": "move", // your custom type
"data": { ... } // your custom payload
}
Messages are automatically encrypted and delivered to all participants via the existing E2E pipeline. In 1:1 chats, messages go via P2P DataChannel (if connected) or Nostr relay. In groups, messages go to all members.
// web/src/apps/my-app.js
export function createMyApp(sdk) {
const container = sdk.container;
// Render UI
container.innerHTML = '<div id="my-app">Hello from my app!</div>';
// Send a message
document.getElementById('my-app').onclick = () => {
sdk.sendMessage({ _app: 'my-app', type: 'ping', ts: Date.now() });
};
// Receive messages
const unsubscribe = sdk.onMessage((data) => {
if (data._app !== 'my-app') return;
console.log('Received:', data);
});
// Return cleanup function
return function cleanup() {
unsubscribe();
container.innerHTML = '';
};
}
In web/src/main.js, add to the BUILTIN_APPS object:
const BUILTIN_APPS = {
// ... existing apps ...
'my-app': { id: 'my-app', icon: '💡', name: () => t('myApp'), color: '#3D8B6E' },
};
And add the lazy import in launchAppInChatScreen():
} else if (appId === 'my-app') {
import('./apps/my-app.js').then(m => {
cleanup = m.createMyApp(sdk);
activeApp = { id: appId, cleanup: cleanup || (() => {}) };
});
}
export function createMyApp(sdk) {
if (sdk.isGroup) {
console.log('Running in group with', sdk.participants.length, 'members');
// Show participant list, handle multi-player logic
} else {
console.log('Running in 1:1 chat with', sdk.contact.label);
}
}
const myRole = sdk.getRole(sdk.myNostrPkHex, 2); // 2 = max players
if (myRole === 'player') {
// Allow interaction (moves, draws, etc.)
} else {
// Read-only view -- show board state but disable input
}
For apps like Sketch where everyone participates equally:
// All participants draw on the same canvas
// No role distinction needed
// Messages are broadcast to everyone automatically
sdk.sendMessage({ _app: 'sketch', type: 'stroke', points: [...], color: '#000' });
Apps should support replaying state from history so they work correctly when re-opened:
// Load past messages on init
const history = await sdk.loadHistory();
for (const msg of history) {
if (msg._app !== 'my-app') continue;
// Replay each message to rebuild state
applyMessage(msg);
}
loadHistory() returns an array of parsed app messages, each with a fromMe boolean indicating direction.
// Check current P2P status
if (sdk.isP2PConnected) {
// Low latency -- enable real-time features
}
// Listen for status changes
sdk.onP2PStatusChange((connected) => {
if (connected) showLowLatencyUI();
else showRelayFallbackUI();
});
P2P DataChannel provides ~50ms latency vs ~2-5s via relay. Apps that need real-time interaction (drawing, gaming) should indicate connection quality to users.
| App | ID | Mode | Source | Description |
|---|---|---|---|---|
| Chess | chess | 1:1, 2P+spectators, vs CF Worker | web/src/apps/chess.js | Friend mode uses sdk.sendMessage; bot mode uses sdk.connectEdgePeer to chess.noone.chat |
| Pong | pong | 1:1 friend, vs CF Worker | web/src/apps/pong.js | Friend mode uses sdk.sendMessage; edge mode uses sdk.connectEdgePeer to pong.noone.chat |
| Sketch | sketch | All draw simultaneously | web/src/apps/sketch.js | Real-time whiteboard with live stroke preview, undo, clear |
| Location | location | All share positions | web/src/apps/location.js | GPS sharing with distance calculation |
| Miner | miner | Local-only | web/src/apps/miner.js | Hash-based PoW token mining (no wire traffic) |
The chess and pong apps are the canonical references for two patterns: (1) talking to friends through Noone's encrypted P2P/relay pipeline, and (2) talking to Cloudflare Workers as a Noone peer. Both patterns are implemented in the same file — read them side by side.
Server-side worker examples live at:
workers/_shared/noone-server-peer.js — the acceptNoonePeer helper used by every built-in workerworkers/pong-edge/src/index.js — minimal pong AI behind an encrypted channelworkers/chess-engine/src/index.js — minimax chess engine behind an encrypted channel{ _app: 'chess', type: 'start', color: 'white' } // Start game
{ _app: 'chess', type: 'move', from: 'e2', to: 'e4' } // Make move
{ _app: 'chess', type: 'resign' } // Resign
{ _app: 'chess', type: 'draw_offer' } // Offer draw
{ _app: 'chess', type: 'draw_accept' } // Accept draw
{ _app: 'sketch', type: 'stroke', points: [[x,y],...], color: '#000', width: 3 }
{ _app: 'sketch', type: 'stroke', ..., partial: true } // Live preview
{ _app: 'sketch', type: 'clear' } // Clear canvas
{ _app: 'location', type: 'position', lat: 51.5, lng: -0.1, acc: 10, ts: 1700000000 }
{ _app: 'location', type: 'stop' }
MIT License. All app code is open source and auditable.