Per-send substitution on Stripo modules works end-to-end, plus a new skill that walks you through binding them
Two halves of the same release. First, the sync bug that was silently breaking every per-send substitution call is fixed. Smart Properties in Stripo carry two labels — a canonical API identifier (e.g. p_title) and a human label shown in the editor (e.g. Title) — and Orbit's sync was storing the human label, so the compose validator kept rejecting correctly-formed payloads asking for p_title. The extractor now reads the API identifier, the CSS class hook gets persisted alongside it, and slot_values substitution works against the variable names Stripo's docs already tell you to use. Second, a new skill — stripo-module-bindings — walks you through the editor work that has to happen before any of this is useful. Registering a Smart Property in Stripo's UI has a load-bearing picker that's the same shape as a similar wrong option: pick the wrong one and the binding looks fine in the editor and is invisible to the API. The skill is the antidote: step-by-step registration, the verification check that catches the silent-failure mode, and a naming convention that matches Stripo's own.
What shipped
•Bug fix — orbit_compose_stripo_email now accepts the canonical Smart Property variable names (p_title, p_description, etc.) instead of the human-readable editor labels (Title, Description). Stripo's variable config gives each Smart Property both a 'variable' field (the API key) and a 'name' field (the editor label); Orbit was reading the wrong one and storing the editor label as the substitution key. Every compose call passing the documented variable identifier was getting rejected with 'Variable p_title is not defined.' The extractor now reads 'variable' first, falling back to 'name' then to the legacy cssClass field for older modules.
•Bug fix — synced modules now record the CSS class each Smart Property is bound to, not null. The class hook (e.g. esd-gen-title) lives on the variable's blockMapping selector and that's where the extractor reads it from. No user-visible behaviour change at compose time — that path already read the selector directly — but anything downstream that depended on the persisted css_class field would have been working off null. Locked in with a fixture-based regression test.
•New skill — stripo-module-bindings. Walks you through registering a Smart Property on a Stripo module so its text, link, or image can vary per send. The skill exists because Stripo's Configuration dialog has two ways to target an element that look interchangeable: Block Type and Your CSS Selector. Block Type works in the editor's preview and is invisible to the REST API. CSS Selector works through the API. Pick the wrong one and the variable saves fine, renders correctly inside Stripo, and silently no-ops on every compose call. The skill walks you through the right picker, the right attribute, the naming convention that matches Stripo's own (p_title / p_description / p_cta_text), and the verification step using orbit_inspect_stripo_module_bindings that catches the silent-failure mode before you ship.
•Regression coverage — new test suite at tests/suites/16-stripo-modules-sync.test.mjs locks in the variable-over-name precedence, the blockMapping-derived CSS class, the legacy name-only fallback, and the degraded cssClass-only path. Stops this exact bug recurring on either side of the extractor.
•End-to-end verification — pushed a probe email to Stripo with { p_title: 'PROBE-A', p_description: 'PROBE-B' } against a module bound to those variables, and confirmed visually in the Stripo editor that both substitutions landed on the rendered email. Compose is now production-ready for any module whose Smart Properties are registered in the Data tab via CSS Selector targeting — which is what the new skill teaches.