EVERJUST Website Snippets — Agent Skill
Author and edit the public website of an everjust.app tenant as an operating
agent: create/publish pages, drop and build reusable content blocks, expose
builder options for those blocks, and safely edit the two Tailwind-QWeb marketing
ports (website_connectdomain, website_tcsw) — all through the platform's Odoo
MCP. This is Odoo 19 / html_builder, not the pre-19 web_editor: snippet
options are OWL Plugin classes keyed by a CSS selector, not
data-*-driven snippet-option widgets.
You reach the tenant through everjust_agent_mcp — see [[everjust-agent-mcp]] to
connect and [[everjust-platform]] for the invariants you must not break. Website
edits need the connected user to hold Administrator / Website Designer (Odoo
raises AccessError otherwise). Every recipe below is an MCP tool call or an ORM
call routed through that server.
When to use this skill
- Create / publish a page or wire it into the nav (
website_new_page,website_publish,website_menu,website_redirect). - Edit a page's body — rewrite its QWeb arch COW-safely with
website_edit_page(never a rawir.ui.viewwrite on a module view). - Build a reusable content block ("snippet") the human builder can drag,
duplicate and edit: an
s_*QWeb template + its<t t-snippet>registration inwebsite.snippets+ optional SCSS + an optionPlugin. - Expose builder options (a color/size/icon toggle, a text field, a class
switch) on a block via the
html_builderPlugin/BuilderActionpattern. - Safely modify the ported marketing sites — the connectdomain / TCSW pages are verbatim Tailwind-utility QWeb, NOT snippets. Edit the QWeb arch directly and reuse the existing Tailwind classes ("wrap-don't-rewrite").
Do NOT use this skill for, and stop if the task is really:
- Non-page ORM data (CRM, contacts, events records, products) — that's plain [[everjust-agent-mcp]] CRUD, not website authoring.
- Registrar DNS / domain verification — [[godaddy-api]] /
everjust.mail.*, unrelated toir.ui.view. - Sending mail from a page's form — the form posts to Odoo; delivery is the
everjust.mail.*stack ([[everjust-mail-ops]]). - Installing a new module / theme, or adding a new option
Plugin.js asset — that ships in an addon and needs a module upgrade (-u) on the server; the MCP can't hot-load JS. You can author the QWebs_*template +website.snippetsregistration throughir.ui.view, but a new OWL Plugin file must land in an addon and be deployed (flag it; see Pitfalls #7).
The model map
Everything the website is made of lives in ir.ui.view (QWeb templates) plus
a thin website.page URL layer. There is no separate "snippet" table — a snippet
is just a QWeb template whose root carries an s_* class, plus a one-line
registration inside a well-known view. Real _names and the fields that matter:
| Model | Role | Key fields |
|---|---|---|
ir.ui.view |
Every QWeb template — layouts, pages, snippet bodies, and the snippet menu itself. | key (dotted xmlid-style, e.g. website.s_alert), arch (the XML you read/write), arch_db, type (qweb for web templates), mode (primary|extension), inherit_id (set for extension/inherit views), website_id (empty = module/global; set = a site-specific COW fork), active, name |
website.page |
A URL bound to a view — the page layer over ir.ui.view. |
url, view_id (→ ir.ui.view), website_published, website_indexed (sitemap), date_publish (schedule), is_in_menu, website_id |
website.snippets |
The snippet MENU — itself an ir.ui.view (key='website.snippets', type='qweb', module-global). Its arch holds <snippets> groups and one <t t-snippet=...> per block. Editing a page never touches this; registering a NEW draggable block does. |
(it IS an ir.ui.view) |
website.menu |
Nav menu item. | name, url, parent_id, sequence, new_window, website_id |
website.rewrite |
A 301/302 redirect. | url_from, url_to, redirect_type, website_id |
website |
The site record. | id, name, domain, menu_id |
The core website module ships ~155 s_* templates (more once
website_event/website_forum/etc. are installed — a real tenant shows ~190).
Never invent a snippet name; search/read the existing arch first.
COW — the one invariant that makes edits durable
Writing a view's arch with the website in context forks a site-specific
copy (copy-on-write) instead of mutating the shipped module view:
page.view_id.with_context(website_id=web.id).write({"arch": new_arch})
After this, page.view_id.website_id is set — a new ir.ui.view row with the
same key but bound to the site. The original module view stays intact, so a
module upgrade (-u) re-applies cleanly and never clobbers your edit. This is
exactly what the MCP website_edit_page tool does for you. (Verified live: the
homepage exists as both a module-global website.homepage primary view AND a
website_id-set COW fork.) Corollary: a raw generic update('ir.ui.view', …)
on a module view mutates the shipped record directly — not COW-safe, and a future
-u may fight it. Always go through website_edit_page for page bodies.
The three surfaces on a page
oe_structure= a drop zone.<div id="wrap" class="oe_structure">(or any.oe_structure) is where the builder lets a human drag snippets in.oe_structure oe_empty= an empty drop zone..oe_structure_soloand.oe_structure[t-ignore="true"](the header/footer variants) are structural, not free drop zones.- Editable inline zones — text/attrs the builder can edit in place, marked by
t-field/data-oe-field/contenteditable-derived attributes on a bound record field. These edit a record value, not the arch. - Everything else — plain QWeb the builder treats as fixed markup (what our Tailwind ports are almost entirely made of).
Keeping the page body inside a single oe_structure is what preserves the
human's ability to drop snippets later; stripping it (or replacing #wrap
wholesale with non-oe_structure markup) makes the page agent-only.
The snippet triad (build a draggable block)
A reusable, draggable "snippet" is three things (option Plugin is a 4th,
optional). Here is the canonical shape, distilled from core s_alert:
1. The s_* QWeb template (ir.ui.view, type='qweb'). Its root element
must carry the s_<name> class — that class is the block's identity and the
selector every option/drop-zone keys off:
<template id="s_cd_callout" name="CD Callout">
<section class="s_cd_callout oe_structure_solo"><!-- root carries s_cd_callout -->
<div class="mx-auto max-w-6xl px-6 py-16 text-center">
<p class="eyebrow">Eyebrow</p>
<h2 class="font-display mt-4 text-4xl text-stone-900">Callout title</h2>
<a href="/signup" class="mt-8 inline-block rounded-md bg-stone-900 px-7 py-3 text-stone-50">Get started</a>
</div>
</section>
</template>
2. The registration inside website.snippets — one <t t-snippet> line makes
it appear in the builder's "Add block" panel, filed under a group:
<!-- appended into the website.snippets view arch, inside <snippets id="snippet_structure"> -->
<t t-snippet="website_connectdomain.s_cd_callout" string="CD Callout" group="content">
<keywords>cta, callout, banner, action</keywords>
</t>
t-snippet = the template key; string = its builder label; group = which
category tab (intro/columns/content/images/people/text/
contact_and_forms/social/custom/…); optional t-thumbnail="/…svg" and
<keywords> power the panel preview + search.
3. Optional SCSS — bundle per-snippet styles into the frontend assets via an
<asset> record (core s_alert does this). For our Tailwind ports this is
usually unnecessary — the compiled Tailwind CSS already ships as a static asset
and the block reuses those utility classes.
<asset id="s_cd_callout_scss" name="CD Callout SCSS">
<bundle>web.assets_frontend</bundle>
<path>website_connectdomain/static/src/snippets/s_cd_callout/000.scss</path>
</asset>
4. Optional option Plugin — see next section.
Option Plugin = OWL Plugin keyed by selector (Odoo 19, NOT web_editor)
In Odoo 19 a snippet's options panel is an OWL Plugin registered in the
builder-plugins registry; the Plugin declares builder_options,
builder_actions, and the static selector on the option component binds it
to the s_* class. This is the exact core alert_option_plugin.js shape:
import { Plugin } from "@html_editor/plugin";
import { registry } from "@web/core/registry";
import { withSequence } from "@html_editor/utils/resource";
import { before, WIDTH } from "@html_builder/utils/option_sequence";
import { BuilderAction } from "@html_builder/core/builder_action";
import { BaseOptionComponent } from "@html_builder/core/utils";
class CdCalloutOptionPlugin extends Plugin {
static id = "cdCalloutOption";
resources = {
builder_actions: { CdCalloutStyleAction },
builder_options: [withSequence(before(WIDTH), CdCalloutOption)],
so_content_addition_selector: [".s_cd_callout"], // where it may be dropped
};
}
export class CdCalloutOption extends BaseOptionComponent {
static template = "website_connectdomain.CdCalloutOption"; // a QWeb <BuilderRow> tmpl
static selector = ".s_cd_callout"; // <-- binds the panel to the block
}
export class CdCalloutStyleAction extends BuilderAction {
static id = "cdCalloutStyle";
apply({ editingElement, params: { mainParam: cls } }) { editingElement.classList.add(cls); }
clean({ editingElement, params: { mainParam: cls } }) { editingElement.classList.remove(cls); }
isApplied({ editingElement, params: { mainParam: cls } }) { return editingElement.classList.contains(cls); }
}
registry.category("builder-plugins").add(CdCalloutOptionPlugin.id, CdCalloutOptionPlugin);
Key facts: options are .js in an addon (deployed via -u, not writable over
MCP — Pitfall #7); a BuilderAction's apply/clean/isApplied toggle
classes/attrs on editingElement; drop targeting is so_content_addition_selector
(content blocks) / so_snippet_addition_selector (top-level sections); and the
builder's drop-zone plugin already treats :not(p).oe_structure:not(.oe_structure_solo)
and [data-oe-type=html] as valid drop containers — you rarely add drop selectors.
The everjust reality: "wrap-don't-rewrite"
The connectdomain and TCSW marketing sites are NOT snippets. They are
verbatim Tailwind-utility QWeb, ported 1:1 from the live Next.js/Tailwind sites
(class strings copied unchanged, className→class), wrapped in
<t t-call="website.layout"> (or, in practice, an inherit_id="website.homepage"
view that position="replace"s #wrap). The compiled live Tailwind CSS ships as
a single static asset so those utility classes resolve identically; Preflight's
@layer base is stripped so it never fights Odoo's Bootstrap chrome. There are
deliberately no html_builder snippets, no publicWidget, no OWL in these
modules — only vanilla JS for the interactive bits.
Consequences for you as an operating agent:
- To edit a marketing page, edit the QWeb arch directly and reuse the
existing Tailwind classes — do not "snippetize" it, do not reach for the
builder options panel, do not swap in Bootstrap/
s_*markup. Match the surrounding idiom (e.g.class="mx-auto max-w-6xl px-6 py-16",font-display,text-stone-900,eyebrow). - The page body is kept inside
<div id="wrap" class="oe_structure oe_empty">and wrapped in the site's root scope class (.cd-rootfor connectdomain,.tcsw-rootfor TCSW) so the compiled CSS + chrome apply. Preserve that wrapper. s_cd_*blocks are the bridge: if a section of a Tailwind port needs to be both agent- and human-editable, extract it into ans_cd_<name>QWeb template (root classs_cd_<name>, body = the same verbatim Tailwind markup), register it inwebsite.snippets, and drop it inside the page'soe_structure. Now a human can drag/duplicate/remove it in the builder and an agent can edit its template arch — without touching the surrounding hand-ported page. This is the "wrap-don't-rewrite" method: wrap a Tailwind section in ans_cd_*shell; never rewrite the section into generic builder markup.
Debrand facts you operate under (see [[everjust-platform]]): the public site at
/ is gated by ir.config_parameter everjust.public_website == '1' (else
/ bounces to the /odoo backend and the builder iframe can't load); the backend
lives at /odoo; the site is forced light mode (no dark-mode variant — author
for light only). Do not read/echo config-param secrets.
Recipes
Tool calls are shown as tool(args); in Claude Code they are namespaced
mcp__everjust__<tool>. All website tools run as the connected user and need
Website Designer / Administrator.
1. Orient: list the site's pages (do this first)
website_pages()
→ { website: {id, name, domain},
pages: [ { id, url:"/", name:"Home", view_id, published:true,
indexed:true, in_menu:false,
website_specific:false }, # false = still the module view (not yet COW'd)
{ url:"/careers", ... }, ... ] }
website_specific:false means the page still points at the shipped module view —
your first website_edit_page will COW-fork it (then it flips to true). To scope
to one site on a multi-site tenant, pass website_id.
2. Read a page's current arch before editing (never edit blind)
The website tools don't return arch, so read it via the ORM using the view_id
from step 1:
# from website_pages(): the "/" page's view_id, e.g. 1994
get(model="ir.ui.view", ids=[1994], fields=["key","arch","website_id","mode"])
Read the whole arch, note the wrapper (<div id="wrap" class="oe_structure …">,
.cd-root/.tcsw-root) and the Tailwind idiom, then edit within it.
3. Edit a page COW-safely (the core workflow)
website_edit_page(
url="/", # or page_id=…
arch="<t name='Home Page' t-name='website.homepage'> … full template … </t>")
→ { page_id, url:"/", view_id, website_specific:true, _count:1 }
arch is the complete QWeb template (you rewrite the whole view body, so
paste back everything you read in step 2 with your change applied — keep the
oe_structure wrapper and the verbatim Tailwind classes). The write forks a
site-specific ir.ui.view, so the upgrade path is safe. Do not use generic
update('ir.ui.view', …) for page bodies — it isn't COW and can be re-swept
(Pitfall #2 and [[everjust-platform]] rule 11).
4. Create + publish a new page, add it to the nav
website_new_page(name="Pricing", template="website.default_page",
add_menu=true, published=false)
→ { created: { url:"/pricing", view_id, page_id, … }, _count:1 }
# fill the body (reuse the port's Tailwind idiom, keep it inside #wrap.oe_structure)
website_edit_page(url="/pricing", arch="… full QWeb …")
# go live (and control sitemap indexing)
website_publish(url="/pricing", published=true, indexed=true)
# ensure it's in the primary menu (if add_menu didn't place it where you want)
website_menu(name="Pricing", url="/pricing", sequence=25)
For connectdomain/TCSW, prefer starting from an existing ported page's arch over
website.default_page so you inherit the right root scope + Tailwind classes.
To schedule publication instead of going live now, set date_publish on the
website.page via update and leave website_published=false.
5. Register a reusable s_cd_* block (agent- + human-editable)
Two ir.ui.view writes: (a) create the s_* template, (b) append its
<t t-snippet> into website.snippets. Since these are new/global views, use the
generic ORM (create/update) — they are not page bodies, so COW doesn't
apply; but a new option Plugin (.js) still needs an addon deploy (Pitfall #7).
# (a) the block template — root MUST carry the s_cd_* class
create(model="ir.ui.view", values={
"name": "CD Callout", "type": "qweb", "key": "website_connectdomain.s_cd_callout",
"arch": "<template id='s_cd_callout' name='CD Callout'>"
"<section class='s_cd_callout'>"
"<div class='mx-auto max-w-6xl px-6 py-16 text-center'>"
"<h2 class='font-display text-4xl text-stone-900'>Callout title</h2>"
"</div></section></template>"})
# (b) surface it in the builder panel: read website.snippets arch, add one <t t-snippet>
find(model="ir.ui.view", name="website.snippets") # → the id of key='website.snippets'
get(model="ir.ui.view", ids=[<snippets_id>], fields=["arch"])
# then update its arch inserting inside <snippets id="snippet_structure">:
# <t t-snippet="website_connectdomain.s_cd_callout" string="CD Callout" group="content"/>
update(model="ir.ui.view", ids=[<snippets_id>], values={"arch": "<full edited arch>"})
Now the block is draggable in the builder and its body is a plain template an
agent can re-edit. Drop it onto a page by placing
<t t-snippet-call="website_connectdomain.s_cd_callout"/>
(or the rendered markup) inside that page's oe_structure via website_edit_page.
6. Redirect / rename a URL
website_redirect(url_from="/old-pricing", url_to="/pricing", redirect_type="301")
→ { rewrite_id, url_from, url_to, type:"301", _count:1 }
Use a 301 when you rename a page so existing links + SEO carry over; 302 for temporary. Add the redirect before unpublishing the old URL.
Pitfalls
This is
html_builder(Odoo 19), NOTweb_editor. There is noweb_editormodule here, nosnippet-options.js, nodata-js/data-selectoroption widgets. Options are OWLPluginclasses in thebuilder-pluginsregistry, bound by astatic selectoron aBaseOptionComponent, withBuilderAction.apply/clean/isApplied. Porting a pre-19 snippet-option verbatim will silently do nothing.Edit page bodies with
website_edit_page, not rawir.ui.viewwrites.website_edit_pagewrites withwebsite_idin context → COW fork, so a-unever clobbers it and the shipped view survives. A genericupdate('ir.ui.view', …)on a module page view mutates the shipped record and can be re-swept on the next upgrade ([[everjust-platform]] rule 11). (Genericcreate/updateonir.ui.viewis fine for new snippet templates and thewebsite.snippetsmenu — those aren't COW page bodies.)The
s_*root class is load-bearing — put it on the root element. The registration (t-snippet), the option Pluginselector, and drop targeting all key off.s_<name>. A block whose root lacks itss_*class won't get options and may not be recognized as a snippet.website.snippetsis a globalir.ui.view, not per-page. Registering a new draggable block edits that one menu view; it changes what's available on every page, and is orthogonal to editing any single page's arch. Don't confuse "add a block to the panel" (editwebsite.snippets) with "put a block on this page" (website_edit_page).The marketing ports are verbatim Tailwind-QWeb — edit, don't snippetize.
website_connectdomain/website_tcswhave no snippets by design. Reuse the existing Tailwind utility classes, keep the#wrap.oe_structurewrapper and the.cd-root/.tcsw-rootscope, and don't inject Bootstraps_*core snippets into a Tailwind page (the compiled CSS + light-mode + Preflight-stripping assume the ported idiom).Preserve
oe_structure(the drop zone) and the root scope wrapper. If you replace#wrapwith markup that isn't inside anoe_structure, the human builder loses the ability to drop/rearrange snippets and the page becomes agent-only. Keep the body inside<div id="wrap" class="oe_structure …">.You can author QWeb over MCP, but you CANNOT hot-load a new option
Plugin(.js).ir.ui.viewtemplates (thes_*body +website.snippetsregistration) are ORM records you can write. A new OWLPlugin/BuilderActionis a.jsasset in an addon that only loads after the module is deployed and upgraded (-u) on the server. If a task needs a new options panel, author the template + registration and flag the .js + deploy as a code change, don't pretend it's live.Website tools need Website Designer / Administrator;
AccessError= role, not a bug. Ifwebsite_edit_page/website_new_pagefails with access, the connected user lacks the role — get a key from a designer/admin user. Do not grantbase.group_systemto a bot to work around it ([[everjust-platform]] rule 3: on this fork Administrator implies every app)./only serves the site wheneverjust.public_website == '1'. If pages 404 or/bounces to/odoo(and the builder iframe won't load), the tenant isn't flagged as a public-website tenant. That flag is a platform-ops toggle — read it if diagnosing, but don't flip integration secrets, and remember the backend is permanently at/odoo.Author for light mode only. The public site is force light-mode ([[everjust-platform]]); there is no dark variant. Don't add
prefers-color-scheme/dark-mode classes expecting them to switch.
See also
- [[everjust-agent-mcp]] — how to connect to the tenant and the generic
search/get/create/update/call+ website_* tool surface. - [[everjust-platform]] — the platform invariants (COW,
/odoodebrand,everjust.public_website, re-sweep on-u, never self-escalate) that gate every edit here. - [[everjust-mail-ops]] — if a page's contact form needs to actually send.
- [[godaddy-api]] — registrar DNS for the site's domain (separate system).
- [[frontend-design]] / [[ui-ux-audit]] — visual/UX judgement when composing a page's Tailwind markup.