Public: anyone can read this; the crew can change it.

The `new_app` tool's answer draws a ui://forge/app card: the app coming alive, its preview link and its agent #3760

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3674-the-new-app-tool-s-answer-draws-a-ui-for → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/3674-the-new-app-tool-s-answer-draws-a-ui-for was merged into main as 5f3c3d6.

Review

Sign in to review this change.
4 files changed +30 −2
docs/MCP.md +2 −2
@@ -28,7 +28,7 @@ for a tunnel and a forge that sleeps.
2828 | tool | does | writes |
2929 | -------------- | ------------------------------------------------------------------------------------------------- | ------ |
3030 | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | |
31 | `new_app` | `{ name, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; answers `{ repo, url, issue, agent }`; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken) | yes |
31 | `new_app` | `{ name, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken) | yes |
3232 | `repo` | `{ repo }`: one repository's description, visibility, autonomy and `audience { level, sentence }`, the same as `GET /api/v1/repos/:owner/:name` | |
3333 | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes |
3434 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
@@ -170,7 +170,7 @@ answers
170170
171171 ## Cards, the contract and the plugins (#1116)
172172
173 - **Cards.** `apps`, `now`, `asks` and `milestones` name a `ui://` resource in
173 - **Cards.** `apps`, `new_app`, `now`, `asks` and `milestones` name a `ui://` resource in
174174 `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`,
175175 `decision`, `wave`. `resources/list` and `resources/read` serve one small
176176 sandboxed page, which draws the `structuredContent.card` model that the
src/http/mcp.ts +2
@@ -486,11 +486,13 @@ const TOOLS: readonly Tool[] = [
486486 }
487487 }
488488 const { repo, app: record, issue } = made.value;
489 const first = listActiveRuns(ctx.app.db, [repo.id])[0];
489490 return good({
490491 repo: `${repo.ownerName}/${repo.name}`,
491492 url: appsRuntime(ctx.app)?.urlFor(record, "production") ?? null,
492493 issue: issue === undefined ? null : issue.number,
493494 agent: issue?.assigneeName ?? null,
495 run: first?.state ?? "none",
494496 });
495497 },
496498 },
src/web/studio/cards.ts +15
@@ -41,6 +41,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
4141 now: "run",
4242 asks: "decision",
4343 milestones: "wave",
44 new_app: "app",
4445 };
4546
4647 const CARD_TITLE: Readonly<Record<CardKind, string>> = {
@@ -65,6 +66,20 @@ export function cardFor(tool: string, value: unknown): Card | undefined {
6566 const rows: CardRow[] = [];
6667 const actions: CardAction[] = [];
6768 let status = "";
69 if (tool === "new_app") {
70 const made = obj(value);
71 const repo = text(made["repo"]);
72 const run = text(made["run"]);
73 status = run === "none" || run === "" ? "alive" : `alive · first run ${run}`;

Nit: alive is claimed before anything has checked that production answers. newApp has only started it. started (or the runtime's env status, if it is cheap to read) would be truer to what a zero-technical owner sees. Not blocking.

74 rows.push({ label: "App", value: repo });
75 if (made["url"]) rows.push({ label: "Production", value: text(made["url"]) });
76 if (made["issue"]) rows.push({ label: "First issue", value: `#${text(made["issue"])}` });
77 if (made["agent"]) rows.push({ label: "Agent", value: text(made["agent"]) });
78 rows.push({ label: "First run", value: run === "" ? "none" : run });
79 if (repo !== "")
80 actions.push({ label: `Tell ${repo}`, tool: "tell", args: { repo, context: {} }, words: true });
81 return { kind, title: "New app", status, rows, actions };
82 }
6883 if (kind === "app") {
6984 const apps = list(value).map(obj);
7085 status = `${apps.length} app${apps.length === 1 ? "" : "s"}`;
test/mcp.test.ts +11
@@ -911,6 +911,7 @@ describe("the forge over MCP", () => {
911911 const tools = (await call(rw, "tools/list")).body.result.tools;
912912 const meta = (n: string) => tools.find((t: any) => t.name === n)._meta?.ui?.resourceUri;
913913 expect(meta("apps")).toBe("ui://forge/app");
914 expect(meta("new_app")).toBe("ui://forge/app");
914915 expect(meta("now")).toBe("ui://forge/run");
915916 expect(meta("asks")).toBe("ui://forge/decision");
916917 expect(meta("milestones")).toBe("ui://forge/wave");
@@ -1086,6 +1087,16 @@ describe("the forge over MCP", () => {
10861087 expect(made.value.repo).toBe("alice/sentence");
10871088 expect(made.value.issue).toBe(1);
10881089 expect(made.value.agent).toBe("newappbot");
1090 expect(["queued", "running", "none"]).toContain(made.value.run);
1091 const cardRes = await call(rw, "tools/call", { name: "new_app", arguments: { name: "carded", description: "<b>hi</b>", agent: "newappbot" } });
1092 const card = cardRes.body.result.structuredContent.card;
1093 expect(card.kind).toBe("app");
1094 expect(card.title).toBe("New app");
1095 const byLabel = (l: string) => card.rows.find((r: any) => r.label === l)?.value;
1096 expect(byLabel("App")).toBe("alice/carded");
1097 expect(byLabel("First issue")).toBe("#1");
1098 expect(byLabel("Agent")).toBe("newappbot");
1099 expect(byLabel("First run")).toBeDefined();
10891100 const seen = (await tool(rw, "apps")) as any;
10901101 const row = seen.value.find((r: any) => r.app === "alice/sentence");
10911102 expect(row).toBeDefined();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs