@cloudgrid-io/mcp 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +1 -1
  2. package/src/corpus/capability-map.md +12 -6
  3. package/src/corpus/cloudgrid-yaml.md +7 -5
  4. package/src/corpus/cookbook.md +4 -4
  5. package/src/corpus/install-for-agents.md +1 -2
  6. package/src/corpus/skill-plug.md +69 -6
  7. package/src/corpus/skills-readme.md +9 -9
  8. package/src/corpus/templates/api-docs/README.md +1 -1
  9. package/src/corpus/templates/api-docs/index.md +2 -3
  10. package/src/corpus/templates/appointment-booking/AGENTS.md +1 -1
  11. package/src/corpus/templates/changelog/README.md +1 -1
  12. package/src/corpus/templates/changelog/index.md +2 -3
  13. package/src/corpus/templates/company-website/README.md +2 -3
  14. package/src/corpus/templates/company-website/cloudgrid.yaml +1 -1
  15. package/src/corpus/templates/company-website/index.md +2 -3
  16. package/src/corpus/templates/dashboard/cloudgrid.yaml +1 -1
  17. package/src/corpus/templates/dashboard/index.md +2 -3
  18. package/src/corpus/templates/deck/cloudgrid.yaml +1 -1
  19. package/src/corpus/templates/deck/index.md +2 -3
  20. package/src/corpus/templates/docs-site/README.md +1 -1
  21. package/src/corpus/templates/docs-site/index.md +2 -3
  22. package/src/corpus/templates/landing-page/cloudgrid.yaml +1 -1
  23. package/src/corpus/templates/landing-page/index.md +2 -3
  24. package/src/corpus/templates/membership-site/AGENTS.md +1 -1
  25. package/src/corpus/templates/portfolio/README.md +1 -1
  26. package/src/corpus/templates/portfolio/index.md +2 -3
  27. package/src/corpus/templates/product-launch/README.md +2 -3
  28. package/src/corpus/templates/product-launch/cloudgrid.yaml +1 -1
  29. package/src/corpus/templates/product-launch/index.md +2 -3
  30. package/src/corpus/templates/report/cloudgrid.yaml +1 -1
  31. package/src/corpus/templates/report/index.md +2 -3
  32. package/src/corpus/templates/saas-marketing/README.md +1 -1
  33. package/src/corpus/templates/saas-marketing/index.md +2 -3
  34. package/src/corpus/templates/status-page/README.md +1 -1
  35. package/src/corpus/templates/status-page/index.md +2 -3
  36. package/src/corpus/templates/subscription-management/AGENTS.md +1 -1
  37. package/src/corpus/templates/waitlist/README.md +1 -1
  38. package/src/corpus/templates/waitlist/index.md +2 -3
  39. package/src/corpus/templates/web-app/cloudgrid.yaml +1 -1
  40. package/src/corpus/templates/web-app/index.md +2 -3
  41. package/src/corpus/usage.md +2 -2
  42. package/src/corpus/workflows/api-docs.md +1 -1
  43. package/src/corpus/workflows/changelog.md +1 -1
  44. package/src/corpus/workflows/company-website.md +1 -1
  45. package/src/corpus/workflows/dashboard.md +1 -1
  46. package/src/corpus/workflows/docs-site.md +1 -1
  47. package/src/corpus/workflows/landing-page.md +1 -1
  48. package/src/corpus/workflows/portfolio.md +1 -1
  49. package/src/corpus/workflows/presentation.md +1 -1
  50. package/src/corpus/workflows/product-launch.md +1 -1
  51. package/src/corpus/workflows/report.md +1 -1
  52. package/src/corpus/workflows/saas-marketing.md +1 -1
  53. package/src/corpus/workflows/status-page.md +1 -1
  54. package/src/corpus/workflows/waitlist.md +1 -1
  55. package/src/corpus/workflows/web-app.md +1 -1
  56. package/src/tools.js +353 -650
  57. package/src/web.js +1 -1
  58. package/src/widgets/org-picker.html +3 -3
  59. package/src/corpus/skill-drop.md +0 -140
package/src/tools.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // Two editions register from here:
4
4
  // - local (stdio): full toolset, including the CLI-wrapping tools. Identity
5
5
  // comes from ~/.cloudgrid/credentials.
6
- // - web (HTTP, hosted): the light, CLI-free toolset (drop, claim, login).
6
+ // - web (HTTP, hosted): the light, CLI-free toolset (plug, claim, login).
7
7
  // Identity is a per-session token held in memory.
8
8
  //
9
9
  // The difference is injected as a `ctx` object, so the tool logic is written once.
@@ -44,6 +44,14 @@ const ANON_HTML_MAX_BYTES = 2_000_000;
44
44
  const AUTHED_HTML_MAX_BYTES = 25_000_000;
45
45
  const CONSOLE_URL = "https://console.cloudgrid.io/";
46
46
 
47
+ const VISIBILITY_LABELS = {
48
+ private: "Only you",
49
+ org: "Your org",
50
+ authenticated: "Anyone signed in",
51
+ space: "A space",
52
+ link: "Anyone with the link",
53
+ };
54
+
47
55
  // ── Widget resources (ChatGPT Apps SDK, web edition only) ────────────────────
48
56
  // The Apps-SDK UI widgets (openai/outputTemplate → a ui:// html resource) render
49
57
  // as a broken black frame in ChatGPT today, hiding the plain-text result. Gate
@@ -64,14 +72,6 @@ const WIDGET_CSP = {
64
72
  resourceDomains: ["https://*.cloudgrid.io"],
65
73
  };
66
74
 
67
- const VISIBILITY_LABELS = {
68
- private: "Only you",
69
- org: "Your org",
70
- authenticated: "Anyone signed in",
71
- space: "A space",
72
- link: "Anyone with the link",
73
- };
74
-
75
75
  function ok(text) {
76
76
  return { content: [{ type: "text", text }] };
77
77
  }
@@ -94,14 +94,18 @@ Operating rules:
94
94
  6. If a signed-in publish fails with a server error, do not fall back to anonymous publishing (it burns the anonymous quota and downgrades ownership); surface the error, use the CLI fallback if offered, or ask the user.
95
95
  7. When signed in and the user has more than one grid, do not assume a target — the publish tools will ask; relay the choice to the user and pass the chosen grid.
96
96
  8. When a build/deploy fails unexpectedly, offer to report it to the CloudGrid team — only with the user's explicit consent (ask first). Send just the error + the failed request by default (call grid_report), and never send the whole conversation unless the user agrees (include_conversation). Respect privacy.
97
- 9. To modify an existing drop when you don't already have its HTML in context, first call grid_source to fetch the current HTML, apply your change, then call grid_drop/grid_plug with target_entity_id (the drop's entity_id) to update the SAME URL in place. Do not ask the user to paste the HTML back.
98
- 10. Publishing a heavy or local file: in the local edition use the path parameter so it is read from disk (no inline size limit); never base64-encode HTML and never pass a file path (or an @-prefixed path) as html. If a drop looks empty, use grid_source to check what was actually published, then re-plug with the real HTML/path and target_entity_id.
97
+ 9. To modify an existing page when you don't already have its HTML in context, first call grid_source to fetch the current HTML, apply your change, then call grid_plug with target_entity_id (the entity_id) to update the SAME URL in place. Do not ask the user to paste the HTML back.
98
+ 10. Publishing a single HTML page: pass it inline as grid_plug's html parameter (a full self-contained document). For a heavy or image-heavy page in the local edition, pass the path parameter instead so it is read from disk (no inline size limit); never base64-encode HTML and never pass a file path (or an @-prefixed path) as html. If a page looks empty, use grid_source to check what was actually published, then re-plug with the real HTML/path and target_entity_id.
99
99
  11. Persistence check: if the user needs to SAVE data, share state across users/sessions, log in, or store submissions, that's a runtime app-with-data (Mongo-backed), NOT a static page — static templates keep state only in memory and lose it on refresh. Use the app-with-data workflow. This requires the LOCAL edition (Claude Desktop/Code or the CLI); on the hosted edition, tell the user persistence isn't available there and offer a static version.
100
- 12. To choose what to build: match the request against the workflow when: triggers and the capability-map (grid_fetch({kind:"doc", name:"capability-map"})). Pick the template whose needs: matches what the app requires (persistence → database; scheduled → cron; etc.). Static (needs: none) deploys as an inspiration (instant, any edition); anything needing needs: is a runtime (async, local edition).
100
+ 12. To choose what to build: match the request against the workflow when: triggers and the capability-map (grid_fetch({kind:"doc", name:"capability-map"})). Pick the template whose needs: matches what the app requires (persistence → database; scheduled → cron; etc.). Classify the ARTIFACT to pick the deploy: ONE self-contained HTML page (a single file — CSS+JS inline, images/fonts as data: URIs; that is the normal hosted output) → an inspiration — instant, ANY edition, deploy via grid_plug with the inline html param. Only genuinely SEPARATE files/folders (a real assets/ dir, separate .css/.js files, multiple pages, a SPA build) — OR anything needing needs: (data/server/LLM/cron) → a runtime app — grid_plug on a linked folder with a cloudgrid.yaml, local edition only, async build.
101
101
  13. Before writing a cloudgrid.yaml, fetch the reference: grid_fetch({kind:"doc", name:"cloudgrid-yaml"}) — it has the full schema and the needs: vocabulary. Declare infrastructure with needs: (the deployer injects from it): needs: { database: true } → Mongo (DATABASE_MONGODB_URL); needs: { cache: true } → Redis; scheduled work → a service of type: cron (Python or Node). Use needs: OR requires:, never both — declaring both is rejected.
102
102
  14. Databases — CloudGrid supports both, so never tell the user to self-host. Managed: needs: { database: true } provisions Mongo and injects DATABASE_MONGODB_URL. Bring-your-own (they already run Postgres / MySQL / MongoDB / Supabase / Neon / PlanetScale / etc.): needs: { database: { tier: external, secret: MY_DB } } plus grid secrets set MY_DB=<connection-string> — the connection string lives in env SECRETS, never committed. If asked "what databases does CloudGrid support?": all of them — the managed CloudGrid database out of the box, or bring your own via keys — ask which they want.
