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'
}
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 | Group Mode | Description |
|---|---|---|---|
| Chess | chess | 2 players + spectators | Full rules, algebraic notation, move log, captured pieces |
| Sketch | sketch | All draw simultaneously | Real-time whiteboard with live stroke preview, undo, clear |
| Location | location | All share positions | GPS sharing on OSM map tiles (grayscale), distance calculation |
{ _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.