diff --git a/backend/apps/agents/apps_mcp_server.py b/backend/apps/agents/apps_mcp_server.py
index 532652f7..c820cf49 100644
--- a/backend/apps/agents/apps_mcp_server.py
+++ b/backend/apps/agents/apps_mcp_server.py
@@ -106,7 +106,7 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
"",
f"NEXT: read {path}/SKILL.md now — it's the full App Builder reference (stack, layout, rules); follow it.",
"Then build by writing files under the workspace path; the preview hot-reloads on save.",
- "Housekeeping: write meta.json (name/description) first; `bash restart.sh` restarts the runtime; `.openswarm/terminal.log` is the live terminal (check it before declaring done).",
+ "Housekeeping: write meta.json (name, description, and icon: ONE emoji that stands for the app, shown in the dock and the Applications window) first; `bash restart.sh` restarts the runtime; `.openswarm/terminal.log` is the live terminal (check it before declaring done).",
]
return {"content": [{"type": "text", "text": "\n".join(lines)}]}
diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md
index d721ccf9..c45ef16f 100644
--- a/backend/apps/outputs/app_builder_skill.md
+++ b/backend/apps/outputs/app_builder_skill.md
@@ -559,7 +559,7 @@ Common deps already in the template:
- **Edits are auto-saved**. As soon as you write a file via the Edit/Write tool, it's on disk. Vite HMR re-renders the preview within ~100ms.
- **`bash restart.sh` restarts the app runtime yourself** — backend + vite, no user action needed. Use it after `bash backend_init.sh`, after editing `.env`, or whenever backend code must reload (uvicorn runs WITHOUT --reload, so backend edits do NOT hot-apply). Never ask the user to restart for you, and never try to kill/rerun run.sh — the harness owns the process. If `restart.sh` is missing (older app), `mkdir -p .openswarm && touch .openswarm/restart-requested` does the same thing.
- After a restart, wait a few seconds and check `.openswarm/terminal.log` to confirm the boot looked clean.
-- **`meta.json`** at workspace root drives the app's name + description in the OpenSwarm sidebar and on the app's live card on the dashboard. Write it FIRST when starting a new app (see step 1 of the Quick start checklist), and revise it any time the app's purpose shifts.
+- **`meta.json`** at workspace root drives the app's name, description and icon (one emoji) in the OpenSwarm dock, the Applications window and the app's live card on the dashboard. Write it FIRST when starting a new app (see step 1 of the Quick start checklist), and revise it any time the app's purpose shifts.
---
@@ -638,8 +638,10 @@ When making a new app from scratch:
The sidebar and the app's dashboard card show this name to the user; until
you write it, both surfaces sit at "Untitled App". Don't wait until the end of
the turn to fill it in, pick a name from the user's prompt and ship it now.
+ Add `icon`: ONE emoji that stands for the app (it becomes the app's mark in the dock and the
+ Applications window; words and icon names render as the generic grid, never as a letter).
Example: prompt "make doodle jump" → `{"name": "Doodle Jumper", "description":
- "Endless platform-hopper inspired by Doodle Jump."}`. You can revise it later
+ "Endless platform-hopper inspired by Doodle Jump.", "icon": "🦘"}`. You can revise it later
if the app's purpose shifts.
2. **REPLACE** `frontend/src/pages/index.tsx`. The starter ships with a
"Brewing your app" placeholder — this is intentional, it's what the user
diff --git a/backend/apps/outputs/app_icon.py b/backend/apps/outputs/app_icon.py
new file mode 100644
index 00000000..61c0e73e
--- /dev/null
+++ b/backend/apps/outputs/app_icon.py
@@ -0,0 +1,20 @@
+"""One rule for what counts as an app icon, shared by the meta.json sync and the API.
+
+An icon is a single emoji symbol. The model's default ("view_quilt") is a Material icon NAME
+that nothing renders, so names and words are rejected rather than stored; the renderer's
+`appIconGlyph` applies the same rule, so the two can never disagree about a value.
+"""
+from __future__ import annotations
+
+import re
+
+WORDISH = re.compile(r"[\w]", re.UNICODE)
+
+
+def glyph_icon(value: object) -> str | None:
+ if not isinstance(value, str):
+ return None
+ g = value.strip()
+ if not g or len(g) > 4:
+ return None
+ return None if WORDISH.search(g) else g
diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py
index c40571b4..80cb42f3 100644
--- a/backend/apps/outputs/outputs.py
+++ b/backend/apps/outputs/outputs.py
@@ -19,6 +19,7 @@ from backend.apps.outputs.models import (
)
from backend.apps.outputs.code_safety import get_code_warnings
from backend.apps.outputs.executor import execute_backend_code
+from backend.apps.outputs.app_icon import glyph_icon
from backend.apps.outputs.publish_capability import check_publish_capability
from backend.apps.outputs.publish_common import slugify, PublishError
from backend.apps.outputs.publish_scan import scan_for_publish, quick_ast_gate
@@ -217,13 +218,15 @@ def write_meta_json_fields(workspace_id: str, fields: dict) -> bool:
def sync_output_from_meta_json(workspace_id: str, fallback_name: str | None = None) -> bool:
- """Sync the Output row's name/description from meta.json (or fallback_name when
- meta.json has no name). Only overwrites placeholder values; user renames win."""
+ """Sync the Output row's name/description/icon from meta.json (or fallback_name when
+ meta.json has no name). Name and description only overwrite placeholder values (user renames
+ win); the icon is the agent's channel, so a new emoji there always lands."""
try:
folder = os.path.join(WORKSPACE_DIR, workspace_id)
meta_path = os.path.join(folder, "meta.json")
name = ""
description = ""
+ icon = None
if os.path.exists(meta_path):
try:
with open(meta_path) as f:
@@ -231,11 +234,12 @@ def sync_output_from_meta_json(workspace_id: str, fallback_name: str | None = No
if isinstance(meta, dict):
name = str(meta.get("name") or "").strip()
description = str(meta.get("description") or "").strip()
+ icon = glyph_icon(meta.get("icon"))
except (OSError, json.JSONDecodeError, ValueError):
pass
if not name and fallback_name:
name = str(fallback_name).strip()
- if not name and not description:
+ if not name and not description and not icon:
return False
matching = [o for o in load_all() if o.workspace_id == workspace_id]
if not matching:
@@ -248,6 +252,9 @@ def sync_output_from_meta_json(workspace_id: str, fallback_name: str | None = No
if description and not output.description and output.description != description:
output.description = description
changed = True
+ if icon and output.icon != icon:
+ output.icon = icon
+ changed = True
if changed:
output.updated_at = datetime.now().isoformat()
save(output)
@@ -685,9 +692,16 @@ async def update_output(output_id: str, body: OutputUpdate):
save(output)
# A rename must reach meta.json too, or the UI and the file agents read drift apart (ENG-308).
p_sent = body.model_dump(exclude_unset=True)
- p_meta = {k: getattr(output, k) for k in ("name", "description") if k in p_sent and getattr(output, k)}
+ p_meta = {k: getattr(output, k) for k in ("name", "description", "icon") if k in p_sent and getattr(output, k)}
if p_meta:
write_meta_json_fields(getattr(output, "workspace_id", "") or "", p_meta)
+ # The dock and the Applications window hold their own copy of the row; without this an icon or
+ # name an agent set through the API stayed on the old glyph until the next full fetch.
+ from backend.apps.agents.core.ws_manager import ws_manager
+ try:
+ await ws_manager.broadcast_global("agent:output_upserted", {"output": output.model_dump(mode="json")})
+ except Exception:
+ logger.exception("output update broadcast failed for %s", output_id)
return {"ok": True, "output": output.model_dump()}
diff --git a/backend/tests/test_app_icon_glyph.py b/backend/tests/test_app_icon_glyph.py
new file mode 100644
index 00000000..7584e400
--- /dev/null
+++ b/backend/tests/test_app_icon_glyph.py
@@ -0,0 +1,14 @@
+from backend.apps.outputs.app_icon import glyph_icon
+
+
+def test_an_emoji_is_an_icon_and_names_words_and_initials_are_not():
+ assert glyph_icon("🚀") == "🚀"
+ assert glyph_icon(" 🇫🇷 ") == "🇫🇷"
+ assert glyph_icon("👨💻") == "👨💻"
+ assert glyph_icon("view_quilt") is None
+ assert glyph_icon("rocket") is None
+ assert glyph_icon("A") is None
+ assert glyph_icon("42") is None
+ assert glyph_icon("") is None
+ assert glyph_icon(None) is None
+ assert glyph_icon({"emoji": "🚀"}) is None
diff --git a/backend/tests/test_app_icon_sync.py b/backend/tests/test_app_icon_sync.py
new file mode 100644
index 00000000..af145c71
--- /dev/null
+++ b/backend/tests/test_app_icon_sync.py
@@ -0,0 +1,50 @@
+"""meta.json is the agent's channel for an app's icon: a new emoji there lands on the row, a word does not."""
+import json
+import os
+import uuid
+
+from backend.apps.outputs import outputs as outputs_mod
+from backend.apps.outputs.models import Output
+from backend.apps.outputs.outputs import WORKSPACE_DIR, sync_output_from_meta_json
+
+
+def workspace_with_meta(meta: dict) -> str:
+ ws = "icontest-" + uuid.uuid4().hex[:8]
+ folder = os.path.join(WORKSPACE_DIR, ws)
+ os.makedirs(folder, exist_ok=True)
+ with open(os.path.join(folder, "meta.json"), "w") as f:
+ json.dump(meta, f)
+ return ws
+
+
+def make_row(ws: str, name: str = "Doodle Jumper") -> Output:
+ return Output(id=uuid.uuid4().hex, name=name, workspace_id=ws)
+
+
+def test_an_emoji_in_meta_json_lands_on_the_row_and_is_broadcast_worthy(monkeypatch):
+ ws = workspace_with_meta({"name": "Doodle Jumper", "icon": "🦘"})
+ row = make_row(ws)
+ saved = []
+ monkeypatch.setattr(outputs_mod, "load_all", lambda: [row])
+ monkeypatch.setattr(outputs_mod, "save", lambda o: saved.append(o.icon))
+ assert sync_output_from_meta_json(ws) is True
+ assert row.icon == "🦘"
+ assert saved == ["🦘"]
+
+
+def test_a_word_or_icon_name_in_meta_json_changes_nothing(monkeypatch):
+ ws = workspace_with_meta({"name": "Doodle Jumper", "icon": "rocket"})
+ row = make_row(ws)
+ monkeypatch.setattr(outputs_mod, "load_all", lambda: [row])
+ monkeypatch.setattr(outputs_mod, "save", lambda o: (_ for _ in ()).throw(AssertionError("must not save")))
+ assert sync_output_from_meta_json(ws) is False
+ assert row.icon == "view_quilt"
+
+
+def test_the_same_emoji_again_is_a_no_op(monkeypatch):
+ ws = workspace_with_meta({"name": "Doodle Jumper", "icon": "🦘"})
+ row = make_row(ws)
+ row.icon = "🦘"
+ monkeypatch.setattr(outputs_mod, "load_all", lambda: [row])
+ monkeypatch.setattr(outputs_mod, "save", lambda o: (_ for _ in ()).throw(AssertionError("must not save")))
+ assert sync_output_from_meta_json(ws) is False
diff --git a/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx b/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx
index 37727562..f97e4861 100644
--- a/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx
+++ b/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx
@@ -4,6 +4,7 @@ import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
import { MessageCircle } from 'lucide-react';
import { pickIcon } from '../canvas/DashboardGlyph';
+import { appIconGlyph } from '@/shared/appIconGlyph';
import { displayChatTitle } from '@/shared/state/sessionDisplay';
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
@@ -102,7 +103,9 @@ export function buildDockEntries({ sessions, cards, viewCards, browserCards, wor
rect: vc,
tileBg: 'linear-gradient(135deg, #ef9552, #d96a2b)',
// Never letters in the dock: a real symbol reads as an app, a glyph initial reads as a bug.
- icon: ,
+ icon: appIconGlyph(output?.icon)
+ ? {appIconGlyph(output?.icon)}
+ : ,
thumbnail: output?.thumbnail,
});
}
diff --git a/frontend/src/shared/appIconGlyph.test.ts b/frontend/src/shared/appIconGlyph.test.ts
new file mode 100644
index 00000000..f184bcf7
--- /dev/null
+++ b/frontend/src/shared/appIconGlyph.test.ts
@@ -0,0 +1,16 @@
+// Run: npm test (frontend/scripts/run-tests.mjs)
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { appIconGlyph } from './appIconGlyph.ts';
+
+test('an emoji is an icon; the stored default, words, initials and empties are not', () => {
+ assert.equal(appIconGlyph('🚀'), '🚀');
+ assert.equal(appIconGlyph(' 🇫🇷 '), '🇫🇷');
+ assert.equal(appIconGlyph('👨💻'), '👨💻');
+ assert.equal(appIconGlyph('view_quilt'), null);
+ assert.equal(appIconGlyph('rocket'), null);
+ assert.equal(appIconGlyph('A'), null);
+ assert.equal(appIconGlyph('42'), null);
+ assert.equal(appIconGlyph(''), null);
+ assert.equal(appIconGlyph(undefined), null);
+});
diff --git a/frontend/src/shared/appIconGlyph.ts b/frontend/src/shared/appIconGlyph.ts
new file mode 100644
index 00000000..a9a00006
--- /dev/null
+++ b/frontend/src/shared/appIconGlyph.ts
@@ -0,0 +1,10 @@
+/**
+ * An app's icon is one emoji symbol or nothing. The stored default ("view_quilt") is a Material
+ * icon NAME that no surface ever rendered, so names and words count as "no icon" and the surface
+ * falls back to its generic mark (never a letter: an initial on a tile reads as a bug).
+ */
+export function appIconGlyph(icon: string | null | undefined): string | null {
+ const g = (icon || '').trim();
+ if (!g || [...g].length > 4) return null;
+ return /[\p{L}\p{N}_]/u.test(g) ? null : g;
+}