103
+ 15. Editing an existing thing from just its URL (a fresh chat, no prior context — e.g. "change the background to green here <url>"): call grid_source(url) first. It resolves the entity_id and returns the current HTML plus its kind, single_html, capabilities, and replug_handle — read those to pick the branch:
104
+ - Single-HTML inspiration you can re-plug (single_html: true and capabilities.replug: true): edit the returned HTML and call grid_plug with target_entity_id (or grid+slug — the replug_handle) to update the SAME URL in place. This works on every edition, including hosted.
105
+ - Multi-file app or agent (kind is app or agent, or single_html: false): do NOT try to edit it as one inline HTML file. Tell the user it is a multi-file <kind>, give them the entity_id and the source (source_download_url), and explain that rebuilding it needs the local edition (Claude Desktop/Code) or the CLI — the hosted server cannot rebuild a multi-file app.
106
+ - Not yours (capabilities.replug: false, reason not_owner): do NOT attempt a re-plug. Offer to fork it into the user's own grid with grid_fork and edit the copy instead.
103
107
 
104
- Deploy is edition-dependent: on the hosted MCP call the drop tool with the HTML; on local MCP / CLI write the file and run the plug tool. An HTML page deploys synchronously, so you get a URL right away.`;
108
+ Deploy is via grid_plug on every edition: for a single HTML page pass it inline as the html param (works on the hosted MCP too); for a multi-file app write the files and pass a folder path (local MCP / CLI). A single HTML page deploys synchronously as an inspiration, so you get a URL right away.`;
105
109
 
106
110
  // The corpus subdirectories that grid_fetch serves, keyed by `kind`. Each
107
111
  // lives in its own subtree of src/corpus/ (populated by scripts/snapshot-corpus.mjs
@@ -710,9 +714,9 @@ async function fetchUserOrgs(token) {
710
714
  }
711
715
  }
712
716
 
713
- // ── Shared grid disambiguation (grid_drop + grid_plug) ──────────────────
714
- // The stateless "which grid?" ask, used identically by both publish verbs so
715
- // they never drift. Given the caller's token and a supplied grid, it decides:
717
+ // ── Shared grid disambiguation (grid_plug) ──────────────────
718
+ // The stateless "which grid?" ask on an authed create. Given the caller's token
719
+ // and a supplied grid, it decides:
716
720
  // - supplied grid matches a membership → { proceed: true, grid }
717
721
  // - >1 grid and none supplied → { picker } (a ready-to-return result)
718
722
  // - exactly one grid → { single: annotatedOrg } — the caller
@@ -771,31 +775,6 @@ export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, de
771
775
  return { proceed: true };
772
776
  }
773
777
 
774
- // After an authenticated web drop, upgrade visibility to "link" so the artifact
775
- // is shareable and its preview renders without a sign-in wall. Best-effort — a
776
- // failure here does not fail the drop; the user can always call grid_visibility.
777
- async function upgradeVisibilityToLink(ctx, entityId, orgSlug) {
778
- const token = await ctx.getToken();
779
- if (!token || !entityId) return false;
780
- try {
781
- const hdrs = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
782
- // Send the grid-native header; keep X-CloudGrid-Org in parallel (same value)
783
- // during the org→grid soak. Never send conflicting values → 400 GRID_HEADER_CONFLICT.
784
- if (orgSlug) {
785
- hdrs["X-CloudGrid-Grid"] = orgSlug;
786
- hdrs["X-CloudGrid-Org"] = orgSlug;
787
- }
788
- const res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`, {
789
- method: "PATCH",
790
- headers: hdrs,
791
- body: JSON.stringify({ visibility: "link" }),
792
- });
793
- return res.ok;
794
- } catch {
795
- return false;
796
- }
797
- }
798
-
799
778
  // ── Direct-API tools (both editions) ───────────────────────────────────────────
800
779
  function looksLikeFullHtml(s) {
801
780
  const head = s.replace(/^/, "").trimStart().slice(0, 256).toLowerCase();
@@ -846,6 +825,55 @@ function looksLikePath(s) {
846
825
  return /^(~|\.{0,2}\/|[A-Za-z]:[\\/]|\/)/.test(t) || /\.[A-Za-z0-9]{1,8}$/.test(t);
847
826
  }
848
827
 
828
+ // Normalize an inline `html` string — grid_plug's ergonomic single-file publish
829
+ // path — into ONE index.html artifact, reusing the same hardening the old drop
830
+ // verb used (decodeIfBase64Html, the @-path/file-path rejection, the base64
831
+ // guard, and the small-fragment wrap). Returns { path, buffer, type }. Throws on
832
+ // a file-path-looking or non-HTML input so a path/base64 blob is never published
833
+ // as page content. The auth-aware inline size cap is enforced later in runPlug
834
+ // (it depends on the anon-vs-authed wire).
835
+ function htmlToArtifact(html, filename) {
836
+ if (typeof html !== "string" || html.length === 0) {
837
+ throw new Error("`html` must be the complete HTML document as a string.");
838
+ }
839
+ // Strip an invented `@`-prefix shorthand, then reject a bare file path — the
840
+ // inline html path takes HTML CONTENT, not a path (use `path` for a file).
841
+ let candidate = html.startsWith("@") ? html.slice(1) : html;
842
+ if (!looksLikeFullHtml(candidate) && looksLikePath(candidate)) {
843
+ throw new Error(
844
+ `This looks like a file path (\`${candidate.trim()}\`), not HTML. Pass the raw HTML inline as ` +
845
+ "`html`, or pass the file/folder via `path` — do not pass a path as `html`.",
846
+ );
847
+ }
848
+ // Rescue a base64-of-HTML blob passed as `html` (real user repro): decode it
849
+ // rather than publishing a wall of base64 text.
850
+ const { html: resolved } = decodeIfBase64Html(candidate);
851
+ let content = resolved;
852
+ if (!looksLikeFullHtml(content)) {
853
+ const stripped = content.replace(/\s+/g, "");
854
+ const looksBase64 =
855
+ stripped.length >= 64 && stripped.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(stripped);
856
+ const isShortFragment = Buffer.byteLength(content, "utf8") <= 8192;
857
+ const hasTag = /<[a-z][\s\S]*>/i.test(content);
858
+ if (isShortFragment && (hasTag || (!looksBase64 && !looksLikePath(content)))) {
859
+ // Legit "share this snippet" — wrap a small text/markup fragment into a
860
+ // full document (preserve the old drop's friendly behavior).
861
+ content =
862
+ `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8">` +
863
+ `<title>Shared on CloudGrid</title></head>\n<body>\n${content}\n</body>\n</html>\n`;
864
+ } else {
865
+ // Large, or base64 that failed to decode to HTML, or a bare file path:
866
+ // refuse instead of silently publishing garbage.
867
+ const kind = looksBase64 ? "base64" : looksLikePath(content) ? "a file path" : "raw data";
868
+ throw new Error(
869
+ `This doesn't look like an HTML document (it looks like ${kind}). Pass the raw HTML as \`html\`, ` +
870
+ "or in the local edition pass `path` to the .html file — do NOT base64-encode it.",
871
+ );
872
+ }
873
+ }
874
+ return { path: filename || "index.html", buffer: Buffer.from(content, "utf8"), type: "text/html" };
875
+ }
876
+
849
877
  // Anonymous drops are owned by the platform Guest Org, whose slug is the apex
850
878
  // the public URL hangs off (https://guest.cloudgrid.io/<slug>).
851
879
  const GUEST_ORG_SLUG = "guest";
@@ -908,430 +936,6 @@ export function resolvePlugUrl(data) {
908
936
  return composePlugUrl(data);
909
937
  }
910
938
 
