Docs / Ref. 01 aleja.app ↗

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

AppIDPlayersWhat it does
📺 Watch Togetherwatch-togetherAny numberYouTube in sync, with a shared queue
❌ Tic-Tac-Toetic-tac-toe2, spectators welcomeFirst two in play, the rest take the next game
🔴 Connect Fourconnect-four2, spectators welcomeSeven columns, four in a row
🟩 Five Lettersfive-lettersSolo, shared resultsDaily word puzzle; the board shows colors, never letters
🐍 SnakesnakeSolo, shared leaderboardArrow keys, WASD or swipes
🔢 20482048Solo, shared leaderboardSlide and merge
🏰 Bastion Tower Defensetower-defenseSolo, shared leaderboardPhaser canvas game: build a maze, upgrade towers, hold the waves
🎨 Doodle BoarddoodleAny numberLive 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: 1 and 2 pick a tower, U upgrades, S sells, Space sends the next wave, Esc cancels.

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
  1. 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.
  2. The client loads /apps/{app}/ from the API host in a sandboxed iframe. The server inlines the SDK into the app's HTML.
  3. The SDK posts ready. The client answers with init, which carries you, the channel, the participants and the state.
  4. When the app calls Aleja.send or Aleja.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 }
  • user and each participant: { id, username, name }
  • channel: { id, name, kind }, where kind is text, dm or group

participants is in join order, which games use to decide who plays (for example, "the first two are players").

Properties

Property
Aleja.contextWhat ready() resolved with, or null before that
Aleja.meThe current user
Aleja.participantsEveryone in the session, in join order (a copy)
Aleja.stateThe latest shared state, or null
Aleja.versionThe 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.

NameHandler receivesWhen
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
participantsusers[]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, send and setState (each needs an id), plus hud, toast and close.
  • From the client: init, reply ({ id, ok, result | error }), event, state, participants and close.

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/appsThe catalog: { apps: [app] }
GET /v1/servers/{server}/appsInstalled 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}/appsOpen 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}/eventBody { 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.

opSent toFields
app_participantsPeople in the sessionchannel_id, app_id, users: [{ id, username, name }]
app_activityEveryone who can see the channelchannel_id, app_id, users: [user_id] (empty when the last person leaves)
app_eventOther people in the sessionchannel_id, app_id, from, data
app_stateOther people in the sessionchannel_id, app_id, from, state, version, updated_at
app_closedPeople in the sessionchannel_id, app_id, reason: "removed"
apps_changedNest membersserver_id (an app was installed or removed)

Limits

Event size8 KB of JSON
State size64 KB of JSON
People in one session25
RequestsShared with the account's normal budget of 50 per second
DisconnectsYou'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.