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), anddate_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_tiktokon thewebsiteso they become schema.orgsameAs(a citation/authority signal AI reads), and setsocial_default_imageas the fallback OG card. - Edit
robots.txt— the site-widewebsite.robots_txt(per-site override). - Verify Google Search Console — the
google_search_consoletoken onwebsite. - Enable IndexNow — set the
everjust_visibility.indexnow_keyconfig 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_visibilityfrom thewebsiterecord.
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_consolefield 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 with the website in context copy-on-writes a site-specific fork. |
arch, key, type='qweb', website_id (empty = module view; set = COW fork), plus the inherited website_meta_* / seo_name / is_seo_optimized |
website |
The site record — source of the site-wide social/OG/robots signals and the JSON-LD feed. | robots_txt (html — the editable robots.txt), social_default_image (binary — default OG card) + has_social_default_image (computed), social_facebook / social_linkedin / social_twitter / social_github / social_instagram / social_youtube / social_tiktok / social_discord (char URLs — note which reach sameAs, below), google_search_console (char — verification token), google_analytics_key, plausible_site / plausible_shared_key, name, domain, company_id |
ir.config_parameter |
Structural config (confirm:true to write). Holds everjust_visibility.indexnow_key (enables IndexNow) and everjust.public_website (the public-site gate, '1' here). |
key, value |
ir.cron |
The opt-in IndexNow cron, xmlid everjust_visibility.ir_cron_everjust_indexnow — ships active=False (id 35 here). |
active, code (=model._everjust_indexnow_cron()), model_id→website, interval_number=1 interval_type='days' |
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
updateonwebsite.pagewrites through to the view. Going through thewebsite.pagewrite is the correct, COW-safe path — prefer it. - If you instead wrote
website_meta_*directly on a moduleir.ui.view(a view whosewebsite_idis 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 thewebsite.pagerow, never on a rawir.ui.view. is_seo_optimizedis 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), andemail(only ifcompany.emailis set). - a WebSite block with a
SearchActiontargeting{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:true. |
| Read a page's view (rarely needed) | generic get on ir.ui.view |
Only to confirm what view a page points at. |
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
// tool: website_pages (no args → the tenant's one website)
{}
// → { website:{id,name,domain},
// pages:[{id,url,name,view_id,published,indexed,in_menu,website_specific}], _count }
Then pull the SEO columns the page tool doesn't return, in one read:
// tool: get (generic, on website.page)
{ "model": "website.page", "ids": [/* all 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):
// tool: update (on website.page)
{ "model": "website.page", "ids": [<page_id>],
"values": {
"website_meta_title": "Pricing — Connect Domain", // <title> + og:title
"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", // clean canonical slug
"website_meta_og_img": "/web/image/website/1/social_default_image" // or a page-specific image URL
} }
// is_seo_optimized flips true once title+description are both set (computed; read to confirm)
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 == falseANDwebsite_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; setseo_namewhere the URL isn't already a clean slug. updatein a loop, then re-run recipe 1 and confirm every published page now readsis_seo_optimized:true.
3. Control sitemap membership + publish + schedule
// publish + include in /sitemap.xml // tool: website_publish
{ "url": "/pricing", "published": true, "indexed": true }
// keep live but REMOVE from the sitemap (e.g. a thin/duplicate page)
{ "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:
// tool: update (on website.page)
{ "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):
// tool: update (on website)
{ "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):
// tool: update (on website)
{ "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.
// (a) set the key — any string; it's the shared secret AND the key-file body.
// tool: create on ir.config_parameter (or update if it already exists) — STRUCTURAL → confirm:true
{ "model": "ir.config_parameter",
"values": { "key": "everjust_visibility.indexnow_key", "value": "<32-64 char key>" },
"confirm": true }
// (b) enable the daily submission cron (ships active=False) — STRUCTURAL → confirm:true
// xmlid everjust_visibility.ir_cron_everjust_indexnow (id 35 here); code = model._everjust_indexnow_cron()
// tool: update on ir.cron
{ "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:
// tool: call (non-read → confirm:true)
{ "model": "website", "ids": [1], "method": "_everjust_indexnow_submit", "confirm": true }
// → the HTTP status int (200/202) on success; False if no key is set or the POST failed
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_nameonwebsite.pagearerelated='view_id.…', store=False(writable). Set them viaupdateon thewebsite.pagerow (correct, COW-safe path). Never writewebsite_meta_*onto a raw moduleir.ui.view(emptywebsite_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.layoutrenders<title>and metas fromwebsite_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_publishedandwebsite_indexedare independent — and shaped differently:website_publishedis computed (publish state);website_indexedis stored on the page (sitemap membership). Publishing a page does not add it to the sitemap-intent you want, and markingwebsite_indexeddoesn'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.xmlthe instant they publish.is_seo_optimizedis 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_imgis a URL/path;social_default_imageIS a binary. Per-pagewebsite_meta_og_imgtakes an image URL (/web/image/...or static). The site-wide fallbackwebsite.social_default_imageis a binary (base64). Different field types, different write shapes. The fallback chain is pageog_img→ sitesocial_default_image→ company logo.You don't author the JSON-LD — you feed it. The Organization/WebSite structured data on every page is generated by
website._everjust_jsonld()from thewebsiterecord (company name/email/logo + socialsameAs). To change it, change those fields — do NOT inject your own<script type="application/ld+json">into a page arch (you'd get duplicate/competing blocks). The generator swallows every error and emits empty markup, so a bad social value fails silently — always verify by fetching a page and greppingapplication/ld+json.Seven social fields reach
sameAs;social_discorddoes NOT. The addon's_SOCIAL_FIELDSisfacebook, linkedin, twitter, github, instagram, youtube, tiktok.social_discordexists as a field onwebsitebut is NOT in that list, so it never appears in the JSON-LDsameAs— set it if you want, but it won't be a citation signal. Values muststartswith("http"). (If a task genuinely needs Discord insameAs, that's an addon-code change to_SOCIAL_FIELDSineverjust_visibility, not an MCP write — flag it; see [[everjust-website-snippets]] for the "author QWeb, flag py/JS" boundary.)IndexNow is Bing/Yandex-only, confirm-gated, and OFF by default. The key config param, the cron toggle, AND the one-shot
_everjust_indexnow_submitcall each needconfirm:true(structural models / non-read call), and the cron shipsactive=False, so nothing submits until you explicitly enable BOTH the key and the cron. It never reaches Google. Setting the key also starts serving/everjust-indexnow-key.txt(was 404) — that's expected. Don't enable it on a site whose/sitemap.xmlis wrong or whose pages aren't ready to be crawled.everjust_visibilitydoes NOT override robots.txt — Odoo'swebsite.robots_txtdoes. The addon deliberately ships no robots override (the AI-crawler Content-Signals override is an un-shipped follow-up); its extra routes (/llms.txt,/.well-known/security.txt,/everjust-indexnow-key.txt) are all NEW andsitemap=False. To change robots, editwebsite.robots_txt(recipe 5); don't look for an addon route. Keep theSitemap:line and don'tDisallow: /a live public site.The public site (and thus all of this) only matters when
everjust.public_website == '1'. If/bounces to/odoothe site is app-only — SEO/sitemap/JSON-LD render for nobody. That gate is anir.config_parameter(confirm:trueto write) and flipping it is platform-ops, not SEO work ([[everjust-platform]]). On the connectdomain tenant it's'1'(public site live).You act with YOUR role, everything scoped by
website_id. SEO writes need admin / website-designer (elseAccessError), run as the connected user, and are audit-logged. Odoo 19 note: the users' group field isgroup_ids, notgroups_id— check your role viagroup_idsif you introspect. Onewebsiteper tenant (id 1 here) — confirm you're on the right tenant DB before a backfill pass (see [[everjust-agent-mcp]] / [[everjust-platform]]).
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_builderoptions 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
/odoodebrand,everjust.public_website, re-sweep on-u, structural-model confirm gates, never self-escalate, Odoo-19group_ids. - [[godaddy-api]] — registrar DNS (domain-property GSC verification, the site's own domain), a separate system from these fields.