| name | everjust-website-seo |
| description | SEO + discoverability for an everjust.app (Odoo 19) tenant's public marketing site, via the everjust_agent_mcp MCP as an admin / website-designer user. Use when the task is to set or backfill a page's meta title/description/keywords/OG image/canonical slug (website_meta_* + seo_name on website.page — related proxies onto its ir.ui.view), control sitemap membership or crawlability (website_indexed / website_published / date_publish), add the site's social_* profiles + social_default_image (the seven _SOCIAL_FIELDS feed the everjust_visibility schema.org JSON-LD sameAs on every page so AI engines cite it), edit robots_txt, verify Google Search Console, or enable IndexNow (the everjust_visibility.indexnow_key ir.config_parameter, confirm-gated, plus its opt-in cron). NOT for page CONTENT/layout ([[everjust-website]] / [[everjust-website-snippets]]) nor a customer's OWN-domain DNS/SSL (the Connect Domain product). Cross-references [[everjust-platform]], [[everjust-agent-mcp]], [[everjust-website]]. |
EVERJUST Website SEO — Agent Skill
Make a live everjust.app tenant's public marketing site discoverable — by search
engines AND by AI answer engines (ChatGPT Search, Perplexity, Google AI Overviews).
You set per-page SEO metadata, control sitemap / crawl state, wire the site's
social profiles (which feed the schema.org JSON-LD the platform injects on every
page), edit robots.txt, verify Google Search Console, and enable IndexNow.
Everything runs through the everjust_agent_mcp server as the connected Odoo user,
so you need an admin / website-designer role (Odoo raises AccessError otherwise).
This is the discoverability layer that sits ON TOP of the page-editing skills. If the
task is to change a page's copy or layout, that's [[everjust-website]] (Tailwind-QWeb
ports) or [[everjust-website-snippets]] (the on-brand s_cd_* snippets). This skill is
only about the metadata, indexing, structured-data, and crawler-facing surfaces. For
opening a session against the right tenant DB and what each tool returns, see
[[everjust-agent-mcp]]; for the platform invariants (COW, the /odoo debrand,
everjust.public_website, re-sweep on -u, never self-escalate) read [[everjust-platform]]
first.
When to use this skill
- Set / backfill a page's SEO metadata —
<title>, meta description, keywords, the
Open-Graph image, and the canonical URL slug (seo_name) — for one page or across the
whole site (website.page).
- Control the sitemap and crawlability —
website_indexed (is it in /sitemap.xml),
website_published (is it live at all), and date_publish (schedule a go-live).
- Wire the site's social profiles — set
social_facebook / social_linkedin /
social_twitter / social_github / social_instagram / social_youtube /
social_tiktok on the website so they become schema.org sameAs (a citation/authority
signal AI reads), and set social_default_image as the fallback OG card.
- Edit
robots.txt — the site-wide website.robots_txt (per-site override).
- Verify Google Search Console — the
google_search_console token on website.
- Enable IndexNow — set the
everjust_visibility.indexnow_key config param, serve the
key file, and switch on the opt-in daily cron that pushes URLs to Bing/Yandex.
- Understand the JSON-LD feed — why the Organization + WebSite structured data on
every page is auto-generated by
everjust_visibility from the website record.
Do NOT use this skill for, and stop if the task is really:
- Page content / layout / snippets — copy, sections, CTAs, blocks. That's
[[everjust-website]] (edit the Tailwind-QWeb arch) / [[everjust-website-snippets]]
(the on-brand
s_cd_* snippets). SEO metadata is separate from the arch even though
it lands on the same view (see the model map).
- The Connect Domain PRODUCT (auto-DNS + SSL + reverse-proxy for a customer's own
domain) — that's the control-plane under
Customdomain/app/, unrelated to this Odoo
site's SEO.
- Registrar DNS for connectdomain.app itself — GoDaddy / Route 53, see [[godaddy-api]].
(Search Console domain-property verification via a DNS TXT record is registrar work;
the
google_search_console field here is the URL-prefix HTML-tag / meta method.)
- The site's theme / palette / fonts — SCSS in
everjust_theme / everjust_brand_website,
shipped in code, not MCP-editable.
- Sending mail from a contact form — delivery is the
everjust.mail.* stack
([[everjust-mail-ops]]).
The model map (real _names + live-verified fields)
One tenant DB = one website (on connectdomain: id 1, name "Connect Domain",
domain https://connectdomain.app). SEO metadata is a mixin whose fields are delegated
onto the page's VIEW — this is the single most important structural fact, and it's why
COW matters for SEO too.
| Model | Role | Key fields (live-verified on connectdomain) |
|---|
website.seo.metadata | The SEO mixin. ir.ui.view _inherits it, so the metadata physically stores on the view. You rarely touch this model by name. | website_meta_title (char, store=True), website_meta_description (text, store=True), website_meta_keywords (char, store=True), website_meta_og_img (char — an image URL/path, store=True), seo_name (char — canonical URL slug, store=True), is_seo_optimized (bool, computed+stored, read-only = title+description present) |
website.page | A URL ↔ view binding + publish/index state. Its SEO fields are related='view_id.website_meta_*', store=False, compute=True, writable — writing them writes the view. | url (store=True), name (related view_id.name), view_id (m2o, store=True), website_id (related view_id.website_id), website_published (bool, store=False/computed — the publish state), website_indexed (bool, store=True on the page — sitemap membership), date_publish (datetime, store=True — schedule), visibility (related view_id.visibility), is_seo_optimized (related, read-only), and the writable delegated website_meta_title / _description / _keywords / _og_img / seo_name |
ir.ui.view | The QWeb template AND where the SEO metadata actually stores. The website_meta_* / seo_name you set "on the page" persist here. Writing this a site-specific fork. |
The delegation gotcha (READ THIS FIRST)
website.page.website_meta_title is related='view_id.website_meta_title', store=False,
compute=True, writable — a live-writable proxy. The value lands on the page's
ir.ui.view, the same view that holds the page's arch. Consequences:
- Setting page SEO via a generic
update on website.page writes through to the
view. Going through the website.page write is the correct, COW-safe path — prefer it.
- If you instead wrote
website_meta_* directly on a module ir.ui.view (a view whose
website_id is empty) without the site in context, you'd mutate the SHIPPED view for
every site and it wouldn't COW — same trap as raw-editing arch ([[everjust-website]]).
Set SEO on the website.page row, never on a raw ir.ui.view.
is_seo_optimized is computed and read-only (true once title + description are both
set). You never write it; you read it to confirm the page is done.
How SEO renders (what wins at page load)
website.layout renders <title> and the <meta>/OG tags from these fields, overriding
any <title> you put in the arch. So set the page's title/description here, don't
hand-roll <meta> in the QWeb ([[everjust-website]] Pitfall 4). Per-page
website_meta_og_img wins for that page; if unset, the site falls back to
website.social_default_image, then the company logo. seo_name is the clean canonical
slug used in <link rel="canonical"> and the SEO URL.
The JSON-LD feed — auto-generated from the website record
The everjust_visibility addon (auto_install=True on any tenant that has a website)
inherits website.layout and injects, into every public page's <head> (via
t-out="website._everjust_jsonld()" xpathed //head), two
<script type="application/ld+json"> blocks built by website._everjust_jsonld():
- an Organization block:
name (company name, else website name), url (base),
logo ({base}/web/image/res.company/{id}/logo), sameAs (the site's set social
URLs — see below), and email (only if company.email is set).
- a WebSite block with a
SearchAction targeting {base}/website/search?search={term}.
You never author this markup. You influence it by setting the website record's social
fields and the company name/email/logo. The generator is wrapped in try/except and
returns empty Markup("") on ANY error (or when base/name is missing) — so it can
never break the layout, and a bad value fails silently (verify by fetching a page and
grepping for application/ld+json).
Exactly the seven fields in the addon's _SOCIAL_FIELDS reach sameAs:
social_facebook, social_linkedin, social_twitter, social_github,
social_instagram, social_youtube, and social_tiktok. social_discord is a
field on the website record but is NOT in that list — it never reaches sameAs (see
Pitfall 7). A value must startswith("http") to be emitted.
The extra discoverability routes (everjust_visibility)
New public routes the addon adds (all sitemap=False, and they DON'T override Odoo's own
controllers): /llms.txt (an llmstxt.org-format index of published pages),
/.well-known/security.txt (RFC 9116, contact = company.email), and
/everjust-indexnow-key.txt (404 until a key is set, then serves the key). robots.txt
and /sitemap.xml remain Odoo's own — this addon deliberately does NOT override robots
(the AI-crawler Content-Signals override is a documented, un-shipped follow-up).
The tool surface (dedicated vs. generic)
There is no dedicated SEO tool — SEO/indexing/social are all generic ORM writes
through everjust_agent_mcp. The dedicated website_* tools cover the adjacent content
ops. Route each recipe through the MCP against the tenant (see [[everjust-agent-mcp]]).
| Need | Tool | Notes |
|---|
| List pages + their published/indexed state | website_pages | Start here; returns url, view_id, published, indexed, in_menu, website_specific. |
| Publish / index a page | website_publish | published (default true) + optional indexed. The one dedicated tool that touches sitemap membership. |
| Per-page SEO metadata | generic update on website.page | Set website_meta_title / _description / _keywords / _og_img / seo_name. No confirm. |
| Schedule a go-live | generic update on website.page | date_publish (datetime) + website_published. |
| Site-wide social / OG / robots / GSC | generic update on website | social_*, social_default_image, robots_txt, google_search_console. No confirm. |
| Enable IndexNow (set key) | generic create/update on ir.config_parameter | everjust_visibility.indexnow_key — confirm:true (structural). Not classified secret. |
| Turn on the IndexNow cron | generic update on ir.cron | active:true on xmlid everjust_visibility.ir_cron_everjust_indexnow — confirm:true (structural). |
| Submit to IndexNow once, now | generic call on website | method _everjust_indexnow_submit — non-read → . |
Confirm gates that apply here: delete always needs confirm:true; writing
ir.config_parameter and ir.cron (structural models) needs confirm:true; a
non-read call (e.g. the IndexNow submit) needs confirm:true. website.page /
website updates are NOT confirm-structural. Granting admin and writing secret
config params are hard-blocked regardless — everjust_visibility.indexnow_key is not
classified secret, so it's writable with confirm.
Recipes
Route each through the everjust_agent_mcp MCP against the tenant ([[everjust-agent-mcp]]).
1. Audit the site's SEO / index state before touching anything
{}
Then pull the SEO columns the page tool doesn't return, in one read:
{ "model": "website.page", "ids": [],
"fields": ["url","name","website_published","website_indexed","date_publish",
"is_seo_optimized","website_meta_title","website_meta_description",
"website_meta_keywords","website_meta_og_img","seo_name","visibility"] }
Read is_seo_optimized per page — false means the page is missing a title or
description (the two the compute checks). That's your backfill worklist. Two live signals
worth spotting on connectdomain right now (13 pages, 5 published, 11 website_indexed):
pages that are website_indexed:true but website_published:false (drafts still marked
crawlable — harmless while unpublished, but they'll appear in /sitemap.xml the moment
they go live, so decide each deliberately), and two rows with the same url (/ often
exists as both the module view and a COW fork — edit/set SEO on the site-specific one).
2. Set / backfill a page's SEO metadata
Write the delegated fields on the page (they proxy to its view — the correct path):
{ "model": "website.page", "ids": [<page_id>],
"values": {
"website_meta_title": "Pricing — Connect Domain",
"website_meta_description": "Bring-your-own-domain onboarding from $0. Automatic DNS, SSL and edge.",
"website_meta_keywords": "custom domain, DNS, SSL, Entri alternative",
"seo_name": "pricing",
"website_meta_og_img": "/web/image/website/1/social_default_image"
} }
Keep titles ~50-60 chars and descriptions ~150-160; the rendered <title>/metas come from
here and override any <title> in the arch — never hand-roll <meta> in the QWeb.
website_meta_og_img expects an image URL/path (a /web/image/... route or a static
asset), NOT a binary. Leave it unset to fall back to the site's social_default_image,
then the company logo.
Backfill across website.page (whole site, one pass):
- Read all pages (recipe 1) with
is_seo_optimized, website_published, name, url.
- Filter to
is_seo_optimized == false AND website_published == true (fix the
live/indexable pages first; drafts later).
- For each, derive a title from
page.name (mirror the tenant's existing pattern —
several pages already carry "{Name} | Connect Domain") and a one-line description
from the page's purpose; set seo_name where the URL isn't already a clean slug.
update in a loop, then re-run recipe 1 and confirm every published page now reads
is_seo_optimized:true.
3. Control sitemap membership + publish + schedule
{ "url": "/pricing", "published": true, "indexed": true }
{ "url": "/welcome", "published": true, "indexed": false }
website_published = live vs draft (computed on the page); website_indexed = whether it
appears in /sitemap.xml / is offered to crawlers (stored on the page). They're
independent — a page can be live but out of the sitemap, or a draft flagged indexed.
Because website_indexed is a stored field, a raw update on website.page also works:
{ "model": "website.page", "ids": [<page_id>], "values": { "website_indexed": false } }
To schedule a future go-live, set date_publish and leave it published-forward:
{ "model": "website.page", "ids": [<page_id>],
"values": { "date_publish": "2026-08-01 13:00:00", "website_published": true } }
4. Wire the site's social profiles (feeds JSON-LD sameAs) + default OG image
Set the URL fields on the website record — the seven in _SOCIAL_FIELDS become
schema.org sameAs on every page's Organization block (an AI citation/authority signal):
{ "model": "website", "ids": [1],
"values": {
"social_linkedin": "https://www.linkedin.com/company/connectdomain",
"social_twitter": "https://x.com/connectdomain",
"social_github": "https://github.com/ever-just",
"social_youtube": "https://www.youtube.com/@connectdomain",
"social_tiktok": "https://www.tiktok.com/@connectdomain"
} }
facebook / linkedin / twitter / github / instagram / youtube / tiktok all reach sameAs;
social_discord does not (Pitfall 7). Each value must start with http or the
JSON-LD generator skips it. Set the default social share card (used when a page has no
website_meta_og_img): social_default_image is a binary — write a base64 image
string to it via update on website (or set it in backend Website settings);
has_social_default_image then reads true. Verify JSON-LD after: fetch a public page
and confirm two application/ld+json blocks with your sameAs (the generator swallows
errors silently, so "no error thrown" is not proof).
5. Edit robots.txt and verify Google Search Console
robots_txt is an html field on website (a per-site override of the default):
{ "model": "website", "ids": [1],
"values": {
"robots_txt": "User-agent: *\nAllow: /\nSitemap: https://connectdomain.app/sitemap.xml" }
}
Keep the Sitemap: line pointing at the site's real /sitemap.xml. Don't Disallow: /
a live public site. Verify Google Search Console (URL-prefix HTML-tag method) by setting
its token — Odoo renders it as the verification <meta> in the head:
{ "model": "website", "ids": [1],
"values": { "google_search_console": "<the token from Search Console>" } }
(Domain-property verification instead uses a registrar DNS TXT record — that's
[[godaddy-api]] / Route 53, not this field.)
6. Enable IndexNow (Bing/Yandex → also feeds ChatGPT Search) — CONFIRM-GATED
IndexNow is inert until you (a) set the key config param and (b) switch on the cron. The
key file at /everjust-indexnow-key.txt returns 404 until the key exists, then serves it.
{ "model": "ir.config_parameter",
"values": { "key": "everjust_visibility.indexnow_key", "value": "<32-64 char key>" },
"confirm": true }
{ "model": "ir.cron", "ids": [<cron_id>], "values": { "active": true }, "confirm": true }
Once both are set, /everjust-indexnow-key.txt returns the key (confirm by fetching it),
and the cron calls _everjust_indexnow_cron() daily → for each website
_everjust_indexnow_submit() POSTs _everjust_published_urls() to
https://api.indexnow.org/indexnow. To submit once immediately without waiting for the
cron, call the submit method:
{ "model": "website", "ids": [1], "method": "_everjust_indexnow_submit", "confirm": true }
IndexNow reaches Bing + Yandex, NOT Google — Google ignores it; Google discovery is via
the sitemap + Search Console. Don't promise "instant Google indexing."
Pitfalls
-
SEO metadata stores on the page's VIEW, not the page table. website_meta_* /
seo_name on website.page are related='view_id.…', store=False (writable). Set them
via update on the website.page row (correct, COW-safe path). Never write
website_meta_* onto a raw module ir.ui.view (empty website_id) — that mutates the
shipped view for every site and doesn't COW, the same trap as editing arch
([[everjust-website]] / [[everjust-platform]]).
-
Set the title/description here, not in the QWeb <head>. website.layout renders
<title> and metas from website_meta_*, overriding any <title> in the arch. Don't
hand-roll <meta> tags in the page body — they'll be shadowed. (Content edits are
[[everjust-website]] / [[everjust-website-snippets]]; metadata is this skill.)
-
website_published and website_indexed are independent — and shaped differently:
website_published is computed (publish state); website_indexed is stored on the
page (sitemap membership). Publishing a page does not add it to the sitemap-intent you
want, and marking website_indexed doesn't publish it. On this tenant 11 pages are
indexed but only 5 published — decide both per page (recipe 3), and beware drafts
marked crawlable that will surface in /sitemap.xml the instant they publish.
-
is_seo_optimized is computed and read-only. It flips true only when BOTH title and
description are set. Don't try to write it — write the two fields and re-read it to
confirm. Use it as your backfill checklist (recipe 2).
-
og_img is a URL/path; social_default_image IS a binary. Per-page
website_meta_og_img takes an image URL (/web/image/... or static). The site-wide
fallback website.social_default_image is a binary (base64). Different field types,
different write shapes. The fallback chain is page og_img → site
→ company logo.
See also
- [[everjust-website]] — edit page CONTENT/layout (Tailwind-QWeb arch, COW, publish/menu/
redirect). Its SEO recipe #6 is a subset; this skill is the full discoverability layer.
- [[everjust-website-snippets]] — the on-brand
s_cd_* snippets + html_builder options
and the "wrap-don't-rewrite" method for the marketing ports.
- [[everjust-agent-mcp]] — connect to the tenant; the generic
search/get/create/
update/call/describe_model + website_* tool surface and the confirm gates.
- [[everjust-platform]] — COW, the
/odoo debrand, everjust.public_website, re-sweep on
-u, structural-model confirm gates, never self-escalate, Odoo-19 group_ids.
- [[godaddy-api]] — registrar DNS (domain-property GSC verification, the site's own domain),
a separate system from these fields.