911
- export async function runDrop(
912
- ctx,
913
- { html, path: filePath, filename, anonymous, org, grid, fresh, entity_id, owner_token },
914
- deps = {},
915
- ) {
916
- // `grid` is an accepted alias for `org` (Gilad's org→grid rename); `org` still works.
917
- const targetGrid = org ?? grid;
918
- // The `deps` seam (readFile/exists) exists ONLY so the content-handling
919
- // regression tests can drive the `path` / `@`-path routes offline. Production
920
- // always calls this with no deps.
921
- const { readFile: readFileImpl = readFile, fsExists = existsSync } = deps;
922
-
923
- // Defensive: the web edition schema excludes `path`, but if a model still
924
- // passes one (e.g. from a cached tool description), reject early with a
925
- // clear explanation.
926
- if (ctx.edition === "web" && filePath) {
927
- throw new Error(
928
- "The hosted server cannot read local files — pass the full document as `html` instead of a `path`.",
929
- );
930
- }
931
-
932
- // An agent may pass an `@`-prefixed path (invented `@/home/claude/...`
933
- // shorthand) or a bare filesystem path as `html`. Normalise: strip a leading
934
- // `@`, and in the local edition promote a path-looking `html` to the `path`
935
- // route (read from disk) when the file exists. On the hosted server there is
936
- // no disk, so a path-looking `html` is a clear error.
937
- let effectivePath = filePath;
938
- let htmlInput = typeof html === "string" ? html : undefined;
939
- if (!effectivePath && typeof htmlInput === "string") {
940
- let candidate = htmlInput;
941
- if (candidate.startsWith("@")) candidate = candidate.slice(1);
942
- if (!looksLikeFullHtml(candidate) && looksLikePath(candidate)) {
943
- if (ctx.edition === "web") {
944
- throw new Error(
945
- `This looks like a file path (\`${candidate.trim()}\`), but the hosted server cannot read local ` +
946
- `files — pass the raw HTML inline as \`html\`.`,
947
- );
948
- }
949
- const resolvedPath = candidate.trim();
950
- if (fsExists(resolvedPath)) {
951
- effectivePath = resolvedPath;
952
- htmlInput = undefined;
953
- } else {
954
- throw new Error(
955
- `The path \`${resolvedPath}\` does not exist. Pass a valid \`path\` to the .html file, or pass ` +
956
- `the raw HTML inline as \`html\` (do not base64-encode it, and there is no artifact_files parameter).`,
957
- );
958
- }
959
- } else if (candidate !== htmlInput) {
960
- // Leading `@` on genuine inline content — strip it and keep the content.
961
- htmlInput = candidate;
962
- }
963
- }
964
-
965
- let bytes;
966
- let name;
967
- let type;
968
- // Anonymous drops are inline-capped hard; signed-in drops get a larger cap.
969
- // The cap is enforced AFTER auth is resolved (see below), so only inline
970
- // routes flag themselves here — `path` (local, read from disk) is uncapped.
971
- let enforceInlineCap = false;
972
-
973
- if (effectivePath) {
974
- const raw = await readFileImpl(effectivePath);
975
- const asText = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
976
- const { html: decoded, wasBase64 } = decodeIfBase64Html(asText);
977
- const lowerName = String(effectivePath).toLowerCase();
978
- const nameIsHtml = lowerName.endsWith(".html") || lowerName.endsWith(".htm");
979
- if (wasBase64) {
980
- // A base64 `.txt` (or similar) that decoded to real HTML → publish the
981
- // decoded HTML as text/html under an index.html name.
982
- bytes = Buffer.from(decoded, "utf8");
983
- type = "text/html";
984
- name = filename || (nameIsHtml ? basename(effectivePath) : "index.html");
985
- } else {
986
- bytes = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
987
- // Content-type sniff (fix #4): serve HTML as text/html so it renders as a
988
- // page instead of downloading as a blob.
989
- if (looksLikeFullHtml(asText) || nameIsHtml) {
990
- type = "text/html";
991
- name = filename || (nameIsHtml ? basename(effectivePath) : "index.html");
992
- } else {
993
- type = "application/octet-stream";
994
- name = filename || basename(effectivePath);
995
- }
996
- }
997
- } else if (typeof htmlInput === "string" && htmlInput.length > 0) {
998
- // Rescue a base64-of-HTML blob passed as `html` (real user repro).
999
- const { html: resolved } = decodeIfBase64Html(htmlInput);
1000
- let content = resolved;
1001
- if (!looksLikeFullHtml(content)) {
1002
- const stripped = content.replace(/\s+/g, "");
1003
- const looksBase64 =
1004
- stripped.length >= 64 && stripped.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(stripped);
1005
- const isShortFragment = Buffer.byteLength(content, "utf8") <= 8192;
1006
- const hasTag = /<[a-z][\s\S]*>/i.test(content);
1007
- if (isShortFragment && (hasTag || (!looksBase64 && !looksLikePath(content)))) {
1008
- // Legit "share this snippet" — wrap a small text/markup fragment into a
1009
- // full document (preserve existing friendly behavior).
1010
- content =
1011
- `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8">` +
1012
- `<title>Shared on CloudGrid</title></head>\n<body>\n${content}\n</body>\n</html>\n`;
1013
- } else {
1014
- // Large, or base64 that failed to decode to HTML, or a bare file path:
1015
- // refuse instead of silently publishing garbage.
1016
- const kind = looksBase64
1017
- ? "base64"
1018
- : looksLikePath(content)
1019
- ? "a file path"
1020
- : "raw data";
1021
- throw new Error(
1022
- `This doesn't look like an HTML document (it looks like ${kind}). Pass the raw HTML as \`html\`, ` +
1023
- `or in the local edition pass \`path\` to the .html file — do NOT base64-encode it. ` +
1024
- `There is no artifact_files parameter.`,
1025
- );
1026
- }
1027
- }
1028
- bytes = Buffer.from(content, "utf8");
1029
- name = filename || "index.html";
1030
- type = "text/html";
1031
- enforceInlineCap = true;
1032
- } else {
1033
- throw new Error("Provide either `html` (inline content) or `path` (a local file).");
1034
- }
1035
-
1036
- // The caller's authed identity (unless they force an anonymous drop).
1037
- let authToken = null;
1038
- let orgSlug = null;
1039
- if (anonymous !== true) {
1040
- authToken = await ctx.getToken();
1041
- if (authToken) {
1042
- orgSlug = targetGrid || (await ctx.getActiveGrid());
1043
- }
1044
- }
1045
-
1046
- // ── Re-plug targeting (unified plug contract / spec v2) ──────
1047
- // A `target_entity_id` on the wire UPDATES THAT SAME ENTITY in place — same
1048
- // entity_id, same slug, same URL, deploy history preserved. Absent → create.
1049
- // The session remembers the last drop, so a re-drop updates it by default;
1050
- // `fresh: true` forces a create; an explicit `entity_id` targets any earlier
1051
- // drop (the durable re-plug handle a stateless caller persisted).
1052
- if (fresh === true && entity_id) {
1053
- throw new Error("`fresh: true` forces a new drop — do not pass `entity_id` with it.");
1054
- }
1055
- const sessionDrop = ctx.state.lastDrop;
1056
- let targetId = null;
1057
- let ownerToken = typeof owner_token === "string" && owner_token.length > 0 ? owner_token : null;
1058
- if (fresh !== true) {
1059
- targetId = entity_id || sessionDrop?.entity_id || null;
1060
- // An anon-minted drop is edited with its OWNER TOKEN (a bearer capability,
1061
- // the anon owner-token contract). Recover it from session state when not passed.
1062
- if (targetId && !ownerToken) {
1063
- if (sessionDrop?.entity_id === targetId && sessionDrop.owner_token) {
1064
- ownerToken = sessionDrop.owner_token;
1065
- } else if (ctx.state.lastAnonClaim?.entity_id === targetId && ctx.state.lastAnonClaim.token) {
1066
- ownerToken = ctx.state.lastAnonClaim.token;
1067
- }
1068
- }
1069
- }
1070
-
1071
- // Pick the edit wire. An anon-owned (Guest-Grid, unclaimed) drop is edited via
1072
- // the owner-token wire EVEN IF the caller is signed in — the entity still lives
1073
- // in the Guest Grid, so an authed edit of it would 404. An authed edit targets
1074
- // an entity the caller owns in their grid.
1075
- let mode = "create"; // "create" | "authed-edit" | "anon-edit"
1076
- if (targetId) {
1077
- if (ownerToken) {
1078
- mode = "anon-edit";
1079
- } else if (authToken) {
1080
- mode = "authed-edit";
1081
- } else if (entity_id) {
1082
- throw new Error(
1083
- "Re-plugging a drop anonymously needs its owner_token (returned when it was created). " +
1084
- "Pass owner_token, sign in if you own it, or pass fresh: true to publish a new drop.",
1085
- );
1086
- } else {
1087
- // Session target, but no way to authorize an edit — fall back to create.
1088
- targetId = null;
1089
- }
1090
- }
1091
- const isAnonymousWire = mode === "anon-edit" || !authToken;
1092
-
1093
- // Auth-aware inline size cap (relocated here — auth is only known now). Anon
1094
- // inline drops stay capped at 2MB; signed-in inline drops get the larger
1095
- // AUTHED cap. `path` (read from disk) is uncapped and does not set the flag.
1096
- if (enforceInlineCap) {
1097
- if (isAnonymousWire) {
1098
- if (bytes.byteLength > ANON_HTML_MAX_BYTES) {
1099
- throw new Error(
1100
- `This HTML is ${(bytes.byteLength / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
1101
- `Trim it, or sign in to publish larger.`,
1102
- );
1103
- }
1104
- } else if (bytes.byteLength > AUTHED_HTML_MAX_BYTES) {
1105
- throw new Error(
1106
- `This HTML is ${(bytes.byteLength / 1e6).toFixed(2)} MB. Inline drops are capped at ` +
1107
- `${(AUTHED_HTML_MAX_BYTES / 1e6).toFixed(0)} MB. In the local edition pass \`path\` to the ` +
1108
- `.html file (read from disk — no inline size limit) instead of pasting it inline.`,
1109
- );
1110
- }
1111
- }
1112
-
1113
- const headers = {};
1114
- if (!isAnonymousWire) {
1115
- headers["Authorization"] = `Bearer ${authToken}`;
1116
- // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1117
- if (orgSlug) {
1118
- headers["X-CloudGrid-Grid"] = orgSlug;
1119
- headers["X-CloudGrid-Org"] = orgSlug;
1120
- }
1121
- }
1122
-
1123
- // Hosted server: attach the trusted-server credential on EVERY anonymous call
1124
- // (creates AND owner-token edits — anon edits consume the same daily anon cap,
1125
- // re-keyed per end user). Falls back gracefully server-side if absent. Only
1126
- // the web edition sets ctx.trustedServer.
1127
- if (isAnonymousWire && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
1128
- headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
1129
- headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
1130
- }
1131
-
1132
- // Ownership continuity: replay the platform's anon-session cookie across drops in
1133
- // this session, so cookie-class callers can redrop (and claim) what they dropped.
1134
- if (isAnonymousWire && ctx.state.anonCookie) {
1135
- headers["Cookie"] = ctx.state.anonCookie;
1136
- }
1137
-
1138
- const form = new FormData();
1139
- // The artifact part name is unchanged from `/drop/auto` (`artifact`); the plug
1140
- // create path — and the inspiration edit paths — treat every
1141
- // non-`cloudgrid.yaml` part as raw artifact bytes.
1142
- form.append("artifact", new Blob([bytes], { type }), name);
1143
- if (mode !== "create") {
1144
- form.append("target_entity_id", targetId);
1145
- }
1146
- if (mode === "anon-edit") {
1147
- // Anon owner-token contract: ownership is proven by HOLDING the token (a form
1148
- // field, NOT an Authorization header). The response re-mints it.
1149
- form.append("owner_token", ownerToken);
1150
- }
1151
- if (mode === "authed-edit") {
1152
- // The authed update path requires a `cloudgrid.yaml` part on the wire
1153
- // (materializePlugTarball). An inspiration edit ignores its content, so an
1154
- // empty part satisfies the contract without changing the entity's config.
1155
- form.append("cloudgrid.yaml", new Blob([""], { type: "text/plain" }), "cloudgrid.yaml");
1156
- }
1157
- // `/plug` resolves the authed org from the `X-CloudGrid-Org` header (set
1158
- // above), not a form field — so `org_slug` is no longer sent.
1159
-
1160
- let res;
1161
- try {
1162
- res = await fetch(`${API_BASE}/api/v2/plug`, { method: "POST", headers, body: form });
1163
- } catch (err) {
1164
- throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
1165
- }
1166
-
1167
- const raw = await res.text();
1168
- let data = null;
1169
- try {
1170
- data = JSON.parse(raw);
1171
- } catch {
1172
- /* handled below */
1173
- }
1174
- if (!res.ok) {
1175
- const code = data?.error?.code;
1176
- const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
1177
- const hint = data?.error?.details?.[0]?.hint;
1178
- const isEdit = mode !== "create";
1179
- // Self-heal rung: a signed-in CREATE that hits the known 400 SCOPE_INVALID
1180
- // platform bug is retried through the bundled CLI — LOCAL edition only,
1181
- // create only, never anonymous. (Mirrors runPlug.)
1182
- if (
1183
- res.status === 400 &&
1184
- code === "SCOPE_INVALID" &&
1185
- ctx.edition === "local" &&
1186
- mode === "create" &&
1187
- !isAnonymousWire &&
1188
- authToken
1189
- ) {
1190
- return plugViaCliFallback(ctx, [{ path: name, buffer: bytes }], deps);
1191
- }
1192
- // An explicit edit NEVER silently creates — surface the reason clearly.
1193
- if (isEdit && res.status === 409) {
1194
- throw new Error(
1195
- `Re-plug rejected (HTTP 409): ${msg} ` +
1196
- "The drop can no longer be edited in place (expired, archived, claimed, or a deploy is running). " +
1197
- "Pass fresh: true to publish a new drop instead.",
1198
- );
1199
- }
1200
- if (mode === "anon-edit" && res.status === 401) {
1201
- throw new Error(
1202
- `Re-plug rejected (HTTP 401): ${msg} ` +
1203
- "The owner_token did not authorize this entity (wrong entity, expired with the drop, or already claimed). " +
1204
- "Pass fresh: true to publish a new drop instead.",
1205
- );
1206
- }
1207
- // Known-code guidance (SCOPE_INVALID on web, 429 anon cap, etc.); unknown
1208
- // codes pass through as the bare error line, unchanged.
1209
- const guidance = errorGuidance({
1210
- status: res.status,
1211
- code,
1212
- edition: ctx.edition,
1213
- isEdit,
1214
- isAnon: isAnonymousWire,
1215
- signedIn: Boolean(authToken),
1216
- });
1217
- const baseLine = `Drop failed (HTTP ${res.status}): ${msg}${hint ? ` ${hint}` : ""}`;
1218
- throw new Error(guidance ? `${baseLine} — ${guidance}` : baseLine);
1219
- }
1220
-
1221
- // Persist the platform's anon-session cookie for ownership continuity (so a
1222
- // cookie-class caller can claim — via pickup — what it dropped).
1223
- const setCookies = res.headers.getSetCookie
1224
- ? res.headers.getSetCookie()
1225
- : [res.headers.get("set-cookie")].filter(Boolean);
1226
- const anonCookie = (setCookies || [])
1227
- .map((c) => (c || "").split(";")[0])
1228
- .find((c) => c.startsWith("cg_anon_session="));
1229
- if (anonCookie) ctx.state.anonCookie = anonCookie;
1230
-
1231
- // The server composes the canonical URL; compose client-side only as
1232
- // a fallback when it came back empty.
1233
- const url = resolvePlugUrl(data);
1234
- const isEdit = mode !== "create";
1235
-
1236
- // The anon owner token: the create response mints it; every anon edit
1237
- // RE-MINTS it to the reset TTL (replace the stored one — the freshest token
1238
- // is the one that lives as long as the drop). Same JWT the claim uses.
1239
- let freshOwnerToken = typeof data.owner_token === "string" && data.owner_token.length > 0
1240
- ? data.owner_token
1241
- : null;
1242
- if (!freshOwnerToken && data.claim_url) {
1243
- try {
1244
- freshOwnerToken = new URL(data.claim_url).searchParams.get("token");
1245
- } catch {
1246
- freshOwnerToken = null;
1247
- }
1248
- }
1249
-
1250
- // Remember the drop for session continuity — any caller class. The
1251
- // `{entity_id, owner_token}` pair is the stateless anon re-plug handle.
1252
- if (data.entity_id || url) {
1253
- ctx.state.lastDrop = {
1254
- entity_id: data.entity_id ?? ctx.state.lastDrop?.entity_id ?? null,
1255
- url: url ?? ctx.state.lastDrop?.url ?? null,
1256
- owner_token: isAnonymousWire
1257
- ? (freshOwnerToken ?? (mode === "anon-edit" ? ownerToken : null))
1258
- : null,
1259
- };
1260
- }
1261
-
1262
- // Authenticated wire (create or in-place edit): the entity is owned by the caller.
1263
- if (!isAnonymousWire) {
1264
- ctx.state.lastAnonClaim = null;
1265
- const structured = {
1266
- url,
1267
- status: isEdit ? "updated" : "created",
1268
- owned_by: "authenticated",
1269
- ...(data.entity_id ? { entity_id: data.entity_id } : {}),
1270
- };
1271
- const lines = [];
1272
- if (isEdit) {
1273
- lines.push(`Updated in place: ${url}`);
1274
- lines.push("Same link, new content. The expiry timer was reset.");
1275
- } else if (ctx.edition === "web") {
1276
- lines.push(`Your app is live: ${url}`);
1277
- } else {
1278
- lines.push(`Published to your org: ${url}`, "Owned by you.");
1279
- }
1280
- if (ctx.edition === "web" && !isEdit) {
1281
- // Default authed web CREATES to "link" visibility so the URL is shareable
1282
- // and the console thumbnail renders without a sign-in wall. An edit keeps
1283
- // the entity's existing visibility untouched.
1284
- if (data.entity_id) {
1285
- await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
1286
- }
1287
- lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
1288
- const vis = "link";
1289
- lines.push(`Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`);
1290
- structured.console_url = CONSOLE_URL;
1291
- structured.current_visibility = vis;
1292
- structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
1293
- }
1294
- return { text: lines.join("\n"), structured };
1295
- }
1296
-
1297
- // Anonymous wire — Guest-Grid inspiration (create 201, or owner-token edit).
1298
- // The response carries the reward fields (`claim_url` + `claim_message`), the
1299
- // real `entity_id`, and the OWNER TOKEN — the bearer capability that
1300
- // authorizes BOTH a later anonymous re-plug and the claim (pickup-by-id).
1301
- if (data.claim_url || data.entity_id) {
1302
- ctx.state.lastAnonClaim = {
1303
- token: freshOwnerToken,
1304
- entity_id: data.entity_id ?? ctx.state.lastAnonClaim?.entity_id ?? null,
1305
- url,
1306
- };
1307
- }
1308
- const lines = [];
1309
- if (isEdit) {
1310
- lines.push(`Updated in place: ${url}`);
1311
- lines.push("Same link, new content. The expiry timer (and owner_token) were refreshed.");
1312
- } else {
1313
- lines.push(ctx.edition === "web" ? `Your app is live: ${url}` : `Live: ${url}`);
1314
- }
1315
- if (data.claim_message) {
1316
- lines.push(data.claim_message);
1317
- } else if (data.claim_url) {
1318
- lines.push("Sign in, then run grid_claim to keep it past the expiry window.");
1319
- }
1320
- if (data.entity_id && freshOwnerToken) {
1321
- lines.push(
1322
- `Re-plug handle (persist to update or claim this drop in a later session): entity_id=${data.entity_id}`,
1323
- );
1324
- }
1325
- return {
1326
- text: lines.join("\n"),
1327
- structured: {
1328
- url,
1329
- status: isEdit ? "updated" : "created",
1330
- ...(data.entity_id ? { entity_id: data.entity_id } : {}),
1331
- ...(freshOwnerToken ? { owner_token: freshOwnerToken } : {}),
1332
- },
1333
- };
1334
- }
1335
939
 
1336
940
  // ── Consent-gated error reporting (Task 34 / 0.8.1) ──────────────────────────
1337
941
  // Key names that look like they carry a secret. Mirrors the server's
@@ -1873,6 +1477,32 @@ export async function plugViaCliFallback(ctx, artifacts, deps = {}) {
1873
1477
  }
1874
1478
  }
