@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 +44 -31
- package/package.json +1 -1
- package/skill/CHANGELOG.json +9 -2
- package/skill/awesomate-n8n/SKILL.md +1 -0
- package/skill/awesomate-n8n/evals/webhook-page-build/graders/grader.md +36 -0
- package/skill/awesomate-n8n/evals/webhook-page-build/prompt.md +1 -0
- package/skill/awesomate-n8n/references/webhook-pages.md +137 -0
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,
|
|
3654
|
+
function resolveComponent(base, relative2, options, skipNormalization) {
|
|
3655
3655
|
const target = {};
|
|
3656
3656
|
if (!skipNormalization) {
|
|
3657
3657
|
base = parse3(serialize(base, options), options);
|
|
3658
|
-
|
|
3658
|
+
relative2 = parse3(serialize(relative2, options), options);
|
|
3659
3659
|
}
|
|
3660
3660
|
options = options || {};
|
|
3661
|
-
if (!options.tolerant &&
|
|
3662
|
-
target.scheme =
|
|
3663
|
-
target.userinfo =
|
|
3664
|
-
target.host =
|
|
3665
|
-
target.port =
|
|
3666
|
-
target.path = removeDotSegments(
|
|
3667
|
-
target.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 (
|
|
3670
|
-
target.userinfo =
|
|
3671
|
-
target.host =
|
|
3672
|
-
target.port =
|
|
3673
|
-
target.path = removeDotSegments(
|
|
3674
|
-
target.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 (!
|
|
3676
|
+
if (!relative2.path) {
|
|
3677
3677
|
target.path = base.path;
|
|
3678
|
-
if (
|
|
3679
|
-
target.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 (
|
|
3685
|
-
target.path = removeDotSegments(
|
|
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 = "/" +
|
|
3688
|
+
target.path = "/" + relative2.path;
|
|
3689
3689
|
} else if (!base.path) {
|
|
3690
|
-
target.path =
|
|
3690
|
+
target.path = relative2.path;
|
|
3691
3691
|
} else {
|
|
3692
|
-
target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) +
|
|
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 =
|
|
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 =
|
|
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, {
|
|
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
|
-
...
|
|
42012
|
+
...body,
|
|
42000
42013
|
uploaded: { path: abs, filename, sizeBytes: st.size, contentType },
|
|
42001
|
-
note
|
|
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
|
+
"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",
|
package/skill/CHANGELOG.json
CHANGED
|
@@ -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
|
|
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
|
|
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.
|