@wix/mcp 1.0.95 → 1.0.96

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.
@@ -1,13 +1,21 @@
1
1
  import { z } from 'zod';
2
2
  import { HttpClient } from '@wix/http-client';
3
3
  import { logger } from '../infra/logger.js';
4
- import { handleWixAPIResponse } from '../tool-utils.js';
4
+ import { buildErrorMessage, handleWixAPIResponse, missingSiteContext } from '../tool-utils.js';
5
5
  import { rethrowIfUnauthorized } from '../auth/index.js';
6
6
  import { isApiUrlAllowed } from './validation.js';
7
7
  import { addUploadImageTool } from './upload-image-tool.js';
8
8
  import { CALL_WIX_SITE_API_NO_EXECUTE_LINE, CALL_WIX_SITE_API_RECONNECT_LINE, defaultToolDescriptions } from '../config/default-config.js';
9
9
  import { desc, WHY_NOT_EXECUTE_NO_EXECUTE, WHY_NOT_EXECUTE_RECONNECT } from '../config/param-descriptions.js';
10
10
  import { MAX_ALLOWED_RESPONSE_CHARS } from '../infra/response-size-limits.js';
11
+ // ManageWixSite sends the account's credential and no site, which is right for the account
12
+ // and for sites as objects in it, and wrong for anything inside a site. The site call goes
13
+ // through ExecuteWixAPI where this server serves it, else through CallWixSiteAPI.
14
+ const siteToolCall = (executeServed) => executeServed
15
+ ? "through ExecuteWixAPI with that site's `siteId` (`scope: \"site\"`), loading ExecuteWixAPI through your client's tool search if it isn't loaded"
16
+ : "through CallWixSiteAPI with that site's `siteId`";
17
+ const manageNoSiteHint = (executeServed) => `This is a site-level API: it acts inside a site, and ManageWixSite sends no site, so the same call fails here every time. Call the same URL, method and body ${siteToolCall(executeServed)}.`;
18
+ const manageEmpty403Hint = (executeServed) => `If this endpoint acts inside a site (its products, data, bookings, members, pages, publishing), it is site-level, and ManageWixSite sends no site: call the same URL, method and body ${siteToolCall(executeServed)}. If it acts on the account or on sites as objects in it, the account lacks the permission; don't retry.`;
11
19
  export { addUploadImageTool } from './upload-image-tool.js';
12
20
  const DEFAULT_MAX_RESPONSE_SIZE = 50000; // ~50KB cap
13
21
  /**
@@ -325,6 +333,20 @@ export function addApiCallTool(server, strategy, allowedTools = ['CallWixSiteAPI
325
333
  if (!response) {
326
334
  throw new Error(`Failed to call Wix Account API: ${error}`);
327
335
  }
336
+ // A site-level API called here gets no site, and the generic hint sends the agent
337
+ // back to the docs for an endpoint that was right all along.
338
+ const noSite = missingSiteContext(response.status, response.data);
339
+ if (noSite) {
340
+ throw new Error(buildErrorMessage({
341
+ status: response.status,
342
+ statusText: response.statusText,
343
+ requestId: response.headers?.['x-wix-request-id'] ?? null,
344
+ responseData: response.data,
345
+ retryHint: (noSite === 'certain'
346
+ ? manageNoSiteHint
347
+ : manageEmpty403Hint)(!!options?.executeWixAPIServed)
348
+ }));
349
+ }
328
350
  return handleWixAPIResponse(response);
329
351
  }
330
352
  });
@@ -1,9 +1,20 @@
1
1
  import { type SiteFile } from './site-files.js';
2
2
  import { WixMcpServer } from '../wix-mcp-server.js';
3
+ import { type StartDeployWebsiteFetchOptions } from '../docs/start-deploy-website.js';
4
+ import type { HttpClient } from '@wix/http-client';
3
5
  import { type McpAuthenticationStrategy } from '../auth/index.js';
4
6
  export declare const UPLOAD_HEADLESS_WEBSITE_FILES_TOOL_NAME = "UploadHeadlessWebsiteFiles";
5
7
  /** The largest site zip `bundle-upload-url` presigns (headless-business-setup's MAX_BUNDLE_BYTES). */
6
8
  export declare const MAX_BUNDLE_BYTES: number;
9
+ /**
10
+ * The static files a build's client folder can hold and still release. Builds of up to 2,550
11
+ * released and every one from 4,818 failed (headless-business-setup logs, October 2026): the
12
+ * deployments service's reply outgrows its 4 MiB gRPC limit well before its 5,000-file cap.
13
+ * Both failures reach us as a bare 500.
14
+ */
15
+ export declare const MAX_RELEASE_STATIC_FILES = 2500;
16
+ /** Code Store refuses a larger source zip (`REPOSITORY_ARCHIVE_TOO_LARGE`), and with it the release. */
17
+ export declare const MAX_SOURCE_ZIP_BYTES: number;
7
18
  /**
8
19
  * Attribution the drop endpoint stamps on its upload BI event (`source` from
9
20
  * `campaign`, `agent` from `agent`, both ≤ 64 chars) — the same parser as the
@@ -18,6 +29,10 @@ export interface UploadHeadlessWebsiteFilesOptions {
18
29
  fetchImpl?: typeof fetch;
19
30
  /** Client identity for drop attribution when the server cannot tell (the toolkit's configured name). */
20
31
  clientName?: string;