1875
1479
 
1480
+ // After an authenticated WEB inspiration create, upgrade visibility to "link" so
1481
+ // the page is shareable and its preview renders without a sign-in wall — the
1482
+ // whole point of the hosted "share a link with a friend" flow. Best-effort: a
1483
+ // failure here never fails the publish (the user can always call grid_visibility).
1484
+ async function upgradeVisibilityToLink(ctx, entityId, orgSlug) {
1485
+ const token = await ctx.getToken();
1486
+ if (!token || !entityId) return false;
1487
+ try {
1488
+ const hdrs = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
1489
+ // Grid-native header + X-CloudGrid-Org alias (same value) during the soak.
1490
+ // Never send conflicting values → 400 GRID_HEADER_CONFLICT.
1491
+ if (orgSlug) {
1492
+ hdrs["X-CloudGrid-Grid"] = orgSlug;
1493
+ hdrs["X-CloudGrid-Org"] = orgSlug;
1494
+ }
1495
+ const res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`, {
1496
+ method: "PATCH",
1497
+ headers: hdrs,
1498
+ body: JSON.stringify({ visibility: "link" }),
1499
+ });
1500
+ return res.ok;
1501
+ } catch {
1502
+ return false;
1503
+ }
1504
+ }
1505
+
1876
1506
  /**
1877
1507
  * The unified create/re-plug verb (spec v2 §3). Two intents on one tool, keyed
1878
1508
  * by `target_entity_id`:
@@ -1889,27 +1519,46 @@ export async function runPlug(ctx, input, deps = {}) {
1889
1519
  const {
1890
1520
  path: srcPath,
1891
1521
  artifact_files,
1522
+ html,
1523
+ filename,
1892
1524
  cloudgrid_yaml,
1893
1525
  target_entity_id,
1894
1526
  grid,
1527
+ slug,
1895
1528
  hints,
1896
1529
  anon,
1897
1530
  owner_token,
1898
1531
  } = input || {};
1899
1532
 
1900
- // ── Source: path XOR artifact_files ────────────────────────────────────────
1901
- if (srcPath && Array.isArray(artifact_files) && artifact_files.length > 0) {
1902
- throw new Error("Pass either `path` or `artifact_files`, not both.");
1533
+ // ── Source: exactly one of html | artifact_files | path ─────────────────────
1534
+ const hasHtml = typeof html === "string" && html.length > 0;
1535
+ const hasArtifacts = Array.isArray(artifact_files) && artifact_files.length > 0;
1536
+ const hasPath = Boolean(srcPath);
1537
+ if ((hasHtml ? 1 : 0) + (hasArtifacts ? 1 : 0) + (hasPath ? 1 : 0) > 1) {
1538
+ throw new Error(
1539
+ "Pass exactly one source: `html` (a single inline HTML document), `artifact_files` " +
1540
+ "(multiple inline files), or `path` (a local file/folder).",
1541
+ );
1903
1542
  }
1904
- if (ctx.edition === "web" && srcPath) {
1543
+ if (ctx.edition === "web" && hasPath) {
1905
1544
  throw new Error(
1906
- "The hosted server cannot read local files — pass the source inline via `artifact_files`.",
1545
+ "The hosted server cannot read local files — pass the source inline via `html` or `artifact_files`.",
1907
1546
  );
1908
1547
  }
1909
1548
  let artifacts;
1910
- if (srcPath) {
1549
+ // Set on the single-file `html` path so the auth-aware inline size cap can be
1550
+ // enforced once the anon-vs-authed wire is known (see below).
1551
+ let inlineHtmlBytes = null;
1552
+ if (hasHtml) {
1553
+ // The ergonomic single-file publish path (the old drop verb): one self-
1554
+ // contained HTML document → one index.html artifact, with the shared
1555
+ // hardening (base64 rescue, @-path/file-path rejection, fragment wrap).
1556
+ const art = htmlToArtifact(html, filename);
1557
+ inlineHtmlBytes = art.buffer.byteLength;
1558
+ artifacts = [art];
1559
+ } else if (hasPath) {
1911
1560
  artifacts = collectPathArtifacts(srcPath);
1912
- } else if (Array.isArray(artifact_files) && artifact_files.length > 0) {
1561
+ } else if (hasArtifacts) {
1913
1562
  let total = 0;
1914
1563
  artifacts = artifact_files.map((f) => {
1915
1564
  if (!f || typeof f.path !== "string" || typeof f.content !== "string") {
@@ -1925,12 +1574,25 @@ export async function runPlug(ctx, input, deps = {}) {
1925
1574
  } else {
1926
1575
  throw new Error(
1927
1576
  ctx.edition === "web"
1928
- ? "Provide the source via `artifact_files`."
1929
- : "Provide the source via `path` (a local file or folder) or `artifact_files`.",
1577
+ ? "Provide the source via `html` (a single inline HTML document) or `artifact_files`."
1578
+ : "Provide the source via `html` (a single inline HTML document), `path` (a local file " +
1579
+ "or folder), or `artifact_files`.",
1930
1580
  );
1931
1581
  }
1932
1582
 
1933
- const isEdit = typeof target_entity_id === "string" && target_entity_id.length > 0;
1583
+ // grid+slug re-plug handle (the pickup contract's `replug_handle`): when no
1584
+ // explicit target_entity_id was given but a grid+slug pair is, resolve it to
1585
+ // an existing entity_id and re-plug that in place. A slug that does NOT resolve
1586
+ // to an existing entity → targetEntityId stays empty → this is a CREATE (no
1587
+ // false-positive re-plug). target_entity_id remains the primary/documented
1588
+ // handle. Best-effort resolve (pickup contract); never fetches the public URL.
1589
+ let targetEntityId = target_entity_id;
1590
+ if ((typeof targetEntityId !== "string" || targetEntityId.length === 0) && grid && slug) {
1591
+ const resolved = await resolveEntityViaPickup(ctx, { target: slug, grid });
1592
+ if (resolved?.entity_id) targetEntityId = resolved.entity_id;
1593
+ }
1594
+
1595
+ const isEdit = typeof targetEntityId === "string" && targetEntityId.length > 0;
1934
1596
 
1935
1597
  // An inspiration edit content-versions the FIRST uploaded artifact — when a
1936
1598
  // multi-file folder rides a re-plug, put the primary entry first so the edit
@@ -1950,10 +1612,10 @@ export async function runPlug(ctx, input, deps = {}) {
1950
1612
  if (isEdit && !ownerToken) {
1951
1613
  // Recover the owner token from session state when re-plugging the drop this
1952
1614
  // session made anonymously.
1953
- if (ctx.state.lastDrop?.entity_id === target_entity_id && ctx.state.lastDrop.owner_token) {
1615
+ if (ctx.state.lastDrop?.entity_id === targetEntityId && ctx.state.lastDrop.owner_token) {
1954
1616
  ownerToken = ctx.state.lastDrop.owner_token;
1955
1617
  } else if (
1956
- ctx.state.lastAnonClaim?.entity_id === target_entity_id &&
1618
+ ctx.state.lastAnonClaim?.entity_id === targetEntityId &&
1957
1619
  ctx.state.lastAnonClaim.token
1958
1620
  ) {
1959
1621
  ownerToken = ctx.state.lastAnonClaim.token;
@@ -1970,7 +1632,30 @@ export async function runPlug(ctx, input, deps = {}) {
1970
1632
  );
1971
1633
  }
1972
1634
 
1635
+ // Auth-aware inline size cap for the single-file `html` path (the old drop
1636
+ // cap). Anonymous inline pages stay capped at 2 MB; signed-in inline pages get
1637
+ // the larger AUTHED cap. `path` (read from disk) and `artifact_files` are
1638
+ // bounded by PLUG_MAX_TOTAL_BYTES instead, so they never set inlineHtmlBytes.
1639
+ if (inlineHtmlBytes != null) {
1640
+ if (useAnonWire) {
1641
+ if (inlineHtmlBytes > ANON_HTML_MAX_BYTES) {
1642
+ throw new Error(
1643
+ `This HTML is ${(inlineHtmlBytes / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
1644
+ "Trim it, or sign in to publish larger.",
1645
+ );
1646
+ }
1647
+ } else if (inlineHtmlBytes > AUTHED_HTML_MAX_BYTES) {
1648
+ throw new Error(
1649
+ `This HTML is ${(inlineHtmlBytes / 1e6).toFixed(2)} MB. Inline drops are capped at ` +
1650
+ `${(AUTHED_HTML_MAX_BYTES / 1e6).toFixed(0)} MB. In the local edition pass \`path\` to the ` +
1651
+ ".html file (read from disk — no inline size limit) instead of pasting it inline.",
1652
+ );
1653
+ }
1654
+ }
1655
+
1973
1656
  const headers = {};
