@plitzi/sdk-server 0.32.18 → 0.32.19

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 (37) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +18 -0
  3. package/dist/modules/mcp/apps/render/index.js +2 -1
  4. package/dist/modules/mcp/apps/render/view/heldBatch.ts +107 -0
  5. package/dist/modules/mcp/apps/shared/assets.js +10 -0
  6. package/dist/modules/mcp/apps/shared/bundle.js +15 -14
  7. package/dist/modules/mcp/handler.js +1 -1
  8. package/dist/modules/mcp/helpers/guide.js +2 -1
  9. package/dist/modules/mcp/helpers/space.js +13 -1
  10. package/dist/modules/mcp/resources/register.js +8 -3
  11. package/dist/modules/mcp/resources/renderGuide.js +12 -5
  12. package/dist/modules/mcp/resources/router.js +13 -1
  13. package/dist/modules/mcp/server.js +31 -19
  14. package/dist/modules/mcp/tools/read.js +21 -3
  15. package/dist/modules/mcp/tools/render.js +1 -1
  16. package/dist/modules/mcp/tools/shared/tool.js +16 -11
  17. package/dist/src/modules/mcp/apps/shared/assets.d.ts +10 -0
  18. package/dist/src/modules/mcp/apps/shared/bundle.d.ts +3 -0
  19. package/dist/src/modules/mcp/apps/shared/index.d.ts +1 -0
  20. package/dist/src/modules/mcp/e2e/mcpEndpoint.d.ts +6 -1
  21. package/dist/src/modules/mcp/helpers/guide.d.ts +1 -0
  22. package/dist/src/modules/mcp/helpers/space.d.ts +9 -0
  23. package/dist/src/modules/mcp/resources/index.d.ts +2 -1
  24. package/dist/src/modules/mcp/resources/register.d.ts +6 -2
  25. package/dist/src/modules/mcp/resources/renderGuide.d.ts +5 -0
  26. package/dist/src/modules/mcp/resources/router.d.ts +4 -0
  27. package/dist/src/modules/mcp/server.d.ts +6 -5
  28. package/dist/src/modules/mcp/tools/read.d.ts +1 -0
  29. package/dist/src/modules/mcp/tools/shared/tool.d.ts +8 -0
  30. package/package.json +6 -5
  31. package/skills/plitzi-render/SKILL.md +170 -0
  32. package/dist/src/modules/mcp/apps/render/heldBatch.d.ts +0 -10
  33. package/dist/src/modules/mcp/apps/render/heldBatch.test.d.ts +0 -1
  34. package/dist/src/modules/mcp/apps/render/view.d.ts +0 -1
  35. /package/dist/modules/mcp/apps/example/{view.tsx → view/index.tsx} +0 -0
  36. /package/dist/modules/mcp/apps/render/{view.tsx → view/index.tsx} +0 -0
  37. /package/dist/src/modules/mcp/{apps/example/view.d.ts → tests/skill.test.d.ts} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @plitzi/sdk-server
2
2
 
3
+ ## 0.32.19
4
+
5
+ ### Patch Changes
6
+
7
+ - v0.32.19
8
+ - Updated dependencies
9
+ - @plitzi/plitzi-sdk@0.32.19
10
+ - @plitzi/sdk-schema@0.32.19
11
+ - @plitzi/sdk-shared@0.32.19
12
+
3
13
  ## 0.32.18
4
14
 
5
15
  ### Patch Changes
package/README.md CHANGED
@@ -839,6 +839,24 @@ When `devMode: true`, per-phase timing is instrumented on every render and repor
839
839
 
840
840
  In production (`devMode: false`) timing instrumentation is skipped entirely — no `Server-Timing` header, no console output.
841
841
 
