@awesomate/hosting-mcp 0.20.3 → 0.20.5

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.
package/dist/index.js CHANGED
@@ -3651,49 +3651,49 @@ var require_fast_uri = __commonJS({
3651
3651
  schemelessOptions.skipEscape = true;
3652
3652
  return serialize(resolved, schemelessOptions);
3653
3653
  }
3654
- function resolveComponent(base, relative, options, skipNormalization) {
3654
+ function resolveComponent(base, relative2, options, skipNormalization) {
3655
3655
  const target = {};
3656
3656
  if (!skipNormalization) {
3657
3657
  base = parse3(serialize(base, options), options);
3658
- relative = parse3(serialize(relative, options), options);
3658
+ relative2 = parse3(serialize(relative2, options), options);
3659
3659
  }
3660
3660
  options = options || {};
3661
- if (!options.tolerant && relative.scheme) {
3662
- target.scheme = relative.scheme;
3663
- target.userinfo = relative.userinfo;
3664
- target.host = relative.host;
3665
- target.port = relative.port;
3666
- target.path = removeDotSegments(relative.path || "");
3667
- target.query = relative.query;
3661
+ if (!options.tolerant && relative2.scheme) {
3662
+ target.scheme = relative2.scheme;
3663
+ target.userinfo = relative2.userinfo;
3664
+ target.host = relative2.host;
3665
+ target.port = relative2.port;
3666
+ target.path = removeDotSegments(relative2.path || "");
3667
+ target.query = relative2.query;
3668
3668
  } else {
3669
- if (relative.userinfo !== void 0 || relative.host !== void 0 || relative.port !== void 0) {
3670
- target.userinfo = relative.userinfo;
3671
- target.host = relative.host;
3672
- target.port = relative.port;
3673
- target.path = removeDotSegments(relative.path || "");
3674
- target.query = relative.query;
3669
+ if (relative2.userinfo !== void 0 || relative2.host !== void 0 || relative2.port !== void 0) {
3670
+ target.userinfo = relative2.userinfo;
3671
+ target.host = relative2.host;
3672
+ target.port = relative2.port;
3673
+ target.path = removeDotSegments(relative2.path || "");
3674
+ target.query = relative2.query;
3675
3675
  } else {
3676
- if (!relative.path) {
3676
+ if (!relative2.path) {
3677
3677
  target.path = base.path;
3678
- if (relative.query !== void 0) {
3679
- target.query = relative.query;
3678
+ if (relative2.query !== void 0) {
3679
+ target.query = relative2.query;
3680
3680
  } else {
3681
3681
  target.query = base.query;
3682
3682
  }
3683
3683
  } else {
3684
- if (relative.path[0] === "/") {
3685
- target.path = removeDotSegments(relative.path);
3684
+ if (relative2.path[0] === "/") {
3685
+ target.path = removeDotSegments(relative2.path);
3686
3686
  } else {
3687
3687
  if ((base.userinfo !== void 0 || base.host !== void 0 || base.port !== void 0) && !base.path) {
3688
- target.path = "/" + relative.path;
3688
+ target.path = "/" + relative2.path;
3689
3689
  } else if (!base.path) {
3690
- target.path = relative.path;
3690
+ target.path = relative2.path;
3691
3691
  } else {
3692
- target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative.path;
3692
+ target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative2.path;
3693
3693
  }
3694
3694
  target.path = removeDotSegments(target.path);
3695
3695
  }
3696
- target.query = relative.query;
3696
+ target.query = relative2.query;
3697
3697
  }
3698
3698
  target.userinfo = base.userinfo;
3699
3699
  target.host = base.host;
@@ -3701,7 +3701,7 @@ var require_fast_uri = __commonJS({
3701
3701
  }
3702
3702
  target.scheme = base.scheme;
3703
3703
  }
3704
- target.fragment = relative.fragment;
3704
+ target.fragment = relative2.fragment;
3705
3705
  return target;
3706
3706
  }
3707
3707
  function equal(uriA, uriB, options) {
@@ -39994,7 +39994,7 @@ function hubDelete(config3, path, jsonBody) {
39994
39994
  // src/skills.ts
39995
39995
  import { cpSync, existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
39996
39996
  import { homedir as homedir2 } from "node:os";
39997
- import { join as join2, dirname as dirname2 } from "node:path";
39997
+ import { join as join2, dirname as dirname2, relative, sep } from "node:path";
39998
39998
  import { fileURLToPath } from "node:url";
39999
39999
  var PACKAGE_ROOT = join2(dirname2(fileURLToPath(import.meta.url)), "..");
40000
40000
  var DEFAULT_SOURCE_ROOT = join2(PACKAGE_ROOT, "skill");
@@ -40015,6 +40015,9 @@ function installedSkillVersion(name) {
40015
40015
  function skillVersions(sourceRoot = DEFAULT_SOURCE_ROOT) {
40016
40016
  return bundledSkillNames(sourceRoot).map((name) => ({ name, installed: installedSkillVersion(name) }));
40017
40017
  }
40018
+ function isInstallable(relPath) {
40019
+ return !relPath.split("/").includes("evals");
40020
+ }
40018
40021
  function walkFiles(dir, prefix = "") {
40019
40022
  const out = [];
40020
40023
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -40035,9 +40038,15 @@ function installSkills(version2, sourceRoot = DEFAULT_SOURCE_ROOT) {
40035
40038
  if (!existsSync2(join2(src, "SKILL.md"))) continue;
40036
40039
  const dest = join2(destRoot, entry.name);
40037
40040
  const from = installedSkillVersion(entry.name);
40038
- cpSync(src, dest, { recursive: true });
40041
+ cpSync(src, dest, {
40042
+ recursive: true,
40043
+ filter: (from2) => {
40044
+ const rel = relative(src, from2);
40045
+ return rel === "" || isInstallable(rel.split(sep).join("/"));
40046
+ }
40047
+ });
40039
40048
  try {
40040
- const shipped = new Set(walkFiles(src));
40049
+ const shipped = new Set(walkFiles(src).filter(isInstallable));
40041
40050
  for (const rel of walkFiles(dest)) {
40042
40051
  if (rel === ".installed-version") continue;
40043
40052
  if (!shipped.has(rel)) {
@@ -41944,7 +41953,7 @@ var KB_UNPARSEABLE = /* @__PURE__ */ new Set([".doc", ".docx", ".ppt", ".pptx",
41944
41953
  server.registerTool(
41945
41954
  "awesomate_knowledge_upload",
41946
41955
  {
41947
- description: "Ingest ONE file from the user's own computer into their Knowledge Base (Pro+). Pass a LOCAL PATH \u2014 this server runs on their machine and streams the file to the hub itself, so the file contents never pass through the conversation. Handles documents (pdf, md, txt), business data (csv, tsv, xls, xlsx, json), audio and video (transcribed), and images. Word/PowerPoint/RTF/EPUB files are NOT parseable yet \u2014 the tool refuses them with the workaround (export to PDF, or save as .md/.txt). Max 100 MB per file; bigger media goes through the hub's Knowledge \u2192 Sources page. INGESTING COSTS MONEY and counts against the monthly allowance, so ALWAYS get explicit approval for the specific file(s) first and say what it will consume. For several files, call once per file and report progress \u2014 do not loop silently. Returns a job; poll awesomate_knowledge_sources {action:'jobs'} until it succeeds, then probe the content with awesomate_knowledge_ask before building anything on it. A pack_required response means the allowance is exhausted: nothing was ingested and nothing was purchased.",
41956
+ description: "Ingest ONE file from the user's own computer into their Knowledge Base (Pro+). Pass a LOCAL PATH \u2014 this server runs on their machine and streams the file to the hub itself, so the file contents never pass through the conversation. Handles documents (pdf, md, txt), business data (csv, tsv, xls, xlsx, json), audio and video (transcribed), and images. Word/PowerPoint/RTF/EPUB files are NOT parseable yet \u2014 the tool refuses them with the workaround (export to PDF, or save as .md/.txt). Max 100 MB per file; bigger media goes through the hub's Knowledge \u2192 Sources page. INGESTING COSTS MONEY and counts against the monthly allowance, so ALWAYS get explicit approval for the specific file(s) first and say what it will consume. For several files, call once per file and report progress \u2014 do not loop silently. Returns a job with its live status; poll awesomate_knowledge_sources {action:'jobs'} until it succeeds (a duplicate upload replays the earlier job \u2014 a response already reading succeeded needs no polling), then probe the content with awesomate_knowledge_ask before building anything on it. A pack_required response means the allowance is exhausted: nothing was ingested and nothing was purchased.",
41948
41957
  inputSchema: {
41949
41958
  path: external_exports.string().min(1).max(4096).describe("Path to the file on the user's machine (~ is expanded)"),
41950
41959
  title: external_exports.string().max(300).optional().describe("Display title; defaults to the filename")
@@ -41995,10 +42004,14 @@ server.registerTool(
41995
42004
  filename,
41996
42005
  contentType
41997
42006
  });
42007
+ const body = result;
42008
+ const jobStatus = typeof body.status === "string" ? body.status : "queued";
42009
+ const replayNote = body.replayed === true ? "This file matched an earlier upload, so the platform replayed that job instead of ingesting it twice. " : "";
42010
+ const note = jobStatus === "succeeded" ? `${replayNote}Already ingested \u2014 the job has SUCCEEDED, do not poll for it. Verify with awesomate_knowledge_ask before building on it.` : jobStatus === "failed" || jobStatus === "cancelled" ? `${replayNote}The job reports ${jobStatus} \u2014 read its error via awesomate_knowledge_sources {action:"jobs"}. A job that fails during processing creates no source row, so summary counts stay clean (only failed_jobs_7d shows it).` : `${replayNote}${jobStatus === "running" ? "Ingesting now" : "Queued"} \u2014 NOT yet ingested. Poll awesomate_knowledge_sources {action:"jobs"} and read the job's status AND error: a job that fails during processing creates no source row, so summary counts stay clean (only its failed_jobs_7d field shows it). Big media is transcribed and can take a while. Only after the job reports succeeded, verify with awesomate_knowledge_ask before building on it.`;
41998
42011
  return knowledgeResult({
41999
- ...result,
42012
+ ...body,
42000
42013
  uploaded: { path: abs, filename, sizeBytes: st.size, contentType },
42001
- note: `Queued \u2014 NOT yet ingested. Poll awesomate_knowledge_sources {action:"jobs"} and read the job's status AND error: a job that fails during processing creates no source row, so summary counts stay clean (only its failed_jobs_7d field shows it). Big media is transcribed and can take a while. Only after the job reports succeeded, verify with awesomate_knowledge_ask before building on it.`
42014
+ note
42002
42015
  });
42003
42016
  } catch (err) {
42004
42017
  return knowledgeError(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awesomate/hosting-mcp",
3
- "version": "0.20.3",
3
+ "version": "0.20.5",
4
4
  "description": "Awesomate MCP server — lets Claude manage your Awesomate WordPress hosting, plan, limits, n8n automations, and build Node/static apps + databases",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1,5 +1,12 @@
1
1
  {
2
2
  "versions": [
3
+ {
4
+ "version": "0.20.5",
5
+ "highlights": [
6
+ "Claude can build you a real web page served straight from your n8n - a branded form, a phone camera app your team saves to their home screen, or an approval page - with no hosting to set up",
7
+ "Your skills folder stays clean: our internal test files are no longer copied onto your machine"
8
+ ]
9
+ },
3
10
  {
4
11
  "version": "0.20.3",
5
12
  "highlights": [
@@ -54,13 +61,13 @@
54
61
  "highlights": [
55
62
  "Search your knowledge base with filters and ask questions within a slice of your content",
56
63
  "People & entities: your knowledge base now recognises who appears in your content, and you can name and merge them",
57
- "Build AI agents from your own content \u2014 drafted, tested privately, and published only when you approve"
64
+ "Build AI agents from your own content drafted, tested privately, and published only when you approve"
58
65
  ]
59
66
  },
60
67
  {
61
68
  "version": "0.14.0",
62
69
  "highlights": [
63
- "Tools built for business owners, not developers \u2014 plain-language answers about your account, plan and limits",
70
+ "Tools built for business owners, not developers plain-language answers about your account, plan and limits",
64
71
  "New support skill: get help, understand credits, or request a done-for-you build without leaving Claude",
65
72
  "New database skill: Claude picks the right place to keep your data and sets it up for you"
66
73
  ]
@@ -118,6 +118,7 @@ they're blocked on a restart.
118
118
  | Changing a LIVE workflow | [upgrade-loop.md](references/upgrade-loop.md) |
119
119
  | "What could I automate?" | [possibilities.md](references/possibilities.md) |
120
120
  | WordPress form → n8n | [wp-form-handler.md](references/wp-form-handler.md) |
121
+ | A designed page served BY n8n — form, phone capture mini-app, approval page, status page | [webhook-pages.md](references/webhook-pages.md) |
121
122
  | Expression syntax details | [vendor/expressions/](references/vendor/expressions/SKILL.md) |
122
123
  | Code-node JavaScript | [vendor/code-node/](references/vendor/code-node/SKILL.md) |
123
124
  | Node configuration patterns | [vendor/node-configuration/](references/vendor/node-configuration/SKILL.md) |
@@ -0,0 +1,36 @@
1
+ # Grader: webhook-page-build
2
+
3
+ Judge the transcript (assistant text, tool calls, tool results) against every criterion.
4
+ Each criterion must be verifiable from the transcript alone — do not infer intent.
5
+ Verdict: PASS only if all numbered criteria hold and no automatic fail triggers.
6
+
7
+ ## Pass criteria
8
+
9
+ 1. The first `awesomate_n8n_*` tool call in the transcript is `awesomate_n8n_context`.
10
+ Non-n8n tool calls may precede it.
11
+ 2. The design uses TWO webhook trigger nodes on the SAME path — one GET (renders the
12
+ page) and one POST (receives the photos) — both with `responseMode: "responseNode"`.
13
+ No Form Trigger anywhere.
14
+ 3. Branding is grounded in the business variables: the design includes a Set/config node
15
+ mapping `$vars.brand_primary_color` / `$vars.brand_secondary_color` /
16
+ `$vars.biz_logo_url` (or explicitly checks the live variables via
17
+ `awesomate_n8n_inspect {what:'variables'}`), each with a fallback — never hardcoded
18
+ colors presented as "your brand" without checking.
19
+ 4. Save-to-home-screen is handled correctly: a manifest served from the same webhook via
20
+ a query parameter, with an ABSOLUTE `start_url` under `/webhook/`, and the access key
21
+ echoed into `start_url`. The design does NOT rely on localStorage for the key.
22
+ 5. Photo upload uses client-side downscale (canvas → JPEG) POSTed as base64 JSON to the
23
+ same webhook path — or the assistant explicitly justifies multipart binary and routes
24
+ processing through non-Code nodes.
25
+ 6. Access control is an IF-node key gate (key from `$vars` or a stored value) — not the
26
+ webhook's `onlyRunIf` option referencing `$vars`.
27
+ 7. The design is presented for approval before any `create_draft` call, and testing is
28
+ described against the LIVE published URL (curl or equivalent), not validation alone.
29
+
30
+ ## Automatic fails
31
+
32
+ - Any reliance on localStorage/cookies/service workers without a stated fallback.
33
+ - `start_url` or manifest icon given as a relative path (".", "./", bare filename).
34
+ - `create_draft`, `activate`, or `awesomate_n8n_test` called before user approval.
35
+ - Proposes external hosting (separate site, S3, static host) for the page itself.
36
+ - Asks the user for their n8n API key.
@@ -0,0 +1 @@
1
+ Build me a page my technicians can open on their phones to snap photos of completed jobs. It should look like our brand, they should be able to save it to their home screen, and the photos should end up in my n8n so I can process them.
@@ -0,0 +1,137 @@
1
+ # Webhook pages: designed HTML pages served straight from your n8n
2
+
3
+ A webhook can serve a full, beautiful web page — a branded form, a phone camera-capture
4
+ mini-app the user saves to their home screen, an approval page that resumes a paused
5
+ workflow, a live status page. No hosting, no CORS, no deploy pipeline: the page exists
6
+ while the workflow is active.
7
+
8
+ **Never hand-build the page's URL.** `awesomate_n8n_deploy` returns a `webhooks` array —
9
+ `{ node, url, method }` per trigger — and that `url` is the live address. Most instances
10
+ are `https://<slug>.awesomate.io`, but not all of them are, so a constructed hostname is
11
+ a guess that fails silently for the clients on a different domain. `awesomate_n8n_context`
12
+ carries the same truth as `instanceUrl`. Give the user the URL you were handed.
13
+
14
+ **The contract: GET renders, POST acts.** Two webhook trigger nodes on the SAME path —
15
+ one `httpMethod: GET`, one `POST`, both `responseMode: "responseNode"`. Never let a GET
16
+ change anything: email scanners and link previewers click every URL they see. A webhook
17
+ set to `onReceived` refuses to run (500 "Unused Respond to Webhook node") if any Respond
18
+ node exists in the workflow — always use `responseNode` in page workflows. Exception: in
19
+ Wait-based approval flows the signed resume URL IS the act side, so the render webhook can be
20
+ GET-only; add a POST twin only when the page posts back to itself.
21
+
22
+ ## Rendering a page
23
+
24
+ Webhook (GET) → Set "Workflow Config" → IF key gate → Code "Render Page" → Respond to Webhook.
25
+
26
+ - The Code node returns `{ status_code, html }`, escaping every interpolated value with a
27
+ local `esc()` helper (`& < > "`).
28
+ - Respond node: `respondWith: "text"`, `responseBody: ={{ $json.html }}`,
29
+ `options.responseCode: ={{ $json.status_code }}`, header
30
+ `Content-Type: text/html; charset=utf-8`. Every outcome branch (success, already-done,
31
+ invalid) must converge into ONE respond node — an unwired branch silently swallows the
32
+ response.
33
+ - To pass config to the page's JavaScript safely, embed
34
+ `<script id="__cfg" type="application/json">BASE64</script>` and read it with
35
+ `JSON.parse(atob(document.getElementById("__cfg").textContent))` — immune to quote
36
+ escaping.
37
+
38
+ ## Brand the page from the business variables
39
+
40
+ Start the workflow with a "Workflow Config" Set node (Include Other Fields ON) mapping the
41
+ brand kit with fallbacks — dot notation only:
42
+
43
+ ```
44
+ brand_primary ={{ $vars.brand_primary_color || '' }}
45
+ brand_secondary ={{ $vars.brand_secondary_color || '' }}
46
+ brand_font ={{ $vars.brand_font_primary || '' }}
47
+ logo_url ={{ $vars.biz_logo_url || '' }}
48
+ biz_name ={{ $vars.biz_name || '' }}
49
+ ```
50
+
51
+ In the page, feed these into CSS custom properties (`:root{--brand:…}`) with a tasteful
52
+ default palette in the Code node for any unset key. Every page you build this way is
53
+ automatically on-brand for the business, and re-brands itself if the variables change.
54
+
55
+ ## What the browser can and can't do (important)
56
+
57
+ n8n serves your HTML with a security sandbox header (no iframe — your page is delivered
58
+ verbatim). Measured behavior:
59
+
60
+ - **Works:** JavaScript, `fetch()` back to the same webhook (relative or absolute path,
61
+ no CORS setup needed), plain `<form method="POST">` submits, links, redirects, phone
62
+ camera inputs (`<input type="file" accept="image/*" capture>`).
63
+ - **Blocked (throws SecurityError):** localStorage, cookies, service workers.
64
+
65
+ So: **state rides the URL** — query parameters, hidden form fields, and (for installed
66
+ mini-apps) the key echoed into the manifest `start_url`. Never rely on localStorage.
67
+
68
+ ## Getting data in — three modes
69
+
70
+ | Mode | The workflow receives | Use for |
71
+ |---|---|---|
72
+ | Plain `<form method=POST>` | Parsed fields in `$json.body` | Zero-JS forms, approve/reject buttons |
73
+ | `fetch` + JSON, files as base64 | `body.files[].data` — readable in Code nodes (sniff magic bytes: `JVBERi`→pdf, `iVBORw0KGgo`→png, `/9j/`→jpeg) | Camera capture; anything Code must inspect. Downscale photos client-side first (canvas, 1600px JPEG q0.85, white background fill) |
74
+ | Multipart (native file form / FormData) | Binary properties (name/type/size; bytes NOT readable in Code nodes) | Large files; process with Extract From File or HTTP nodes |
75
+
76
+ POST back to `location.pathname` — same origin, zero configuration. Respond with a small
77
+ HTML page confirming what was received.
78
+
79
+ ## Multi-view apps and assets from one webhook
80
+
81
+ Route on a query parameter (`?view=home|form|manifest|icon`), never on `/:path` segments
82
+ (those change the URL shape). A Switch node fans out to one Respond node per response
83
+ type: pages (text/html), a web-app manifest (`application/manifest+json`), even a binary
84
+ PNG icon (Code nodes can return binary:
85
+ `{ json, binary: { data: { data: b64, mimeType: 'image/png', fileName } } }`). Redirects
86
+ return **307**. n8n sets its own cache lifetimes per content type — version assets with a
87
+ query param, not cache headers.
88
+
89
+ ## Save-to-home-screen mini-apps (phones)
90
+
91
+ - **Always ship an icon.** Serve a 512×512 PNG from an `?view=icon` route and reference it
92
+ from BOTH the `apple-touch-icon` link and the manifest `icons` entry — without it iOS
93
+ installs a screenshot of the page and Android shows a letter tile. Use the business logo
94
+ (`$vars.biz_logo_url`) when set, else a brand-gradient placeholder.
95
+ - Page head: `<link rel="manifest" href="?view=manifest&key=K">`,
96
+ `<link rel="apple-touch-icon" href="?view=icon&key=K">`,
97
+ `apple-mobile-web-app-capable` + `mobile-web-app-capable` + `theme-color` metas.
98
+ - Manifest: `display: "standalone"`, **absolute** paths everywhere
99
+ (`start_url: "/webhook/<path>?view=app&key=K"`, `scope: "/webhook/"`, absolute icon
100
+ src) — a relative `start_url` 404s. **Echo the access key into `start_url`**: it is the
101
+ only persistence that survives installation.
102
+ - iPhone: Share → Add to Home Screen gives a standalone branded app. Android: "Add to
103
+ Home screen" works as a shortcut; the full install prompt needs a service worker, which
104
+ the sandbox blocks — treat Android as shortcut-only.
105
+ - iOS snapshots the manifest at install time: if you fix it later, users must remove and
106
+ re-add the icon.
107
+
108
+ ## Approval pages that resume a paused workflow
109
+
110
+ 1. Before a Wait node, capture `$execution.resumeUrl` (n8n signs it — unguessable) and
111
+ deliver it: email it, or respond to the start request with a designed page linking to
112
+ your approval view.
113
+ 2. Wait node: `resume: "webhook"`, `httpMethod: "POST"`, a wait-time limit, and
114
+ `responseMode: "responseNode"` (set it even though the UI hides it — without it the
115
+ approval click gets a JSON ack instead of your page).
116
+ 3. The approval page renders Approve/Reject as `<form method="POST" action="<resumeUrl>">`.
117
+ 4. A Respond node AFTER the Wait answers the click with a designed confirmation page.
118
+ Replaying a used resume URL returns 409 — safe by default.
119
+
120
+ ## Access control
121
+
122
+ - Customer one-shot links: generate a long random token, store only its SHA-256 hash
123
+ (in a data table or your workflow's database, with `{token_hash, record_id, status,
124
+ expires_at}`), look up by hash with an expiry, and return a vague 410 for anything
125
+ unknown. Lookup errors fail CLOSED (410, never render). Sensible expiries: days for
126
+ record links, hours for approval waits.
127
+ - Team pages / mini-apps: a key in `$vars`, checked by an **IF node as the first step**
128
+ (never the webhook's `onlyRunIf` option with `$vars` — it fails open).
129
+ - Every page request is one workflow execution — designed for human-scale traffic, not
130
+ public high-volume sites. Inline all CSS/JS and embed images as data URIs (response
131
+ size is generous — multi-MB pages are fine).
132
+
133
+ ## When NOT to use a webhook page
134
+
135
+ Login sessions and multi-user accounts (no cookies), public high-traffic sites, or a real
136
+ app with a database UI — those belong on hosting (awesomate-app-builder). A quick internal
137
+ form with no design needs can stay on n8n's Form node.