eleven / messenger Public

web: member apps can opt into group chats (groupCapable) (#553)

* web: member apps can opt into group chats (groupCapable)

A member-authored app can now declare `groupCapable: true` in its defineApp
spec, letting any member add it to a group (⋮ → Add apps) — one shared
instance folds the group thread's own event log for everyone, reusing the
existing groupapps add/remove machinery built-in group apps already use.
groupCapable is read verbatim from what the source declares (unlike `chat`,
it can't be derived from code shape — it's a trust decision, not a capability
signal), validated in validateManifest, and surfaced in App Studio's check
preview and deployed-apps list.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: park the Battleship member-app example under disabled-apps/, updated for groupCapable

Not deployed anywhere — this is the hand-written source from the group-capable
member-apps design discussion, kept as a worked example: paste it into App
Studio to try it. Only change from the original is groupCapable: true on the
defineApp spec; verified end-to-end through the real extractManifest →
validateManifest → registerMemberApp pipeline (PR #553).

Co-Authored-By: Claude <noreply@anthropic.com>

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam a6d68c18e4267463e585f9459c7d5f438503b0cd parent f148914
7 files changed, +770 −7
  • changelog/2026-07-09-member-apps-in-groups.md +6 −0
  • disabled-apps/battleship.js +655 −0
  • docs/building-member-apps.md +50 −4
  • test/sandbox.test.mjs +36 −0
  • web/static/apps/appstudio.js +5 −1
  • web/static/sandbox-frame.js +6 −2
  • web/static/sandbox.js +12 −0
diff --git a/changelog/2026-07-09-member-apps-in-groups.md b/changelog/2026-07-09-member-apps-in-groups.md
new file mode 100644
index 0000000..222e9f3
--- /dev/null
+++ b/changelog/2026-07-09-member-apps-in-groups.md
@@ -0,0 +1,6 @@
+🤖 Member apps can now be shared with a group. Building your own app in App
+Studio and want the whole chat to use it, not just you? Declare it
+group-capable and anyone in a group can add it (⋮ → Add apps) — everyone gets
+one shared instance, same as adding a person, visible to the chat and just as
+easy to remove. As always, it's a member-built tool, not admin-reviewed code,
+so it's shown as such wherever it's added.
diff --git a/disabled-apps/battleship.js b/disabled-apps/battleship.js
new file mode 100644
index 0000000..9e43d05
--- /dev/null
+++ b/disabled-apps/battleship.js
@@ -0,0 +1,655 @@
+// A member app (not a built-in — it's not imported anywhere and isn't part
+// of the trusted web/static/apps/ registry). Parked here, not deployed: to
+// activate it, paste this source into App Studio (⋮ → App Studio → paste +
+// Deploy) on an instance. Updated for groupCapable member apps (see PR #553)
+// so it can be added to a group chat, not just used personally.
+
+import { defineApp, addItem, updateItem, removeItem } from "../apps.js";
+
+const GRID_SIZE = 10;
+const SHIP_LENGTHS = [4, 3, 2, 2];
+
+export const app = defineApp({
+ name: "battleship",
+ title: "Battleship",
+ glyph: "🚢",
+ groupCapable: true,
+
+ initialState: () => ({
+ players: [],
+ p1: null,
+ p2: null,
+ p1Ships: [],
+ p2Ships: [],
+ p1Ready: false,
+ p2Ready: false,
+ currentPlayer: null,
+ shots: [],
+ gameOver: false,
+ winner: null,
+ }),
+
+ events: {
+ "battleship.player-joined": (s, e) => {
+ if (!e.uid || typeof e.playerNumber !== "number") return s;
+ const playerNum = e.playerNumber;
+ if (playerNum === 1 && !s.p1) {
+ return {
+ ...s,
+ p1: { uid: e.uid, name: e.name },
+ players: [...s.players, { uid: e.uid, playerNumber: 1 }],
+ currentPlayer: s.p2 ? 1 : null,
+ };
+ }
+ if (playerNum === 2 && !s.p2) {
+ return {
+ ...s,
+ p2: { uid: e.uid, name: e.name },
+ players: [...s.players, { uid: e.uid, playerNumber: 2 }],
+ currentPlayer: s.p1 ? 1 : null,
+ };
+ }
+ return s;
+ },
+ "battleship.ships-placed": (s, e) => {
+ if (!e.uid || !Array.isArray(e.ships)) return s;
+ if (s.p1 && s.p1.uid === e.uid) {
+ return { ...s, p1Ships: e.ships };
+ }
+ if (s.p2 && s.p2.uid === e.uid) {
+ return { ...s, p2Ships: e.ships };
+ }
+ return s;
+ },
+ "battleship.ready": (s, e) => {
+ if (!e.uid) return s;
+ if (s.p1 && s.p1.uid === e.uid) {
+ return { ...s, p1Ready: true };
+ }
+ if (s.p2 && s.p2.uid === e.uid) {
+ return { ...s, p2Ready: true };
+ }
+ return s;
+ },
+ "battleship.shot-fired": (s, e) => {
+ if (!e.uid || typeof e.x !== "number" || typeof e.y !== "number") return s;
+ const newShots = [
+ ...s.shots,
+ { x: e.x, y: e.y, result: e.result, firedBy: e.uid, ts: e.ts },
+ ];
+ const nextPlayer = s.currentPlayer === 1 ? 2 : 1;
+ return {
+ ...s,
+ shots: newShots,
+ currentPlayer: nextPlayer,
+ };
+ },
+ "battleship.game-over": (s, e) => {
+ if (!e.winner) return s;
+ return { ...s, gameOver: true, winner: e.winner };
+ },
+ },
+
+ styles: `
+.battleship-container {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 16px;
+ height: 100%;
+ overflow-y: auto;
+}
+.battleship-header {
+ text-align: center;
+ font-size: 18px;
+ font-weight: bold;
+ color: var(--ink);
+}
+.battleship-status {
+ text-align: center;
+ font-size: 14px;
+ color: var(--muted);
+}
+.battleship-boards {
+ display: flex;
+ gap: 24px;
+ justify-content: center;
+}
+.battleship-board {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.battleship-board-title {
+ text-align: center;
+ font-weight: bold;
+ font-size: 14px;
+ color: var(--ink);
+}
+.battleship-grid {
+ display: inline-grid;
+ grid-template-columns: repeat(10, 24px);
+ gap: 2px;
+ background: var(--panel);
+ padding: 4px;
+ border-radius: var(--radius);
+}
+.battleship-cell {
+ width: 24px;
+ height: 24px;
+ border: 1px solid var(--border);
+ background: var(--bg);
+ cursor: var(--ui-cursor, pointer);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ border-radius: 2px;
+}
+.battleship-cell.ship {
+ background: var(--accent);
+ color: var(--accent-ink);
+}
+.battleship-cell.hit {
+ background: var(--danger);
+ color: white;
+ font-weight: bold;
+}
+.battleship-cell.miss {
+ background: var(--muted);
+ color: var(--ink);
+}
+.battleship-cell.sunk {
+ background: var(--danger);
+ color: white;
+ font-weight: bold;
+}
+.battleship-cell.disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.battleship-cell.preview {
+ background: rgba(0, 150, 255, 0.3);
+ border: 2px dashed var(--accent);
+}
+.battleship-button-group {
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+.battleship-button {
+ padding: 8px 16px;
+ background: var(--accent);
+ color: var(--accent-ink);
+ border: none;
+ border-radius: var(--radius);
+ cursor: var(--ui-cursor, pointer);
+ font-weight: bold;
+ font-size: 14px;
+}
+.battleship-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.battleship-button:hover:not(:disabled) {
+ opacity: 0.8;
+}
+.battleship-waiting {
+ text-align: center;
+ padding: 32px 16px;
+ color: var(--muted);
+ font-size: 16px;
+}
+.battleship-placement-container {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ align-items: center;
+}
+.battleship-ship-list {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+.battleship-ship-badge {
+ padding: 4px 12px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 12px;
+ color: var(--ink);
+}
+.battleship-ship-badge.placed {
+ background: var(--accent);
+ color: var(--accent-ink);
+ border-color: var(--accent);
+}
+`,
+
+ render(state, el, ctx) {
+ const me = ctx.me;
+ const myPlayerNum =
+ state.p1 && state.p1.uid === me ? 1 : state.p2 && state.p2.uid === me ? 2 : null;
+ const opponentPlayerNum = myPlayerNum === 1 ? 2 : myPlayerNum === 2 ? 1 : null;
+
+ const container = document.createElement("div");
+ container.className = "battleship-container";
+
+ // Waiting for opponent
+ if (!state.p1 || !state.p2) {
+ const header = document.createElement("div");
+ header.className = "battleship-header";
+ header.textContent = "⚓ Battleship";
+ container.appendChild(header);
+
+ if (!myPlayerNum) {
+ const joinBtn = document.createElement("button");
+ joinBtn.className = "battleship-button";
+ joinBtn.textContent = state.p1 ? "Join as Player 2" : "Join as Player 1";
+ joinBtn.onclick = () => {
+ const playerNum = state.p1 ? 2 : 1;
+ ctx.emit({ type: "battleship.player-joined", playerNumber: playerNum });
+ };
+ const btnGroup = document.createElement("div");
+ btnGroup.className = "battleship-button-group";
+ btnGroup.appendChild(joinBtn);
+ container.appendChild(btnGroup);
+ }
+
+ const waiting = document.createElement("div");
+ waiting.className = "battleship-waiting";
+ waiting.textContent = state.p1 && !state.p2 ? "Waiting for Player 2…" : "Waiting for Player 1…";
+ container.appendChild(waiting);
+ el.appendChild(container);
+ return;
+ }
+
+ // Both players joined
+ const myShips = myPlayerNum === 1 ? state.p1Ships : state.p2Ships;
+ const opponentShips = opponentPlayerNum === 1 ? state.p1Ships : state.p2Ships;
+ const myReady = myPlayerNum === 1 ? state.p1Ready : state.p2Ready;
+ const opponentReady = opponentPlayerNum === 1 ? state.p1Ready : state.p2Ready;
+
+ // Ship placement phase
+ if (myShips.length === 0 || !myReady) {
+ renderShipPlacement(state, el, ctx, me, myPlayerNum, myShips, container);
+ return;
+ }
+
+ // Game over
+ if (state.gameOver) {
+ renderGameOver(state, container);
+ el.appendChild(container);
+ return;
+ }
+
+ // Battle phase
+ const header = document.createElement("div");
+ header.className = "battleship-header";
+ header.textContent = "⚓ Battleship";
+ container.appendChild(header);
+
+ const status = document.createElement("div");
+ status.className = "battleship-status";
+ if (state.currentPlayer === myPlayerNum) {
+ status.textContent = "🎯 Your turn!";
+ } else {
+ status.textContent = "⏳ Opponent's turn…";
+ }
+ container.appendChild(status);
+
+ const boardsDiv = document.createElement("div");
+ boardsDiv.className = "battleship-boards";
+
+ // Own board (shows opponent's shots)
+ const ownBoardDiv = document.createElement("div");
+ ownBoardDiv.className = "battleship-board";
+ const ownTitle = document.createElement("div");
+ ownTitle.className = "battleship-board-title";
+ ownTitle.textContent = "Your Board";
+ ownBoardDiv.appendChild(ownTitle);
+ const ownGrid = renderBoardGrid(
+ myShips,
+ state.shots.filter((s) => s.firedBy !== me),
+ false
+ );
+ ownBoardDiv.appendChild(ownGrid);
+ boardsDiv.appendChild(ownBoardDiv);
+
+ // Opponent board (clickable if my turn)
+ const oppBoardDiv = document.createElement("div");
+ oppBoardDiv.className = "battleship-board";
+ const oppTitle = document.createElement("div");
+ oppTitle.className = "battleship-board-title";
+ oppTitle.textContent = "Opponent's Board";
+ oppBoardDiv.appendChild(oppTitle);
+
+ const myShotsOnOpponent = state.shots.filter((s) => s.firedBy === me);
+ const oppGrid = renderBoardGrid(opponentShips, myShotsOnOpponent, true, (x, y) => {
+ if (state.currentPlayer !== myPlayerNum) return;
+ if (myShotsOnOpponent.some((s) => s.x === x && s.y === y)) return;
+ fireShot(ctx, x, y, state.shots, opponentShips);
+ });
+ if (state.currentPlayer !== myPlayerNum) {
+ oppGrid.classList.add("battleship-disabled");
+ oppGrid.style.opacity = "0.5";
+ oppGrid.style.pointerEvents = "none";
+ }
+ oppBoardDiv.appendChild(oppGrid);
+ boardsDiv.appendChild(oppBoardDiv);
+
+ container.appendChild(boardsDiv);
+ el.appendChild(container);
+ },
+});
+
+function renderShipPlacement(state, el, ctx, me, myPlayerNum, myShips, container) {
+ const header = document.createElement("div");
+ header.className = "battleship-header";
+ header.textContent = "Place Your Ships";
+ container.appendChild(header);
+
+ const status = document.createElement("div");
+ status.className = "battleship-status";
+ status.textContent = `Ships placed: ${myShips.length} / 4`;
+ container.appendChild(status);
+
+ const placementDiv = document.createElement("div");
+ placementDiv.className = "battleship-placement-container";
+
+ const grid = document.createElement("div");
+ grid.className = "battleship-grid";
+ grid.style.gridTemplateColumns = "repeat(10, 32px)";
+
+ let dragStart = null;
+ let dragPreview = [];
+
+ const cells = [];
+ for (let y = 0; y < GRID_SIZE; y++) {
+ for (let x = 0; x < GRID_SIZE; x++) {
+ const cell = document.createElement("div");
+ cell.className = "battleship-cell";
+ cell.style.width = "32px";
+ cell.style.height = "32px";
+ cell.dataset.x = x;
+ cell.dataset.y = y;
+
+ const ship = myShips.find(
+ (s) =>
+ (s.orientation === "h" &&
+ s.y === y &&
+ x >= s.x &&
+ x < s.x + s.length) ||
+ (s.orientation === "v" && s.x === x && y >= s.y && y < s.y + s.length)
+ );
+
+ if (ship) {
+ cell.classList.add("ship");
+ cell.textContent = "🚢";
+ }
+
+ cell.onmousedown = () => {
+ dragStart = { x, y };
+ dragPreview = [];
+ };
+
+ cell.onmouseover = () => {
+ if (!dragStart) return;
+ clearPreview(grid);
+ dragPreview = [];
+
+ const dx = x - dragStart.x;
+ const dy = y - dragStart.y;
+ const nextShipLen = SHIP_LENGTHS[myShips.length];
+
+ if (nextShipLen === undefined) return;
+
+ let isValid = true;
+ let ship = null;
+
+ if (Math.abs(dx) > 0 && dy === 0) {
+ const sx = Math.min(dragStart.x, x);
+ const ex = Math.max(dragStart.x, x);
+ if (ex - sx + 1 === nextShipLen) {
+ ship = { x: sx, y, length: nextShipLen, orientation: "h" };
+ for (let i = sx; i <= ex; i++) {
+ dragPreview.push({ x: i, y });
+ }
+ }
+ } else if (dx === 0 && Math.abs(dy) > 0) {
+ const sy = Math.min(dragStart.y, y);
+ const ey = Math.max(dragStart.y, y);
+ if (ey - sy + 1 === nextShipLen) {
+ ship = { x, y: sy, length: nextShipLen, orientation: "v" };
+ for (let i = sy; i <= ey; i++) {
+ dragPreview.push({ x, y: i });
+ }
+ }
+ }
+
+ if (ship && !shipsOverlap(myShips, ship)) {
+ dragPreview.forEach((p) => {
+ const c = grid.querySelector(`[data-x="${p.x}"][data-y="${p.y}"]`);
+ if (c) c.classList.add("preview");
+ });
+ } else {
+ isValid = false;
+ }
+ };
+
+ cell.onmouseup = () => {
+ if (!dragStart) return;
+ const dx = x - dragStart.x;
+ const dy = y - dragStart.y;
+ const nextShipLen = SHIP_LENGTHS[myShips.length];
+
+ if (nextShipLen === undefined) {
+ dragStart = null;
+ return;
+ }
+
+ let ship = null;
+ if (Math.abs(dx) > 0 && dy === 0) {
+ const sx = Math.min(dragStart.x, x);
+ const ex = Math.max(dragStart.x, x);
+ if (ex - sx + 1 === nextShipLen) {
+ ship = { x: sx, y, length: nextShipLen, orientation: "h" };
+ }
+ } else if (dx === 0 && Math.abs(dy) > 0) {
+ const sy = Math.min(dragStart.y, y);
+ const ey = Math.max(dragStart.y, y);
+ if (ey - sy + 1 === nextShipLen) {
+ ship = { x, y: sy, length: nextShipLen, orientation: "v" };
+ }
+ }
+
+ if (ship && !shipsOverlap(myShips, ship)) {
+ ctx.emit({ type: "battleship.ships-placed", ships: [...myShips, ship] });
+ }
+
+ dragStart = null;
+ clearPreview(grid);
+ };
+
+ grid.appendChild(cell);
+ cells.push(cell);
+ }
+ }
+
+ placementDiv.appendChild(grid);
+
+ const shipList = document.createElement("div");
+ shipList.className = "battleship-ship-list";
+ SHIP_LENGTHS.forEach((len, idx) => {
+ const badge = document.createElement("div");
+ badge.className = "battleship-ship-badge";
+ if (idx < myShips.length) badge.classList.add("placed");
+ badge.textContent = `🚢 ${len}`;
+ shipList.appendChild(badge);
+ });
+ placementDiv.appendChild(shipList);
+
+ if (myShips.length === SHIP_LENGTHS.length) {
+ const readyBtn = document.createElement("button");
+ readyBtn.className = "battleship-button";
+ readyBtn.textContent = "Ready!";
+ readyBtn.onclick = () => {
+ ctx.emit({ type: "battleship.ready" });
+ };
+ placementDiv.appendChild(readyBtn);
+ }
+
+ container.appendChild(placementDiv);
+ el.appendChild(container);
+}
+
+function renderBoardGrid(ships, shots, isOpponentBoard, onCellClick) {
+ const grid = document.createElement("div");
+ grid.className = "battleship-grid";
+
+ for (let y = 0; y < GRID_SIZE; y++) {
+ for (let x = 0; x < GRID_SIZE; x++) {
+ const cell = document.createElement("div");
+ cell.className = "battleship-cell";
+ cell.dataset.x = x;
+ cell.dataset.y = y;
+
+ const shot = shots.find((s) => s.x === x && s.y === y);
+ const ship = ships.find(
+ (s) =>
+ (s.orientation === "h" && s.y === y && x >= s.x && x < s.x + s.length) ||
+ (s.orientation === "v" && s.x === x && y >= s.y && y < s.y + s.length)
+ );
+
+ if (shot) {
+ if (shot.result === "hit") {
+ cell.classList.add("hit");
+ cell.textContent = "💥";
+ } else if (shot.result === "miss") {
+ cell.classList.add("miss");
+ cell.textContent = "○";
+ } else if (shot.result === "sunk") {
+ cell.classList.add("sunk");
+ cell.textContent = "🔥";
+ }
+ } else if (!isOpponentBoard && ship) {
+ cell.classList.add("ship");
+ cell.textContent = "🚢";
+ }
+
+ if (onCellClick && !shot) {
+ cell.onclick = () => onCellClick(x, y);
+ cell.style.cursor = "var(--ui-cursor, pointer)";
+ }
+
+ grid.appendChild(cell);
+ }
+ }
+
+ return grid;
+}
+
+function fireShot(ctx, x, y, shots, opponentShips) {
+ if (shots.some((s) => s.x === x && s.y === y)) return;
+
+ const ship = opponentShips.find(
+ (s) =>
+ (s.orientation === "h" && s.y === y && x >= s.x && x < s.x + s.length) ||
+ (s.orientation === "v" && s.x === x && y >= s.y && y < s.y + s.length)
+ );
+
+ let result = "miss";
+ if (ship) {
+ const hitsOnShip = shots.filter((s) => {
+ return (
+ s.result === "hit" &&
+ ((ship.orientation === "h" &&
+ s.y === ship.y &&
+ s.x >= ship.x &&
+ s.x < ship.x + ship.length) ||
+ (ship.orientation === "v" &&
+ s.x === ship.x &&
+ s.y >= ship.y &&
+ s.y < ship.y + ship.length))
+ );
+ }).length;
+
+ if (hitsOnShip + 1 === ship.length) {
+ result = "sunk";
+ } else {
+ result = "hit";
+ }
+ }
+
+ ctx.emit({ type: "battleship.shot-fired", x, y, result });
+}
+
+function shipsOverlap(ships, newShip) {
+ for (const ship of ships) {
+ if (ship.orientation === "h" && newShip.orientation === "h") {
+ if (
+ ship.y === newShip.y &&
+ !(ship.x + ship.length <= newShip.x || newShip.x + newShip.length <= ship.x)
+ ) {
+ return true;
+ }
+ } else if (ship.orientation === "v" && newShip.orientation === "v") {
+ if (
+ ship.x === newShip.x &&
+ !(ship.y + ship.length <= newShip.y || newShip.y + newShip.length <= ship.y)
+ ) {
+ return true;
+ }
+ } else if (ship.orientation === "h" && newShip.orientation === "v") {
+ for (let i = 0; i < newShip.length; i++) {
+ const cy = newShip.y + i;
+ if (
+ cy === ship.y &&
+ !(ship.x + ship.length <= newShip.x || newShip.x + 1 <= ship.x)
+ ) {
+ return true;
+ }
+ }
+ } else if (ship.orientation === "v" && newShip.orientation === "h") {
+ for (let i = 0; i < newShip.length; i++) {
+ const cx = newShip.x + i;
+ if (
+ cx === ship.x &&
+ !(ship.y + ship.length <= newShip.y || newShip.y + 1 <= ship.y)
+ ) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+}
+
+function clearPreview(grid) {
+ grid.querySelectorAll(".preview").forEach((c) => c.classList.remove("preview"));
+}
+
+function renderGameOver(state, container) {
+ const header = document.createElement("div");
+ header.className = "battleship-header";
+ header.textContent = "⚓ Game Over";
+ container.appendChild(header);
+
+ const result = document.createElement("div");
+ result.className = "battleship-status";
+ result.style.fontSize = "18px";
+ result.style.fontWeight = "bold";
+ result.style.marginTop = "16px";
+
+ if (state.winner === "p1") {
+ result.textContent = "🎉 Player 1 Wins!";
+ } else if (state.winner === "p2") {
+ result.textContent = "🎉 Player 2 Wins!";
+ } else {
+ result.textContent = "Draw!";
+ }
+
+ container.appendChild(result);
+}
diff --git a/docs/building-member-apps.md b/docs/building-member-apps.md
index 4fe42be..a050184 100644
--- a/docs/building-member-apps.md
+++ b/docs/building-member-apps.md
@@ -43,14 +43,17 @@ You also get, for free:
## What a member app can and can't use
The sandbox now supports **slash commands** (with a **`defaultCommand`** so plain
-text captures), a **companion Chat view**, and the pure **`../format.js`** text
-helpers, in addition to the surface + events core.
+text captures), a **companion Chat view**, a **`groupCapable`** opt-in (see
+Group-capable apps), and the pure **`../format.js`** text helpers, in addition
+to the surface + events core.
These fields still do **nothing** in a member app — do not emit them:
- `messageAction`, `renderEvent`, `preview`, `unread`, `notify`, `hostEvents`,
`requiresService`, `chatOnly` — host-side hooks.
-- `scope` — member apps are always `"personal"`: the member's own private
- tool, its own thread in the sidebar. Omit the field or set `"personal"`.
+- `scope` — member apps are always `"personal"`: the member always gets their
+ own private instance, its own thread in the sidebar. Omit the field or set
+ `"personal"`. (`groupCapable` is a separate, orthogonal opt-in — it doesn't
+ change `scope`.)
- `ctx.open`, `ctx.host` — absent in the sandbox. (`ctx.reply` **is**
available inside a command handler; see Commands.)
@@ -118,6 +121,7 @@ export const app = defineApp({ /* fields below */ });
| `commands` | | `{ "/cmd": (args, ctx) => void }` or `{ "/cmd": { run, desc, args } }`. `args` passed to the handler is the **raw text string** (not a parsed object); the object form's `desc`/`args` are typeahead labels. See Commands. |
| `defaultCommand` | | The name of one of your `commands` (e.g. `"/add"`). In the companion Chat view, plain (non-slash) text runs it — so typing "buy milk" adds an item, just like To-do. Requires that command; omit if not wanted. |
| `chatMessage` | | `(event) => { text, action? } \| string \| null`. Opting in makes the app a **companion** (Chat + Interface views); see Companion chat. |
+| `groupCapable` | | `true` to let members ALSO add this app to a group chat, one shared instance for everyone; see Group-capable apps. |
| `styles` | | A CSS string, injected once. Use the theme variables below. |
| `incremental` | | `true` to keep the surface between renders and reconcile with `keyedList`. |
| `ephemeral` | | `(event, ctx) => void`. Relay-only events (live presence), rarely needed. |
@@ -345,6 +349,48 @@ chatMessage(e) {
Use companion for list-like tools where "added X" reads naturally as a
conversation; use takeover (omit `chatMessage`) for boards, games, dashboards.
+## Group-capable apps — one shared instance for a whole chat
+
+By default a member app is **personal**: only its author ever opens it, in
+their own private thread. Set **`groupCapable: true`** and a member can also
+add it to a group chat (⋮ → Add apps) — the same app, but now **one shared
+instance runs for everyone in that group**. Its events post into the *group
+thread's* encrypted log instead of your personal one, so every member's
+sandboxed frame folds the same events and arrives at the same state. Nothing
+else about the app changes — same `events`, same reducer, same `render`.
+
+```js
+export const app = defineApp({
+ name: "scoreboard",
+ title: "Scoreboard",
+ glyph: "🏆",
+ groupCapable: true,
+ // ...initialState, events, render as normal
+});
+```
+
+Adding or removing the app from a group is a member's own call — any member
+can do it, like adding a person, and it's visible to everyone (an action line
+in the chat), never silent or server-tracked. There is no admin review step:
+the app runs whatever code its author deployed.
+
+**What this means for state and secrecy:**
+
+- **A group thread's copy of the app has its own state**, independent of your
+ personal instance (or any other group it's added to) — same app definition,
+ different thread, different fold.
+- **Every member of the group can decrypt every event and every snapshot**,
+ because a thread's encrypted log is readable by every member of that thread
+ by construction — that's not a bug to work around, it's how E2EE threads
+ work here. So a group-capable app has **no way to keep a secret from one
+ member of the group while showing it to another** — there is no per-member
+ privacy inside a shared thread.
+- If your app wants to show different members different things (e.g. "your
+ own stuff in full, everyone else's summarised"), that's a **rendering**
+ choice, not a secrecy one: use `ctx.me` in `render` to decide what to draw
+ for the current viewer. The underlying state — and anyone determined enough
+ to inspect it directly — sees everything regardless.
+
## Formatting — safe rich text from `../format.js`
Import the pure text helpers to linkify and format user text **without**
diff --git a/test/sandbox.test.mjs b/test/sandbox.test.mjs
index d274445..f27a560 100644
--- a/test/sandbox.test.mjs
+++ b/test/sandbox.test.mjs
@@ -18,6 +18,8 @@ import {
resetThread,
installMemberAppState,
isAppType,
+ appByName,
+ groupCapableApps,
} from "../web/static/apps.js";
import {
addItem,
@@ -211,6 +213,7 @@ test("manifestOf derives a valid manifest from an evaluated spec", () => {
assert.deepEqual(m.types.sort(), ["mx.a", "mx.b"]);
assert.deepEqual(m.commands, [{ name: "/mx", desc: "do", args: "<x>" }]);
assert.equal(m.chat, true);
+ assert.equal(m.groupCapable, false); // not declared → false, not just absent
assert.equal(m.defaultCommand, undefined); // none declared
// defaultCommand is derived and must name a declared command.
@@ -238,6 +241,18 @@ test("manifestOf derives a valid manifest from an evaluated spec", () => {
);
assert.equal(manifestOf(plain).chat, false);
assert.deepEqual(manifestOf(plain).commands, []);
+
+ // groupCapable is read verbatim from what the source declares — it can't be
+ // derived from code shape the way chat is, since it's a trust decision.
+ const grouped = evalMemberApp(
+ `import { defineApp } from "../apps.js";
+ const app = defineApp({
+ name: "gx", title: "GX", glyph: "G", groupCapable: true,
+ initialState: () => ({}), events: { "gx.a": (s) => s }, render() {},
+ });`,
+ "gx",
+ );
+ assert.equal(manifestOf(grouped).groupCapable, true);
});
test("normalizeCommands accepts a bare function or a {run} object", () => {
@@ -324,6 +339,11 @@ test("validateManifest accepts the good and names the bad", () => {
assert.match(validateManifest({ ...MANIFEST, commands: [{ name: "/UP" }] }), /command name/);
assert.match(validateManifest({ ...MANIFEST, commands: [{ name: "/n", desc: "x".repeat(90) }] }), /command desc/);
assert.match(validateManifest({ ...MANIFEST, chat: "yes" }), /chat flag/);
+ // Optional groupCapable — orthogonal to (and doesn't loosen) scope: personal.
+ assert.equal(validateManifest({ ...MANIFEST, groupCapable: true }), null);
+ assert.equal(validateManifest({ ...MANIFEST, groupCapable: false }), null);
+ assert.match(validateManifest({ ...MANIFEST, groupCapable: "yes" }), /groupCapable flag/);
+ assert.equal(validateManifest({ ...MANIFEST, groupCapable: true, scope: "personal" }), null);
});
test("cleanJSON admits plain data and rejects the hostile", () => {
@@ -403,6 +423,22 @@ test("registerMemberApp registers a queueing proxy; frame state installs and tri
assert.equal(appStateFor(tid, "plainapp-sbtest").hacked, undefined);
});
+test("a groupCapable member app registers as group-capable; a personal one doesn't", () => {
+ const grouped = { v: 1, name: "gscore", title: "Scoreboard", glyph: "🏆", types: ["gscore.set"], groupCapable: true };
+ const res = registerMemberApp({ id: "app-group1", manifest: grouped, source: NOTES_APP.replace(/notes/g, "gscore"), author: "Ana" });
+ assert.equal(res.ok, true, res.reason);
+ const app = appByName("gscore");
+ assert.equal(app.groupCapable, true);
+ assert.equal(app.scope, "personal"); // still gets its own personal thread too
+ assert.ok(groupCapableApps().some((a) => a.name === "gscore"));
+
+ const personal = { v: 1, name: "gplain", title: "Plain", glyph: "P", types: ["gplain.set"] };
+ const res2 = registerMemberApp({ id: "app-group2", manifest: personal, source: NOTES_APP.replace(/notes/g, "gplain") });
+ assert.equal(res2.ok, true, res2.reason);
+ assert.equal(appByName("gplain").groupCapable, undefined);
+ assert.ok(!groupCapableApps().some((a) => a.name === "gplain"));
+});
+
test("installMemberAppState preserves the `lost` overflow flag", () => {
const manifest = { v: 1, name: "lossyapp", title: "L", glyph: "L", types: ["lossyapp.e"] };
const res = registerMemberApp({ id: "app-lost1", manifest, source: NOTES_APP.replace(/notes/g, "lossyapp") });
diff --git a/web/static/apps/appstudio.js b/web/static/apps/appstudio.js
index 788259d..7109eee 100644
--- a/web/static/apps/appstudio.js
+++ b/web/static/apps/appstudio.js
@@ -232,7 +232,10 @@ export const app = defineApp({
by.appendChild(document.createTextNode("by " + (m.by || "another member")));
}
if (a.mine) {
- const extra = (m.chat ? " · chat view" : "") + ((m.commands || []).length ? " · " + m.commands.map((c) => c.name).join(" ") : "");
+ const extra =
+ (m.chat ? " · chat view" : "") +
+ (m.groupCapable ? " · group-capable" : "") +
+ ((m.commands || []).length ? " · " + m.commands.map((c) => c.name).join(" ") : "");
if (extra) by.appendChild(document.createTextNode(extra));
}
meta.appendChild(by);
@@ -345,6 +348,7 @@ export const app = defineApp({
const bits = [`${m.glyph || "▦"} ${m.title || m.name} (${m.name})`, `${(m.types || []).length} event type(s)`];
if ((m.commands || []).length) bits.push("commands: " + m.commands.map((c) => c.name).join(" "));
if (m.chat) bits.push("companion chat view");
+ if (m.groupCapable) bits.push("can be added to groups");
showNote("Looks good — " + bits.join(" · "), "ok");
};
diff --git a/web/static/sandbox-frame.js b/web/static/sandbox-frame.js
index 83e654e..01a6553 100644
--- a/web/static/sandbox-frame.js
+++ b/web/static/sandbox-frame.js
@@ -307,8 +307,8 @@ export function evalMemberApp(source, expectedName) {
// manifestOf derives the deploy manifest from an evaluated app spec — the
// authoritative extractor App Studio uses (it runs the real defineApp in the
-// cage, so name/title/glyph/types/commands/chat are exactly what the app
-// declares). Mirrors the shape validateManifest (sandbox.js) checks.
+// cage, so name/title/glyph/types/commands/chat/groupCapable are exactly what
+// the app declares). Mirrors the shape validateManifest (sandbox.js) checks.
export function manifestOf(spec) {
const m = {
v: 1,
@@ -323,6 +323,10 @@ export function manifestOf(spec) {
args: (c && c.args) || "",
})),
chat: typeof spec.chatMessage === "function",
+ // groupCapable, unlike chat, can't be derived from code shape — it's a
+ // trust decision the author makes explicitly (defineApp({ groupCapable:
+ // true, ... })), so we just read what they declared.
+ groupCapable: !!spec.groupCapable,
};
// defaultCommand: the command plain (non-slash) text runs in the app's Chat
// view (like To-do's /add). Host-side routing hint — validateManifest checks it
diff --git a/web/static/sandbox.js b/web/static/sandbox.js
index 64722a4..5c5a9a5 100644
--- a/web/static/sandbox.js
+++ b/web/static/sandbox.js
@@ -109,6 +109,11 @@ export function validateManifest(m) {
}
// Optional companion opt-in: the app projects each event into a Chat view.
if (m.chat !== undefined && typeof m.chat !== "boolean") return "bad chat flag";
+ // Optional group opt-in: the app may ALSO be added to a group chat, where
+ // one shared instance runs for everyone (same meaning as a built-in app's
+ // `groupCapable` — see apps.js). Orthogonal to `scope`, which stays
+ // "personal": the app still gets its own personal thread too.
+ if (m.groupCapable !== undefined && typeof m.groupCapable !== "boolean") return "bad groupCapable flag";
// Optional defaultCommand: plain text in the Chat view runs it. Must name a
// command the app declares (so the host has a handler to route text to).
if (m.defaultCommand !== undefined) {
@@ -758,6 +763,13 @@ export function registerMemberApp({ id, manifest, source, author }) {
// above to be one of `commands`). defaultCommandFor(threadId) reads it, so a
// member app gets the same "type to add" behaviour trusted apps have.
...(manifest.defaultCommand && commands[manifest.defaultCommand] ? { defaultCommand: manifest.defaultCommand } : {}),
+ // groupCapable: the author opted this app into ALSO running as one shared
+ // instance in a group thread (manifest.groupCapable, declared explicitly
+ // in the source's defineApp — never derived from code shape, since this is
+ // a trust decision, not a capability the code happens to have). Once set,
+ // groupCapableApps() (apps.js) lists it in "Add apps" and the ordinary
+ // groupapps.add/remove + per-thread fold machinery does the rest.
+ ...(manifest.groupCapable ? { groupCapable: true } : {}),
// memberApp marks the proxy (apps.js gates installMemberAppState on it)
// and carries what the frame needs at init.
memberApp: { id, source, manifest, author: author || "" },