diff --git a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py
index f05f9ba0..e99841df 100644
--- a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py
+++ b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py
@@ -17,6 +17,7 @@ from backend.apps.agents.manager.prompt.prompt_context import (
build_installed_skills_catalog,
build_mcp_registry_summary,
build_selected_app_context,
+ build_unselected_app_context,
build_selected_settings_context,
compose_system_prompt,
)
@@ -122,6 +123,9 @@ def compose_turn_system_prompt(
# App cards the user picked via the dashboard element picker: give the agent each app's on-disk path + meta + SKILL.md pointer so it can edit them in place (the dashboard card's runtime live-reloads). Additive and independent of view-builder mode above.
app_ctx = build_selected_app_context(selected_app_output_ids)
+ # Nothing picked: name the apps anyway so the agent can point the user at the selection step instead of acting like their app does not exist.
+ if not app_ctx:
+ app_ctx = build_unselected_app_context()
if app_ctx:
composed_prompt = f"{composed_prompt}\n\n{app_ctx}" if composed_prompt else app_ctx
diff --git a/backend/apps/agents/manager/prompt/prompt_context.py b/backend/apps/agents/manager/prompt/prompt_context.py
index b9ae5bb3..e9d8a75e 100644
--- a/backend/apps/agents/manager/prompt/prompt_context.py
+++ b/backend/apps/agents/manager/prompt/prompt_context.py
@@ -132,6 +132,39 @@ def build_browser_context(dashboard_id: Optional[str], selected_browser_ids: Opt
return "\n".join(lines)
+@typechecked
+def build_unselected_app_context() -> Optional[str]:
+ """Name the user's existing apps when none is selected.
+
+ Selecting a card is what grants edit access, but a user who has not learned that ritual just
+ sees an agent that appears not to know their app exists, and the agent cannot tell them what to
+ do because it was never told the app was there either. Names only, no paths: enough to say
+ "select it and I can edit it", not enough to start editing something the user did not point at.
+ """
+ from backend.apps.outputs.workspace_io import load_all
+ try:
+ apps = [o for o in load_all() if o.workspace_id and not getattr(o, "deleted_at", None)]
+ except Exception:
+ return None
+ if not apps:
+ return None
+ names = ", ".join(f'"{o.name or "Untitled App"}"' for o in apps[:P_UNSELECTED_APP_CAP])
+ more = "" if len(apps) <= P_UNSELECTED_APP_CAP else f", and {len(apps) - P_UNSELECTED_APP_CAP} more"
+ return (
+ "\n"
+ f"The user has these Apps on their dashboard: {names}{more}.\n"
+ "You do NOT have access to their files right now. If the user asks you to change, look at, "
+ "or use one of them, say so plainly and tell them to click the App card to select it, then "
+ "resend. Do not guess at file paths, do not scaffold a replacement, and do not claim the app "
+ "does not exist.\n"
+ ""
+ )
+
+
+# A long list burns prompt tokens for no gain; past a handful the agent only needs to know apps exist.
+P_UNSELECTED_APP_CAP = 12
+
+
@typechecked
def build_selected_app_context(selected_app_output_ids: Optional[List[str]]) -> Optional[str]:
"""Build a context block for dashboard App cards the user selected to edit.
diff --git a/backend/tests/test_unselected_app_context.py b/backend/tests/test_unselected_app_context.py
new file mode 100644
index 00000000..aad4dcf5
--- /dev/null
+++ b/backend/tests/test_unselected_app_context.py
@@ -0,0 +1,116 @@
+"""An agent that cannot see an app must still know it exists.
+
+Selecting an App card is what grants edit access, and that gate is deliberate. But a user who has
+not learned the ritual just sees an agent behaving as though their app was never built, and the
+agent cannot explain the problem because it was never told the app was there either. So with
+nothing selected we name the apps and nothing more: enough to say "select it and I can edit it",
+not enough to start editing something the user never pointed at.
+
+Run:
+ cd backend && .venv/bin/python -m pytest tests/test_unselected_app_context.py -v
+"""
+
+from __future__ import annotations
+
+from backend.apps.agents.manager.prompt import prompt_context
+from backend.apps.outputs.models import Output
+
+
+def p_app(name: str, workspace_id: str = "ws") -> Output:
+ return Output(name=name, workspace_id=workspace_id)
+
+
+def test_names_the_apps_when_nothing_is_selected(monkeypatch):
+ monkeypatch.setattr(prompt_context, "load_all", lambda: [p_app("Budget Tracker")], raising=False)
+ monkeypatch.setattr(
+ "backend.apps.outputs.workspace_io.load_all",
+ lambda: [p_app("Budget Tracker"), p_app("Habit Log")],
+ )
+
+ out = prompt_context.build_unselected_app_context()
+
+ assert out is not None
+ assert "Budget Tracker" in out and "Habit Log" in out
+
+
+def test_tells_the_agent_to_ask_for_a_selection(monkeypatch):
+ """The whole point: the agent has to hand the user the next step, not stonewall them."""
+ monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [p_app("Budget Tracker")])
+
+ out = prompt_context.build_unselected_app_context() or ""
+
+ assert "select" in out.lower()
+ assert "does not exist" in out, "must explicitly forbid claiming the app is missing"
+
+
+def test_leaks_no_paths(monkeypatch):
+ """Names only. A path here would let the agent edit an app the user never pointed at, which is
+ exactly the access the selection gate exists to withhold."""
+ monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [p_app("Budget Tracker")])
+
+ out = prompt_context.build_unselected_app_context() or ""
+
+ assert "/" not in out.replace("", ""), "no filesystem paths may appear"
+ assert "workspace" not in out.lower()
+
+
+def test_silent_when_the_user_has_no_apps(monkeypatch):
+ monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [])
+ assert prompt_context.build_unselected_app_context() is None
+
+
+def test_skips_apps_with_no_workspace(monkeypatch):
+ """A row without a workspace is not a real app the user can select."""
+ monkeypatch.setattr(
+ "backend.apps.outputs.workspace_io.load_all",
+ lambda: [Output(name="Ghost", workspace_id=None)],
+ )
+ assert prompt_context.build_unselected_app_context() is None
+
+
+def test_caps_a_long_list(monkeypatch):
+ many = [p_app(f"App {i}") for i in range(30)]
+ monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: many)
+
+ out = prompt_context.build_unselected_app_context() or ""
+
+ assert "App 0" in out
+ assert "App 29" not in out, "an uncapped list burns prompt tokens on every turn"
+ assert "more" in out, "the user must still learn there are others"
+
+
+def test_a_broken_store_does_not_break_the_turn(monkeypatch):
+ def p_boom():
+ raise OSError("disk gone")
+
+ monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", p_boom)
+ assert prompt_context.build_unselected_app_context() is None
+
+
+def test_selection_still_wins(monkeypatch):
+ """With a real selection the rich block must be used; this fallback must not shadow it."""
+ monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [p_app("Budget Tracker")])
+ selected = prompt_context.build_selected_app_context(None)
+ assert selected is None, "no ids means no rich block, which is what triggers the fallback"
+
+
+def test_the_block_actually_reaches_the_composed_prompt(monkeypatch):
+ """The one that matters. Every test above passes even with the builder unwired from the prompt,
+ so without this the feature could ship doing nothing at all."""
+ from backend.apps.agents.core.models import AgentSession
+ from backend.apps.agents.manager.prompt.compose_turn_system_prompt import compose_turn_system_prompt
+
+ monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [p_app("Budget Tracker")])
+ session = AgentSession(id="s1", name="t", model="sonnet", mode="agent")
+
+ composed = compose_turn_system_prompt(
+ session=session,
+ mode_sys_prompt=None,
+ default_system_prompt=None,
+ selected_browser_ids=None,
+ selected_app_output_ids=None,
+ selected_setting_ids=None,
+ ) or ""
+
+ assert "Budget Tracker" in composed
+ assert "" in composed