1657
+ // The authed target grid — also reused for the post-create visibility upgrade.
1658
+ let orgSlug = null;
1974
1659
  if (!useAnonWire && authToken) {
1975
1660
  headers["Authorization"] = `Bearer ${authToken}`;
1976
1661
  // On create, `grid` picks where the entity lands. On re-plug the entity's
@@ -1978,7 +1663,7 @@ export async function runPlug(ctx, input, deps = {}) {
1978
1663
  // the caller's membership from this header and requires it to MATCH the
1979
1664
  // entity's grid, so pass `grid` here too when the target lives outside the
1980
1665
  // active grid.
1981
- const orgSlug = grid || (await ctx.getActiveGrid());
1666
+ orgSlug = grid || (await ctx.getActiveGrid());
1982
1667
  // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
1983
1668
  if (orgSlug) {
1984
1669
  headers["X-CloudGrid-Grid"] = orgSlug;
@@ -2012,10 +1697,13 @@ export async function runPlug(ctx, input, deps = {}) {
2012
1697
  // ── Wire assembly ───────────────────────────────────────────────────────────
2013
1698
  const form = new FormData();
2014
1699
  for (const a of artifacts) {
2015
- form.append("artifact", new Blob([a.buffer], { type: "application/octet-stream" }), a.path);
1700
+ // Folder-walk / artifact_files parts ride as octet-stream (server sniffs by
1701
+ // name); the single-file `html` path carries text/html so a bare inline page
1702
+ // renders instead of downloading (the old drop behavior).
1703
+ form.append("artifact", new Blob([a.buffer], { type: a.type || "application/octet-stream" }), a.path);
2016
1704
  }
2017
1705
  if (isEdit) {
2018
- form.append("target_entity_id", target_entity_id);
1706
+ form.append("target_entity_id", targetEntityId);
2019
1707
  if (useAnonWire) {
2020
1708
  form.append("owner_token", ownerToken);
2021
1709
  } else {
@@ -2086,7 +1774,7 @@ export async function runPlug(ctx, input, deps = {}) {
2086
1774
  throw new Error(plugErrorMessage(res.status, code, msg, flags));
2087
1775
  }
2088
1776
 
2089
- // Anon-session cookie continuity (mirrors runDrop).
1777
+ // Anon-session cookie continuity.
2090
1778
  const setCookies = res.headers.getSetCookie
2091
1779
  ? res.headers.getSetCookie()
2092
1780
  : [res.headers.get("set-cookie")].filter(Boolean);
@@ -2107,7 +1795,7 @@ export async function runPlug(ctx, input, deps = {}) {
2107
1795
  }
2108
1796
  }
2109
1797
 
2110
- // Session continuity — the same state runDrop keeps.
1798
+ // Session continuity — remember the last plug for re-plug handles.
2111
1799
  if (data.entity_id || url) {
2112
1800
  ctx.state.lastDrop = {
2113
1801
  entity_id: data.entity_id ?? null,
@@ -2146,6 +1834,20 @@ export async function runPlug(ctx, input, deps = {}) {
2146
1834
  // the terminal states get the live wording; anything still building points at
2147
1835
  // the poll_url and grid_status.
2148
1836
  const isBuilding = data.status === "building" || Boolean(data.poll_url);
1837
+
1838
+ // An authed CREATE of an INSPIRATION (the single-file `html` path, or a server-
1839
+ // detected inspiration — NOT a runtime app/agent, NOT a build in flight, NOT an
1840
+ // anon guest drop). This is the case that (on the hosted edition) must be made
1841
+ // link-visible so the shared URL renders without a sign-in wall.
1842
+ const detectedKind = data.detection?.kind;
1843
+ const isInspirationCreate =
1844
+ !isEdit &&
1845
+ !useAnonWire &&
1846
+ !isBuilding &&
1847
+ detectedKind !== "app" &&
1848
+ detectedKind !== "agent" &&
1849
+ (inlineHtmlBytes != null || detectedKind === "inspiration");
1850
+
2149
1851
  const lines = [];
2150
1852
  if (isBuilding) {
2151
1853
  lines.push(
@@ -2158,8 +1860,14 @@ export async function runPlug(ctx, input, deps = {}) {
2158
1860
  ? `Poll ${data.poll_url} or run grid_status until it is ready (trace ${data.trace_id ?? "n/a"}). Do not report it as live until then.`
2159
1861
  : "Run grid_status until it is ready. Do not report it as live until then.",
2160
1862
  );
1863
+ } else if (isEdit) {
1864
+ lines.push(`Updated in place: ${url}`);
1865
+ } else if (isInspirationCreate) {
1866
+ // Authed inspiration create — owned by the caller. Wording mirrors the drop verb.
1867
+ lines.push(ctx.edition === "web" ? `Your app is live: ${url}` : `Published to your org: ${url}`);
1868
+ if (ctx.edition !== "web") lines.push("Owned by you.");
2161
1869
  } else {
2162
- lines.push(isEdit ? `Updated in place: ${url}` : `Live: ${url}`);
1870
+ lines.push(`Live: ${url}`);
2163
1871
  }
2164
1872
  if (data.entity_id) {
2165
1873
  lines.push(
@@ -2170,6 +1878,22 @@ export async function runPlug(ctx, input, deps = {}) {
2170
1878
  if (isEdit && useAnonWire) {
2171
1879
  lines.push("The owner_token was re-minted for the reset expiry — replace the stored one.");
2172
1880
  }
1881
+
1882
+ // Hosted "share a link with a friend": a signed-in WEB inspiration create is
1883
+ // upgraded to "link" visibility so its preview renders without a sign-in wall.
1884
+ // Scoped EXACTLY to web + create + inspiration — never runtime apps/agents,
1885
+ // edits, anon drops, or the local edition (which stays owned/org-scoped).
1886
+ if (isInspirationCreate && ctx.edition === "web" && data.entity_id) {
1887
+ await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
1888
+ const vis = "link";
1889
+ structured.console_url = CONSOLE_URL;
1890
+ structured.current_visibility = vis;
1891
+ structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
1892
+ lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
1893
+ lines.push(
1894
+ `Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`,
1895
+ );
1896
+ }
2173
1897
  return { text: lines.join("\n"), structured };
2174
1898
  }
2175
1899
 
@@ -2331,8 +2055,10 @@ function isCloudgridHost(hostname) {
2331
2055
  }
2332
2056
 
2333
2057
  // Shape an HTML string into the grid_source result (shared by the API-read
2334
- // and the public-fetch paths). Caps the body at SOURCE_MAX_BYTES.
2335
- function shapeSourceResult(sourceUrl, entityId, htmlStr) {
2058
+ // and the public-fetch paths). Caps the body at SOURCE_MAX_BYTES. `extra` carries
2059
+ // optional edition metadata resolved from the pickup contract (kind, single_html,
2060
+ // capabilities, replug_handle, source_download_url) — merged into structured.
2061
+ function shapeSourceResult(sourceUrl, entityId, htmlStr, extra = {}) {
2336
2062
  const buf = Buffer.from(htmlStr, "utf-8");
2337
2063
  const totalBytes = buf.length;
2338
2064
  const truncated = totalBytes > SOURCE_MAX_BYTES;
@@ -2348,7 +2074,7 @@ function shapeSourceResult(sourceUrl, entityId, htmlStr) {
2348
2074
  lines.push("", html);
2349
2075
  return {
2350
2076
  text: lines.join("\n"),
2351
- structured: { url: sourceUrl, entity_id: entityId ?? null, bytes: totalBytes, truncated, html },
2077
+ structured: { url: sourceUrl, entity_id: entityId ?? null, bytes: totalBytes, truncated, html, ...extra },
2352
2078
  };
2353
2079
  }
2354
2080
 
@@ -2384,6 +2110,50 @@ async function readInspirationSourceViaApi(ctx, { entityId, slug, grid }) {
2384
2110
  return null;
2385
2111
  }
2386
2112
 
2113
+ // Resolve a public URL / grid+slug to a REAL entity_id (+ edition metadata) via
2114
+ // the deployed pickup contract (POST /api/v2/entities/:target/pickup — the same
2115
+ // endpoint runClaim uses). Used by runSource when a bare URL arrives with no
2116
+ // session entity_id (a fresh chat): the contract returns
2117
+ // { entity_id, slug, grid, kind, single_html, capabilities, replug_handle,
2118
+ // source_download_url, ... }
2119
+ // so the agent can re-plug the SAME entity in place. This is a metadata resolve
2120
+ // (no claim_token in the body → no ownership transfer). Best-effort: returns null
2121
+ // on any failure so the caller falls back to today's behavior — and NEVER fetches
2122
+ // the public *.cloudgrid.io URL (the hosted pod cannot egress to it).
2123
+ async function resolveEntityViaPickup(ctx, { target, url, grid }) {
2124
+ const pathSeg = target || url;
2125
+ if (!pathSeg) return null;
2126
+ let token = null;
2127
+ try { token = await ctx.getToken(); } catch { /* anonymous is fine */ }
2128
+ const headers = { "Content-Type": "application/json" };
2129
+ if (token) headers["Authorization"] = `Bearer ${token}`;
2130
+ // Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
2131
+ if (grid) {
2132
+ headers["X-CloudGrid-Grid"] = grid;
2133
+ headers["X-CloudGrid-Org"] = grid;
2134
+ }
2135
+ if (ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
2136
+ try {
2137
+ const res = await fetch(
2138
+ `${API_BASE}/api/v2/entities/${encodeURIComponent(pathSeg)}/pickup`,
2139
+ {
2140
+ method: "POST",
2141
+ headers,
2142
+ // Send the URL as a resolution key too (the contract accepts id, slug, or
2143
+ // a {url} body). NO claim_token → resolve only, never a claim.
2144
+ body: JSON.stringify(url ? { url } : {}),
2145
+ signal: AbortSignal.timeout(15_000),
2146
+ },
2147
+ );
2148
+ if (!res.ok) return null;
2149
+ const data = await res.json().catch(() => null);
2150
+ if (data && typeof data.entity_id === "string" && data.entity_id.length > 0) return data;
2151
+ return null;
2152
+ } catch {
2153
+ return null;
2154
+ }
2155
+ }
2156
+
2387
2157
  // Fetch a drop's/inspiration's current deployed HTML inline as text so an agent
2388
2158
  // that lost the content can edit it and re-plug in place. Resolves the fetch URL
2389
2159
  // (explicit url → session lastDrop → composed grid+slug), then reads the HTML
@@ -2422,19 +2192,40 @@ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
2422
2192
  }
2423
2193
 
2424
2194
  const resolvedUrl = parsed.toString();
2425
- const eid = entity_id ?? (last && (!entity_id || last.entity_id === entity_id) ? last.entity_id : null);
2195
+ let eid = entity_id ?? (last && (!entity_id || last.entity_id === entity_id) ? last.entity_id : null);
2426
2196
 
2427
- // ── API-first (reachable) ────────────────────────────────────────────────
2428
- // Read the HTML from the API server-side rather than fetching the public URL.
2429
- // The hosted MCP pod can reach the API but NOT the public *.cloudgrid.io
2430
- // ingress ("fetch failed") — this is the fix for hosted edit-in-place. Derive
2431
- // the grid/slug hint from the inspiration path URL (`grid.cloudgrid.io/slug`).
2197
+ // Derive the grid/slug hint from the inspiration path URL (`grid.cloudgrid.io/slug`).
2432
2198
  const host = parsed.hostname;
2433
2199
  const gridHint = grid ?? (host.endsWith(".cloudgrid.io") && !host.includes("--") && host.split(".").length === 3
2434
2200
  ? host.split(".")[0] : null);
2435
2201
  const slugHint = slug ?? (parsed.pathname.replace(/^\/+/, "").split("/")[0] || null);
2202
+
2203
+ // ── URL → entity_id (fresh chat, no session) via the pickup contract ──────
2204
+ // When a bare URL/slug arrived with no known entity_id, resolve a REAL
2205
+ // entity_id (+ edition metadata) so the agent can re-plug in place. Best-
2206
+ // effort: a failure falls back to today's behavior (never regress the read),
2207
+ // and we NEVER fetch the public URL for resolution.
2208
+ let extra = {};
2209
+ if (!eid) {
2210
+ const pickup = await resolveEntityViaPickup(ctx, { target: slugHint, url: resolvedUrl, grid: gridHint });
2211
+ if (pickup?.entity_id) {
2212
+ eid = pickup.entity_id;
2213
+ extra = {
2214
+ ...(pickup.kind ? { kind: pickup.kind } : {}),
2215
+ ...(typeof pickup.single_html === "boolean" ? { single_html: pickup.single_html } : {}),
2216
+ ...(pickup.capabilities ? { capabilities: pickup.capabilities } : {}),
2217
+ ...(pickup.replug_handle ? { replug_handle: pickup.replug_handle } : {}),
2218
+ ...(pickup.source_download_url ? { source_download_url: pickup.source_download_url } : {}),
2219
+ };
2220
+ }
2221
+ }
2222
+
2223
+ // ── API-first (reachable) ────────────────────────────────────────────────
2224
+ // Read the HTML from the API server-side rather than fetching the public URL.
2225
+ // The hosted MCP pod can reach the API but NOT the public *.cloudgrid.io
2226
+ // ingress ("fetch failed") — this is the fix for hosted edit-in-place.
2436
2227
  const apiHtml = await readInspirationSourceViaApi(ctx, { entityId: eid, slug: slugHint, grid: gridHint });
2437
- if (apiHtml != null) return shapeSourceResult(resolvedUrl, eid, apiHtml);
2228
+ if (apiHtml != null) return shapeSourceResult(resolvedUrl, eid, apiHtml, extra);
2438
2229
 
2439
2230
  // ── Fallback: direct public fetch ────────────────────────────────────────
2440
2231
  // Local edition has normal egress; last resort on hosted (e.g. a runtime app
@@ -2470,7 +2261,7 @@ export async function runSource(ctx, { entity_id, url, grid, slug } = {}) {
2470
2261
  }
2471
2262
 
2472
2263
  const buf = Buffer.from(await res.arrayBuffer());
2473
- return shapeSourceResult(resolvedUrl, eid, buf.toString("utf-8"));
2264
+ return shapeSourceResult(resolvedUrl, eid, buf.toString("utf-8"), extra);
2474
2265
  }
2475
2266
 
2476
2267
  // ── Registration ───────────────────────────────────────────────────────────────
@@ -2521,144 +2312,6 @@ export function registerTools(server, ctx) {
2521
2312
 
2522
2313
  // ── Direct-API tools (both editions) ──────────────────────────────────────
2523
2314
 
2524
- // Drop — both editions, but the input schema is edition-aware: the web
2525
- // edition removes `path` (the cloud server cannot read local files) and
2526
- // strengthens `html` so the model always pastes the full document inline.
2527
- const dropReplugParams = {
2528
- fresh: z.boolean().optional().describe("Force a brand-new drop (new URL) even if you already dropped in this session. Default: a re-drop updates the session's drop in place — same URL, new content."),
2529
- entity_id: z.string().optional().describe("Re-plug a SPECIFIC entity by id (the durable handle a previous drop returned) — updates it in place, same URL. Defaults to this session's last drop."),
2530
- owner_token: z.string().optional().describe("The owner token of an anonymously-created drop (returned when it was created; refreshed on every anonymous edit). Needed to re-plug or claim it from a new session."),
2531
- };
2532
- const dropInputSchema = ctx.edition === "web"
2533
- ? {
2534
- html: z.string().optional().describe(
2535
- "The COMPLETE HTML document to publish — paste the full file contents here (not a path). " +
2536
- "For a game, include all HTML/CSS/JS inline so it runs standalone. " +
2537
- "A fragment is wrapped into a full document automatically.",
2538
- ),
2539
- filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
2540
- anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
2541
- grid: z.string().optional().describe("Leave unset; the tool will ask the user which grid to publish into. Only set this after the user picks from the list the tool returns."),
2542
- org: z.string().optional().describe("Deprecated alias for `grid` (kept for compatibility). Prefer `grid`."),
2543
- ...dropReplugParams,
2544
- }
2545
- : {
2546
- html: z.string().optional().describe("Inline HTML to publish (a small fragment is wrapped into a full document). Pass the RAW HTML — do NOT base64-encode it, and do NOT pass an `@`-prefixed path or a file path here. For a large or image-heavy document, use `path` instead."),
2547
- path: z.string().optional().describe("Path to a local .html file to upload instead of inline HTML — read from disk with no inline size limit. Preferred for large or image-heavy documents. There is no `artifact_files` parameter."),
2548
- filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
2549
- anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
2550
- grid: z.string().optional().describe("Leave unset; the tool will ask the user which grid to publish into. Only set this after the user picks from the list the tool returns."),
2551
- org: z.string().optional().describe("Deprecated alias for `grid` (kept for compatibility). Prefer `grid`."),
2552
- ...dropReplugParams,
2553
- };
2554
-
2555
- reg(
2556
- "grid_drop",
2557
- {
2558
- description: "Publish an HTML page or file to CloudGrid and get a public shareable URL. Use when the user wants to share, publish, send, or 'deploy' an artifact, or wants a link to send a friend. Re-drops in the same session UPDATE THE SAME entity in place — same link, new content, expiry reset (pass fresh: true to force a new drop, or entity_id to target a specific earlier drop). If signed in, it publishes into the user's grid as an owned inspiration; if not, it drops anonymously into the Guest Grid, claimable later — the result includes an entity_id + owner_token to persist as the re-plug/claim handle for later sessions. Drops expire per the platform default (7 days) unless claimed/owned; every in-place edit resets the timer. If you want to edit an existing drop but no longer have its HTML, call grid_source first to retrieve it, then re-plug with target_entity_id. For a large or image-heavy document in the local edition, pass `path` to the .html file (read from disk — no inline size limit); do NOT base64-encode the content and do NOT pass an `@`-prefixed path or a file path as `html`. There is no `artifact_files` parameter. Calls POST /api/v2/plug directly.",
2559
- inputSchema: dropInputSchema,
2560
- outputSchema: {
2561
- url: z.string().optional().describe("The public URL of the drop (stable across re-drops of the same entity)."),
2562
- status: z.enum(["created", "updated", "unchanged"]).optional().describe("What happened to the drop."),
2563
- entity_id: z.string().optional().describe("The entity's durable id — pass back as entity_id to update it in place later."),
2564
- owner_token: z.string().optional().describe("Anonymous drops only: the bearer owner token for later re-plug/claim. Refreshed on every anonymous edit — always persist the newest one."),
2565
- owned_by: z.string().optional().describe("Ownership class, e.g. 'authenticated'."),
2566
- expires_at: z.string().optional().describe("Expiry timestamp, if any."),
2567
- console_url: z.string().optional().describe("URL to manage all apps in the grid."),
2568
- current_visibility: z.string().optional().describe("Current visibility of the drop."),
2569
- visibility_options: z.array(z.object({
2570
- value: z.string().describe("Visibility value to pass to grid_visibility."),
2571
- label: z.string().describe("Human-readable label."),
2572
- })).optional().describe("Available visibility levels."),
2573
- needs_grid: z.boolean().optional().describe("True when the user must choose a grid before dropping."),
2574
- needs_org: z.boolean().optional().describe("Deprecated alias of needs_grid (kept so the org-picker widget keeps working)."),
2575
- grids: z.array(z.object({
2576
- slug: z.string().describe("Grid slug to pass as the grid parameter."),
2577
- name: z.string().describe("Human-readable grid name."),
2578
- role: z.string().describe("User's role in the grid."),
2579
- is_active: z.boolean().optional().describe("True if this is the user's currently active grid."),
2580
- })).optional().describe("The user's grids, when a grid choice is needed."),
2581
- orgs: z.array(z.object({
2582
- slug: z.string().describe("Grid slug to pass as the grid parameter."),
2583
- name: z.string().describe("Human-readable grid name."),
2584
- role: z.string().describe("User's role in the grid."),
2585
- is_active: z.boolean().optional().describe("True if this is the user's currently active grid."),
2586
- })).optional().describe("Deprecated alias of grids (kept so the org-picker widget keeps working)."),
2587
- needs_sign_in: z.boolean().optional().describe("True when sign-in is needed before dropping."),
2588
- login_url: z.string().optional().describe("Sign-in URL when authentication is needed."),
2589
- },
2590
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2591
- ...(ctx.edition === "web" && APPS_WIDGETS_ENABLED ? {
2592
- _meta: {
2593
- ui: { resourceUri: LIVE_RESULT_URI, csp: WIDGET_CSP },
2594
- "openai/outputTemplate": LIVE_RESULT_URI,
2595
- },
2596
- } : {}),
2597
- },
2598
- async (input) => {
2599
- try {
2600
- // Web edition: reject `path` early — the hosted server cannot read
2601
- // local files. The schema already omits it, but a model with a
2602
- // cached tool description might still send one.
2603
- if (ctx.edition === "web" && input?.path) {
2604
- return fail(
2605
- "The hosted server cannot read local files — pass the full document as `html` instead of a `path`.",
2606
- );
2607
- }
2608
-
2609
- // Web edition: sign-in guidance when unauthenticated.
2610
- if (ctx.edition === "web" && input?.anonymous !== true) {
2611
- const token = await ctx.getToken();
2612
- if (!token) {
2613
- const url = buildLoginUrl(newLoginCode());
2614
- return okResult({
2615
- text: `Sign in to publish to your org.\n${url}`,
2616
- structured: { needs_sign_in: true, login_url: url },
2617
- });
2618
- }
2619
- }
2620
-
2621
- // Stateless grid disambiguation — both editions, when authenticated.
2622
- // No dependency on prior-call state so it works even when the client
2623
- // reconnects on every tool call (ChatGPT Apps SDK behaviour). `grid` is
2624
- // accepted as an alias for `org` (Gilad's rename); `org` still works.
2625
- if (input?.anonymous !== true) {
2626
- const token = await ctx.getToken();
2627
- if (token) {
2628
- const suppliedGrid = input?.grid || input?.org;
2629
- const decision = await resolveGridOrAsk(ctx, {
2630
- token,
2631
- suppliedGrid,
2632
- edition: ctx.edition,
2633
- });
2634
- if (decision.picker) {
2635
- return okResult(decision.picker);
2636
- }
2637
- if (decision.single) {
2638
- if (!decision.single.render_ready) {
2639
- // Single grid but not set up — tell the agent so it can warn
2640
- // the user and offer anonymous drop as fallback. (drop-specific:
2641
- // drop blocks here; plug proceeds with a warning.)
2642
- const annotated = [{ ...decision.single, render_ready: false }];
2643
- return okResult({
2644
- text: `Your only grid "${decision.single.slug}" isn't fully set up yet — pages published there may not load. You can drop anonymously (set anonymous: true) or wait until provisioning completes, then re-run.`,
2645
- structured: { needs_grid: true, needs_org: true, grid_not_ready: true, org_not_ready: true, grids: annotated, orgs: annotated },
2646
- });
2647
- }
2648
- input = { ...(input || {}), org: decision.single.slug };
2649
- } else if (decision.grid) {
2650
- // Supplied grid matched — normalize onto `org` for runDrop.
2651
- input = { ...(input || {}), org: decision.grid };
2652
- }
2653
- }
2654
- }
2655
- return okResult(await runDrop(ctx, input || {}));
2656
- } catch (err) {
2657
- return fail(err.message);
2658
- }
2659
- },
2660
- );
2661
-
2662
2315
  // Claim — both editions.
2663
2316
  reg(
2664
2317
  "grid_claim",
@@ -2689,13 +2342,21 @@ export function registerTools(server, ctx) {
2689
2342
  // CLI-wrapping grid_plug: create and re-plug are one verb, keyed by
2690
2343
  // target_entity_id, and work identically on the hosted transport.
2691
2344
  const plugInputSchema = {
2345
+ html: z.string().optional().describe(
2346
+ "A single self-contained HTML document to publish as an inspiration — the fast single-file path. " +
2347
+ "Pass the COMPLETE raw HTML inline (CSS+JS inline, images/fonts as data: URIs); a small fragment is " +
2348
+ "wrapped into a full document. Do NOT base64-encode it, and do NOT pass an `@`-prefixed path or a " +
2349
+ "file path here. Mutually exclusive with `path` and `artifact_files`. Materialized as one index.html " +
2350
+ "and published instantly on any edition, anonymously (claimable) or into your grid when signed in.",
2351
+ ),
2352
+ filename: z.string().optional().describe("Filename for the single-file `html` path. Defaults to index.html."),
2692
2353
  ...(ctx.edition === "web"
2693
2354
  ? {}
2694
2355
  : {
2695
2356
  path: z.string().optional().describe(
2696
2357
  "Local edition: path to the entity folder (or a single file) to upload. A folder is read " +
2697
2358
  "recursively, honoring .gitignore/.cloudgridignore (plus .git/node_modules always skipped). " +
2698
- "Mutually exclusive with artifact_files.",
2359
+ "Mutually exclusive with `html` and `artifact_files`.",
2699
2360
  ),
2700
2361
  }),
2701
2362
  artifact_files: z.array(z.object({
@@ -2703,8 +2364,10 @@ export function registerTools(server, ctx) {
2703
2364
  content: z.string().describe("File content. Base64 when encoding is base64, otherwise UTF-8 text."),
2704
2365
  encoding: z.enum(["utf8", "base64"]).optional().describe("Content encoding. Default utf8."),
2705
2366
  })).optional().describe(
2706
- "The source inline, one entry per file — for hosted/no-filesystem transports (an HTML deck, a one-file app)." +
2707
- (ctx.edition === "web" ? "" : " Prefer `path` on the local edition."),
2367
+ "The source inline, one entry per file — for hosted/no-filesystem transports (a multi-file app). " +
2368
+ "For a single HTML page prefer `html`." +
2369
+ (ctx.edition === "web" ? "" : " Prefer `path` on the local edition.") +
2370
+ " Mutually exclusive with `html` and `path`.",
2708
2371
  ),
2709
2372
  cloudgrid_yaml: z.string().optional().describe(
2710
2373
  "Inline cloudgrid.yaml (the entity manifest). Optional — server auto-detection applies when omitted. " +
@@ -2720,6 +2383,12 @@ export function registerTools(server, ctx) {
2720
2383
  "never moves grids, but pass its home grid here when it differs from your active grid (the API " +
2721
2384
  "checks your membership in the entity's grid). Anonymous → always the Guest grid.",
2722
2385
  ),
2386
+ slug: z.string().optional().describe(
2387
+ "Alternative RE-PLUG handle: paired with `grid`, resolves to an existing entity (the pickup " +
2388
+ "contract's replug_handle) and updates it in place — for a client that holds only grid+slug, not the " +
2389
+ "raw entity_id. target_entity_id takes precedence. A grid+slug that does NOT resolve to an existing " +
2390
+ "entity is treated as a CREATE (never a false-positive re-plug).",
2391
+ ),
2723
2392
  hints: z.object({
2724
2393
  kind: z.enum(["inspiration", "app", "agent"]).optional().describe("Force the detected kind; omit to let the server auto-detect."),
2725
2394
  yaml: z.string().optional().describe("An inline cloudgrid.yaml override used as a detection hint."),
@@ -2738,17 +2407,22 @@ export function registerTools(server, ctx) {
2738
2407
  "grid_plug",
2739
2408
  {
2740
2409
  description:
2741
- "Surface a creation onto the grid — the unified create/re-plug verb (POST /api/v2/plug). " +
2410
+ "Deploy or share a creation on CloudGrid and get a public URL — the ONE create/re-plug verb " +
2411
+ "(POST /api/v2/plug). Use it whenever the user wants to share, publish, send, ship, 'deploy', go " +
2412
+ "live, or get a link to send a friend — for a single HTML page OR a full app. " +
2413
+ "Sources (pass exactly one): `html` — a single self-contained HTML page (the fast path: instant, any " +
2414
+ "edition, anonymous+claimable or into your grid when signed in); " +
2415
+ (ctx.edition === "web"
2416
+ ? "or `artifact_files` — a multi-file app inline. "
2417
+ : "`path` — a local folder/file (a multi-file app); or `artifact_files` — inline files. ") +
2742
2418
  "No target_entity_id → CREATE a new entity (inspiration/app/agent, auto-detected or hinted); " +
2743
- "with target_entity_id → RE-PLUG: update the SAME entity in place — same entity_id, same URL, same " +
2419
+ "with target_entity_id (or grid+slug — the replug_handle, when you hold only those) → RE-PLUG: " +
2420
+ "update the SAME entity in place — same entity_id, same URL, same " +
2744
2421
  "deploy history, expiry reset. The returned entity_id + url are the durable re-plug handle; persist " +
2745
- "them (plus owner_token for anonymous drops) to update the entity in later sessions. " +
2746
- (ctx.edition === "web"
2747
- ? "Pass the source inline via artifact_files."
2748
- : "Pass the source as a local `path` (folder or file) or inline via artifact_files.") +
2749
- " Note: in-place re-plug currently supports inspirations (HTML/static drops); to rebuild a deployed " +
2750
- "app/agent, use the CloudGrid CLI (`cloudgrid plug`) in its linked folder." +
2751
- " If you want to edit an existing drop but no longer have its HTML, call grid_source first to " +
2422
+ "them (plus owner_token for anonymous pages) to update the entity in later sessions. " +
2423
+ "Note: in-place re-plug currently supports inspirations (HTML/static pages); to rebuild a deployed " +
2424
+ "app/agent, use the CloudGrid CLI (`cloudgrid plug`) in its linked folder. " +
2425
+ "If you want to edit an existing page but no longer have its HTML, call grid_source first to " +
2752
2426
  "retrieve it, then re-plug with target_entity_id.",
2753
2427
  inputSchema: plugInputSchema,
2754
2428
  outputSchema: {
@@ -2761,12 +2435,24 @@ export function registerTools(server, ctx) {
2761
2435
  claim_url: z.string().optional().describe("Anon create only: sign-in link to claim ownership."),
2762
2436
  claim_message: z.string().optional().describe("Anon create only: the claim nudge to relay."),
2763
2437
  owner_token: z.string().optional().describe("Anonymous drops: the bearer owner token (re-plug + claim). Re-minted on every anonymous edit — persist the newest."),
2438
+ console_url: z.string().optional().describe("Web authed inspiration create: URL to manage all apps in the grid."),
2439
+ current_visibility: z.string().optional().describe("Web authed inspiration create: the visibility set after publish (link)."),
2440
+ visibility_options: z.array(z.object({
2441
+ value: z.string().describe("Visibility value to pass to grid_visibility."),
2442
+ label: z.string().describe("Human-readable label."),
2443
+ })).optional().describe("Web authed inspiration create: available visibility levels."),
2764
2444
  },
2765
2445
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
2446
+ ...(ctx.edition === "web" && APPS_WIDGETS_ENABLED ? {
2447
+ _meta: {
2448
+ ui: { resourceUri: LIVE_RESULT_URI, csp: WIDGET_CSP },
2449
+ "openai/outputTemplate": LIVE_RESULT_URI,
2450
+ },
2451
+ } : {}),
2766
2452
  },
2767
2453
  async (input) => {
2768
2454
  try {
2769
- // Grid-picker parity with grid_drop: a signed-in user with >1 grid is
2455
+ // Grid-picker: a signed-in user with >1 grid is
2770
2456
  // ASKED which grid to publish to on every CREATE. Only for authed creates
2771
2457
  // (no target_entity_id, not anon). Edits NEVER ask — the grid is fixed by
2772
2458
  // the entity. Anon proceeds as a Guest-Grid drop. Explicit valid grid
@@ -2881,10 +2567,14 @@ export function registerTools(server, ctx) {
2881
2567
  description:
2882
2568
  "Retrieve the CURRENT deployed HTML of an inspiration/drop inline as text, so you can edit it and " +
2883
2569
  "re-plug the SAME URL when you no longer have its source in context (e.g. the user asks to 'change the " +
2884
- "color' of a drop you made earlier). Defaults to this session's last drop; otherwise pass the public " +
2885
- "url (or grid+slug). Works for single-document HTML drops/inspirations (the common case). For " +
2886
- "multi-file or runtime (app/agent) deploys, use grid_download (signed tarball URLs) instead. " +
2887
- "Fetches the public *.cloudgrid.io URL server-side; read-only, creates nothing.",
2570
+ "color' of a page — even in a fresh chat with only its URL). Defaults to this session's last drop; " +
2571
+ "otherwise pass the public url (or grid+slug). Given just a URL with no session, it resolves the " +
2572
+ "entity_id via the pickup contract and also returns the entity's kind, single_html, capabilities " +
2573
+ "(replug/fork), and replug_handle — read those to decide whether to edit in place (single-HTML + " +
2574
+ "capabilities.replug), fall back for a multi-file app/agent (use source_download_url + the local " +
2575
+ "edition/CLI), or offer a fork when it isn't yours. For multi-file or runtime (app/agent) source " +
2576
+ "bundles, use grid_download (signed tarball URLs). Reads the HTML from the API server-side; " +
2577
+ "read-only, creates nothing.",
2888
2578
  inputSchema: {
2889
2579
  entity_id: z.string().optional().describe("The drop's durable id. Defaults to this session's last drop."),
2890
2580
  url: z.string().optional().describe("The public URL of the drop (e.g. https://<grid>.cloudgrid.io/<slug>). Defaults to this session's last drop URL."),
@@ -2897,6 +2587,19 @@ export function registerTools(server, ctx) {
2897
2587
  bytes: z.number().describe("Total size of the live document in bytes."),
2898
2588
  truncated: z.boolean().describe("True if the body exceeded 1.5MB and was cut — the drop may be multi-file."),
2899
2589
  html: z.string().describe("The current deployed HTML (truncated to 1.5MB when oversized)."),
2590
+ kind: z.string().optional().describe("Resolved via the pickup contract: inspiration | app | agent."),
2591
+ single_html: z.boolean().optional().describe("Resolved via the pickup contract: true when this is a single editable HTML document (edit-in-place); false → multi-file."),
2592
+ capabilities: z.object({
2593
+ replug: z.boolean().describe("Whether the caller may re-plug (write) this entity in place."),
2594
+ fork: z.boolean().optional().describe("Whether the caller may fork it."),
2595
+ reason: z.string().optional().describe("Why an action is unavailable, e.g. not_owner."),
2596
+ }).optional().describe("Resolved via the pickup contract: what the caller may do with this entity."),
2597
+ replug_handle: z.object({
2598
+ target_entity_id: z.string().optional().describe("Pass as grid_plug's target_entity_id to re-plug in place."),
2599
+ grid: z.string().optional().describe("The entity's home grid slug."),
2600
+ slug: z.string().optional().describe("The entity's grid-scoped slug."),
2601
+ }).optional().describe("Resolved via the pickup contract: the durable re-plug handle."),
2602
+ source_download_url: z.string().optional().describe("Resolved via the pickup contract: the source-download route (used for the multi-file fallback message)."),
2900
2603
  },
2901
2604
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
2902
2605
  },
@@ -3174,7 +2877,7 @@ export function registerTools(server, ctx) {
3174
2877
  .describe("Short summary of what failed (required). Do not paste the whole conversation here."),
3175
2878
  context: z
3176
2879
  .object({
3177
- tool: z.string().optional().describe("The CloudGrid tool that failed, e.g. grid_drop."),
2880
+ tool: z.string().optional().describe("The CloudGrid tool that failed, e.g. grid_plug."),
3178
2881
  inputs: z.any().optional().describe("The failing inputs (e.g. the HTML/args). Keep it minimal; secrets are scrubbed."),
3179
2882
  grid: z.string().optional().describe("The grid/org slug involved, if any."),
3180
2883
  original_request: z.string().optional().describe("What the user asked for, in one line."),