Skills
Skill 7 of 67
Edit Wix Harmony sites through an authenticated Harmony editor using Playwright over a local Chrome CDP session.
10 minutes · 2,173 words · 9 sections
Install
npx skills add wix/skills --skill wix-harmony-editornpx skills add wix/skills/plugin marketplace add wix/skillsThe first command installs just this skill, by the name in its SKILL.md; the second installs the whole repository.
Work through the Wix Harmony editor, not the published site. Use the local Chrome launcher and Playwright; the standard in-app browser may not expose the isolated Chrome CDP session.
This file is how to connect to the editor and work in it. Copying an existing site’s page into Harmony has its own rules: read COPY-SITE.md (opens in a new tab) before starting a copy.
Order for a migration: create the site, then bring the data (store, bookings, blog and other app data, public and/or private), then build the front end here. Do not start the editor for a store or other app-backed site before its data is in, unless the user asks and approves (COPY-SITE.md, “Order”).
http://127.0.0.1:9333 endpoint launched for this task.connectOverCDP with EPERM, request elevation specifically for the local loopback connection. Do not use a remote host, a different port, or a workaround that exposes the debugger.Run the bundled launcher with the Wix editor URL:
skills/wix-harmony-editor/scripts/launch.sh 'https://example.editor.wix.com/edit/...'It starts a headed Chrome instance with an isolated, private profile and a loopback-only CDP port. If Wix shows a sign-in page, let the user authenticate in that browser. Never ask for or enter credentials yourself.
The launcher is intentionally quiet about CDP internals. It reports only whether local Chrome is reachable.
The first launch usually lands on users.wix.com/signin. Tell the user to sign in in that
window (the account that owns the site), then poll until the editor tab exists and
window.repluggableAppDebug is defined. Do not treat the sign-in page as an error.
Confirm the expected editor is open before doing anything else:
node --input-type=module -e "
import { chromium } from 'playwright';
const browser = await chromium.connectOverCDP('http://127.0.0.1:9333');
const pages = browser.contexts().flatMap(context => context.pages());
console.log(await Promise.all(pages.map(async page => ({ url: page.url(), title: await page.title() }))));
await browser.close();
"In a Playwright script, resolve the editor page mechanically:
const page = browser.contexts()
.flatMap(context => context.pages())
.find(page => page.url().includes('editor.wix.com/edit/'));
if (!page) throw new Error('No authenticated Wix Harmony editor page is open.');browser.close() detaches Playwright; it does not close the launched Chrome instance.
All API code runs in page.evaluate:
const api = await repluggableAppDebug.host.getAPI({
name: 'RunEditorLLMCodeToolAPI',
public: true,
layer: 'DATA_SERVICE',
});Execute editor code with the required editor context:
await api.execute(
{ code: 'return await editorLLMApis.catalog.list();' },
{ biParams: {} },
);The { biParams: {} } second argument is required in this workflow. Omitting it produces Cannot read properties of undefined (reading 'biParams'); that error changed nothing in the observed case, but do not use failed calls to discover write signatures.
Start with editorLLMApis.catalog.list() for namespace orientation.
Read documentation only for namespaces needed by the task, in one documentation-only call:
return await editorLLMApis.catalog.describe({
namespaces: ['pages', 'components', 'layout', 'breakpoints', 'appearance', 'site'],
});describe has a response budget. A namespace that does not fit comes back as
[NOT RETURNED …] or [Truncated …]; fetch that one alone with
catalog.describe({ namespaces: ['<name>'] }). components, richText, media,
layout and appearance each need their own call. Save the docs to files and read
them from there instead of re-fetching.
Never introspect undocumented API objects or guess a write method’s arguments.
Open/focus the correct page before reads, because component reads resolve against the focused page.
Use IDs, element paths, units, tokens, and schemas returned by live reads—never assumed values.
After every write, do a fresh read of the normalized, user-visible result. A write echo alone is not verification.
Common namespaces: pages (focus the page), components (find and inspect), layout (measure and resize), breakpoints (desktop/mobile view), appearance (borders/backgrounds), and site (save/publish status).
const result = await page.evaluate(async () => {
const api = await repluggableAppDebug.host.getAPI({
name: 'RunEditorLLMCodeToolAPI',
public: true,
layer: 'DATA_SERVICE',
});
return api.execute({
code: `
const focused = await editorLLMApis.pages.getFocused();
return { focused };
`,
}, { biParams: {} });
});Check the returned action result for errors before taking the next step.
A failed snippet reports completedCalls with the ids it created. Those writes persist:
continue from the failed call reusing those ids, never re-run the whole snippet.
Run from this skill’s directory. They import playwright, resolved from the working tree.
| Script | Use |
|---|---|
scripts/launch.sh <editorUrl> | Start the isolated, headed Chrome on loopback CDP 9333. |
node scripts/copy/lint-api.mjs --capabilities caps.json [--snippet] <file…> | Check every A.<ns>.<method>( call against the editor’s real method list (recorded by snapshot-capabilities.mjs) before it is sent. |
node scripts/copy/editor-run.mjs <snippet.js> [--site <metaSiteId>] [--capabilities caps.json] | Run one editor-API snippet kept in a file; prints result or the failure summary. Lints the snippet when --capabilities / HARMONY_CAPABILITIES is set. Prefer this over inline -e code (no quoting bugs, replayable record). |
node scripts/copy/upload-media-manager.mjs <sectionId> <file…> | Upload local files to the site’s Media Manager through the editor’s own dialog. |
node scripts/copy/compare-preview.mjs <sourceUrl> <outDir> | Editor Preview at true canvas width vs the source, plus stacked zoom crops. |
node scripts/copy/preview-widths.mjs <sourceUrl> <outDir> --widths … [--scale-check <sectionId>] | Preview at exact widths (slider) vs the source, plus a check that every element scales with the width. |
node scripts/copy/build-description.mjs, check-description.mjs, deploy-description.mjs | Page description → local compliance/overlap/render check → one-pass deploy with read-back; see COPY-SITE.md (opens in a new tab). |
node scripts/copy/export-page.mjs, snapshot-capabilities.mjs, import-media.mjs | Recipes from a copied page, the editor’s accepted schemas, public images imported by URL. |
node scripts/copy/mobile-stage.mjs hide|bg-opacity … | The two mobile-only changes with no API: Hide, and the background image opacity. |
node scripts/copy/mirror-site.mjs, serve-mirror.mjs, check-mirror.mjs | Offline copy of the source site, served locally and pixel-checked against live; see COPY-SITE.md (opens in a new tab) step 0. |
node scripts/copy/measure-source.mjs, node scripts/copy/render-plate.mjs | Site copying; see COPY-SITE.md (opens in a new tab). |
These hold for the Harmony editor API as observed; check the live read when one disagrees.
sections.list returns the page’s header/footer as a
RefComponent (role: header|footer); background and layout writes on that id are
refused. Target its masterId (on masterPage) for setContainerBackground,
components.add and layout.*.richText.setColor, setLink, setAlign, setFontFamily: “cannot be selected”).
Style it with appearance.setStyles on the text component (color, textAlign,
fontFamily, fontSize, lineHeight, …). For a link, put an inline
<a href> in richText.setText HTML (it survives), or use a Button with links.set.wixSystemElements.InternalContainer, child of the master; find it in the stage DOM
as [class*="internalContainerRoot"] under #<pageHeaderOrFooterId>). The background
layer and the visible height follow the internal container, not the header/footer box.
layout.setSize both to the same height; the background may update only after the next
setContainerBackground. The internal container’s grid rows are
[minContent, 1fr]: a second text block that stacks under the first can be clipped.
Merge lines into one rich text with <br> instead.layout.setComposition resolves all of a container’s children as one set and
rejects the whole arrangement if any two boxes overlap or leave the container,
sometimes silently, leaving the auto-placed positions. Even sub-pixel overlaps of
touching text boxes count. Send one complete, overlap-free arrangement per container,
read getComposition back, and fix the inputs. Do not correct drift with
layout.move after a composition: it re-flows the rows and makes it worse.lines × line-height. A single-line text whose
box is exactly as wide as its words can wrap on a sub-pixel font difference and push
everything below it down. Give single-line boxes ≥10 px of slack (left-aligned, so the
text start does not move). Buttons grow to fit a wrapped label; setSize height on a
content-sized axis is only a minimum.spx (scaled to the 1280 reference width), so a desktop layout grows
and shrinks proportionally between the desktop breakpoints. Anything that stays a real
px breaks that proportion: a lineHeight: '42px' on text, a px line-height inside a
button label, a px section height. Write line heights as em ratios
('1.4em'), write fontSize and lineHeight in the same setStyles call (writing
only lineHeight can rescale the stored font size), and read both back. Prove it with
preview-widths.mjs --scale-check: every child must scale by w2/w1.sections.add has height: auto, set by its
content rows, so it scales with the page. A section that came with the template, or
one whose height was set with layout.setSize, has a fixed px height that does not
scale. For a copied page, use a new auto-height section and move the content into it
with components.canReparent + components.reparent (ids and styles are kept).layout.setComposition positions, widths
and the section box.height. Shared (a write in mobile view changes desktop too):
image heights (send none on mobile, the ratio derives them), layout.setSize,
appearance.setStyles, appearance.setContainerBackground. Stage-only, per view: Hide
and the background image opacity (scripts/copy/mobile-stage.mjs).hide-scrollbars.mjs
(Emulation.setScrollbarsHidden, which lasts only while their CDP session is attached). If you
drive the editor another way, check layout.getComposition on a section: width must be 1280/390.getComposition matches (deploy-description.mjs does both).setText keeps inline <span style="color|font-weight|font-family|font-size">
and <a href> as sent (a px font size in a span does not scale: use em). links.set
on a rich text rewrites it: the root becomes <h1> with an underline span. Keep text
links inline. A plain single line goes through injectText.lineHeight in em."[Truncated: … chars]" in place of the value).
Read in batches, or compare inside the snippet and return only the differences.pages.add creates an empty placeholder section (shouldReuse: true); a deploy that
adds its own section removes it.[data-hook="layers-item@DESKTOP--<id>"] button).[minContent, 1fr] grid) and their background does not follow the page content.
For an exact copy, collapse them (header, footer and their internal containers set to
1 px, background transparent) and build the nav and footer inside the page’s own
section, where they scale with everything else.media.uploadFiles accepts only temporary wixmp chat URLs. Import public web
images from a copied page with scripts/copy/import-media.mjs, which downloads each one
itself (global-unicast destinations only, at most 10 MB, six at a time, once per distinct URL)
and uploads the bytes through POST https://www.wixapis.com/site-media/v1/files/generate-upload-url (site token, header
wix-site-id); never pass an untrusted page’s URL to files/import, which makes Wix fetch it.
Then read the uri with media.searchSiteFiles. Upload local files with
scripts/copy/upload-media-manager.mjs. Image values for content.set are
{ uri, name, alt, width, height }; add displayMode: 'fit' to show the whole image..ttf through the media API creates a FONT file that is not registered. Register
fonts through the editor: select any text, open the font dropdown in the text toolbar,
click Upload fonts, then feed input[type=file][accept*=".ttf"] with
setInputFiles. Afterwards fonts.resolve('<Name>') refuses but suggests the uploaded
family (wfont_<x>_<id>,wf_<id25>,orig_<name>): use that full string as fontFamily.label element is the reliable linked text in global header/footer. Theme
button looks are saved styles: restyle one button locally, then
themeComponents.updateFromComponent({ kind: 'button', styleId: 'button-primary', … }).layout.pinToScreen({ position: 'center-bottom', offsetPx }) pins a
floating button and moves it to the page (out of the section). A pinned component cannot
be resized: set its size first, or unpin, resize and pin again.npx @wix/cli@latest token --site <id>) stops working during
a long session (REST calls start returning 403). Re-mint and retry once before
diagnosing anything else.[data-hook="slider-handle"], aria-valuenow, range 390 to the
window width minus 84) is only rendered while the window is at least ~1300 px wide.
Click the slider track near the target, then use the arrow keys for the exact width
(preview-widths.mjs does this). A free site adds a 44 px “Upgrade” banner above
the page. The editor stage is zoomed (Fit (60–110%)), so stage screenshots are not a
pixel reference.site.getStatus().isPublished became true during
scripted UI work on this site, cause unknown. Read it before and after UI automation,
never click by raw coordinates in the top bar (Publish is there), and report a change
to the user instead of quietly reverting it.When a hero frame scales in width but its text overflows at wide desktop sizes, find visible title text, inspect its ancestors, and identify the bordered frame container:
const title = await editorLLMApis.components.find({
textContains: 'Hero title',
limit: 20,
});
const inspection = await editorLLMApis.components.inspect({
componentId: title.components[0].id,
aspects: ['layout', 'styles'],
neighbourhood: true,
});Read the frame’s declared layout and composition. A typical problem is a percentage width paired with fixed pixel height while the text uses spx sizing:
const frameId = 'REPLACE_WITH_FRAME_COMPONENT_ID';
const layout = await editorLLMApis.layout.get({ componentId: frameId });
const composition = await editorLLMApis.layout.getComposition({ componentId: frameId });
const measured = await editorLLMApis.layout.measure({ componentId: frameId });
return { layout, composition, measured };For a frame declared as height: { value: 536, unit: 'px' }, the responsive repair is normally to preserve the value and use Wix scaled pixels. After authorization:
const updated = await editorLLMApis.layout.setSize({
componentId: frameId,
height: 536, // plain pixels: the component keeps its own unit (spx stays spx) and the value is recomputed
});
const verifiedLayout = await editorLLMApis.layout.get({ componentId: frameId });
const status = await editorLLMApis.site.getStatus();
return { updated, verifiedLayout, status };Verify that the stored height is { type: 'spx', value: 536 }, that the editor reports a successful save state, and that the site remains unpublished unless publication was explicitly requested.
Edit Wix Harmony sites through an authenticated Harmony editor using Playwright over a local Chrome CDP session. Use for inspecting, changing, saving, and verifying Harmony editor components and layouts, and for copying an existing website's pages into Harmony exactly (see COPY-SITE.md); do not use for Wix Studio, the classic Editor, Dashboard data, or publishing without explicit approval.
The verbatim description from this skill’s front matter — the string an agent matches on to decide whether to load it.
main, last pushed 8 November 2026.SKILL.md, not by matching a directory convention. 5 distinct layouts observed: skills/*/SKILL.md, skills/wix-headless-replatform/resources/*/SKILL.md, skills/wix-replatform/resources/*/SKILL.md, */SKILL.md, wix-replatform/resources/*/SKILL.md.h1 and no skipped levels:.claude-plugin/marketplace.json by Wix, declaring 1 plugin. It is read for editorial metadata only — never as the skill index, which is always the repository tree./wix/skills.md, and each skill at its own .md URL.29 files · 248 KB
Everything this skill ships beside its prose. All of it is set here, as subchapters of skill 7.
Executable code the skill can run.
scripts/copy/25 files · 192 KBscripts/1 file · 16 KB
Everything else published alongside the skill.