Apps
Apps are small web apps that people open together inside a conversation: Watch Together for YouTube, games like Connect Four and Bastion Tower Defense, and a shared Doodle Board. Everyone who opens the same app in the same channel joins one session. They see each other, receive each other's events, and share one saved state.
This page covers how apps work, the built-in catalog, the SDK that app code uses, and the HTTP and WebSocket API behind it.
Opening an app
- In a nest channel, someone with Manage Nest first adds the app from Nest settings → App Directory. After that, anyone with the Use Activities permission in a channel can open it from the apps button in the channel header. Use Activities is granted to everyone by default.
- In a DM or group, every app in the catalog is available to everyone in the conversation. No install step is needed.
While someone has an app open, everyone else in the conversation sees a "… is in Connect Four · Join" strip above the messages. Leaving the conversation, or switching to another one, closes the app for you. When a nest removes an app, every open session of it closes and its saved state is deleted.
Privacy
App traffic is not end-to-end encrypted. Events and state go through the Aleja server as plain JSON. The server keeps each channel's latest app state, such as the board, the leaderboard or the drawing, so it is there the next time someone opens the app. Messages and files stay end-to-end encrypted as before. Apps never see them, and they never see your session token.
An app learns your user ID, username and display name, plus those of the other people in the session, and the name of the channel it was opened in.
Catalog
| App | ID | Players | What it does |
|---|---|---|---|
| 📺 Watch Together | watch-together | Any number | YouTube in sync, with a shared queue |
| ❌ Tic-Tac-Toe | tic-tac-toe | 2, spectators welcome | First two in play, the rest take the next game |
| 🔴 Connect Four | connect-four | 2, spectators welcome | Seven columns, four in a row |
| 🟩 Five Letters | five-letters | Solo, shared results | Daily word puzzle; the board shows colors, never letters |
| 🐍 Snake | snake | Solo, shared leaderboard | Arrow keys, WASD or swipes |
| 🔢 2048 | 2048 | Solo, shared leaderboard | Slide and merge |
| 🏰 Bastion Tower Defense | tower-defense | Solo, shared leaderboard | Phaser canvas game: build a maze, upgrade towers, hold the waves |
| 🎨 Doodle Board | doodle | Any number | Live shared whiteboard |
Apps are first-party and ship inside the server binary. There is no way to upload a third-party app yet. The SDK below is how the built-in apps are written, and it's the contract a future third-party app would follow.
Bastion Tower Defense
A canvas game built with Phaser 3 (/apps/vendor/phaser-3.90.0.min.js, MIT licensed).
- Map: a 20 × 12 grid. Creeps enter on the left and head for the exit on the right. Towers block tiles, so where you build becomes the maze. Placing a tower that would seal the path, or trap a creep that's already walking, is refused.
- Pathfinding: a breadth-first flow field computed from the exit. Every tile points to its next step, so all creeps re-route the moment a tower goes up or is sold.
- Towers: Bolt (cheap, fast single-target shots) and Mortar (slow shells that splash). Each upgrades twice. Selling returns 70% of what you spent.
- Waves: grunts, runners and tanks, with a boss every fifth wave. Health grows each wave. You get 20 seconds between waves; sending the next one early pays a gold bonus.
- Sound: every effect is synthesized with the Web Audio API (oscillators and a noise buffer), so there are no audio files. The speaker button mutes it.
- HUD: lives, gold and the current wave are drawn in the game and also sent to Aleja with
Aleja.setHud, which shows them in the app's title bar. - Keys:
1and2pick a tower,Uupgrades,Ssells,Spacesends the next wave,Esccancels.
When a run ends, your best wave goes on the channel's leaderboard.
How it works
Aleja client ──postMessage──▶ app iframe (API origin, sandboxed)
│ ▲
HTTPS│ │WebSocket (app_event, app_state, app_participants, …)
▼ │
Aleja server ── sessions in memory, state saved per channel
- The client asks the server to join:
POST /v1/channels/{channel}/apps/{app}/join. The server checks permissions and the nest install, adds you to the session, and returns the participants and the current state. - The client loads
/apps/{app}/from the API host in a sandboxed iframe. The server inlines the SDK into the app's HTML. - The SDK posts
ready. The client answers withinit, which carries you, the channel, the participants and the state. - When the app calls
Aleja.sendorAleja.setState, the SDK posts to the client, the client makes the HTTP call with your token, and the server relays the result to the other participants over their WebSocket. Their client forwards it into their iframe.
Sandbox and origins
Apps run in an iframe with allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation. They're served from the API origin, so
allow-same-origin gives the app the API host's origin, not the client's: it can't read the
client's storage or token. If the API and client share one origin, the client drops
allow-same-origin. Every app still works except Watch Together, because YouTube's player refuses
to run inside an origin-less frame. Serve the API on its own host to use Watch Together.
Each app response carries this Content Security Policy:
default-src 'none'; script-src 'self' 'unsafe-inline' https://www.youtube.com https://s.ytimg.com;
style-src 'unsafe-inline'; img-src data: blob: https://i.ytimg.com;
frame-src https://www.youtube-nocookie.com https://www.youtube.com; connect-src 'none';
base-uri 'none'; form-action 'none'
connect-src 'none' means an app can't fetch anything. All of its traffic goes through the SDK.
'self' lets it load the vendored libraries under /apps/vendor/.
SDK reference
Every app gets window.Aleja. Put the marker <!-- aleja-sdk --> in your HTML where the SDK
script should go.
<!-- aleja-sdk -->
<script>
Aleja.ready().then(({ user, participants }) => render(Aleja.state));
Aleja.on("state", ({ state }) => render(state));
board.onclick = () => Aleja.updateState((s) => ({ ...s, clicks: (s?.clicks || 0) + 1 }));
</script>
Aleja.ready()
Resolves once the client has sent init, with { app, user, channel, participants }:
app:{ id, name, category, icon, tagline, description, players, url }userand each participant:{ id, username, name }channel:{ id, name, kind }, wherekindistext,dmorgroup
participants is in join order, which games use to decide who plays (for example, "the first
two are players").
Properties
| Property | |
|---|---|
Aleja.context | What ready() resolved with, or null before that |
Aleja.me | The current user |
Aleja.participants | Everyone in the session, in join order (a copy) |
Aleja.state | The latest shared state, or null |
Aleja.version | The latest state version. Starts at 0, goes up by one per write |
Aleja.serverNow() | The server's clock in milliseconds. Use it for anything that has to line up between people, like a video position |
Aleja.on(name, handler)
Returns an unsubscribe function.
| Name | Handler receives | When |
|---|---|---|
event | { from, data } | Someone else called send |
state | { state, version, updatedAt, from, conflict } | The state changed. That includes your own writes and a newer state returned by a conflict |
participants | users[] | Someone joined or left |
close | {} | The app is closing |
A state handler only ever sees versions newer than the last one it saw.
Aleja.send(data)
Sends data (any JSON, up to 8 KB) to everyone else in the session. Events aren't saved, so
someone who joins later won't see them. Use them for things that are live and short-lived, like a
pen stroke in progress. Resolves { ok: true }.
Calls from one app are delivered in order. Wait for the previous send to resolve before sending
the next, so a slow connection can't build a backlog.
Aleja.setState(state, { baseVersion })
Replaces the shared state (any JSON, up to 64 KB). It's saved for the channel and pushed to everyone in the session.
- Without
baseVersion, the write always lands. - With
baseVersion, it lands only if the state is still at that version. Otherwise it resolves{ ok: false, conflict: true, state, version, updatedAt }and the newer state is applied.
On success it resolves { ok: true, version, updatedAt }.
Aleja.updateState(fn, { retries = 4 })
The safe way to change shared state. Calls fn(currentState) and writes the result with
baseVersion set. On a conflict it calls fn again with the newer state, up to retries times.
Return undefined from fn to cancel; it then resolves { ok: false, cancelled: true }.
// Only record a score if it beats the saved one.
Aleja.updateState((s) => {
const best = s?.scores?.[Aleja.me.id]?.score || 0;
if (score <= best) return undefined;
return { ...s, scores: { ...s?.scores, [Aleja.me.id]: { name: Aleja.me.name, score } } };
});
Aleja.setHud(items)
Shows up to six small stats in Aleja's title bar above the app, e.g.
[{ icon: "❤️", label: "Lives", value: 20 }, { icon: "🪙", label: "Gold", value: 120 }]. Icons are
cut to 8 characters, and labels and values to 24. The label is read out by screen readers and shown
on hover. Pass [] to clear it. This is local: other people's HUDs aren't affected.
Aleja.toast(text) and Aleja.close()
toast shows a short notice (up to 140 characters) in Aleja. close closes the app for the
current user.
The bridge protocol
The SDK and the client exchange postMessage objects shaped
{ aleja: 1, type, id, payload }.
- From the app:
ready,sendandsetState(each needs anid), plushud,toastandclose. - From the client:
init,reply({ id, ok, result | error }),event,state,participantsandclose.
The client ignores messages from any window except the app's iframe, and anything that doesn't match this shape.
HTTP API
All endpoints need a session token (Authorization: Bearer …). IDs are strings.
| Method and path | |
|---|---|
GET /v1/apps | The catalog: { apps: [app] } |
GET /v1/servers/{server}/apps | Installed apps: { apps: [{ app_id, installed_by, installed_at }], can_manage }. Any member |
PUT /v1/servers/{server}/apps/{app} | Install. Needs Manage Nest |
DELETE /v1/servers/{server}/apps/{app} | Uninstall. Needs Manage Nest. Closes open sessions and deletes saved state |
GET /v1/channels/{channel}/apps | Open sessions: { sessions: [{ app_id, users: [user_id] }] } |
POST /v1/channels/{channel}/apps/{app}/join | { app, users, state, version, updated_at, server_time } |
POST /v1/channels/{channel}/apps/{app}/leave | { ok: true } |
POST /v1/channels/{channel}/apps/{app}/event | Body { data?, state?, base_version? }. See below |
GET /apps/{app}/ | The app's HTML, with the SDK inlined |
GET /apps/vendor/{file} | Vendored libraries, cached for a year |
event needs you to be in the session. With state it returns { ok: true, version, updated_at }.
With a stale base_version it returns 200 with
{ ok: false, conflict: true, state, version, updated_at }. With only data it returns
{ ok: true }.
Errors use the usual { error } body: 400 for an oversized payload or an empty body; 403
without permission, when the nest hasn't added the app, or when you aren't in the session; 404
for an unknown app; and 409 when the session is full.
WebSocket events
Sent on the normal /v1/ws connection.
op | Sent to | Fields |
|---|---|---|
app_participants | People in the session | channel_id, app_id, users: [{ id, username, name }] |
app_activity | Everyone who can see the channel | channel_id, app_id, users: [user_id] (empty when the last person leaves) |
app_event | Other people in the session | channel_id, app_id, from, data |
app_state | Other people in the session | channel_id, app_id, from, state, version, updated_at |
app_closed | People in the session | channel_id, app_id, reason: "removed" |
apps_changed | Nest members | server_id (an app was installed or removed) |
Limits
| Event size | 8 KB of JSON |
| State size | 64 KB of JSON |
| People in one session | 25 |
| Requests | Shared with the account's normal budget of 50 per second |
| Disconnects | You're removed from sessions 30 seconds after your last connection closes. The client rejoins when it reconnects |
Sessions live in the server's memory. A server restart empties them, but saved state stays.
