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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3674-the-new-app-tool-s-answer-draws-a-ui-for → main 1 commit fork 1 thread · 1 unresolved
4 files
changed +30
−2
docs/MCP.md +2 −2
| @@ -28,7 +28,7 @@ for a tunnel and a forge that sleeps. | ||
| 28 | 28 | | tool | does | writes | |
| 29 | 29 | | -------------- | ------------------------------------------------------------------------------------------------- | ------ | |
| 30 | 30 | | `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 | | |
| 32 | 32 | | `repo` | `{ repo }`: one repository's description, visibility, autonomy and `audience { level, sentence }`, the same as `GET /api/v1/repos/:owner/:name` | | |
| 33 | 33 | | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes | |
| 34 | 34 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | |
| @@ -170,7 +170,7 @@ answers | ||
| 170 | 170 | |
| 171 | 171 | ## Cards, the contract and the plugins (#1116) |
| 172 | 172 | |
| 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 | |
| 174 | 174 | `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`, |
| 175 | 175 | `decision`, `wave`. `resources/list` and `resources/read` serve one small |
| 176 | 176 | sandboxed page, which draws the `structuredContent.card` model that the |
src/http/mcp.ts +2
| @@ -486,11 +486,13 @@ const TOOLS: readonly Tool[] = [ | ||
| 486 | 486 | } |
| 487 | 487 | } |
| 488 | 488 | const { repo, app: record, issue } = made.value; |
| 489 | const first = listActiveRuns(ctx.app.db, [repo.id])[0]; | |
| 489 | 490 | return good({ |
| 490 | 491 | repo: `${repo.ownerName}/${repo.name}`, |
| 491 | 492 | url: appsRuntime(ctx.app)?.urlFor(record, "production") ?? null, |
| 492 | 493 | issue: issue === undefined ? null : issue.number, |
| 493 | 494 | agent: issue?.assigneeName ?? null, |
| 495 | run: first?.state ?? "none", | |
| 494 | 496 | }); |
| 495 | 497 | }, |
| 496 | 498 | }, |
src/web/studio/cards.ts +15
| @@ -41,6 +41,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 41 | 41 | now: "run", |
| 42 | 42 | asks: "decision", |
| 43 | 43 | milestones: "wave", |
| 44 | new_app: "app", | |
| 44 | 45 | }; |
| 45 | 46 | |
| 46 | 47 | const CARD_TITLE: Readonly<Record<CardKind, string>> = { |
| @@ -65,6 +66,20 @@ export function cardFor(tool: string, value: unknown): Card | undefined { | ||
| 65 | 66 | const rows: CardRow[] = []; |
| 66 | 67 | const actions: CardAction[] = []; |
| 67 | 68 | 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}`; | |
| 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 | } | |
| 68 | 83 | if (kind === "app") { |
| 69 | 84 | const apps = list(value).map(obj); |
| 70 | 85 | status = `${apps.length} app${apps.length === 1 ? "" : "s"}`; |
test/mcp.test.ts +11
| @@ -911,6 +911,7 @@ describe("the forge over MCP", () => { | ||
| 911 | 911 | const tools = (await call(rw, "tools/list")).body.result.tools; |
| 912 | 912 | const meta = (n: string) => tools.find((t: any) => t.name === n)._meta?.ui?.resourceUri; |
| 913 | 913 | expect(meta("apps")).toBe("ui://forge/app"); |
| 914 | expect(meta("new_app")).toBe("ui://forge/app"); | |
| 914 | 915 | expect(meta("now")).toBe("ui://forge/run"); |
| 915 | 916 | expect(meta("asks")).toBe("ui://forge/decision"); |
| 916 | 917 | expect(meta("milestones")).toBe("ui://forge/wave"); |
| @@ -1086,6 +1087,16 @@ describe("the forge over MCP", () => { | ||
| 1086 | 1087 | expect(made.value.repo).toBe("alice/sentence"); |
| 1087 | 1088 | expect(made.value.issue).toBe(1); |
| 1088 | 1089 | 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(); | |
| 1089 | 1100 | const seen = (await tool(rw, "apps")) as any; |
| 1090 | 1101 | const row = seen.value.find((r: any) => r.app === "alice/sentence"); |
| 1091 | 1102 | expect(row).toBeDefined(); |
new above
offline
Nit:
aliveis claimed before anything has checked that production answers.newApphas 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.