Apps SDK v2

Build E2E encrypted apps that run inside noone. chats -- 1:1 and groups.

Concept

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.

Sandbox Architecture

NOONE CORE Crypto (WASM) Vault (IndexedDB) Relay Manager Message Channel sendMessage() onMessage() loadHistory() participants[] JSON APP SANDBOX Your App Code container (DOM element) sdk.sendMessage(data) sdk.onMessage(callback) No access to keys, vault, relay, or other chats

NooneAppSDK Class

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
}

connectEdgePeer — Cloudflare Worker client

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();

Wire protocol — noone-p2p-v1

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.

Server-side (Cloudflare Worker)

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 });
  }
}
Why this matters. Without this helper, apps tend to ship plaintext WebSocket protocols to their workers — your gameplay, location, voice metadata, etc. would be visible to Cloudflare and any network observer. The full Noone stack remains private only when worker channels use the same envelope as friend mode. Both built-in workers (pong.noone.chat and chess.noone.chat) speak this protocol — there is no plaintext fallback.

Message Format

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.

Creating an App

Minimal Example

// 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 = '';
  };
}

Registering the App

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 || (() => {}) };
  });
}

Group Support

Detecting Group Context

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);
  }
}

Player vs Spectator (Turn-Based Games)

PLAYER 1 can interact (moves, draws) PLAYER 2 can interact (moves, draws) SPECTATOR read-only view SPECTATOR read-only view First N participants (by join order) are players. Rest are spectators. Chess: N=2. Card game: N=4. Sketch: everyone participates (no roles).
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
}

Multi-User Collaboration (No Roles)

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' });

State Replay (History)

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.

P2P Awareness

// 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.

Built-in Apps — reference examples

AppIDModeSourceDescription
Chesschess1:1, 2P+spectators, vs CF Workerweb/src/apps/chess.jsFriend mode uses sdk.sendMessage; bot mode uses sdk.connectEdgePeer to chess.noone.chat
Pongpong1:1 friend, vs CF Workerweb/src/apps/pong.jsFriend mode uses sdk.sendMessage; edge mode uses sdk.connectEdgePeer to pong.noone.chat
SketchsketchAll draw simultaneouslyweb/src/apps/sketch.jsReal-time whiteboard with live stroke preview, undo, clear
LocationlocationAll share positionsweb/src/apps/location.jsGPS sharing with distance calculation
MinerminerLocal-onlyweb/src/apps/miner.jsHash-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:

Security Model

For third-party apps: Currently, apps must be bundled into the build. A future plugin system with stricter sandboxing (iframed, postMessage API) is planned. For now, all apps are reviewed and included in the open-source codebase.

Message Types Reference

Chess

{ _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

Sketch

{ _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

Location

{ _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.