32
+ /** Where the headless guide is read from (the docs tools' portal, repo and commit overrides). */
33
+ guide?: StartDeployWebsiteFetchOptions;
34
+ /** Test seam: the headless guide's text, in place of the fetch. */
35
+ guideFetch?: (httpClient: HttpClient | undefined) => Promise<string | undefined>;
21
36
  }
22
37
  export declare const UPLOAD_HEADLESS_WEBSITE_FILES_PARAM_DESCRIPTIONS: {
23
38
  readonly files: "The site files as text, so file bytes never pass through a code string. Sections delimited by a line `=== FILE: <path> ===` (text, raw) or `=== FILE: <path> base64 ===` (binary, base64); `<path>` is relative to the site root; a static site must include `index.html`. Use base64 for small binaries (icons, logos, fonts); large images/video belong behind a URL in the HTML, not in the bundle. Optional when `attachments` carries the files. A Wix Astro build (`.wix/build-metadata.json` and `dist/` as `astro build` with the Wix hosting adapter wrote them, zipped from the project root) needs no `index.html`: it is released to the site's Wix hosting instead of dropped, and the project's other files in the upload are stored as its source when the site's repository accepts it.";
@@ -39,6 +54,8 @@ export interface DropResult {
39
54
  uploadId?: string;
40
55
  files: string[];
41
56
  }
57
+ /** The headless guide, inline, after a static drop: the same page StartHeadlessBuild returns. */
58
+ export declare function formatInlineGuide(guide: string | undefined): string;
42
59
  export declare function formatDropResult(r: DropResult): string;
43
60
  /** One presigned PUT: where the zip goes and the headers the storage requires on it. */
44
61
  interface PresignedPut {
@@ -87,13 +104,25 @@ export declare function formatBundleReleaseResult(siteId: string, r: BundleRelea
87
104
  * uploaded as files the same call again (the tool asks for new URLs itself).
88
105
  */
89
106
  export type BundleRetry = 'prepare' | 'resend';
107
+ /**
108
+ * The permission every owner route (drop, the upload URL, the release, the files helpers)
109
+ * checks on the caller for the site. A 403 naming it is about the login, not the upload: the
110
+ * connected Wix account is not the one that owns the site, or its role there is too low.
111
+ */
112
+ export declare const SITE_ACCESS_PERMISSION = "OAUTH_APP.APP_CREATE";
113
+ /**
114
+ * A 403 for the site itself, as the agent should act on it; null for any other error.
115
+ * `stagedKey` is the uploadKey of a build already PUT when the refusal came from the
116
+ * release: that upload is kept, and counts against the site's unreleased uploads.
117
+ */
118
+ export declare function describeSiteAccessDenied(err: unknown, siteId?: string, stagedKey?: string): string | null;
90
119
  /** The bundle-upload-url route's errors, as the agent should act on them. */
91
- export declare function describeBundleUploadUrlError(err: unknown): string;
120
+ export declare function describeBundleUploadUrlError(err: unknown, siteId?: string): string;
92
121
  /** The deployment label the service keeps is 200 characters; longer notes are cut, not refused. */
93
122
  export declare const RELEASE_NOTES_MAX_CHARS = 200;
94
123
  export declare function releaseNotesForService(notes: string): string;
95
124
  /** The release-bundle route's errors, as the agent should act on them. */
96
- export declare function describeBundleReleaseError(err: unknown, uploadKey: string, retry?: BundleRetry): string;
125
+ export declare function describeBundleReleaseError(err: unknown, uploadKey: string, retry?: BundleRetry, siteId?: string): string;
97
126
  /**
98
127
  * A build that came as files, released over the bundle routes: an upload URL for the
99
128
  * site zip's size, the zip PUT there, the source zip PUT to the repository's URL when
@@ -33,6 +33,8 @@ import { buildSourceFiles, findHeadlessBuild, withoutEmptyStatics } from './head
33
33
  import { fileParamNote } from '../config/file-params.js';
34
34
  import { WIX_BUSINESS_FEATURES } from '../site-widget-tools/constants.js';
35
35
  import { NO_PERSONAL_DATA } from '../docs/start-deploy-website.js';
36
+ import { HEADLESS_GUIDE_URL } from '../docs/start-headless-build.js';
37
+ import { fetchArticleContent } from '../docs/fetch-article.js';
36
38
  import { logger } from '../infra/logger.js';
37
39
  import { redactPersonalData } from '../infra/redact-personal-data.js';
38
40
  import { defaultToolDescriptions } from '../config/default-config.js';
@@ -43,6 +45,15 @@ export const UPLOAD_HEADLESS_WEBSITE_FILES_TOOL_NAME = 'UploadHeadlessWebsiteFil
43
45
  const HEADLESS_BUSINESS = 'https://www.wixapis.com/headless-business-setup/v1/headless-business';
44
46
  /** The largest site zip `bundle-upload-url` presigns (headless-business-setup's MAX_BUNDLE_BYTES). */
45
47
  export const MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
48
+ /**
49
+ * The static files a build's client folder can hold and still release. Builds of up to 2,550
50
+ * released and every one from 4,818 failed (headless-business-setup logs, October 2026): the
51
+ * deployments service's reply outgrows its 4 MiB gRPC limit well before its 5,000-file cap.
52
+ * Both failures reach us as a bare 500.
53
+ */
54
+ export const MAX_RELEASE_STATIC_FILES = 2_500;
55
+ /** Code Store refuses a larger source zip (`REPOSITORY_ARCHIVE_TOO_LARGE`), and with it the release. */
56
+ export const MAX_SOURCE_ZIP_BYTES = 64 * 1024 * 1024;
46
57
  /**
47
58
  * Attribution the drop endpoint stamps on its upload BI event (`source` from
48
59
  * `campaign`, `agent` from `agent`, both ≤ 64 chars) — the same parser as the
@@ -75,6 +86,19 @@ export const UPLOAD_HEADLESS_WEBSITE_FILES_PARAM_DESCRIPTIONS = {
75
86
  // The wix-manage skill's recipes, which the success reply points to for what comes next.
76
87
  const UPLOAD_RECIPE_SKILL_URL = 'https://www.wix.com/skills/wix-manage/references/sites/upload-static-site.md';
77
88
  const DOMAIN_RECIPE_SKILL_URL = 'https://www.wix.com/skills/wix-manage/references/domains/domain-search-purchase-and-connect.md';
89
+ /** The headless guide, inline, after a static drop: the same page StartHeadlessBuild returns. */
90
+ export function formatInlineGuide(guide) {
91
+ if (!guide || !guide.trim())
92
+ return '';
93
+ return [
94
+ '',
95
+ '---',
96
+ '',
97
+ `Wix's headless guide, the build of a site that needs Wix business features (also at https://www.wix.com/headless/skill.md):`,
98
+ '',
99
+ guide.trim()
100
+ ].join('\n');
101
+ }
78
102
  export function formatDropResult(r) {
79
103
  const shown = r.files.slice(0, 60);
80
104
  return [
@@ -87,7 +111,7 @@ export function formatDropResult(r) {
87
111
  `Give the user both links, each on its own line and named for what it is: the live site, and the site's dashboard. Then offer a custom domain, bought through Wix or one they already own (${DOMAIN_RECIPE_SKILL_URL}).`,
88
112
  '',
89
113
  `- To change this site later: "Change it later" in ${UPLOAD_RECIPE_SKILL_URL}, with this metaSiteId (${r.metaSiteId}). The same site every time, never a new one.`,
90
- `- Wix business features (${WIX_BUSINESS_FEATURES}): call StartHeadlessBuild with startingPoint "an existing Wix Headless site"; this site is one.`
114
+ `- When the site needs Wix business features (${WIX_BUSINESS_FEATURES}), a drop of static pages is not its build: a full Wix Headless site has those features run by Wix and its pages reading from them. That build is Wix's headless guide, https://www.wix.com/headless/skill.md (what StartHeadlessBuild returns; call it with startingPoint "an existing Wix Headless site", this site is one): it installs the solution on this site, seeds its content through the API, and puts up pages that fetch it, uploaded here again. A site with no business features is done.`
91
115
  ].join('\n');
92
116
  }
93
117
  /** The curl that PUTs a zip to its presigned URL, with the headers the route returned. */
@@ -115,7 +139,7 @@ export function formatPrepareResult(siteId, urls, size, given = {}) {
115
139
  const lines = [
116
140
  `Upload URLs ready for site ${siteId}.${expiry} Two steps, then call this tool again.`,
117
141
  '',
118
- `1. ${BUILD_ZIP_CONTENTS} Upload the zip you measured${size ? ` (${size} bytes)` : ''}; the URL takes a body of exactly that length, so a zip changed since needs a new prepare with its size:`,
142
+ `1. ${BUILD_ZIP_CONTENTS} Its client folder (\`dist/client\`) holds at most ${MAX_RELEASE_STATIC_FILES} files (\`find dist/client -type f | wc -l\`); a larger build fails the release. Upload the zip you measured${size ? ` (${size} bytes)` : ''}; the URL takes a body of exactly that length, so a zip changed since needs a new prepare with its size:`,
119
143
  ` ${curlPut('build.zip', urls, zipHeaders)}`
120
144
  ];
121
145
  // The agent's own revision wins: it is the one its project started from. Given, it is echoed
@@ -127,7 +151,7 @@ export function formatPrepareResult(siteId, urls, size, given = {}) {
127
151
  secondCall.sourceUploadId = s.uploadId;
128
152
  if (!secondCall.expectedHeadRevisionId && s.headRevisionId)
129
153
  secondCall.expectedHeadRevisionId = s.headRevisionId;
130
- lines.push('', `2. ${SOURCE_ZIP_CONTENTS} Upload it:`, ` ${curlPut('source.zip', s, { 'content-type': 'application/zip' })}`, ...(s.expiresAt && s.expiresAt !== urls.expiresAt
154
+ lines.push('', `2. ${SOURCE_ZIP_CONTENTS} At most 64 MiB (\`wc -c < source.zip\`): a larger one fails the whole release. Leave large media and generated files out of it; if it is still larger, skip this upload and leave \`sourceUploadId\` out of the second call, and the site is released without its source. Upload it:`, ` ${curlPut('source.zip', s, { 'content-type': 'application/zip' })}`, ...(s.expiresAt && s.expiresAt !== urls.expiresAt
131
155
  ? [` This URL expires at ${s.expiresAt}.`]
132
156
  : []), ...(s.headRevisionId
133
157
  ? [
@@ -141,6 +165,11 @@ export function formatPrepareResult(siteId, urls, size, given = {}) {
141
165
  if (given.releaseNotes)
142
166
  secondCall.releaseNotes = releaseNotesForService(given.releaseNotes);
143
167
  lines.push('', `3. Call UploadHeadlessWebsiteFiles again with ${JSON.stringify(secondCall)} (plus \`calledStartDeployWebsite\`, \`agentIdentity\`, \`reason\`). No \`files\` or \`attachments\` on that call.`);
168
+ if (secondCall.sourceUploadId) {
169
+ const withoutSource = { ...secondCall };
170
+ delete withoutSource.sourceUploadId;
171
+ lines.push(` If you skipped the source upload in step 2, call with ${JSON.stringify(withoutSource)} instead: a \`sourceUploadId\` whose zip was never uploaded fails the release.`);
172
+ }
144
173
  return lines.join('\n');
145
174
  }
146
175
  /** The release-bundle reply, for both the two-call form and a build uploaded as files. */
@@ -161,11 +190,37 @@ const startOver = (retry) => retry === 'prepare'
161
190
  ? "call with `prepare: true` and the zip's `size` again, upload the zips to the new URLs, and release with the new `uploadKey`"
162
191
  : 'call this tool again with the same files';
163
192
  const apiBody = (err) => err.response.data ? ` (${err.response.data.slice(0, 300)})` : '';
193
+ /**
194
+ * The permission every owner route (drop, the upload URL, the release, the files helpers)
195
+ * checks on the caller for the site. A 403 naming it is about the login, not the upload: the
196
+ * connected Wix account is not the one that owns the site, or its role there is too low.
197
+ */
198
+ export const SITE_ACCESS_PERMISSION = 'OAUTH_APP.APP_CREATE';
199
+ /**
200
+ * A 403 for the site itself, as the agent should act on it; null for any other error.
201
+ * `stagedKey` is the uploadKey of a build already PUT when the refusal came from the
202
+ * release: that upload is kept, and counts against the site's unreleased uploads.
203
+ */
204
+ export function describeSiteAccessDenied(err, siteId, stagedKey) {
205
+ if (!(err instanceof WixFetchError) ||
206
+ err.status !== 403 ||
207
+ !err.response.data.includes(SITE_ACCESS_PERMISSION)) {
208
+ return null;
209
+ }
210
+ const site = siteId ? `site ${siteId}` : 'this site';
211
+ const outcome = stagedKey
212
+ ? `Nothing was released; the build you uploaded is still staged under uploadKey ${stagedKey} and counts against the site's unreleased uploads, so do not call \`prepare\` again and do not send the files again. The same call fails the same way until the login changes; once connected as the owning account, release that staged upload by calling with \`uploadKey: "${stagedKey}"\` before preparing a new one.`
213
+ : 'Nothing was uploaded, and the same call fails the same way, so do not retry and do not stage another upload.';
214
+ return `The connected Wix login has no rights on ${site}: Wix refused it the \`${SITE_ACCESS_PERMISSION}\` permission there. That means the site belongs to another Wix account, or this login's role on the owning account is too low (a Basic Team Member cannot upload). ${outcome} Ask the user which Wix account owns the site. Reconnecting Wix helps only if they pick that account, as its owner or an admin, at the consent screen; otherwise the owner has to give this login a higher role on the account, or move the site to it.${apiBody(err)}`;
215
+ }
164
216
  /** The bundle-upload-url route's errors, as the agent should act on them. */
165
- export function describeBundleUploadUrlError(err) {
217
+ export function describeBundleUploadUrlError(err, siteId) {
166
218
  if (!(err instanceof WixFetchError)) {
167
219
  return err instanceof Error ? err.message : String(err);
168
220
  }
221
+ const denied = describeSiteAccessDenied(err, siteId);
222
+ if (denied)
223
+ return denied;
169
224
  const body = apiBody(err);
170
225
  switch (err.status) {
171
226
  case 400:
@@ -198,17 +253,33 @@ export function releaseNotesForService(notes) {
198
253
  .trimEnd()}…`;
199
254
  }
200
255
  /** The release-bundle route's errors, as the agent should act on them. */
201
- export function describeBundleReleaseError(err, uploadKey, retry = 'prepare') {
256
+ export function describeBundleReleaseError(err, uploadKey, retry = 'prepare', siteId) {
202
257
  if (!(err instanceof WixFetchError)) {
203
258
  return err instanceof Error ? err.message : String(err);
204
259
  }
260
+ const denied = describeSiteAccessDenied(err, siteId, uploadKey);
261
+ if (denied)
262
+ return denied;
205
263
  const body = apiBody(err);
206
264
  const code = err.response.data ?? '';
265
+ const sameCall = `calling with \`uploadKey: "${uploadKey}"\`${retry === 'resend' ? ' (plus the `expectedHeadRevisionId` and `releaseNotes` you passed, if any; the source is stored with the next release)' : ' and the same ids and `releaseNotes`'}`;
266
+ // A staged zip is deleted once its release succeeds, and the source it stored moves the
267
+ // repository's head: after a dropped connection, a 404 or a 409 on the same key is the first
268
+ // release having gone through.
269
+ const afterDrop = 'If an earlier call with this `uploadKey` ended in a dropped connection, that release went through: the site is live with this build, so do not upload it again.';
270
+ // The gateway lost the connection or timed out (its own body, not the service's JSON) while
271
+ // the release ran on. The service usually completes it.
272
+ if (err.status >= 502 &&
273
+ err.status <= 504 &&
274
+ !code.includes('RELEASE_NOT_HELD') &&
275
+ !code.includes('{')) {
276
+ return `The connection to Wix dropped while the release ran (HTTP ${err.status}); the release most likely went through. Do not call \`prepare\` again and do not send the files again. Wait two minutes, then check once by ${sameCall}: an answer that nothing is staged for this key, or that the repository moved, means the first release went through and the site is live. Any other answer is about the release itself.${body}`;
277
+ }
207
278
  switch (err.status) {
208
279
  case 404:
209
- return `Nothing uploaded for uploadKey ${uploadKey}: the build zip was not PUT to its URL, or the URL expired. To try again, ${startOver(retry)}.${body}`;
280
+ return `Nothing uploaded for uploadKey ${uploadKey}: the build zip was not PUT to its URL, the URL expired, or a release of it already went through (a staged zip is deleted once released). ${afterDrop} Otherwise, to try again, ${startOver(retry)}.${body}`;
210
281
  case 409:
211
- return `The project's source repository moved since the upload URLs were made; nothing was released. ${retry === 'prepare' ? "Call with `prepare: true` and the zip's `size` again, re-upload the source zip to the new URL, and release with the new `uploadKey`, `sourceUploadId` and `expectedHeadRevisionId`." : 'Download the project again from the repository, carry your change over, and upload with `expectedHeadRevisionId` set to the revision you downloaded.'}${body}`;
282
+ return `The project's source repository moved since the upload URLs were made; nothing was released by this call. ${afterDrop} Otherwise: ${retry === 'prepare' ? "call with `prepare: true` and the zip's `size` again, re-upload the source zip to the new URL, and release with the new `uploadKey`, `sourceUploadId` and `expectedHeadRevisionId`." : 'download the project again from the repository, carry your change over, and upload with `expectedHeadRevisionId` set to the revision you downloaded.'}${body}`;
212
283
  case 403:
213
284
  return `Wix refused to deploy this build onto the site's project; nothing was released, and the same call will be refused again. Do not call \`prepare\` again. Tell the user the release is blocked on Wix's side and give them the request id from the error.${body}`;
214
285
  case 413:
@@ -230,13 +301,18 @@ export function describeBundleReleaseError(err, uploadKey, retry = 'prepare') {
230
301
  return `The uploaded zip was not accepted. Zip \`dist/\` and \`.wix/build-metadata.json\` from the project root after \`astro build\` with the Wix hosting adapter, then ${startOver(retry)}. A static site goes in \`files\` or \`attachments\` instead, or is dropped as the StartDeployWebsite recipe shows.${body}`;
231
302
  default:
232
303
  if (err.status >= 500) {
233
- const what = err.status === 503
304
+ const what = code.includes('RELEASE_NOT_HELD')
234
305
  ? 'The release could not hold its upload'
235
306
  : `Wix's release step failed on its side (HTTP ${err.status})`;
307
+ // The two-call form's zips are the agent's own, so they can be over the limits the
308
+ // service reports only as a 500; the one-call form's upload is capped below them.
309
+ const limits = retry === 'prepare' && !code.includes('RELEASE_NOT_HELD')
310
+ ? ` A build over Wix's limits fails here with no reason given: more than ${MAX_RELEASE_STATIC_FILES} files in its client folder (\`find dist/client -type f | wc -l\`), or a source zip over 64 MiB (\`wc -c < source.zip\`). If either is over, a retry fails the same way: make the build or the source zip smaller, then call with \`prepare: true\` again.`
311
+ : '';
236
312
  // Both forms retry the staged upload itself: the two-call form with its uploadKey, the
237
313
  // one-call form by switching to that key instead of sending the files again, which would
238
314
  // stage another upload. One retry, then the user hears the request id.
239
- return `${what}; nothing was released. The staged upload is kept under uploadKey ${uploadKey}, so do not call \`prepare\` again and do not send the files again: every upload staged and not released counts against the site, which refuses new ones after a few. Retry once, after a minute, by calling with \`uploadKey: "${uploadKey}"\`${retry === 'resend' ? ' (plus the `expectedHeadRevisionId` and `releaseNotes` you passed, if any; the source is stored with the next release)' : ' and the same ids and `releaseNotes`'}. If that fails too, stop and tell the user the release is blocked on Wix's side, with the request id from the error.${body}`;
315
+ return `${what}; nothing was released.${limits} The staged upload is kept under uploadKey ${uploadKey}, so do not call \`prepare\` again${limits ? ' for the same zips' : ''} and do not send the files again: every upload staged and not released counts against the site, which refuses new ones after a few. Retry once, after a minute, by ${sameCall}. If that fails too, stop and tell the user the release is blocked on Wix's side, with the request id from the error.${body}`;
240
316
  }
241
317
  return err.message;
242
318
  }
@@ -358,6 +434,12 @@ export function addUploadHeadlessWebsiteFilesTool(server, options) {
358
434
  server.clientName?.() ??
359
435
  server.server?.getClientVersion?.()?.name;
360
436
  const fetchImpl = options.fetchImpl ?? ((u, i) => fetch(u, i));
437
+ const guideFetch = options.guideFetch ??
438
+ (async (httpClient) => {
439
+ if (!httpClient)
440
+ return undefined;
441
+ return fetchArticleContent(httpClient, HEADLESS_GUIDE_URL, 'article', true, options.guide?.skillsPortalUrl, options.guide?.repo, options.guide?.commitHash);
442
+ });
361
443
  const d = UPLOAD_HEADLESS_WEBSITE_FILES_PARAM_DESCRIPTIONS;
362
444
  const registered = server.tool(UPLOAD_HEADLESS_WEBSITE_FILES_TOOL_NAME, toolDescriptions?.[UPLOAD_HEADLESS_WEBSITE_FILES_TOOL_NAME] ??
363
445
  defaultToolDescriptions.UploadHeadlessWebsiteFiles, {
@@ -474,8 +556,8 @@ export function addUploadHeadlessWebsiteFilesTool(server, options) {
474
556
  rethrowIfUnauthorized(err);
475
557
  await authStrategy.enforceValidWixToken?.(err);
476
558
  return errorResult(uploadKey
477
- ? describeBundleReleaseError(err, uploadKey)
478
- : describeBundleUploadUrlError(err));
559
+ ? describeBundleReleaseError(err, uploadKey, 'prepare', siteId)
560
+ : describeBundleUploadUrlError(err, siteId));
479
561
  }
480
562
  }
481
563
  if (!files?.trim() && !(attachments && attachments.length)) {
@@ -571,8 +653,8 @@ export function addUploadHeadlessWebsiteFilesTool(server, options) {
571
653
  rethrowIfUnauthorized(err);
572
654
  await authStrategy.enforceValidWixToken?.(err);
573
655
  return errorResult(stagedKey
574
- ? describeBundleReleaseError(err, stagedKey, 'resend')
575
- : describeBundleUploadUrlError(err));
656
+ ? describeBundleReleaseError(err, stagedKey, 'resend', siteId)
657
+ : describeBundleUploadUrlError(err, siteId));
576
658
  }
577
659
  }
578
660
  // 2. Account auth (outside the try so UnauthorizedError reaches the wrapper).
@@ -590,6 +672,15 @@ export function addUploadHeadlessWebsiteFilesTool(server, options) {
590
672
  siteId: metaSiteId,
591
673
  upload: { path: 'drop', files: siteFiles.length }
592
674
  });
675
+ // The headless guide rides along, inline: a static drop is not the build of a site
676
+ // that needs Wix business features, and the agent that stops here has read nothing else.
677
+ let guide;
678
+ try {
679
+ guide = await guideFetch(extra?.httpClient);
680
+ }
681
+ catch (e) {
682
+ logger.log(`[UploadHeadlessWebsiteFiles] guide fetch failed: ${e instanceof Error ? e.message : String(e)}`);
683
+ }
593
684
  return {
594
685
  content: [
595
686
  {
@@ -602,7 +693,8 @@ export function addUploadHeadlessWebsiteFilesTool(server, options) {
602
693
  }) +
603
694
  (releaseNotes
604
695
  ? '\n\nRelease notes are kept for a Wix Astro build only; a static drop has none, so yours were not stored.'
605
- : '')
696
+ : '') +
697
+ formatInlineGuide(guide)
606
698
  }
607
699
  ]
608
700
  };
@@ -612,7 +704,7 @@ export function addUploadHeadlessWebsiteFilesTool(server, options) {
612
704
  logger.log(`[UploadHeadlessWebsiteFiles] Error: ${message}`);
613
705
  rethrowIfUnauthorized(err);
614
706
  await authStrategy.enforceValidWixToken?.(err);
615
- return errorResult(message);
707
+ return errorResult(describeSiteAccessDenied(err, siteId) ?? message);
616
708
  }
617
709
  });
618
710
  // ChatGPT resolves `attachments` into download URLs only for params listed
@@ -1,11 +1,13 @@
1
1
  /**
2
- * ChatGPT file attachments for ExecuteWixAPI (`openai/fileParams`).
2
+ * Chat file attachments for ExecuteWixAPI (`openai/fileParams`).
3
3
  *
4
4
  * ChatGPT resolves a file the user attached — or one the model wrote in its
5
5
  * own sandbox — into `{ download_url, file_id, file_name?, mime_type? }` for a
6
- * param the tool lists under `_meta["openai/fileParams"]`. The bytes never go
7
- * through the model. We fetch each `download_url` host-side and hand the
8
- * runtime ready-made base64 file entries, the same shape the `files` bundle
6
+ * param the tool lists under `_meta["openai/fileParams"]`. For ExecuteWixAPI the
7
+ * Wix AI Assistant (Aria) does the same by swapping an `attachment://<id>`
8
+ * reference in the tool args for a signed URL on its own Wix storage. The
9
+ * bytes never go through the model. We fetch each `download_url` host-side and
10
+ * hand the runtime ready-made base64 file entries, the same shape the `files` bundle
9
11
  * parses into, so `files`, `wix.multipart()` and `wix.unzip()` are unchanged.
10
12
  *
11
13
  * This is additive: clients without the extension never populate the param
@@ -24,7 +26,8 @@ export interface AttachmentFile {
24
26
  encoding: 'base64';
25
27
  }
26
28
  export declare const MAX_ATTACHMENTS_BYTES: number;
27
- export declare function isAllowedAttachmentHost(hostname: string): boolean;
29
+ export declare const WIX_AI_ASSISTANT_ATTACHMENT_HOSTS: ReadonlySet<string>;
30
+ export declare function isAllowedAttachmentHost(hostname: string, extraHosts?: ReadonlySet<string>): boolean;
28
31
  export declare class AttachmentError extends Error {
29
32
  }
30
33
  /**
@@ -33,5 +36,5 @@ export declare class AttachmentError extends Error {
33
36
  * (ChatGPT passed a sandbox path or a bare id instead of a URL), a host we
34
37
  * don't trust, a failed download, or more bytes than MAX_ATTACHMENTS_BYTES.
35
38
  */
36
- export declare function resolveAttachments(attachments: unknown, fetchImpl?: typeof fetch): Promise<AttachmentFile[]>;
39
+ export declare function resolveAttachments(attachments: unknown, fetchImpl?: typeof fetch, extraHosts?: ReadonlySet<string>): Promise<AttachmentFile[]>;
37
40
  //# sourceMappingURL=attachments.d.ts.map
@@ -1,11 +1,13 @@
1
1
  /**
2
- * ChatGPT file attachments for ExecuteWixAPI (`openai/fileParams`).
2
+ * Chat file attachments for ExecuteWixAPI (`openai/fileParams`).
3
3
  *
4
4
  * ChatGPT resolves a file the user attached — or one the model wrote in its
5
5
  * own sandbox — into `{ download_url, file_id, file_name?, mime_type? }` for a
6
- * param the tool lists under `_meta["openai/fileParams"]`. The bytes never go
7
- * through the model. We fetch each `download_url` host-side and hand the
8
- * runtime ready-made base64 file entries, the same shape the `files` bundle
6
+ * param the tool lists under `_meta["openai/fileParams"]`. For ExecuteWixAPI the
7
+ * Wix AI Assistant (Aria) does the same by swapping an `attachment://<id>`
8
+ * reference in the tool args for a signed URL on its own Wix storage. The
9
+ * bytes never go through the model. We fetch each `download_url` host-side and
10
+ * hand the runtime ready-made base64 file entries, the same shape the `files` bundle
9
11
  * parses into, so `files`, `wix.multipart()` and `wix.unzip()` are unchanged.
10
12
  *
11
13
  * This is additive: clients without the extension never populate the param
@@ -26,7 +28,7 @@ const MAX_REDIRECTS = 4;
26
28
  // - Azure blob accounts behind them, one per OpenAI region.
27
29
  // `blob.core.windows.net` is a shared namespace, so these are exact — a
28
30
  // prefix could be registered by anyone. A new region shows up as an
29
- // "is not a ChatGPT file host" error in BI; add it here.
31
+ // "is not a chat attachment host" error in BI; add it here.
30
32
  const OAI_USERCONTENT_HOST = /^[a-z0-9-]+\.oaiusercontent\.com$/;
31
33
  const OAI_BLOB_ACCOUNTS = new Set([
32
34
  'australiaeast',
@@ -55,18 +57,23 @@ const OAI_BLOB_ACCOUNTS = new Set([
55
57
  'westus2',
56
58
  'westus3'
57
59
  ].map((region) => `oaisdmntpr${region}.blob.core.windows.net`));
58
- export function isAllowedAttachmentHost(hostname) {
60
+ export const WIX_AI_ASSISTANT_ATTACHMENT_HOSTS = new Set([
61
+ 'wixmp-a85a29aaf55baa3e5171a82d.wixmp.com'
62
+ ]);
63
+ export function isAllowedAttachmentHost(hostname, extraHosts) {
59
64
  const h = hostname.toLowerCase();
60
- return OAI_USERCONTENT_HOST.test(h) || OAI_BLOB_ACCOUNTS.has(h);
65
+ return (OAI_USERCONTENT_HOST.test(h) ||
66
+ OAI_BLOB_ACCOUNTS.has(h) ||
67
+ !!extraHosts?.has(h));
61
68
  }
62
69
  /** https, default port, allowed host — applied to the first URL and every redirect. */
63
- function checkAttachmentUrl(url) {
70
+ function checkAttachmentUrl(url, extraHosts) {
64
71
  if (url.protocol !== 'https:')
65
72
  return 'is not an https URL';
66
73
  if (url.port)
67
74
  return 'uses a non-default port';
68
- if (!isAllowedAttachmentHost(url.hostname)) {
69
- return `host "${url.hostname}" is not a ChatGPT file host`;
75
+ if (!isAllowedAttachmentHost(url.hostname, extraHosts)) {
76
+ return `host "${url.hostname}" is not a chat attachment host`;
70
77
  }
71
78
  return undefined;
72
79
  }
@@ -98,7 +105,7 @@ function toBase64(bytes) {
98
105
  * GET with manual redirects: every hop must pass checkAttachmentUrl, so an
99
106
  * allowed host cannot bounce the server to an arbitrary or internal one.
100
107
  */
101
- async function fetchAllowed(start, fetchImpl, label) {
108
+ async function fetchAllowed(start, fetchImpl, label, extraHosts) {
102
109
  let url = start;
103
110
  for (let hop = 0;; hop++) {
104
111
  const res = await fetchImpl(url.toString(), { redirect: 'manual' });
@@ -117,7 +124,7 @@ async function fetchAllowed(start, fetchImpl, label) {
117
124
  catch {
118
125
  throw new AttachmentError(`${label}: redirect to an invalid URL.`);
119
126
  }
120
- const problem = checkAttachmentUrl(next);
127
+ const problem = checkAttachmentUrl(next, extraHosts);
121
128
  if (problem) {
122
129
  throw new AttachmentError(`${label}: the download redirected to a URL that ${problem}; refused.`);
123
130
  }
@@ -166,7 +173,7 @@ async function readWithin(res, budget, overBudget) {
166
173
  * (ChatGPT passed a sandbox path or a bare id instead of a URL), a host we
167
174
  * don't trust, a failed download, or more bytes than MAX_ATTACHMENTS_BYTES.
168
175
  */
169
- export async function resolveAttachments(attachments, fetchImpl = fetch) {
176
+ export async function resolveAttachments(attachments, fetchImpl = fetch, extraHosts) {
170
177
  if (!Array.isArray(attachments) || attachments.length === 0)
171
178
  return [];
172
179
  const items = [];
@@ -185,9 +192,9 @@ export async function resolveAttachments(attachments, fetchImpl = fetch) {
185
192
  if (!a || !url) {
186
193
  throw new AttachmentError(`attachments[${i}] was not resolved to a download URL (got ${JSON.stringify(typeof raw === 'string' ? raw : ((a && a.download_url) ?? raw))}). \`attachments\` works only in ChatGPT, which swaps each file for a download URL before the call reaches Wix. Here it arrived as written, and Wix cannot read a path on your machine, so retrying the same value fails the same way. Upload another way. With a shell that reaches the internet, send the file from there (for a site, the StartDeployWebsite recipe's curl drop). Otherwise leave \`attachments\` out and put the files in \`files\`: text as is, and a binary as a \`=== FILE: <path> base64 ===\` entry only when it is a few KB (an icon, a logo), since a larger base64 payload does not survive your own output. A larger image goes in by public URL or through the user (the Media Manager).`);
187
194
  }
188
- const problem = checkAttachmentUrl(url);
195
+ const problem = checkAttachmentUrl(url, extraHosts);
189
196
  if (problem) {
190
- throw new AttachmentError(`attachments[${i}].download_url ${problem}. Only platform-resolved attachments are accepted here; for any other URL fetch it in \`code\` via wix.request or put the bytes in the \`files\` bundle.`);
197
+ throw new AttachmentError(`attachments[${i}].download_url ${problem}. \`attachments\` takes only files the chat platform attached and resolved; leave it out. For a file at a URL, import it in \`code\` with \`POST https://www.wixapis.com/site-media/v1/files/import\` and \`{ url }\` (Wix fetches it); for a small file you have as text, use the \`files\` bundle.`);
191
198
  }
192
199
  items.push({ a, url });
193
200
  });
@@ -198,7 +205,7 @@ export async function resolveAttachments(attachments, fetchImpl = fetch) {
198
205
  const label = `attachments[${i}] (${path})`;
199
206
  let res;
200
207
  try {
201
- res = await fetchAllowed(url, fetchImpl, label);
208
+ res = await fetchAllowed(url, fetchImpl, label, extraHosts);
202
209
  }
203
210
  catch (e) {
204
211
  if (e instanceof AttachmentError)
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { AttachmentError, resolveAttachments } from './attachments.js';
2
+ import { AttachmentError, WIX_AI_ASSISTANT_ATTACHMENT_HOSTS, resolveAttachments } from './attachments.js';
3
3
  import { fileParamNote } from '../config/file-params.js';
4
4
  import { logger } from '../infra/logger.js';
5
5
  import { defaultToolDescriptions } from '../config/default-config.js';
@@ -328,7 +328,7 @@ function addExecuteTool(server, options) {
328
328
  // an agent-facing error, not a server failure.
329
329
  let attachmentFiles = [];
330
330
  try {
331
- attachmentFiles = await resolveAttachments(attachments);
331
+ attachmentFiles = await resolveAttachments(attachments, undefined, WIX_AI_ASSISTANT_ATTACHMENT_HOSTS);
332
332
  }
333
333
  catch (e) {
334
334
  if (e instanceof AttachmentError)
@@ -41,7 +41,7 @@ export declare const HELP_CENTER_IS_FOR_PEOPLE = "The Help Center is written for
41
41
  * Each tool named here carries a comment pointing back to this text. A change to any of them (a
42
42
  * name, what it is for, a new site-creation tool) updates this text and that list with it.
43
43
  */
44
- export declare const SERVER_INSTRUCTIONS = "Wix: build and manage Wix sites. A new site is written in code and uploaded: StartDeployWebsite returns the upload guide; StartHeadlessBuild returns the headless guide, for a site that needs Wix business features (forms, a store, bookings, a CMS, members, payments). WixSiteBuilder or SearchSiteTemplates only when the user asked for Wix's editor, the Wix Harmony Editor, a template, Wix Studio or the Classic Editor. For anything else, except ListWixSites and UploadImageToWixSite, call WixREADME first.";
44
+ export declare const SERVER_INSTRUCTIONS = "Wix: build and manage Wix sites. A new site is written in code and uploaded: StartDeployWebsite returns the upload guide; StartHeadlessBuild returns the headless guide, for a site that needs Wix business features (forms, a store, bookings, a CMS, members, payments). WixHarmonyEditorBuild or SearchSiteTemplates only when the user asked for Wix's editor, the Wix Harmony Editor, a template, Wix Studio or the Classic Editor. Call WixREADME first, except ListWixSites, UploadImageToWixSite, GetSuggestedDomains.";
45
45
  /** The tools SERVER_INSTRUCTIONS names. */
46
46
  export declare const SERVER_INSTRUCTIONS_TOOLS: string[];
47
47
  export declare const SITE_CREATION_COPY: {