diff --git a/gateway/app.py b/gateway/app.py
index 67e268b..89107ac 100644
--- a/gateway/app.py
+++ b/gateway/app.py
@@ -15098,6 +15098,15 @@ async def launch_kit(kit_id: str, request: Request, body_in: KitLaunchBody | Non
# current package on the next press of Launch. Same version: nothing is rewritten.
existing = await _kit_plugin_ensure(org, hid0, existing, kit_plugin, tuple(
str(d["name"]) for d in (decl, media_decl) if d and d.get("name"))) or existing
+ # The prompt too: it is the kit's, like the package, and a kit whose prompt changed
+ # (the sheet one gained its output contract on 2026-09-22) must reach the Harness
+ # people already run, or the fix ships to new launches only.
+ want_prompt = str((kit.get("harness") or {}).get("system_prompt") or "")
+ if want_prompt and str(existing.get("system_prompt") or "") != want_prompt:
+ await _vg_upsert("Harness", hid0, {"system_prompt": want_prompt,
+ "updated_at": str(int(time.time() * 1000))})
+ existing = await _vertex_get(hid0) or {**existing, "system_prompt": want_prompt}
+ print(f"[kits] {kit_id}: prompt refreshed on {hid0}", flush=True)
return {"kit": kit_id, "harnessId": hid0,
"route": (kit.get("app") or {}).get("route") or "", "created": False,
"harness": _harness_out(existing)}
diff --git a/gateway/tests/test_kits_workspace.py b/gateway/tests/test_kits_workspace.py
index 96807dc..a61fa14 100644
--- a/gateway/tests/test_kits_workspace.py
+++ b/gateway/tests/test_kits_workspace.py
@@ -116,3 +116,21 @@ async def _mcp_write(hid, servers):
assert len(conns) == 1 and conns[0]["harness"] == hid and conns[0]["secret_key"] == key
assert conns[0]["workspace"] == "org.a__ws1" and conns[0]["database"] == "shop" and "u:p@" not in json.dumps(conns[0])
assert asyncio.run(gw._connections_of("org.a", hid))[0]["database"] == "shop"
+
+
+def test_a_relaunch_carries_the_kits_current_prompt(world, monkeypatch):
+ """The prompt is the kit's, like its package: a kit whose prompt changed reaches the Harness a
+ workspace already runs on the next launch, and an unchanged prompt rewrites nothing."""
+ store, who = world
+ kit = json.loads(json.dumps(KIT)); kit["harness"]["system_prompt"] = "Build decks."
+ monkeypatch.setattr(gw, "_kits", lambda: {"slides": kit})
+ hid = asyncio.run(gw.launch_kit("slides", _Req(), None))["harnessId"]
+ assert store[hid]["system_prompt"] == "Build decks."
+ stamp = store[hid]["updated_at"]
+ again = asyncio.run(gw.launch_kit("slides", _Req(), None))
+ assert not again["created"] and store[hid]["updated_at"] == stamp # same prompt: untouched
+ kit["harness"]["system_prompt"] = "Build decks. The person sees only ./deck.json."
+ third = asyncio.run(gw.launch_kit("slides", _Req(), None))
+ assert not third["created"] and third["harnessId"] == hid
+ assert store[hid]["system_prompt"] == "Build decks. The person sees only ./deck.json."
+ assert third["harness"]["systemPrompt"] == "Build decks. The person sees only ./deck.json."
diff --git a/ui/src/app/(app)/kits/page.tsx b/ui/src/app/(app)/kits/page.tsx
index 58158f6..183385b 100644
--- a/ui/src/app/(app)/kits/page.tsx
+++ b/ui/src/app/(app)/kits/page.tsx
@@ -83,17 +83,17 @@ export default function KitsPage() {
.catch(() => {});
}, []);
- /** A kit app runs outside this Next app, so it gets its own tab. */
- /** The kit app is this origin's own page, so the tab keeps its handle: `noopener` makes
- * window.open return null, which is what left the tab launch opened blank forever. */
- function openApp(route: string) {
- window.open(route, '_blank');
- }
-
+ /** Launch, and open the kit app in its own tab (it runs outside this Next app). Opening a
+ * kit that is already running goes through the same call: launch is idempotent, and it is
+ * where a running kit picks up what its kit definition has changed since (its package, its
+ * prompt, the tools it needs), so a kit fixed after someone launched it is fixed for them the
+ * next time they open it, not only for whoever launches it fresh.
+ *
+ * The tab is opened NOW, on the click, and navigated when the launch returns. Opening it after
+ * the await is a popup the browser is entitled to block, because by then it is no longer a
+ * user gesture. The kit app is this origin's own page, so the tab keeps its handle: `noopener`
+ * makes window.open return null, which is what left the tab launch opened blank forever. */
async function launch(kit: Kit, base?: string, model?: string, database?: DbDraft) {
- // Open the tab NOW, on the click, and navigate it when the launch returns. Opening it after
- // the await is a popup the browser is entitled to block, because by then it is no longer a
- // user gesture.
const tab = window.open('', '_blank');
setBusy(kit.id); setErr('');
try {
@@ -115,7 +115,7 @@ export default function KitsPage() {
if (!r.ok) throw new Error((await r.json().catch(() => null))?.detail || `${r.status}`);
const { route } = await r.json();
const url = route || `/kits/${kit.id}`;
- if (tab) tab.location.href = url; else openApp(url);
+ if (tab) tab.location.href = url; else window.location.assign(url);
setPicking(null); setBusy('');
reload();
} catch (e) {
@@ -233,8 +233,8 @@ export default function KitsPage() {
)}