842
+ ## Agent skill
843
+
844
+ This package ships an [Agent Skill](https://agentskills.io/) for `plitzi_render`, the MCP tool that renders a
845
+ self-contained UI widget from a batch of operations. The skill teaches an agent when to show a widget instead of
846
+ writing prose, the shape of a good call, the layout/theme traps that make a widget look wrong in a chat panel, and
847
+ how to iterate on a widget it already rendered.
848
+
849
+ It lives in [`skills/plitzi-render`](./skills/plitzi-render/SKILL.md) and is a plain `SKILL.md`, so it installs by
850
+ copying that folder into the skills directory of the agent you use (Claude Code, VS Code / Copilot, Codex, Gemini
851
+ CLI, Cline, Goose…):
852
+
853
+ ```bash
854
+ cp -R node_modules/@plitzi/sdk-server/skills/plitzi-render ~/.claude/skills/
855
+ ```
856
+
857
+ The skill only pays off with the Plitzi MCP server connected — it defers every detail to the `plitzi://render/guide`
858
+ resource the server publishes, so the two never drift apart.
859
+
842
860
  ## Exported types
843
861
 
844
862
  ```ts
@@ -1,3 +1,4 @@
1
+ import { VIEW_DIR } from "../shared/assets.js";
1
2
  import { require } from "../shared/resolve.js";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
@@ -9,7 +10,7 @@ var renderApp = {
9
10
  name: "plitzi-render-app",
10
11
  title: "Plitzi widget",
11
12
  description: "Interactive view that renders a plitzi_render widget with the Plitzi SDK.",
12
- entry: path.join(HERE, "view.tsx"),
13
+ entry: path.join(HERE, VIEW_DIR, "index.tsx"),
13
14
  styles: () => [path.join(path.dirname(require.resolve("@plitzi/plitzi-sdk")), "plitzi-sdk.css")]
14
15
  };
15
16
  //#endregion
@@ -0,0 +1,107 @@
1
+ /** Where the batch a widget was built from survives between calls — on the HOST, so the server can keep nothing.
2
+ *
3
+ * It cannot live in the view's memory: the Apps spec binds a view to ONE tool call (`ui/notifications/tool-input`
4
+ * is sent "at most once", and the host renders a fresh iframe when a UI tool is called), so the patch call the
5
+ * model makes arrives in a brand-new instance whose memory is empty. localStorage keyed by an id the server hands
6
+ * back is the spec's own state-persistence pattern (`_meta.viewUUID` in the Apps docs); here the key is the
7
+ * renderId, which the model also carries, so a patch names the widget it means instead of assuming "the last one".
8
+ */
9
+
10
+ const PREFIX = 'plitzi.render.';
11
+ const MAX_ENTRIES = 5;
12
+ // A renderId only lives in one conversation's context, so a batch outlives its usefulness the moment that
13
+ // conversation is over — and a conversation the user abandoned would otherwise leave its widget in the host's
14
+ // storage for good. Generous enough that no live conversation loses its widget, short enough to be housekeeping.
15
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
16
+
17
+ type Stored = { savedAt: number; operations: unknown[] };
18
+
19
+ // A sandboxed iframe without allow-same-origin throws on the ACCESS to localStorage, not on the call — so even
20
+ // reading the property has to be guarded. A host that denies storage simply gets no patching.
21
+ const localStore = (): Storage | undefined => {
22
+ try {
23
+ return window.localStorage;
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ };
28
+
29
+ const parse = (raw: string | null): Stored | undefined => {
30
+ if (raw === null) {
31
+ return undefined;
32
+ }
33
+
34
+ try {
35
+ const value = JSON.parse(raw) as Stored;
36
+
37
+ return Array.isArray(value.operations) ? value : undefined;
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ };
42
+
43
+ const ownKeys = (store: Storage): string[] => {
44
+ const keys: string[] = [];
45
+ for (let index = 0; index < store.length; index += 1) {
46
+ const key = store.key(index);
47
+ if (key !== null && key.startsWith(PREFIX)) {
48
+ keys.push(key);
49
+ }
50
+ }
51
+
52
+ return keys;
53
+ };
54
+
55
+ const dropStale = (store: Storage, keep: string): void => {
56
+ const others = ownKeys(store)
57
+ .filter(key => key !== keep)
58
+ .map(key => ({ key, savedAt: parse(store.getItem(key))?.savedAt ?? 0 }))
59
+ .sort((a, b) => b.savedAt - a.savedAt);
60
+ const expired = Date.now() - MAX_AGE_MS;
61
+
62
+ for (const [index, entry] of others.entries()) {
63
+ if (index >= MAX_ENTRIES - 1 || entry.savedAt < expired) {
64
+ store.removeItem(entry.key);
65
+ }
66
+ }
67
+ };
68
+
69
+ export const readHeldBatch = (renderId: string, store = localStore()): unknown[] | undefined => {
70
+ if (!store) {
71
+ return undefined;
72
+ }
73
+
74
+ const stored = parse(store.getItem(`${PREFIX}${renderId}`));
75
+ if (!stored || stored.savedAt < Date.now() - MAX_AGE_MS) {
76
+ return undefined;
77
+ }
78
+
79
+ return stored.operations;
80
+ };
81
+
82
+ export const writeHeldBatch = (renderId: string, operations: unknown[], store = localStore()): void => {
83
+ if (!store) {
84
+ return;
85
+ }
86
+
87
+ const key = `${PREFIX}${renderId}`;
88
+ const payload = JSON.stringify({ savedAt: Date.now(), operations } satisfies Stored);
89
+
90
+ try {
91
+ dropStale(store, key);
92
+ store.setItem(key, payload);
93
+ } catch {
94
+ // Out of quota: the widget just rendered is the only one still worth patching, so the rest go.
95
+ for (const other of ownKeys(store)) {
96
+ if (other !== key) {
97
+ store.removeItem(other);
98
+ }
99
+ }
100
+
101
+ try {
102
+ store.setItem(key, payload);
103
+ } catch {
104
+ // Storage is full or refused: patching degrades to "re-send the full batch", never to a wrong widget.
105
+ }
106
+ }
107
+ };
@@ -0,0 +1,10 @@
1
+ import "node:path";
2
+ //#region src/modules/mcp/apps/shared/assets.ts
3
+ /** The one folder of an app that ships to dist as SOURCE: its browser entry (`view/index.tsx`) and every module
4
+ * that entry bundles. The server builds it with esbuild at request time, so none of it is in the module graph the
5
+ * library build compiles — a view-side module that does not travel verbatim is simply missing at runtime, which
6
+ * the package only finds out when a host asks for the page. Everything else in an app (its definition) compiles
7
+ * like any other module. */
8
+ var VIEW_DIR = "view";
9
+ //#endregion
10
+ export { VIEW_DIR };
@@ -2,21 +2,22 @@ import { require } from "./resolve.js";
2
2
  import { zodEnglishOnly } from "./zodEnglishOnly.js";
3
3
  import { build } from "esbuild";
4
4
  //#region src/modules/mcp/apps/shared/bundle.ts
5
+ var options = (entry) => ({
6
+ entryPoints: [entry],
7
+ bundle: true,
8
+ write: false,
9
+ format: "iife",
10
+ platform: "browser",
11
+ target: "es2022",
12
+ jsx: "automatic",
13
+ minify: true,
14
+ alias: { "@plitzi/plitzi-sdk": require.resolve("@plitzi/plitzi-sdk") },
15
+ define: { "process.env.NODE_ENV": "\"production\"" },
16
+ plugins: [zodEnglishOnly],
17
+ logLevel: "silent"
18
+ });
5
19
  var bundle = async (entry) => {
6
- return (await build({
7
- entryPoints: [entry],
8
- bundle: true,
9
- write: false,
10
- format: "iife",
11
- platform: "browser",
12
- target: "es2022",
13
- jsx: "automatic",
14
- minify: true,
15
- alias: { "@plitzi/plitzi-sdk": require.resolve("@plitzi/plitzi-sdk") },
16
- define: { "process.env.NODE_ENV": "\"production\"" },
17
- plugins: [zodEnglishOnly],
18
- logLevel: "silent"
19
- })).outputFiles[0].text;
20
+ return (await build(options(entry))).outputFiles[0].text;
20
21
  };
21
22
  //#endregion
22
23
  export { bundle };
@@ -74,7 +74,7 @@ var serveMcp = async (raw, res, server) => {
74
74
  await transport.close();
75
75
  }
76
76
  };
77
- var handleMcp = (raw, res, req, adapters, options = {}) => serveMcp(raw, res, createMcpServer({
77
+ var handleMcp = async (raw, res, req, adapters, options = {}) => serveMcp(raw, res, await createMcpServer({
78
78
  adapters,
79
79
  getSpaceId: () => adapters.getSpaceId?.(req) ?? Promise.resolve(void 0),
80
80
  ...options
@@ -1,5 +1,6 @@
1
1
  //#region src/modules/mcp/helpers/guide.ts
2
2
  var serverInstructions = "Plitzi AI server: read-then-write editing of a Plitzi space. Reads follow a filesystem model — list cheap, read one item in detail on demand; never fetch a whole tree you do not need. Workflow: (1) read plitzi://primer/{env} once — it bundles the guide, types, css-properties and page/definition/variable summaries in a single call; (2) plitzi_search with include:\"detail\" to jump to elements — each hit then carries its uri, stateVersion AND full style/resolvedStyle, so an edit needs no per-element read; open a page skeleton or element only when you need its tree/detail (the skeleton already lists the style classes of each node, and plitzi_read fetches many uris at once); (3) plitzi_apply with dryRun to preview a batch; (4) plitzi_apply to persist, passing expectedResourceVersions to guard against concurrent edits — apply and search both hand back the versions you need for the next edit. Use patchElement / patchDefinition to change only some props / CSS (the upsert variants replace them all). An element read (and search include:\"detail\") inlines the CSS of the definitions it attaches under resolvedStyle, so you rarely need a separate definition read. Refs accept a semantic idRef ([A-Za-z0-9_-] starting with a letter, unique, chosen by you) or the raw id — the idRef is ALSO the runtime wiring key, so a provider source is `<type>_<idRef>.<field>`, visible to the provider’s DESCENDANTS only (bind inside its subtree). CSS is plain kebab-case — write shorthands freely (`border: 1px solid red`, `padding: 8px 16px`, `font: bold 16px/1.5 Arial`), they are expanded to longhands for you and STORED that way, so read-back shows the longhands; style vars are var(--name), schema vars are {{name}}. READERS — do not confuse them: MCP *resources* are the browsable catalog (list them, or open one by URI); plitzi_search FINDS refs by label/type/attribute; plitzi_read BATCH-fetches URIs you already hold. Reach for search/read to work; browse resources to discover. Elements also carry applied style variants + visibility (initialState), data bindings and interaction flows: edit them with patchElement (initialState), upsertBinding/patchBinding/deleteBinding, and upsertInteractionFlow/patchInteractionNode/deleteInteraction. An element read shows all three plus availableVariants (which variant each of its classes offers). Separately, to SHOW the user a small self-contained widget (offline, no space or backend) instead of editing the space — a card, hero, pricing table, a visual answer — use plitzi_render; read plitzi://render/guide for it.";
3
+ var widgetsOnlyInstructions = "Plitzi widget server (widgets-only connection). This connection carries NO Plitzi space: nothing can be read or edited in one, and the space tools are not offered here — do not look for them. What it does do is build self-contained UI widgets fully offline, with no backend, account or setup: call plitzi_render to SHOW the user a real rendered layout (card, hero, pricing table, checklist, form, gallery) instead of describing one. Read plitzi://render/guide first — the element/prop table, the style model and a worked example; plitzi://render/types lists every element type you can author (plitzi_read fetches both). To change a widget you already rendered, call plitzi_render again with patch:true and its renderId. If the user wants to edit their real Plitzi space from here, that is a reconnection they make: the integration must be re-authorized and granted a space (the consent screen lists theirs) — you cannot do it from this connection, and no retry will change it.";
3
4
  var guideQuickstart = `# Plitzi AI MCP — quickstart
4
5
  This is the condensed guide; read \`plitzi://guide\` for the full reference (every resource, op and example).
5
6
 
@@ -463,4 +464,4 @@ Space-level configuration lives in \`plitzi://settings/{env}\` and is edited wit
463
464
  never omit it "to save a call".
464
465
  `;
465
466
  //#endregion
466
- export { guideQuickstart, guideText, serverInstructions };
467
+ export { guideQuickstart, guideText, serverInstructions, widgetsOnlyInstructions };
@@ -280,9 +280,21 @@ var pageRefOfElement = (schema, el) => {
280
280
  /** Total number of descendant elements under a subtree (excluding the root). */
281
281
  var descendantCount = (schema, rootId) => descendantIds(schema, rootId).length;
282
282
  var emptySpaceMessage = "Space data not available";
283
+ /** The code a space-dependent answer carries when the connection reaches no space, so both the agent and a host UI
284
+ * can tell "this connection cannot do that" from "the call failed". */
285
+ var noSpaceError = "NO_SPACE_ATTACHED";
283
286
  var unauthorizedSpaceMessage = "This connection has no space attached (a guest or widgets-only grant, or a token that carries no space), so NOTHING in a space can be read or edited — every other space tool will fail the same way, do not retry them. Use plitzi_render instead: it builds a self-contained widget offline, with no space, backend or account (read plitzi://render/guide). To edit a real space, the user must reconnect the integration and grant access to one.";
287
+ /** Raised when a space-dependent operation runs on a connection that resolves no spaceId. A type of its own so the
288
+ * host answers it as a STATE of the connection — a plain result the agent reads — instead of letting it surface as
289
+ * a failed call, which hosts render to the user as "cannot connect to this server". */
290
+ var NoSpaceError = class extends Error {
291
+ constructor() {
292
+ super(unauthorizedSpaceMessage);
293
+ this.name = "NoSpaceError";
294
+ }
295
+ };
284
296
  var generateObjectId = () => {
285
297
  return `${Math.floor(Date.now() / 1e3).toString(16).padStart(8, "0")}${Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join("")}`;
286
298
  };
287
299
  //#endregion
288
- export { cloneSpace, descendantCount, descendantIds, elementById, elementRefOf, emptySpace, emptySpaceMessage, findElementByRef, findFolderByRef, findPageByRef, folderAncestorIds, generateObjectId, getPageElements, indexAddElement, indexAddPage, indexInvalidateDetails, indexReRefElement, indexReRefPage, indexRemoveElements, indexRemovePage, isPageElement, nameOf, orderedChildren, pageFoldersOf, pageRefOf, pageRefOfElement, resolveRef, routeParamNames, slugRouteParams, slugify, sortFolders, spaceIndex, strOr, unauthorizedSpaceMessage };
300
+ export { NoSpaceError, cloneSpace, descendantCount, descendantIds, elementById, elementRefOf, emptySpace, emptySpaceMessage, findElementByRef, findFolderByRef, findPageByRef, folderAncestorIds, generateObjectId, getPageElements, indexAddElement, indexAddPage, indexInvalidateDetails, indexReRefElement, indexReRefPage, indexRemoveElements, indexRemovePage, isPageElement, nameOf, noSpaceError, orderedChildren, pageFoldersOf, pageRefOf, pageRefOfElement, resolveRef, routeParamNames, slugRouteParams, slugify, sortFolders, spaceIndex, strOr, unauthorizedSpaceMessage };
@@ -2,13 +2,17 @@ import { cssProperties, cssShorthands } from "../catalogs/cssCatalog/index.js";
2
2
  import { guideText } from "../helpers/guide.js";
3
3
  import { resourceErrorMessage } from "./canonical.js";
4
4
  import { envelope, jsonContents } from "./envelope.js";
5
- import { readResource } from "./router.js";
6
5
  import { registerRenderResources } from "./renderGuide.js";
6
+ import { readResource } from "./router.js";
7
7
  import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  //#region src/modules/mcp/resources/register.ts
9
9
  /** Register every resource on the MCP server: fixed listings plus templated per-item reads. The space is
10
- * loaded lazily via getSpace, so listing resources never touches the store — only reading one does. */
11
- var registerResources = (server, getSpace, env, log) => {
10
+ * loaded lazily via getSpace, so listing resources never touches the store — only reading one does.
11
+ *
12
+ * `hasSpace` is false when the connection reaches no space (a guest / widgets-only grant): the space-dependent
13
+ * families are then not registered at all, so the catalog the agent browses holds only what it can actually
14
+ * open — no listing that answers every read with the same refusal. */
15
+ var registerResources = (server, getSpace, env, log, hasSpace) => {
12
16
  const emit = async (uri) => {
13
17
  const start = performance.now();
14
18
  try {
@@ -43,6 +47,7 @@ var registerResources = (server, getSpace, env, log) => {
43
47
  shorthands: cssShorthands
44
48
  }))));
45
49
  registerRenderResources(server, log);
50
+ if (!hasSpace) return;
46
51
  const fixed = [
47
52
  [
48
53
  "Primer",
@@ -161,10 +161,10 @@ and \`patchDefinition\` still changes only some CSS of one class.
161
161
  - Lay containers out with flexbox or grid — pick the direction on purpose, see **Fit the panel** below.
162
162
  - **You are not styling from zero.** Each type lands on the page with CSS you did not write, and it is the usual
163
163
  reason a widget does not look like the definitions say:
164
- - The per-type rule the SDK ships the one that changes layouts is \`container\`, which carries
165
- \`min-width: 50px; min-height: 50px\`. A rail, a divider, a dot, a spacer or a narrow cell will NOT go below
166
- 50px until you say \`"min-width": "0"\` (and/or \`"min-height": "0"\`) on it: a 2px timeline line renders 50px
167
- wide otherwise. Any flex child that must be allowed to shrink needs it too.
164
+ - The per-type CSS the SDK ships imposes **no minimum size**: a rail, a divider, a dot or a narrow cell is
165
+ exactly as small as you make it, and no \`"min-width": "0"\` escape hatch is needed. The flip side is that an
166
+ element with no content and no size takes no space at all — give a spacer its own \`height\`. (The 50px box
167
+ empty containers show on the builder canvas is a drop target, scoped to it; it never reaches a widget.)
168
168
  - The BROWSER's own defaults for the rest: \`heading\` keeps its UA font-size and ~0.67em top/bottom margins,
169
169
  \`paragraph\` ~1em margins, \`list\` a 40px \`padding-left\`, \`button\` its native chrome, \`image\` its intrinsic
170
170
  size, \`link\` its own colour and underline. In a compact widget set these explicitly — usually
@@ -355,6 +355,13 @@ connects that source to a descendant's field.
355
355
  The tool returns \`rendered: false\` with \`errors: [{ path, message, hint }]\`. Read the hint, fix that one op, and
356
356
  retry — you never lose the rest of the batch.
357
357
  `;
358
+ /** The same two documents through the READ path (the router, hence plitzi_read), not only as MCP resources: an
359
+ * agent that reaches for the batch-read tool — the reflex the guide itself teaches — must not be told its own
360
+ * authoring guide does not exist. Space-independent, like the core singletons. */
361
+ var readRenderResource = (uri) => {
362
+ if (uri === "plitzi://render/guide") return envelope(renderGuideText);
363
+ if (uri === "plitzi://render/types") return envelope(renderTypes());
364
+ };
358
365
  var registerRenderResources = (server, log) => {
359
366
  server.registerResource("Render guide", RENDER_GUIDE_URI, {
360
367
  description: "How to author a plitzi_render widget: operations, element types, styling, examples.",
@@ -380,4 +387,4 @@ var registerRenderResources = (server, log) => {
380
387
  });
381
388
  };
382
389
  //#endregion
383
- export { RENDER_GUIDE_URI, RENDER_TYPES_URI, registerRenderResources };
390
+ export { RENDER_GUIDE_URI, RENDER_TYPES_URI, readRenderResource, registerRenderResources };
@@ -1,11 +1,15 @@
1
+ import { emptySpace } from "../helpers/space.js";
2
+ import "../helpers/uris.js";
1
3
  import { canonicalUri } from "./canonical.js";
2
4
  import { readCoreResource } from "./core.js";
3
5
  import { readPrimerResource } from "./primer.js";
6
+ import { readRenderResource } from "./renderGuide.js";
4
7
  import { readSchemaResource } from "./schema.js";
5
8
  import { readStyleResource } from "./style.js";
6
9
  //#region src/modules/mcp/resources/router.ts
7
10
  var resolvers = [
8
11
  (space, _env, uri) => readCoreResource(space, uri),
12
+ (_space, _env, uri) => readRenderResource(uri),
9
13
  readPrimerResource,
10
14
  readSchemaResource,
11
15
  readStyleResource
@@ -19,7 +23,15 @@ var readResource = (space, env, rawUri) => {
19
23
  }
20
24
  return null;
21
25
  };
26
+ /** What a connection carrying NO space (a guest / widgets-only grant) can still read: the space-independent
27
+ * singletons and the render docs. Undefined for everything else, so the caller says the connection has no space
28
+ * instead of answering out of an empty one — an empty page list would read as "this space has no pages". */
29
+ var readPublicResource = (env, rawUri) => {
30
+ const uri = canonicalUri(env, rawUri);
31
+ if (uri === "plitzi://types") return;
32
+ return readRenderResource(uri) ?? readCoreResource(emptySpace(), uri);
33
+ };
22
34
  /** Current version of a resource, for optimistic-concurrency checks. Null when the URI is unknown. */
23
35
  var resourceVersion = (space, env, uri) => readResource(space, env, uri)?.stateVersion ?? null;
24
36
  //#endregion
25
- export { readResource, resourceVersion };
37
+ export { readPublicResource, readResource, resourceVersion };
@@ -1,6 +1,6 @@
1
1
  import { registerApps } from "./apps/index.js";
2
- import { emptySpace, emptySpaceMessage, unauthorizedSpaceMessage } from "./helpers/space.js";
3
- import { serverInstructions } from "./helpers/guide.js";
2
+ import { NoSpaceError, emptySpace, emptySpaceMessage, noSpaceError } from "./helpers/space.js";
3
+ import { serverInstructions, widgetsOnlyInstructions } from "./helpers/guide.js";
4
4
  import { createMcpLog } from "./helpers/log.js";
5
5
  import { registerResources } from "./resources/register.js";
6
6
  import { tools } from "./tools/index.js";
@@ -13,19 +13,20 @@ var asText = (data) => ({ content: [{
13
13
  type: "text",
14
14
  text: JSON.stringify(data)
15
15
  }] });
16
- var createMcpServer = ({ adapters, getSpaceId, preview, screenshot, logger }) => {
16
+ var createMcpServer = async ({ adapters, getSpaceId, preview, screenshot, logger }) => {
17
17
  const log = createMcpLog(logger);
18
- let spaceIdPromise;
19
- const requireSpaceId = () => spaceIdPromise ??= getSpaceId().then((id) => {
20
- if (!id) throw new Error(unauthorizedSpaceMessage);
21
- return id;
22
- });
18
+ const spaceId = await getSpaceId().catch(() => void 0);
19
+ const hasSpace = spaceId !== void 0;
20
+ const requireSpaceId = () => {
21
+ if (spaceId === void 0) throw new NoSpaceError();
22
+ return spaceId;
23
+ };
23
24
  const loadSpace = async () => {
24
- const spaceId = await requireSpaceId();
25
+ const id = requireSpaceId();
25
26
  const [schema, style, catalog] = await Promise.all([
26
- adapters.getSchema?.(spaceId, MCP_ENV),
27
- adapters.getStyle?.(spaceId, MCP_ENV),
28
- adapters.getComponentCatalog?.(spaceId, MCP_ENV).catch(() => void 0)
27
+ adapters.getSchema?.(id, MCP_ENV),
28
+ adapters.getStyle?.(id, MCP_ENV),
29
+ adapters.getComponentCatalog?.(id, MCP_ENV).catch(() => void 0)
29
30
  ]);
30
31
  if (!schema || !style) throw new Error(emptySpaceMessage);
31
32
  return {
@@ -36,22 +37,22 @@ var createMcpServer = ({ adapters, getSpaceId, preview, screenshot, logger }) =>
36
37
  };
37
38
  const { saveSchema, saveStyle } = adapters;
38
39
  const persisters = {
39
- schema: saveSchema ? async (schema) => saveSchema(await requireSpaceId(), MCP_ENV, schema) : void 0,
40
- style: saveStyle ? async (style) => saveStyle(await requireSpaceId(), MCP_ENV, style) : void 0
40
+ schema: saveSchema ? (schema) => saveSchema(requireSpaceId(), MCP_ENV, schema) : void 0,
41
+ style: saveStyle ? (style) => saveStyle(requireSpaceId(), MCP_ENV, style) : void 0
41
42
  };
42
43
  let spacePromise;
43
44
  const getSpace = () => spacePromise ??= loadSpace();
44
45
  const server = new McpServer({
45
46
  name: "plitzi-mcp",
46
- version: "0.32.18"
47
- }, { instructions: serverInstructions });
48
- registerResources(server, getSpace, MCP_ENV, log);
47
+ version: "0.32.19"
48
+ }, { instructions: hasSpace ? serverInstructions : widgetsOnlyInstructions });
49
+ registerResources(server, getSpace, MCP_ENV, log, hasSpace);
49
50
  registerApps(server);
50
51
  const toolContext = async () => ({
51
52
  space: await getSpace(),
52
53
  env: MCP_ENV,
53
54
  persisters,
54
- spaceId: await requireSpaceId(),
55
+ spaceId: requireSpaceId(),
55
56
  preview,
56
57
  screenshot
57
58
  });
@@ -62,16 +63,27 @@ var createMcpServer = ({ adapters, getSpaceId, preview, screenshot, logger }) =>
62
63
  preview,
63
64
  screenshot
64
65
  });
66
+ const behaviorOf = (tool) => {
67
+ if (tool.spaceless) return (args) => tool.execute(args, spacelessContext());
68
+ if (hasSpace) return async (args) => tool.execute(args, await toolContext());
69
+ return tool.executePublic ? (args) => tool.executePublic?.(args, MCP_ENV) : void 0;
70
+ };
65
71
  for (const tool of tools) {
66
72
  if (tool.requires === "screenshot" && !screenshot) continue;
73
+ const behavior = behaviorOf(tool);
74
+ if (!behavior) continue;
67
75
  const run = async (args) => {
68
76
  const start = performance.now();
69
77
  try {
70
- const result = await tool.execute(args, tool.spaceless ? spacelessContext() : await toolContext());
78
+ const result = await behavior(args);
71
79
  log.toolCall(tool.name, args, performance.now() - start);
72
80
  return isCallToolResult(result) ? result : asText(result);
73
81
  } catch (error) {
74
82
  log.toolCall(tool.name, args, performance.now() - start, error);
83
+ if (error instanceof NoSpaceError) return asText({
84
+ error: noSpaceError,
85
+ message: error.message
86
+ });
75
87
  throw error;
76
88
  }
77
89
  };
@@ -1,5 +1,7 @@
1
+ import { noSpaceError, unauthorizedSpaceMessage } from "../helpers/space.js";
1
2
  import { resourceErrorMessage } from "../resources/canonical.js";
2
- import { readResource } from "../resources/router.js";
3
+ import { RENDER_GUIDE_URI, RENDER_TYPES_URI } from "../resources/renderGuide.js";
4
+ import { readPublicResource, readResource } from "../resources/router.js";
3
5
  import { defineTool } from "./shared/tool.js";
4
6
  import { z } from "zod";
5
7
  //#region src/modules/mcp/tools/read.ts
@@ -21,13 +23,29 @@ var read = (input, space, env) => {
21
23
  };
22
24
  }) };
23
25
  };
26
+ var publicHint = `Readable on this connection: ${RENDER_GUIDE_URI}, ${RENDER_TYPES_URI}, plitzi://guide, plitzi://css-properties. Everything else lives in a space this connection has none of.`;
27
+ var readPublic = (input, env) => ({ results: input.uris.map((uri) => {
28
+ const found = readPublicResource(env, uri);
29
+ if (found) return {
30
+ uri,
31
+ stateVersion: found.stateVersion,
32
+ data: found.data
33
+ };
34
+ return {
35
+ uri,
36
+ error: noSpaceError,
37
+ message: unauthorizedSpaceMessage,
38
+ hint: publicHint
39
+ };
40
+ }) });
24
41
  var readTool = defineTool({
25
42
  name: "plitzi_read",
26
43
  title: "Batch read",
27
44
  description: "Batch-fetch resource content for URIs you ALREADY HOLD (from plitzi_search or a write response) — the tool form of opening MCP resources, for many at once. Pass an array of URIs (pages, elements, definitions, variables) and get them all in one call. NOT for finding things: to locate an element by label/type use plitzi_search; to browse what exists, list the plitzi:// resources. Each result is { uri, stateVersion, data } or a teachable error, so one bad URI never fails the batch. Never hand-build a URI to guess your way to an element — search for it.",
28
45
  inputShape: readShape,
29
46
  access: "read",
30
- run: (input, ctx) => read(input, ctx.space, ctx.env)
47
+ run: (input, ctx) => read(input, ctx.space, ctx.env),
48
+ runPublic: readPublic
31
49
  });
32
50
  //#endregion
33
- export { read, readShape, readTool };
51
+ export { read, readPublic, readShape, readTool };
@@ -167,7 +167,7 @@ var toRenderResult = (res, renderId) => {
167
167
  var renderTool = defineTool({
168
168
  name: "plitzi_render",
169
169
  title: "Render widget",
170
- description: "Show the user a real, rendered UI widget instead of describing one — cards, hero sections, pricing tables, forms, menus, checklists, profiles, galleries. It runs the Plitzi SDK fully offline: no backend, account, or setup. Reach for it whenever a visual layout beats prose: the user asks you to design/build/show something, OR your answer is naturally visual (a recipe → a card, a comparison → a table, steps → a checklist). Prefer showing over telling.\n\nAuthor the widget as an ordered list of `operations` that build an element tree under the pre-seeded root page \"render\". Three rules:\n1. STRUCTURE — one upsertElement builds the whole tree: set pageRef:\"render\" and give element a nested `children` array. Each element is { ref (unique), type, subType?, props?, style?, children? }; children render in order. (To attach to something you already made, use a top-level parentRef:\"<existing ref>\" instead.)\n1b. REPEATS — the moment two siblings share a shape and differ only in data (list, steps, cards, table, timeline), do NOT copy-paste them: use repeatElement { pageRef, ref (wrapper), style, template, items }. The template is written once with {{item.field}} placeholders and rendered per row; refs come out numbered (\"step-1\", \"step-2\"…). A list INSIDE each row (days with their own steps) is the same op: give the wrapping node repeat:{ items:\"{{item.<list>}}\", template:… } and put the sub-rows in the row data.\n2. STYLE — declare ALL the classes in ONE upsertDefinitions { definitions: { \"<class>\": { desktop:{ …CSS in kebab-case… } }, … } }, then attach via the element style:{ base:[\"<class ref>\"] }. Lay containers out with flex/grid. Keep the call small: one class per look (not per property), and never hand-draw a scene in a data: URI — it costs more than the whole widget; use an https image, a flat colour or a gradient.\n2b. LAYOUT — it renders in a side panel, so width is free and HEIGHT is scarce. Plain containers stack children vertically, which is the tall half-empty default to avoid: put peers (metrics, plans, options, image + text) in a wrapping row — display:flex, flex-direction:row, flex-wrap:wrap, children flex-grow:\"1\" + flex-basis:\"0%\" + min-width — or a grid with grid-template-columns:\"repeat(auto-fit, minmax(160px, 1fr))\". Keep padding 12-16px and gap 8-12px, and let the outer container fill the panel. Stack only what reads in order (heading over paragraph, forms, steps, prose). Watch the SDK defaults: every container has min-width/min-height 50px (set them to \"0\" for rails, dividers, dots and any flex child that must shrink), and heading/paragraph keep the margins the browser gives them (zero them, space with the parent gap).\n2c. THEME — it is embedded in the host UI, which MAY BE DARK, so never hardcode a light palette. Take colours from the host variables with a light-dark() fallback — background-color:\"var(--color-background-secondary, light-dark(#ffffff, #1f2430))\", color:\"var(--color-text-primary, light-dark(#0f172a, #e8eaed))\", border-color:\"var(--color-border-primary, light-dark(#e2e8f0, #333a48))\" — and always set `color` wherever you set `background-color` (a brand accent states its own text colour too).\n3. CONTENT — visible copy goes in props.content (text, heading, paragraph, button); heading level is the element subType (\"h1\"..\"h6\"); image/video take props.src. An unknown prop comes back as a warning naming the right one.\n\nCommon types: container, heading, paragraph, text, button, link, image, video, list, listItem, markdown (plitzi://render/types lists every built-in type with descriptions). Widgets can also be data-driven and interactive — an apiContainer fetches at runtime, upsertBinding wires data into elements, and upsertInteractionFlow makes them react to clicks (see the guide).\nREAD the resource plitzi://render/guide first — it has the element/prop table, the style model and a full worked example, and following it is the difference between a widget that renders and repeated failed calls.\nITERATING — to change a widget you already rendered, do NOT rebuild it: call again with patch:true, the `renderId` that render answered with, and ONLY the operations that differ (patchDefinition, patchElement, deleteElement…). The widget merges them and reports back what it applied; address rows by the refs you already know. Patch ONLY to modify that widget: a different subject or a different kind of widget is a fresh render, without patch — a patch is merged into the previous batch, so patching a new idea leaves you with both.\nReturns a compact summary including the renderId (the widget itself is shown to the user); on failure it returns teachable errors (path + hint) — read them and retry.",
170
+ description: "Show the user a real, rendered UI widget instead of describing one — cards, hero sections, pricing tables, forms, menus, checklists, profiles, galleries. It runs the Plitzi SDK fully offline: no backend, account, or setup. Reach for it whenever a visual layout beats prose: the user asks you to design/build/show something, OR your answer is naturally visual (a recipe → a card, a comparison → a table, steps → a checklist). Prefer showing over telling.\n\nAuthor the widget as an ordered list of `operations` that build an element tree under the pre-seeded root page \"render\". Three rules:\n1. STRUCTURE — one upsertElement builds the whole tree: set pageRef:\"render\" and give element a nested `children` array. Each element is { ref (unique), type, subType?, props?, style?, children? }; children render in order. (To attach to something you already made, use a top-level parentRef:\"<existing ref>\" instead.)\n1b. REPEATS — the moment two siblings share a shape and differ only in data (list, steps, cards, table, timeline), do NOT copy-paste them: use repeatElement { pageRef, ref (wrapper), style, template, items }. The template is written once with {{item.field}} placeholders and rendered per row; refs come out numbered (\"step-1\", \"step-2\"…). A list INSIDE each row (days with their own steps) is the same op: give the wrapping node repeat:{ items:\"{{item.<list>}}\", template:… } and put the sub-rows in the row data.\n2. STYLE — declare ALL the classes in ONE upsertDefinitions { definitions: { \"<class>\": { desktop:{ …CSS in kebab-case… } }, … } }, then attach via the element style:{ base:[\"<class ref>\"] }. Lay containers out with flex/grid. Keep the call small: one class per look (not per property), and never hand-draw a scene in a data: URI — it costs more than the whole widget; use an https image, a flat colour or a gradient.\n2b. LAYOUT — it renders in a side panel, so width is free and HEIGHT is scarce. Plain containers stack children vertically, which is the tall half-empty default to avoid: put peers (metrics, plans, options, image + text) in a wrapping row — display:flex, flex-direction:row, flex-wrap:wrap, children flex-grow:\"1\" + flex-basis:\"0%\" + min-width — or a grid with grid-template-columns:\"repeat(auto-fit, minmax(160px, 1fr))\". Keep padding 12-16px and gap 8-12px, and let the outer container fill the panel. Stack only what reads in order (heading over paragraph, forms, steps, prose). Nothing carries a minimum size, so an element with no content and no size takes none give a spacer or a rail its own height/width — while heading/paragraph do keep the margins the browser gives them (zero them, space with the parent gap).\n2c. THEME — it is embedded in the host UI, which MAY BE DARK, so never hardcode a light palette. Take colours from the host variables with a light-dark() fallback — background-color:\"var(--color-background-secondary, light-dark(#ffffff, #1f2430))\", color:\"var(--color-text-primary, light-dark(#0f172a, #e8eaed))\", border-color:\"var(--color-border-primary, light-dark(#e2e8f0, #333a48))\" — and always set `color` wherever you set `background-color` (a brand accent states its own text colour too).\n3. CONTENT — visible copy goes in props.content (text, heading, paragraph, button); heading level is the element subType (\"h1\"..\"h6\"); image/video take props.src. An unknown prop comes back as a warning naming the right one.\n\nCommon types: container, heading, paragraph, text, button, link, image, video, list, listItem, markdown (plitzi://render/types lists every built-in type with descriptions). Widgets can also be data-driven and interactive — an apiContainer fetches at runtime, upsertBinding wires data into elements, and upsertInteractionFlow makes them react to clicks (see the guide).\nREAD the resource plitzi://render/guide first — it has the element/prop table, the style model and a full worked example, and following it is the difference between a widget that renders and repeated failed calls.\nITERATING — to change a widget you already rendered, do NOT rebuild it: call again with patch:true, the `renderId` that render answered with, and ONLY the operations that differ (patchDefinition, patchElement, deleteElement…). The widget merges them and reports back what it applied; address rows by the refs you already know. Patch ONLY to modify that widget: a different subject or a different kind of widget is a fresh render, without patch — a patch is merged into the previous batch, so patching a new idea leaves you with both.\nReturns a compact summary including the renderId (the widget itself is shown to the user); on failure it returns teachable errors (path + hint) — read them and retry.",
171
171
  inputShape: renderShape,
172
172
  access: "read",
173
173
  spaceless: true,
@@ -13,16 +13,21 @@ var imageResult = (images, meta) => ({ content: [{
13
13
  /** Author a tool: give it its metadata, its input shape and a typed `run`. The returned descriptor parses the
14
14
  * raw args against the shape before handing them to `run`, so `run` is fully typed and no cast is needed.
15
15
  * Adding a tool is: call defineTool in its own file and append it to the `tools` registry. */
16
- var defineTool = (spec) => ({
17
- name: spec.name,
18
- title: spec.title,
19
- description: spec.description,
20
- inputShape: spec.inputShape,
21
- access: spec.access,
22
- requires: spec.requires,
23
- spaceless: spec.spaceless,
24
- ui: spec.ui,
25
- execute: (args, ctx) => spec.run(z.object(spec.inputShape).parse(args), ctx)
26
- });
16
+ var defineTool = (spec) => {
17
+ const { runPublic } = spec;
18
+ const parse = (args) => z.object(spec.inputShape).parse(args);
19
+ return {
20
+ name: spec.name,
21
+ title: spec.title,
22
+ description: spec.description,
23
+ inputShape: spec.inputShape,
24
+ access: spec.access,
25
+ requires: spec.requires,
26
+ spaceless: spec.spaceless,
27
+ ui: spec.ui,
28
+ execute: (args, ctx) => spec.run(parse(args), ctx),
29
+ ...runPublic ? { executePublic: (args, env) => runPublic(parse(args), env) } : {}
30
+ };
31
+ };
27
32
  //#endregion
28
33
  export { defineTool, imageResult };
@@ -0,0 +1,10 @@
1
+ /** The one folder of an app that ships to dist as SOURCE: its browser entry (`view/index.tsx`) and every module
2
+ * that entry bundles. The server builds it with esbuild at request time, so none of it is in the module graph the
3
+ * library build compiles — a view-side module that does not travel verbatim is simply missing at runtime, which
4
+ * the package only finds out when a host asks for the page. Everything else in an app (its definition) compiles
5
+ * like any other module. */
6
+ export declare const VIEW_DIR = "view";
7
+ /** Does this file ship as source? The build's copy step asks it, and `apps.test.ts` asks it of every real input of
8
+ * every app's bundle — so a view that grows a sibling module either lives in `view/` or fails the suite, instead
9
+ * of failing a deployment. Tests stay behind; the page shell travels with the views it renders. */
10
+ export declare const shipsAsSource: (file: string) => boolean;
@@ -1 +1,4 @@
1
1
  export declare const bundle: (entry: string) => Promise<string>;
2
+ /** Every file the view pulls in, resolved by the build that serves it. The suite asks for this to check each one
3
+ * ships as source: a view-side module the package leaves behind resolves here, in the repo, and nowhere else. */
4
+ export declare const bundleInputs: (entry: string) => Promise<string[]>;
@@ -1,2 +1,3 @@
1
+ export { shipsAsSource, VIEW_DIR } from './assets';
1
2
  export { registerApp } from './registerApp';
2
3
  export { require } from './resolve';
@@ -14,7 +14,12 @@ export interface AppPage {
14
14
  mimeType?: string;
15
15
  meta?: Record<string, unknown>;
16
16
  }
17
+ export interface McpEndpointOptions {
18
+ /** Attach a space to the connection, the way an authorized connector's token does — the server then offers its
19
+ * editing surface. Omitted, the endpoint is the guest one: no space, so only what works without one. */
20
+ spaceId?: number;
21
+ }
17
22
  /** Start the endpoint and connect a real MCP client to it, as a remote connector does. */
18
- export declare const startMcpEndpoint: () => Promise<McpEndpoint>;
23
+ export declare const startMcpEndpoint: ({ spaceId }?: McpEndpointOptions) => Promise<McpEndpoint>;
19
24
  /** Read a ui:// resource. A page that came back as a blob is a bug a host would meet as a blank frame. */
20
25
  export declare const readAppPage: (endpoint: McpEndpoint, uri: string) => Promise<AppPage>;
@@ -1,3 +1,4 @@
1
1
  export declare const serverInstructions: string;
2
+ export declare const widgetsOnlyInstructions: string;
2
3
  export declare const guideQuickstart = "# Plitzi AI MCP \u2014 quickstart\nThis is the condensed guide; read `plitzi://guide` for the full reference (every resource, op and example).\n\nA space is **two schemas you edit together in one atomic `plitzi_apply` batch**: the **element schema** (tree of\npages/elements) and the **style schema** (definitions = CSS classes, tokens, theme). To style an element: write a\n**definition** and attach it via the element's `style.base` in the same batch.\n\n**Workflow:** (1) you already have this primer (guide + types + css + page/style summaries). (2) `plitzi_search`\nwith `include:\"detail\"` to find elements \u2014 each hit carries its `uri`, `stateVersion` and full style, so no\nper-element read. (3) `plitzi_apply` with `dryRun:true` to preview. (4) `plitzi_apply` to persist, passing\n`expectedResourceVersions` (uri \u2192 the stateVersion you read) for every resource you change \u2014 omitting it lets a\nconcurrent edit be lost. Use `patchElement`/`patchDefinition` to change only some props/CSS (upsert replaces all).\n\n**Refs & wiring:** a ref is the semantic `idRef` (letters, numbers, hyphens and underscores, **starting with a\nletter \u2014 no dots**) or the raw id. The idRef is the runtime wiring key: a provider's source is `<type>_<idRef>`, and\ninteractions target by it. A dot would split that path; an **underscore is fine** \u2014 the first `_` separates the type\nfrom the idRef (element types have none), so `list_food_item` reads unambiguously as type `list`, idRef `food_item`.\n\n**Styling:** CSS keys are **kebab-case** (`background-color`); `var(--token)` for style vars, `{{name}}` for schema\nvars. **Write plain CSS** \u2014 shorthands (`border`, `padding`, `margin`, `gap`, `overflow`, `flex`, `background`,\n`font`, `transition`, `animation`, `grid`, `grid-row`/`grid-column`, `place-*`, `outline`, `columns`,\n`list-style`, `text-decoration`) are expanded to their longhands for you, so `border: 1px solid red` is stored as\n`border-top-color`/`border-top-width`/`border-top-style`/\u2026 That is also how you read them back. Flex layout is\nstill `display: flex` + `flex-direction`/`align-items`/\u2026, not a `flex` value. Mind a type's `defaultStyle`\n(`text` is `display: inline`). Global styles (`button {\u2026}`) and id styles (`#id`) have their own ops.\n\n**Data bindings** (`upsertBinding`, category attributes|style|initialState): connect a `source` to a `to` field.\nA source `<type>_<idRef>` is scoped to the provider's **DESCENDANTS only** \u2014 bind inside the provider's subtree\n(module sources state/space/navigation/auth/collection are global). `apiContainer.mockData` is builder-only; set a\nreal `query` for production. `transformers: [{action, params}]` post-process the value \u2014 use exact action names\nfrom `plitzi://data-sources`; `twigTemplate` formats it (the value is `{{source}}`, not `{{value}}`). `when` is\na QueryBuilder RuleGroup gating the binding.\n\n**Interactions** (`upsertInteractionFlow`): a `trigger` node first, then callbacks/utilities **in order** (links\ncomputed for you). Node types: `callback` (an element's own callback \u2014 `elementId` is that element), `globalCallback`\n(a source module \u2014 omit `elementId`, the MCP sets it), `utility` (no element). Element `setState`\n(category/key/value/revertOnFinish) \u2260 global `setState` (source `state`, key/type/value). To turn a step off use\n`patchInteractionNode {enabled:false}` \u2014 `deleteInteraction` removes it (destructive; confirm first). Any param\n**value** can be a binding token `{{ source }}` (e.g. notification `content: \"{{ list_<idRef>.item.name }}\"`).\n\n**Pages & navigation:** `upsertPage` \u2014 always set a **relative** `slug` (no leading `/`; the runtime and folder\nslugs prepend the path). A `:name` segment (`\"posts/:postId\"`) is a route param, readable as `{{name}}` and as the\nsource `navigation.routeParams.name` \u2192 build dynamic pages this way. To move between pages **prefer the `Link`\nelement** (a container: `mode` \"page\"/\"internal\"/\"external\") over a `navigate` interaction.\n\n**Touched resources must be malformation-free.** Editing an element/definition also checks its CURRENT stored content\nand BLOCKS the save on any `Pre-existing malformation in <resource>` error (a broken transformer, malformed node,\ninvalid CSS) \u2014 even parts you did not touch. These are NOT from your change (the message says so); fix them in the\nSAME batch and re-apply (the check runs on the result, so the fix unblocks it). `Pre-existing issue` warnings advise\nbut do not block.\n\nRead `plitzi://guide` before anything above is unclear.\n";
3
4
  export declare const guideText = "# Plitzi AI MCP \u2014 usage guide\n\nA Plitzi space is **two separate schemas** you edit together:\n- **Element schema** \u2014 the tree of pages and elements (their type, label, props, and which style classes they use).\n- **Style schema** \u2014 reusable **definitions** (CSS classes), design tokens (variables), theme.\n\nThey are stored and persisted independently, but a single `plitzi_apply` batch may touch **both atomically**.\nTo style a specific element you do two things in one batch: write a **definition** (style schema) and **attach**\nit via the element's `style.base` (element schema). Example \u2014 \"rename button X to PEPE and make it red\":\n```json\n{ \"operations\": [\n { \"type\": \"upsertDefinition\", \"ref\": \"btn-x\", \"desktop\": { \"color\": \"red\" } },\n { \"type\": \"upsertElement\", \"pageRef\": \"home\",\n \"element\": { \"ref\": \"X\", \"type\": \"button\", \"label\": \"PEPE\", \"style\": { \"base\": [\"btn-x\"] } } }\n] }\n```\n\nReads are cheap by design \u2014 treat them like a filesystem: **list** to navigate, **read one item** for detail.\nNever download a whole tree you do not need.\n\n## Resources (read)\n- `plitzi://primer/{env}` \u2014 **cold-start bundle**: guide + types + css-properties + page/definition/variable\n **summaries** in one read. Fetch this first instead of the individual resources below. Summaries only \u2014 open a\n page skeleton or element for its tree/detail.\n- `plitzi://guide` \u2014 this guide.\n- `plitzi://types` \u2014 element types **observed in this space** (ground truth): props, slots, subTypes, plus each\n type's `label`, `description` (what it is FOR) and `category`, and a `source` (`builtin` | `plugin` | `unknown`).\n Read the descriptions to pick the right type \u2014 e.g. `apiContainer` fetches backend data into the frontend,\n `link` navigates between pages, `list` repeats a template over a data array. `plugin` types are custom elements.\n- `plitzi://css-properties` \u2014 valid kebab-case CSS property keys.\n- `plitzi://schema/{env}/pages` \u2014 page **summaries** (ref, label, elementCount, folder). No element trees.\n- `plitzi://folders/{env}` \u2014 page **folders** (the sidebar tree): ref, name, slug, parentId. `/{ref}` for one.\n- `plitzi://schema/{env}/pages/{ref}` \u2014 one page as a **skeleton tree**: each node is `ref/type/label` **plus the\n style classes it attaches** (`base`, and `slots` for non-base slots) \u2014 names only, no CSS. So you can map every\n element to its class in a single page read, without opening each element just to learn which class it uses.\n- `plitzi://schema/{env}/pages/{ref}/styles` \u2014 **every style the page uses in one read**: the class definitions its\n elements attach (deduplicated, **with full CSS**) plus the global styles affecting any element type on the page.\n Reach for this to recolor/restyle a whole page \u2014 it needs no shared class-name prefix and no per-element reads.\n- `plitzi://schema/{env}/elements/{ref}` \u2014 one element in **full detail** (props, style, parentRef, childRefs).\n Its `resolvedStyle` inlines the **CSS of every definition** the element attaches (keyed by class ref), so you\n can see and edit its style without a separate definition read. Its `globalStyles` lists the **global element\n selectors** that also affect it (the CSS equivalent of `button { \u2026 }`, keyed by the type they target) \u2014 every\n element of that type inherits them. Edit a global only through the global-style tools (never per element). If the\n element carries a DOM `id` that an id rule matches, its `idStyle` (`#id`) is inlined too.\n- `plitzi://definitions/{env}` \u2014 the **names** of every style definition.\n- `plitzi://definitions/{env}/{ref}` \u2014 one definition's CSS.\n- `plitzi://global-styles/{env}` \u2014 element **types** that have a site-wide global style. `/{componentType}` for one.\n- `plitzi://id-styles/{env}` \u2014 DOM **ids** that have an id rule (`#id`) targeting a single element. `/{targetId}` for one.\n- `plitzi://style-variables/{env}` \u2014 design tokens by category. `/{category}` for one.\n- `plitzi://schema-variables/{env}` \u2014 space-level values referenced in props as `{{name}}`.\n- `plitzi://settings/{env}` \u2014 space-level settings: the global `customCss` and the state/auth (user-provider) config.\n- `plitzi://interactions/{env}` \u2014 interaction **actions** observed in this space (grouped by node type): the\n vocabulary for interaction flows.\n- `plitzi://data-sources/{env}` \u2014 data-source **paths** and binding targets observed in this space: the\n vocabulary for data bindings.\n\nThe style resources also answer under the `plitzi://schema/{env}/\u2026` root as aliases \u2014 `plitzi://schema/{env}/definitions/{ref}`, `plitzi://schema/{env}/style-variables/{category}`, `plitzi://schema/{env}/schema-variables` \u2014 but prefer the ready-made `uri` from search / a write response over hand-building either form.\n\nData resources return `{ stateVersion, data }`. Keep `stateVersion` for optimistic concurrency.\n\n**Reuse what you already know \u2014 don't re-scan an unchanged page.** A page read (`plitzi://schema/{env}/pages/{ref}`)\nreturns a `stateVersion` that is an **aggregate of the whole page**: it changes if and only if some element on the\npage changed. Its skeleton `tree` also carries a `stateVersion` **per node**, identical to the one a direct element\nread or search hit returns for that element. So when you come back to a page you already inspected:\n1. Re-read just the page skeleton and compare its top-level `stateVersion` to the one you held. **Same \u2192 nothing\n changed since your read; skip re-reading and re-searching the tree** and act on what you already know.\n2. If it differs, diff the per-node `stateVersion`s against the ones you cached and `plitzi_read` **only the nodes\n that changed** \u2014 never re-search or re-read the whole tree.\n\nThis is a **read-time** shortcut, not a safety guarantee. **Other agents/sub-agents may edit the same space\nconcurrently**, so a version you cached can go stale between your read and your write. Never skip the write-time\ncheck below on the strength of a cached hash \u2014 the guarantee that no concurrent edit is lost comes only from\n`expectedResourceVersions` on `plitzi_apply`, which re-validates against the live data at write time.\n\n## Navigating (files analogy)\nPages and containers are folders; elements are files. **Prefer `plitzi_search` (especially with `include: \"detail\"`)\nover reading elements one by one** \u2014 it jumps straight to elements by label/type/attribute and each hit already\ncarries the element's `uri`, `stateVersion`, `pageUri`, `parentRef` and tree `path`, so you can edit it (with\noptimistic concurrency) **without a follow-up read**. `include: \"detail\"` additionally inlines each hit's props/style\n**and** its `resolvedStyle` (the CSS behind its classes) \u2014 so a search-then-edit is the efficient path and a manual\nelement read is the exception. Search also matches **pages** by name/slug (returned under `pages`, each with its uri +\nstateVersion) and returns any **style definitions** whose name matches the query, with full CSS, under `definitions`.\nWhen you do hold several refs to open (e.g. from a skeleton), read them together with `plitzi_read` rather than one at a time.\n\n## Tools (write)\n- `plitzi_validate` \u2014 check a batch, returns teachable errors/warnings. Writes nothing. Also reports **pre-existing\n malformations** in any resource the batch touches (see below).\n- `plitzi_apply` \u2014 validate \u2192 apply \u2192 persist atomically. Rejects the whole batch on any error or conflict. Pass\n `dryRun: true` to apply in memory only and get the same result back (changed versions + full element detail)\n without persisting \u2014 inspect it, then re-run without `dryRun` to commit.\n- `plitzi_search` \u2014 find elements (and pages/definitions) across the space.\n- `plitzi_read` \u2014 read many resource **uris in one batch** (pages, elements, definitions, variables). Pass the\n ready-made uris from search / a write response; each result is `{ uri, stateVersion, data }` or a teachable error,\n so one bad uri never fails the batch. Use it instead of N single reads whenever you already hold several refs.\n\n## Readers: resources vs plitzi_search vs plitzi_read (do not confuse them)\nThree ways to read, each for a different moment \u2014 pick by what you have in hand:\n- **MCP resources** (the `plitzi://\u2026` catalog above) \u2014 the **browsable index**. List them to discover what exists, or\n open one by URI when you are exploring. This is the passive catalog, not a tool.\n- **`plitzi_search`** \u2014 you know *what* you want but not its **ref/uri** (\"the hero button\"). Search finds it by\n label/type/attribute and hands back the uri + stateVersion (and, with `include:\"detail\"`, the full element).\n- **`plitzi_read`** \u2014 you **already hold one or more uris** (from search or a write response) and want their\n content in one batch. It is the tool form of opening resources, for when you have the addresses.\nRule of thumb: **discover \u2192 resources**, **find a ref \u2192 plitzi_search**, **fetch known uris \u2192 plitzi_read**. Never\nhand-build a URI to guess your way to an element \u2014 search for it instead.\n\nWrite tools return what **changed** (`{ uri, stateVersion }`) plus counts, and the **full detail of every element\nthey created or updated** \u2014 each with its own `uri` and `stateVersion` (`elements: [...]`) so a follow-up edit of\nthe same element needs **no intermediate read**. Other resources (pages, definitions, variables) still report only\nuri+stateVersion \u2014 re-read them if you need their new content. The operation shapes are in each tool's input\nschema (discriminated by `type`).\n\n## Addressing\nRefs are the semantic `idRef` (e.g. `\"hero-cta\"`) or the element's **raw id** \u2014 both resolve. Creating an element\nstores the `ref` you chose as its **idRef**.\n\nThe idRef is not just an alias \u2014 it is the **wiring key the runtime uses**. A provider registers its data source as\n`<type>_<idRef>`, so a `source` you write against a ref resolves to that element at runtime with no id translation.\nRules for a **new** ref (both are enforced; a violation fails the batch):\n- Charset `[A-Za-z0-9_-]`, **starting with a letter** (e.g. `\"products-api\"`, `\"food_item\"`). A `.` would split the\n `<type>_<idRef>.<field>` source path and the interaction target lookup, so **no dots**. An **underscore is\n allowed**: the FIRST `_` separates `<type>` from `<idRef>` and element types are camelCase with none, so\n underscores inside the idRef are unambiguous (`list_food_item` \u2192 type `list`, idRef `food_item`).\n- **Unique across the space**; creating a ref that is taken is rejected (address the existing element instead).\n\nAn idRef is **optional** on an element \u2014 one built in the builder may not have it. The consequence is specific: an\nelement without an idRef **publishes no data source** and **holds no interactions**, because the runtime keys\neverything by idRef and the raw id is never a fallback. You do not have to fix this by hand: writing an\ninteraction **mints an idRef for you** \u2014 the element that hosts the flow, and any element a node targets, is given\na free `<type>-<n>` ref if it lacks one, and the flow is wired to it. A node target you write may be a raw id; it\nis normalised to that element's idRef. (To make an element a data-source **provider** to bind against, give it an\nidRef explicitly with `patchElement`, or create it with the `ref` you want \u2014 a created element stores its ref as\nits idRef.)\n\n**Renaming** an idRef moves the wiring key: every binding source and interaction target across the space that\npointed at the old name is repointed with it, so the element stays wired. You do not have to rewrite them.\n\n## Styling (crosses both schemas)\n- **Mind the type's intrinsic default style.** A type renders with a base CSS *before* any class is attached \u2014 read\n it from `defaultStyle` on the type in `plitzi://types` (the primer includes it). Do not assume `display: block`:\n `text`, for one, defaults to `display: inline`, so margins/width/vertical padding behave differently. If you need\n block/flex layout on such an element, set `display` explicitly in your definition rather than relying on a default.\n- **Images: preserve aspect ratio.** Setting only `width` and `height` (or forcing both) distorts an image. Change\n one dimension and let the other be `auto`, or set `aspect-ratio` with `object-fit: cover`/`contain`, so the\n image scales without stretching.\n- **Font size and line height move together.** When you change `font-size`, set `line-height` in the same edit\n (prefer a unitless ratio like `1.5`, which tracks the font size). Changing one without the other leaves cramped or\n loosely-spaced text \u2014 they are a joint change, not two separate ones.\n- A definition lives in the **style schema**; an element's `style.base` (element schema) is the link that applies\n it. Styling an element = upsertDefinitions + upsertElement with that ref in `style.base`, in one batch.\n- **Repeating siblings**: when a set of siblings shares a shape and differs only in data (a list, cards, rows,\n steps), use `repeatElement` \u2014 the template once with `{{item.field}}` placeholders plus `items`, which\n creates the wrapper and numbers each row's refs (`step-1`, `step-2`\u2026). A list inside each row is the same op:\n the wrapping node carries `repeat: { items: \"{{item.<list>}}\", template: \u2026 }` and its refs number both levels\n (`blk-2-3`). Copy-pasting the subtree N times costs N times the tokens and drifts.\n- CSS keys are **kebab-case** (`background-color`). camelCase is rejected \u2014 read `plitzi://css-properties`.\n- **Write normal CSS \u2014 shorthands are accepted and expanded for you.** `border`, `border-{side}`,\n `border-width`/`-color`/`-style`, `border-radius`, `padding`, `margin`, `inset`, `gap`, `overflow`,\n `flex`, `flex-flow`, `background`, `font`, `transition`, `animation`, `grid`, `grid-template`,\n `grid-area`, `grid-row`, `grid-column`, `place-content`, `place-items`, `place-self`, `outline`,\n `columns`, `list-style`, `text-decoration` \u2014 the full list is in `plitzi://css-properties` under\n `shorthands`. They **persist as longhand keys** (that is what a read gives back), so a breakpoint/state/variant\n can still override one property at a time.\n - `border: 1px solid red` \u2192 `border-top-width: 1px`, `border-top-style: solid`, `border-top-color: red`, \u2026\n for all four sides. A shorthand also RESETS what it omits, so `border: none` clears a width a previous\n definition set.\n - Comma-separated layers are kept per longhand: `transition: opacity 200ms, transform 300ms` \u2192\n `transition-property: opacity, transform` + `transition-duration: 200ms, 300ms`.\n - In a **patch**, a shorthand replaces the longhands it controls (`padding: 8px` overwrites a previous\n `padding-left`), and `\"border\": null` removes all twelve border longhands.\n- **Flex layout is not a `flex` value**: set `display: flex` **plus** `flex-direction`, `align-items`,\n `justify-content` as separate properties.\n- CSS is grouped by breakpoint: `desktop`, `tablet`, `mobile`.\n- Reference a style variable in CSS as `var(--name)`; a schema variable in a prop as `{{name}}`.\n- `element.style.base` is a **list** of definition refs; other slots go under `element.style.slots`.\n- **An element can attach SEVERAL classes at once, and they all apply.** `style.base` holds a list, and each\n non-base slot holds its own \u2014 every attached definition contributes CSS, and they **cascade** (a later class, then a\n global/id rule, overrides an earlier one on the same property). So when a style looks wrong, the culprit may be\n ANY attached class, not the one you just edited: read the element's `resolvedStyle` (it inlines the CSS of every\n class it attaches, keyed by ref) together with its `globalStyles`/`idStyle` and the type's `defaultStyle`, and\n fix the class that actually sets the property \u2014 do not just pile another class on top.\n- **Three kinds of style live in the style schema \u2014 do not confuse them:**\n - **Definitions** = reusable CSS **classes** (`upsertDefinition`/`patchDefinition`/`deleteDefinition`, keyed by a\n class `ref`). Attach one to an element via `style.base` to style **that** element (and anything else that opts in).\n This is the **default** way to style one element. Declaring MORE than one class in a batch? Use\n `upsertDefinitions` \u2014 one op carrying `{ \"<class>\": { desktop: \u2026 }, \u2026 }`, same result as the run of\n `upsertDefinition` it replaces, without repeating the envelope once per class.\n - **Global styles** = the CSS equivalent of a bare element selector like `button { \u2026 }`\n (`upsertGlobalStyle`/`patchGlobalStyle`/`deleteGlobalStyle`, keyed by `componentType`). They style **every**\n element of that type at once. Use these for site-wide intent \u2014 e.g. \"all buttons rounded\":\n `{ \"type\": \"upsertGlobalStyle\", \"componentType\": \"button\", \"desktop\": { \"border-radius\": \"9999px\" } }`.\n - **Id styles** = the CSS equivalent of an id selector like `#hero { \u2026 }`\n (`upsertIdStyle`/`patchIdStyle`/`deleteIdStyle`, keyed by `targetId`). They style the **single** element whose\n DOM `id` attribute equals `targetId` \u2014 so the element must carry that `id` (set it in its props). Prefer a\n **definition** for one element; reach for an id style only when a specific, uniquely-identified node must be\n targeted by id: `{ \"type\": \"upsertIdStyle\", \"targetId\": \"hero\", \"desktop\": { \"min-height\": \"100vh\" } }`.\n - The three share one name space, so an op refuses a name held by another kind (guards against a typo silently\n rewriting every element of a type, or converting a class into an id rule). If refused, you targeted the wrong\n kind \u2014 switch tools or rename.\n\n## Style variants & element state\nA **variant** is a named CSS override on a definition (e.g. a button class with a `primary` variant). It takes two\nsteps across the two schemas:\n- **Declare** the variant CSS on the class (style schema): `upsertDefinition`/`patchDefinition` with\n `variants: { \"primary\": { \"desktop\": { \"background-color\": \"#111\" } } }` (per slot under `slots.<slot>.variants`).\n- **Apply** it to an element (element schema): `initialState.styleVariant` =\n `{ \"<class-ref>\": { \"base\": \"primary\" } }` \u2014 a slot name instead of `base` targets that slot; an array applies\n several. Set it via `upsertElement`/`patchElement`.\nAn element read reports `availableVariants` (which variant each attached class offers) and the element's current\n`initialState`, so you can see a button **has** a `primary` variant and whether it uses it. If the user asks for a\nvariant that does not exist yet, **create it (upsertDefinition variants) and apply it in the same batch**.\n- `initialState.visibility` (boolean) sets whether the element starts shown or hidden.\n\n## Data bindings\nConnect a data **source** to an element field. A binding is `{ to, source, transformers?, when?, enabled? }` grouped\nby **category**: `attributes` (a prop), `style` (a style value), `initialState` (an initial-state key).\n- `upsertBinding` adds one, or replaces the binding already feeding the same `to` (or `id`).\n- `patchBinding` edits an existing one (matched by `to`/`id`); `deleteBinding` removes it.\nDiscover valid source paths **and the transformer catalog** in `plitzi://data-sources/{env}`. Example \u2014 feed an API\nlist into a list element:\n`{ \"type\": \"upsertBinding\", \"pageRef\": \"home\", \"ref\": \"myList\", \"category\": \"attributes\",\n \"binding\": { \"to\": \"items\", \"source\": \"apiContainer_x.data\" } }`.\n\n**Source scope \u2014 a source is visible to the provider's DESCENDANTS only.** An element source named\n`<type>_<idRef>` (e.g. `apiContainer_products`, `list_food-list`) is published by that element into the scope of\nits **subtree**, so **only elements INSIDE the provider can bind to it**. Binding a sibling or an unrelated element\nto it resolves to nothing at runtime. So to consume `apiContainer_products.data`, the bound element must live under\nthat apiContainer; inside a `list`, the repeated `listItem` and its children read the per-row source\n(`list_<idRef>.item.<field>`). Module sources (no `<type>_<idRef>` head \u2014 `state`, `space`, `navigation`,\n`auth`, `collection`) are global and bindable anywhere. Binding an element to an element source outside its\nprovider's subtree is schema-valid but **broken at runtime** (the source is not in scope), so\n`plitzi_validate`/`plitzi_apply` treat it as an **error and reject the batch** \u2014 move the element under the\nprovider, or bind a source that is in scope.\n\n**mockData is builder-only.** An `apiContainer`'s `mockData` prop feeds sample data **while editing in the\nbuilder**; the published runtime fetches the real `query` instead. Never rely on mockData as the production source \u2014\nset a real `query`/`method` so the binding has data at runtime.\n\n**`transformers` \u2014 post-process the value before it reaches the field** (`source \u2192 t\u2081 \u2192 t\u2082 \u2192 field`). An array of\n`{ action, params }`; the runtime runs them in order and resolves each by its `action` alone, so an **unknown\naction is silently skipped** and the raw value passes through. Use the **exact** action names from\n`plitzi://data-sources` (`transformers`). The most common is **`twigTemplate`** to format a value \u2014 the incoming\nvalue is the **`{{source}}`** token (NOT `{{value}}`); `{{sourceTo}}` is the field's previous value. Example \u2014\nshow a number with units:\n`{ \"type\": \"upsertBinding\", \"pageRef\": \"home\", \"ref\": \"food-item-time\", \"category\": \"attributes\",\n \"binding\": { \"to\": \"content\", \"source\": \"list_food-list.item.cookTimeMinutes\",\n \"transformers\": [ { \"action\": \"twigTemplate\", \"params\": { \"template\": \"{{source}} min de cocci\u00F3n\" } } ] } }`.\nOther transformers: `dateConverter` (format a date/timestamp), `capitalize`, `stringToArray` (split on a\nseparator), `arrayMap` (remap the keys of each object in an array), `staticValue`. Transformer `params` values are\nstrings. Each transformer also takes an optional `enabled` flag: set `\"enabled\": false` to keep it in the chain but\nskip it at runtime (defaults to true) \u2014 the value passes through untouched, and a disabled transformer is not\nvalidated.\n\n**`when` \u2014 gate the binding** with a QueryBuilder RuleGroup: the binding only applies when the guard passes against\nthe data source. Shape: `{ \"combinator\": \"and\", \"rules\": [ { \"field\": \"<path>\", \"operator\": \"=\", \"value\": \"x\" } ] }`\n(operators: `=`, `!=`, `<`, `>`, `contains`, `beginsWith`, `empty`, `in`, `between`, \u2026; nest RuleGroups for\nand/or). The guard is validated structurally. Example \u2014 only bind when a flag is set:\n`\"when\": { \"combinator\": \"and\", \"rules\": [ { \"field\": \"status\", \"operator\": \"=\", \"value\": \"published\" } ] }`.\n\n## Interactions\nAn interaction **flow** is a **trigger** (an event like `onClick`, `onPageLoad`) followed by the callbacks/utilities\nit runs, in order. You pass the steps **in order** and the stored beforeNode/afterNode/flowId links are computed for\nyou \u2014 never wire them by hand. Each step also has an `enabled` flag (see disable vs delete below).\n\n**Node types & `elementId`** \u2014 a step names which element (or module) provides the callback it runs. Picking the\n**wrong node type for an action** makes the runtime resolve it against nothing, so the step **silently does nothing**:\n- `trigger` \u2014 the event; belongs to the host element. `elementId` defaults to the host.\n- `callback` \u2014 a callback provided by a **specific element**. `elementId` is that element's ref (the flow host by\n default, or another element to act on); give its ref or raw id and it is normalised to the idRef. Every element\n registers a built-in **`setState`** callback that changes **its own attribute or state**: params\n `category` (`\"attribute\"` \u2014 set a prop like `content`/`disabled` \u2014 or `\"state\"` \u2014 `visibility` or a style\n selector), `key`, `value` (a **scalar** whose type follows the target attribute \u2014 a real boolean `true`/`false`\n for a boolean attribute, a number for a numeric one, otherwise a string), and **`revertOnFinish`**. Set\n `revertOnFinish: true` for a **temporary** change (a \"loading\u2026\" label, disabling a button while it works): it is\n **undone automatically when the flow finishes**, so you do **NOT** add manual restore steps at the end. This element\n `setState` has **no** `type` param (that belongs to the global one below). An element type may also register its\n own extra callbacks.\n- `globalCallback` \u2014 a callback provided by a **source module**, NOT by any element: `addNotification` (source\n `space`), `setState`/`clearState` (`state`), `navigate` (`navigation`), `authLogin`/`authLogout`/\n `authRefreshDetails` (`auth`), `addCollectionRecord`/`updateCollectionRecord`/`removeCollectionRecord`\n (`collection`). Its `elementId` is the **source module id**, never the host element \u2014 a node that stored the host\n idRef here would resolve to nothing at runtime. **Omit `elementId`**: the MCP sets the correct source and fills the\n builder's **param defaults** (e.g. `addNotification` gets `autoDismiss:true`, `autoDismissTimeout:5000`,\n `placement:\"top-right\"`, `appeareance:\"success\"`) for any params you leave out. Use **only** the params each\n callback declares (exact spelling) \u2014 for `addNotification` the visible text goes in `content`; there is **no**\n `title`/`message`/`type` param, and any unknown key is dropped. See the full param schema for each callback under\n `globalCallbacks` in `plitzi://interactions/{env}`.\n- **Two `setState`s \u2014 do not mix them:** the **element** `setState` (nodeType `callback`, on an element,\n category/key/value/revertOnFinish) changes THAT element's attribute/state and is what you want to change a button's\n label or disabled flag. The **global** `setState` (nodeType `globalCallback`, source `state`, key/type/value)\n writes `runtime.state.<key>`. They share a name but have different node types AND different params.\n- `utility` \u2014 a built-in utility action (no element/source module); nodeType `utility`. Use the **exact** param\n names: `delayTime` waits `time` milliseconds (**not** `delay`), `twigTemplate` (`returnMode`, `template`),\n `webHook` (`url`, `method`, \u2026). See `utilities` in `plitzi://interactions/{env}`.\n\n**A param value can be a data binding.** Any interaction param may hold a `{{ source }}` token instead of a literal \u2014\nit resolves at runtime exactly like a prop binding, using the same source grammar (`<type>_<idRef>.<path>`, or a\nmodule source like `navigation`/`state`). This is how a step reacts to *the data in context*: inside a `listItem`,\na click on a row can show `addNotification` with `content: \"{{ list_<idRef>.item.name }}\"` \u2014 the clicked row's field.\nThe value follows the source's type, so a token is valid even where a param expects a boolean/number. Copy/paste of an\nelement repoints these tokens to the new idRefs automatically, along with the element's bindings.\n\n**Navigating between pages \u2014 prefer the `Link` element over an interaction.** For a plain \"go to page X\" the right\ntool is a `link` element (a container, see *Pages & folders*), not a `navigate` interaction step. Use the\n`navigate` globalCallback only when the navigation is one step of a larger flow (e.g. submit a form, then go).\n\nTools:\n- `upsertInteractionFlow` \u2014 create or replace one flow. The FIRST node must be a `trigger`. Pass `flowId` (the\n trigger's node id) to replace an existing flow. Example (elementId omitted \u2014 the MCP wires it to `space` and fills\n the notification defaults):\n `{ \"type\": \"upsertInteractionFlow\", \"pageRef\": \"home\", \"ref\": \"cta\", \"nodes\": [\n { \"nodeType\": \"trigger\", \"action\": \"onClick\", \"title\": \"Click\" },\n { \"nodeType\": \"globalCallback\", \"action\": \"addNotification\", \"title\": \"Notify\",\n \"params\": { \"content\": \"Saved!\" } } ] }`.\n- `patchInteractionNode` \u2014 change one step in place (by `nodeId`); `params` merge onto the node.\n\n**Disable vs delete a step \u2014 do not confuse them (three different intents):**\n- **Disable / deactivate / turn off a step** (keep it in the flow, just stop it running): `patchInteractionNode`\n with `{ \"enabled\": false }`. Re-enable with `{ \"enabled\": true }`. This is NOT a deletion \u2014 the step stays.\n- **Remove one step** from a flow: `deleteInteraction` with `nodeId` (its neighbors are re-linked).\n- **Remove the whole flow**: `deleteInteraction` with `flowId` (the trigger node id).\n\nSo \"deactivate the addNotification step\" means `patchInteractionNode { enabled: false }` \u2014 never delete the step, and\nnever delete the flow. `deleteInteraction` is **destructive and not undoable**: only use it when the user asked to\n*remove* something, and **confirm with the user before deleting** a step or a flow.\n\nDiscover valid actions in `plitzi://interactions/{env}`: `actions` = observed, `globalCallbacks` /\n`elementCallbacks` / `utilities` = the built-in vocabularies with their full param schema, so you know the exact\nnode type and valid params per action. An element read lists its flows as ordered nodes (each with its `id` and\n`enabled`), so a follow-up patch/delete needs no extra read.\n\n## Pages & folders\n- **Always set a `slug` when creating a page** (`upsertPage`) \u2014 it is the page's URL path and good practice for a\n clean, stable route (e.g. `\"pricing\"` or `\"posts/:postId\"`). Omit it and the page ref is used as the slug,\n and `plitzi_validate`/`plitzi_apply` warn so you remember to set a meaningful one.\n- **A page slug is RELATIVE \u2014 do NOT start it with `/`.** The runtime prepends the leading slash (and any folder\n path) itself, so a leading slash doubles it. Write `\"pricing\"`, not `\"/pricing\"`. (upsertPage strips a leading\n slash for you, but write it relative.)\n- **Dynamic pages \u2014 route params.** A slug segment written `:name` (e.g. `\"posts/:postId\"`) is a **route param**,\n exactly like React Router. On that page it is readable **two ways**: as `{{name}}` inside a prop, and as the\n data-binding source **`navigation.routeParams.name`** (the `navigation` module source, global \u2014 bindable\n anywhere). So a blog is a `\"posts\"` list page plus a `\"posts/:postId\"` detail page whose `apiContainer` query\n binds `navigation.routeParams.postId` to fetch that one post. (`navigation.queryParams.<name>` exposes `?query=`\n string params the same way.)\n- **Navigate with the `Link` element, not an interaction.** `link` is a **container** \u2014 it wraps any children and\n navigates on click, so it is the default way to move between pages. Its `mode`: `\"page\"` links to another space\n page (set `href` to the target page, folder path resolved for you); `\"internal\"` takes a path inside the space and\n resolves `{{token}}` templates in it, so a row's \"view\" link is `mode:\"internal\", href:\"posts/{{postId}}\"`;\n `\"external\"` is a full URL. `target` is `self`/`blank`/`parent`/`top`. Reach for the `navigate` globalCallback\n only when navigation must be **one step inside a larger interaction flow** (e.g. save, then go) \u2014 for a plain link,\n use `link`.\n\nPages can be grouped into **folders** (the sidebar tree). A folder is `{ ref, name, slug, parentId? }`; its `ref`\n**is its id** (there is no separate idRef), and that id is what a page and a nested folder reference.\n- **Folder slugs PREPEND to the page URL \u2014 this is how nested URLs are built.** The full path is each ancestor\n folder's slug plus the page slug, joined by `/`: a page at slug `\"1-1-1\"` inside `folder-1` > `folder-1-1`\n resolves to `/folder-1/folder-1-1/1-1-1`. So a folder slug is part of the route; keep folder slugs relative too.\n- Create/rename/move a folder with `upsertFolder` (the `ref` you pass on create becomes its id \u2014 pick a stable one\n like `\"blog\"`). Nest it under another with `parentId` (a folder ref); `parentId: null` moves it back to the root.\n- Put a page in a folder with `upsertPage`'s `folder` (a folder ref). A page's `folder` is always either **empty\n (root)** or an **existing folder id**: `folder: null` or `folder: \"\"` moves it to the root, and any other value\n must resolve to a folder that already exists or is created earlier in the same batch \u2014 an unknown folder is\n rejected, never stored.\n- `deleteFolder` removes a folder and **promotes its contents up one level** \u2014 its child folders and its pages move\n to its parent (or the root). A folder cannot be nested under itself or one of its descendants.\n- **Disable a page** with `upsertPage`'s `enabled: false`; `enabled: true` re-enables it (defaults to enabled, and\n a page read reports its current `enabled`). Disabling only affects the **published SDK runtime** \u2014 the page stops\n being routable/accessible to end users. It stays fully **editable here**: you can still read it and apply any op to\n a disabled page. This does not delete it \u2014 `deletePage` does.\n\n## Settings\nSpace-level configuration lives in `plitzi://settings/{env}` and is edited with a single **`patchSettings`** op\n(merge \u2014 only the fields you pass change):\n- `customCss` \u2014 **raw global CSS** injected for the whole space. Use it only for genuinely site-wide rules\n (`@keyframes`, `@font-face`, resets). To style an element, write a **definition** and attach it \u2014 never customCss.\n- `keepState` / `stateStorage` \u2014 persist element state across reloads (`localStorage`/`sessionStorage`).\n- **User provider / auth**: `userProvider` (`auth0`|`basic`|`custom`|`\"\"` to disable), `auth0Domain`,\n `auth0ClientId`, `tokenStorage`, and the `loginUrl`/`userUrl`/`refreshUrl`/`logoutUrl` + `detailsPath`/\n `tokenPath`/`expirationTimePath` mapping. Example \u2014 inject a keyframe globally:\n `{ \"type\": \"patchSettings\", \"customCss\": \"@keyframes spin { to { transform: rotate(360deg); } }\" }`.\n\n## Semantics\n- **props are fully replaced** on `upsertElement`: send every prop you want to keep. To change only some props,\n use **`patchElement`** \u2014 it merges `props`/`style` onto the existing element (listed keys change, `null` unsets\n a key, everything else is preserved) and never creates. Combined with `plitzi_search` (which returns the ref +\n stateVersion), a targeted edit is two calls with no read.\n- **definition CSS is fully replaced** on `upsertDefinition`: send every property you want to keep. To change only\n some declarations, use **`patchDefinition`** \u2014 it merges CSS per breakpoint/state/variant/slot onto the existing\n definition (listed keys change, `null` removes a property, everything else is preserved) and never creates.\n Example \u2014 recolor one definition without resending it: `{ \"type\": \"patchDefinition\", \"ref\": \"btn-x\",\n \"desktop\": { \"background-color\": \"#111\" } }`.\n- **Atomic batches**: if any operation fails, `plitzi_apply` persists nothing.\n- **Every resource you touch must be malformation-free \u2014 pre-existing errors block the save.** When your batch edits\n an element (or a definition/global/id style), the validator also checks the resource's **current stored content**\n for malformations \u2014 a broken transformer action, a malformed interaction node, invalid CSS \u2014 even in parts your\n edit does not touch. Such a finding is reported as a `Pre-existing malformation in <resource>: \u2026` **error**, and\n `plitzi_apply` rejects the batch until it is fixed. These are **not caused by your change** (the message says so)\n \u2014 do not be confused; fix them **in the same batch** and re-apply. Because the check runs on the resulting state,\n including the fix in your batch is exactly what unblocks the save. (Advisory issues \u2014 an unobserved source/action\n name that may still be a valid plugin, a binding target a plugin manifest does not list \u2014 come back as\n `Pre-existing issue \u2026` **warnings** and do not block.)\n- **Optimistic concurrency \u2014 read before you write, and prove your read is current.** Editing a resource means you\n read it first, so you hold its `stateVersion`. **Always pass `expectedResourceVersions`** (URI \u2192 the stateVersion\n you read) for every resource your batch changes. If another agent edited it in the meantime, the live version no\n longer matches and apply is **rejected with a conflict** \u2014 nothing persists. Then re-read the reported resources\n (their new content + version) and retry on top of the fresh state. This is exactly how a file editor forces a\n re-read after a stale write: it is what keeps concurrent agents from silently overwriting each other's changes, so\n never omit it \"to save a call\".\n";
@@ -102,5 +102,14 @@ export declare const pageRefOfElement: (schema: Schema, el: Element) => string;
102
102
  /** Total number of descendant elements under a subtree (excluding the root). */
103
103
  export declare const descendantCount: (schema: Schema, rootId: string) => number;
104
104
  export declare const emptySpaceMessage = "Space data not available";
105
+ /** The code a space-dependent answer carries when the connection reaches no space, so both the agent and a host UI
106
+ * can tell "this connection cannot do that" from "the call failed". */
107
+ export declare const noSpaceError = "NO_SPACE_ATTACHED";
105
108
  export declare const unauthorizedSpaceMessage: string;
109
+ /** Raised when a space-dependent operation runs on a connection that resolves no spaceId. A type of its own so the
110
+ * host answers it as a STATE of the connection — a plain result the agent reads — instead of letting it surface as
111
+ * a failed call, which hosts render to the user as "cannot connect to this server". */
112
+ export declare class NoSpaceError extends Error {
113
+ constructor();
114
+ }
106
115
  export declare const generateObjectId: () => string;
@@ -1,6 +1,7 @@
1
- export { readResource, resourceVersion } from './router';
1
+ export { readPublicResource, readResource, resourceVersion } from './router';
2
2
  export { registerResources } from './register';
3
3
  export { resourceErrorMessage } from './canonical';
4
+ export { RENDER_GUIDE_URI, RENDER_TYPES_URI } from './renderGuide';
4
5
  export { buildTypeRegistry, cssProperties, cssShorthands, expandShorthand, isCssProperty, suggestCssProperty } from '../catalogs';
5
6
  export type { TypeInfo, TypePropInfo, TypeRegistry } from '../catalogs';
6
7
  export * from '../tools/operations/schema/translator';
@@ -2,5 +2,9 @@ import { McpLog, Space } from '../helpers';
2
2
  import { Env } from '../types';
3
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  /** Register every resource on the MCP server: fixed listings plus templated per-item reads. The space is
5
- * loaded lazily via getSpace, so listing resources never touches the store — only reading one does. */
6
- export declare const registerResources: (server: McpServer, getSpace: () => Promise<Space>, env: Env, log: McpLog) => void;
5
+ * loaded lazily via getSpace, so listing resources never touches the store — only reading one does.
6
+ *
7
+ * `hasSpace` is false when the connection reaches no space (a guest / widgets-only grant): the space-dependent
8
+ * families are then not registered at all, so the catalog the agent browses holds only what it can actually
9
+ * open — no listing that answers every read with the same refusal. */
10
+ export declare const registerResources: (server: McpServer, getSpace: () => Promise<Space>, env: Env, log: McpLog, hasSpace: boolean) => void;
@@ -1,5 +1,10 @@
1
1
  import { McpLog } from '../helpers';
2
+ import { ResourceEnvelope } from '../types';
2
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
4
  export declare const RENDER_GUIDE_URI = "plitzi://render/guide";
4
5
  export declare const RENDER_TYPES_URI = "plitzi://render/types";
6
+ /** The same two documents through the READ path (the router, hence plitzi_read), not only as MCP resources: an
7
+ * agent that reaches for the batch-read tool — the reflex the guide itself teaches — must not be told its own
8
+ * authoring guide does not exist. Space-independent, like the core singletons. */
9
+ export declare const readRenderResource: (uri: string) => ResourceEnvelope<unknown> | undefined;
5
10
  export declare const registerRenderResources: (server: McpServer, log: McpLog) => void;
@@ -2,5 +2,9 @@ import { Space } from '../helpers';
2
2
  import { Env, ResourceEnvelope } from '../types';
3
3
  /** Resolve a resource URI to its versioned envelope, or null if unknown / not found. */
4
4
  export declare const readResource: (space: Space, env: Env, rawUri: string) => ResourceEnvelope<unknown> | null;
5
+ /** What a connection carrying NO space (a guest / widgets-only grant) can still read: the space-independent
6
+ * singletons and the render docs. Undefined for everything else, so the caller says the connection has no space
7
+ * instead of answering out of an empty one — an empty page list would read as "this space has no pages". */
8
+ export declare const readPublicResource: (env: Env, rawUri: string) => ResourceEnvelope<unknown> | undefined;
5
9
  /** Current version of a resource, for optimistic-concurrency checks. Null when the URI is unknown. */
6
10
  export declare const resourceVersion: (space: Space, env: Env, uri: string) => string | null;
@@ -2,10 +2,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { PreviewClient, ScreenshotClient } from './types';
3
3
  import { SSRAdapters, ServerLogger } from '@plitzi/sdk-shared';
4
4
  /** The MCP service is stateless: every request resolves its own `spaceId` (from the request JWT) and reads the
5
- * space fresh through the adapters — schema and style are two documents, read/written independently. Both the
6
- * spaceId and the space itself resolve lazily, so the public surface (handshake, tools-list, resources-list,
7
- * and the space-independent guide / css-properties resources) works even when the request carries no auth
8
- * only a space-dependent tool/resource demands a spaceId, failing with `unauthorizedSpaceMessage` if none. */
5
+ * space fresh through the adapters — schema and style are two documents, read/written independently. The spaceId
6
+ * is resolved once per request, up front, because it decides what this connection IS: with a space, the full
7
+ * editing server; without one (no auth, a guest connection, a widgets-only grant), a widget server that offers
8
+ * only what works there. The space documents themselves still load lazily the handshake and the listings never
9
+ * touch the store. */
9
10
  export interface McpServerContext {
10
11
  adapters: SSRAdapters;
11
12
  getSpaceId: () => Promise<number | undefined>;
@@ -19,4 +20,4 @@ export interface McpServerContext {
19
20
  * consumer renders them); otherwise logging falls back to the console when MCP_DEBUG=1. */
20
21
  logger?: ServerLogger;
21
22
  }
22
- export declare const createMcpServer: ({ adapters, getSpaceId, preview, screenshot, logger }: McpServerContext) => McpServer;
23
+ export declare const createMcpServer: ({ adapters, getSpaceId, preview, screenshot, logger }: McpServerContext) => Promise<McpServer>;
@@ -5,4 +5,5 @@ export declare const readShape: {
5
5
  uris: z.ZodArray<z.ZodString>;
6
6
  };
7
7
  export declare const read: (input: ReadInput, space: Space, env: Env) => ReadResponse;
8
+ export declare const readPublic: (input: ReadInput, env: Env) => ReadResponse;
8
9
  export declare const readTool: import('.').ToolDef;
@@ -39,6 +39,11 @@ export interface ToolSpec<Shape extends ZodRawShape> {
39
39
  * iframe, receiving the tool result). Registered as the tool's `_meta.ui` — see registerAppTool. */
40
40
  ui?: McpUiToolMeta;
41
41
  run: (input: z.infer<ZodObject<Shape>>, ctx: ToolContext) => unknown;
42
+ /** What the tool still does on a connection that carries NO space (a guest / widgets-only grant): it keeps only
43
+ * the part that needs none — plitzi_read serves the public documents and says so for the rest. A tool that
44
+ * defines this stays advertised there; one that does not is not registered at all, so the agent never calls
45
+ * something that cannot work. */
46
+ runPublic?: (input: z.infer<ZodObject<Shape>>, env: Env) => unknown;
42
47
  }
43
48
  /** What the registry holds and the hosts register from: the same metadata plus a type-erased `execute` that
44
49
  * validates raw args against the shape and delegates to the typed `run`. Produced by `defineTool`. */
@@ -52,6 +57,9 @@ export interface ToolDef {
52
57
  spaceless?: boolean;
53
58
  ui?: McpUiToolMeta;
54
59
  execute: (args: unknown, ctx: ToolContext) => unknown;
60
+ /** Present when the tool degrades to a space-less connection (see ToolSpec.runPublic); its absence is what tells
61
+ * the host not to advertise the tool there. */
62
+ executePublic?: (args: unknown, env: Env) => unknown;
55
63
  }
56
64
  /** Author a tool: give it its metadata, its input shape and a typed `run`. The returned descriptor parses the
57
65
  * raw args against the shape before handing them to `run`, so `run` is fully typed and no cast is needed.
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.32.18",
3
+ "version": "0.32.19",
4
4
  "license": "AGPL-3.0",
5
5
  "files": [
6
- "dist"
6
+ "dist",
7
+ "skills"
7
8
  ],
8
9
  "main": "dist/index.js",
9
10
  "module": "dist/index.js",
@@ -29,9 +30,9 @@
29
30
  "dependencies": {
30
31
  "@modelcontextprotocol/ext-apps": "^1.7.5",
31
32
  "@modelcontextprotocol/sdk": "^1.30.0",
32
- "@plitzi/plitzi-sdk": "0.32.18",
33
- "@plitzi/sdk-schema": "0.32.18",
34
- "@plitzi/sdk-shared": "0.32.18",
33
+ "@plitzi/plitzi-sdk": "0.32.19",
34
+ "@plitzi/sdk-schema": "0.32.19",
35
+ "@plitzi/sdk-shared": "0.32.19",
35
36
  "ejs": "^6.0.1",
36
37
  "esbuild": "^0.28.1",
37
38
  "zod": "^4.4.3"
@@ -0,0 +1,170 @@
1
+ ---
2
+ name: plitzi-render
3
+ description: >-
4
+ Show answers as a rendered visual widget instead of plain text, using the Plitzi MCP's plitzi_render tool.
5
+ Use whenever the reply is naturally visual or structured — a recipe, a comparison, pricing tiers, a profile,
6
+ a menu, steps/checklist, a product card, a dashboard-like summary — or whenever the user asks to design,
7
+ build, show, or "make it look nice". Requires the Plitzi MCP server (the plitzi_render tool) to be connected.
8
+ ---
9
+
10
+ # Rendering answers as Plitzi widgets
11
+
12
+ `plitzi_render` renders a small, self-contained UI widget the user can SEE. It runs the Plitzi SDK offline — no
13
+ backend, no account, no space, so it works even on a connection with no credentials. Prefer **showing** a widget
14
+ over a long text answer whenever the content is naturally visual.
15
+
16
+ ## When to reach for it
17
+
18
+ - **Something to design or build**: a card, hero, banner, form, pricing table — anything the user asks you to
19
+ "create / design / build / show / make look nice".
20
+ - **A naturally visual answer**: a recipe → a card with photo + ingredients; a comparison → side-by-side tiles;
21
+ steps → a checklist; a product, place or person → a profile card; options → a tiled menu.
22
+
23
+ Keep writing plain text when the answer is genuinely textual — an explanation, code, a discussion. One good widget
24
+ beats a wall of text, but a widget wrapping a paragraph helps no one.
25
+
26
+ ## Before the first call
27
+
28
+ Read the MCP resource **`plitzi://render/guide`** once per conversation. It is the authoritative authoring model:
29
+ every operation, the element types and their props, the styling system, worked examples. `plitzi://render/types`
30
+ lists every element type with its description. Don't reconstruct the schema from memory — read the guide.
31
+
32
+ ## The shape of a good call
33
+
34
+ Everything the widget needs travels in ONE call, as `operations`. Three ops carry almost every widget:
35
+
36
+ - **`upsertDefinitions`** — declare ALL the CSS classes at once, one class per *look* (not per property).
37
+ - **`upsertElement`** — build the whole tree in a single op, nesting with `children` under `pageRef: "render"`.
38
+ - **`repeatElement`** — the moment two siblings differ only in data, write the row ONCE as a template with
39
+ `{{item.field}}` placeholders and pass `items`. Rows come out numbered (`tile-1`, `tile-2`…). A list inside each
40
+ row is the same op: give a template node `repeat: { items: "{{item.<field>}}", template: … }`.
41
+
42
+ ```json
43
+ {
44
+ "operations": [
45
+ {
46
+ "type": "upsertDefinitions",
47
+ "definitions": {
48
+ "panel": {
49
+ "desktop": {
50
+ "display": "flex",
51
+ "flex-direction": "column",
52
+ "gap": "12px",
53
+ "padding": "16px",
54
+ "min-width": "0",
55
+ "color": "var(--color-text-primary, light-dark(#0f172a, #e8eaed))"
56
+ }
57
+ },
58
+ "row": {
59
+ "desktop": { "display": "flex", "flex-wrap": "wrap", "gap": "12px", "min-width": "0" },
60
+ "mobile": { "flex-direction": "column" }
61
+ },
62
+ "tile": {
63
+ "desktop": {
64
+ "flex": "1 1 0%",
65
+ "min-width": "160px",
66
+ "padding": "12px",
67
+ "border-radius": "10px",
68
+ "border": "1px solid var(--color-border-primary, light-dark(#e2e8f0, #333a48))",
69
+ "background-color": "var(--color-background-secondary, light-dark(#ffffff, #1f2430))"
70
+ }
71
+ },
72
+ "tile-title": { "desktop": { "margin": "0", "font-size": "15px" } },
73
+ "tile-price": { "desktop": { "margin": "0", "font-size": "13px", "opacity": "0.75" } }
74
+ }
75
+ },
76
+ {
77
+ "type": "upsertElement",
78
+ "pageRef": "render",
79
+ "element": {
80
+ "ref": "panel",
81
+ "type": "container",
82
+ "style": { "base": ["panel"] },
83
+ "children": [{ "ref": "title", "type": "heading", "subType": "h3", "props": { "content": "Plans" } }]
84
+ }
85
+ },
86
+ {
87
+ "type": "repeatElement",
88
+ "pageRef": "render",
89
+ "parentRef": "panel",
90
+ "ref": "plans",
91
+ "style": { "base": ["row"] },
92
+ "template": {
93
+ "ref": "tile",
94
+ "type": "container",
95
+ "style": { "base": ["tile"] },
96
+ "children": [
97
+ {
98
+ "ref": "name",
99
+ "type": "heading",
100
+ "subType": "h4",
101
+ "props": { "content": "{{item.name}}" },
102
+ "style": { "base": ["tile-title"] }
103
+ },
104
+ {
105
+ "ref": "price",
106
+ "type": "paragraph",
107
+ "props": { "content": "{{item.price}}" },
108
+ "style": { "base": ["tile-price"] }
109
+ }
110
+ ]
111
+ },
112
+ "items": [
113
+ { "name": "Starter", "price": "$0 / month" },
114
+ { "name": "Team", "price": "$19 / month" },
115
+ { "name": "Business", "price": "$49 / month" }
116
+ ]
117
+ }
118
+ ]
119
+ }
120
+ ```
121
+
122
+ ## The five things that decide whether it looks good
123
+
124
+ 1. **Width is free, height is scarce.** The widget renders in a side panel. A plain container stacks its children
125
+ vertically — that is the tall, half-empty default to avoid. Put peers (metrics, plans, options, image + text) in
126
+ a wrapping row (`display:flex` + `flex-wrap:wrap`, children `flex:1 1 0%` with a `min-width` as the wrap
127
+ threshold) or a grid with `grid-template-columns: repeat(auto-fit, minmax(160px, 1fr))`. Stack only what reads in
128
+ order: heading over paragraph, forms, steps, prose.
129
+ 2. **The host may be in dark mode.** Never hardcode a light palette. Take colours from the host variables with a
130
+ `light-dark()` fallback, and always set `color` wherever you set `background-color`.
131
+ 3. **Watch the SDK defaults.** Every container has `min-width`/`min-height: 50px` — set them to `0` for rails,
132
+ dividers, dots and any flex child that must shrink. Headings and paragraphs keep the browser's margins; zero
133
+ them and space with the parent's `gap`.
134
+ 4. **Never hand-draw a scene in a `data:` URI.** An inline SVG illustration costs more tokens than the entire rest
135
+ of the widget. Use an `https` image, a flat colour, or a CSS gradient.
136
+ 5. **Write CSS plainly.** Kebab-case properties, shorthands welcome (`padding: 8px 16px`, `border: 1px solid red`,
137
+ `font: bold 16px/1.5 Arial`) — they are expanded and stored as longhands, so a breakpoint or state can override
138
+ one property on its own.
139
+
140
+ ## Iterating on a widget you already rendered
141
+
142
+ Every render answers with a **`renderId`**. To change that widget, do NOT rebuild it: call again with
143
+ `patch: true`, that `renderId`, and only the operations that differ (`patchElement`, `patchDefinition`,
144
+ `deleteElement`, a new `repeatElement`). Address rows by the refs you already know (`tile-1`, `tile-2`). The widget
145
+ merges the delta into the batch it was built from and reports back what it applied, errors included.
146
+
147
+ Patch **only** to modify that widget. A different subject, or a different kind of widget, is a fresh render with no
148
+ `patch` — the delta is merged into the previous batch, so patching a new idea leaves the user looking at both at
149
+ once. Rebuilding when you could have patched only costs tokens; patching when you should have rebuilt costs the
150
+ user a wrong widget.
151
+
152
+ If the answer says the widget could not be recovered (a surface that renders no widgets, a host that keeps no
153
+ storage, a conversation resumed elsewhere), send the whole batch again without `patch`.
154
+
155
+ ## When a call fails
156
+
157
+ `plitzi_render` answers with `rendered: false` and `errors: [{ path, message, hint }]`, plus `warnings` for smaller
158
+ issues. The `path` names the operation, so fix that one and call again — you never lose the rest of the batch. An
159
+ unknown prop comes back as a warning naming the right one, so probing is safe.
160
+
161
+ ## After it renders
162
+
163
+ The widget is shown to the user; you get a compact summary. Don't re-describe what they can already see — a short
164
+ caption or a follow-up question is enough.
165
+
166
+ ## Going further
167
+
168
+ Widgets can be data-driven and interactive, not only static: an `apiContainer` fetches at runtime, `upsertBinding`
169
+ wires that data into elements, and `upsertInteractionFlow` makes them react to clicks. See the "Data &
170
+ interactivity" section of `plitzi://render/guide`.
@@ -1,10 +0,0 @@
1
- /** Where the batch a widget was built from survives between calls — on the HOST, so the server can keep nothing.
2
- *
3
- * It cannot live in the view's memory: the Apps spec binds a view to ONE tool call (`ui/notifications/tool-input`
4
- * is sent "at most once", and the host renders a fresh iframe when a UI tool is called), so the patch call the
5
- * model makes arrives in a brand-new instance whose memory is empty. localStorage keyed by an id the server hands
6
- * back is the spec's own state-persistence pattern (`_meta.viewUUID` in the Apps docs); here the key is the
7
- * renderId, which the model also carries, so a patch names the widget it means instead of assuming "the last one".
8
- */
9
- export declare const readHeldBatch: (renderId: string, store?: Storage | undefined) => unknown[] | undefined;
10
- export declare const writeHeldBatch: (renderId: string, operations: unknown[], store?: Storage | undefined) => void;
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};