EVERJUST Website Forms — Agent Skill
Wire a public website form to a backend model on a live everjust.app tenant, and
route where its submissions land. Two halves: (1) the form target — a model must be
opted in (ir.model.website_form_access = True) before the framework will accept a POST
to it; (2) the placement — an HTML <form action="/website/form/<model>"> living in a
marketing page's QWeb arch. The default and most common target is crm.lead
("Contact Us" → a CRM lead), which the website_crm addon further routes with
medium=Website attribution and a default sales team/salesperson.
This is stock Odoo website (website_form) + website_crm under the everjust
white-label brand — the everjust addons don't change the form pipeline; they change the
page chrome it lives in (the pages are hand-written Tailwind QWeb, not drag-drop
snippets — see [[everjust-website]]). You drive it through the everjust_agent_mcp
server: the WEBSITE tools (website_edit_page / website_pages) to put the <form>
on a page, plus the generic ORM tools (search/get/update/describe_model/find)
to flip the model flags and set the routing config. See [[everjust-agent-mcp]] for opening
a session and the tool signatures; read [[everjust-platform]] for the tenancy invariants.
Note: on Odoo 19 there is no standalone website_form module — the form controller
ships inside the website addon (website/controllers/form.py); website_crm is what
adds the crm.lead routing. On the live connectdomain tenant website_crm is
installed (website_form as a separate module is not — it doesn't exist).
When to use this skill
- Add / fix a Contact-Us or lead-capture form on a marketing page that creates a
crm.lead on submit (the canonical case).
- Enable a NEW model as a form target — flip
website_form_access, set the human
label, and choose the field that catches free-text / unmapped inputs.
- Control the field whitelist — allow a field to be submittable (whitelist it via
ir.model.fields.formbuilder_whitelist) or block one from being writable by a public form.
- Route where website leads land — set the default sales team
(
website.crm_default_team_id) and salesperson (website.crm_default_user_id),
and understand the medium=Website attribution that gets stamped automatically.
- Understand / debug the
POST /website/form/<model> contract — why a field silently
dropped, why "The form's specified model does not exist", where custom fields go.
When NOT to use this skill
- Building the marketing page itself, nav, SEO, publish state, the COW arch mechanic —
that is [[everjust-website]]. This skill assumes you already know how to
website_edit_page
a page's arch; it only covers the <form> you put inside it.
- Reading, triaging, or replying to the submitted leads — that is [[everjust-crm-sales]]
(pipeline stages, assignment, won/lost). A form CREATES a lead; working it is CRM.
- Sending email / the
mail.mail "Send an E-mail" form target as real outbound mail —
mail.mail is a valid form target (label "Send an E-mail", default field body_html),
but the everjust NATIVE outbound stack is everjust.mail.*; see [[everjust-mail-ops]].
A website "email me" form still funnels through this same /website/form/mail.mail
path, but for real mailbox send/receive use mail-ops.
- Newsletter signup as a subscription flow — the target
mailing.contact
("Subscribe to Newsletter") is here, but the list/segment/consent logic is
[[everjust-website-newsletter]] / mass_mailing.
- On-brand drag-drop blocks — the
s_cd_* snippet library is
[[everjust-website-snippets]]; a website form is NOT one of those. You hand-write the
<form> markup in the page arch, matching the page's Tailwind vocabulary.
Architecture — how a form submission flows (grounded on the live connectdomain tenant)
public visitor submits <form action="/website/form/crm.lead" method="post"> (Tailwind QWeb on a page)
│ POST (auth="public", csrf=False, captcha='website_form')
▼
WebsiteForm.website_form(model_name) [odoo/addons/website/controllers/form.py]
│ 1. ir.model.search([model=model_name, website_form_access=True]) ← GATE
│ none → {"error":"The form's specified model does not exist"}
│ 2. extract_data(): keep only _get_form_writable_fields() (the WHITELIST);
│ unknown/blacklisted inputs become "custom" text; optional metadata
│ 3. website_form_input_filter(request, values) ← per-model hook
│ (crm.lead: stamps medium_id=Website, team_id/user_id, sets lead vs opp)
│ 4. insert_record(): create AS SUPERUSER; custom+meta text lands in
│ website_form_default_field_id (crm.lead → description)
▼
new crm.lead → worked in the pipeline via [[everjust-crm-sales]]
Key models & real fields (live-verified)
| Model |
Role |
Fields that matter here (real) |
ir.model |
The form-target registry. A model is a legal form target ONLY if its row here has website_form_access=True. |
model, website_form_access (bool — the gate), website_form_label (the human "Action" name shown in the builder), website_form_key (a short server-action key), website_form_default_field_id→ir.model.fields (the field that catches custom/unmapped inputs + metadata) |
ir.model.fields |
Per-field whitelist. Which of a target model's fields a PUBLIC form may write. |
model, name, website_form_blacklisted (bool — defaults to True, so it's a whitelist: a field is submittable ONLY when explicitly set False). Do NOT raw-write this on a standard field — flip it with the formbuilder_whitelist(model, fields) method (raw SQL, no registry reload; needs website.group_website_designer). The effective allow-list is computed by ir.model._get_form_writable_fields. |
crm.lead |
The default target — a website lead/opportunity. |
writable-by-form set (live): name, contact_name, partner_name (Company Name), email_from, phone, description, team_id, user_id, lead_properties. Plus medium_id/campaign_id/source_id set by the input filter, not the form. See [[everjust-crm-sales]] for the full model. |
website |
Holds the CRM routing defaults for forms (these are FIELDS on website, added by website_crm). |
crm_default_team_id→crm.team (Default Sales Team; live = "Sales" id 1), crm_default_user_id→res.users (Default Salesperson; live = empty/unassigned). |
utm.medium |
Attribution dimension. The website form stamps the "Website" medium (id 1 here). |
name ("Website") — resolved via utm.medium._fetch_or_create_utm_medium('website'). |
Live-tenant facts (connectdomain), verified — resolve at runtime, don't hardcode ids:
- Form-target models with
website_form_access=True: crm.lead (label "Create an
Opportunity", key create_lead, default field description), mailing.contact
("Subscribe to Newsletter", key create_mailing_contact, no default field),
project.task ("Create a Task", key create_task, default description), mail.mail
("Send an E-mail", key send_mail, default body_html).
crm.lead public-form-writable fields (the 9 non-blacklisted): name, contact_name,
partner_name, email_from, phone, description, team_id, user_id,
lead_properties. Everything else (stage, revenue, probability, tags…) is blacklisted —
a public form CANNOT set them.
website.crm_default_team_id = Sales (id 1); website.crm_default_user_id =
False (no default owner → lead is unassigned unless rule-based assignment picks it up).
medium=Website (utm.medium id 1) is stamped automatically by
crm.lead.website_form_input_filter on every form-created lead.
The POST /website/form/<model> contract (what the <form> must contain)
The route is @http.route('/website/form/<string:model_name>', type='http', auth="public", methods=['POST'], website=True, csrf=False, captcha='website_form').
Your <form> markup must therefore:
action="/website/form/<model>" and method="post" — <model> is the dotted model
name, e.g. crm.lead. It must be a website_form_access=True model or you get
{"error":"The form's specified model does not exist"}.
- Give each field an
name="<odoo_field_or_custom>". A name that matches an authorized
(non-blacklisted) field of the model is written to the record; any other name
becomes a "custom" line appended (as text) to the model's website_form_default_field_id
(for crm.lead that's the description — this is why stray inputs show up in the lead's
Notes, not lost).
- Special reserved inputs the controller consumes:
csrf_token (partial CSRF — only
enforced for authenticated sessions; anonymous submits skip it), website_form_signature
(an HMAC used only by the mail.mail email_cc path), context. Don't repurpose these
names for your own fields.
- Odoo's website JS submits it via AJAX and expects a JSON reply:
{"id": <new_id>} on
success, {"error": "..."} or {"error_fields": [...]} on failure. If you hand-roll
markup, mimic the standard s_website_form structure (a .s_website_form form) so the
shipped JS binds to it; the simplest safe path is to copy the arch of an existing working
Contact-Us form.
- CAPTCHA: the route is
captcha='website_form'. If the tenant has a captcha provider
configured, the form must render the widget; without a provider it's a no-op. Don't
assume a bare hand-built form passes captcha if one is enabled.
Because everjust marketing pages are hand-written Tailwind QWeb (not s_* snippets —
[[everjust-website]]), you place the form by editing the page arch and giving the form
elements the same Tailwind classes as the surrounding page. Keep the framework
form contract (action, field names, the AJAX/JSON reply) intact.
Recipes
Route each through the everjust_agent_mcp MCP against the tenant (see [[everjust-agent-mcp]]).
Website-page edits need an admin / website-designer role; the model-flag flips
(ir.model / ir.model.fields / website) need admin too. Always describe_model and
read your_access before a write.
1. Survey what's already wired (do this first)
// Which models can be form targets on this tenant? tool: search
{ "model": "ir.model",
"domain": [["website_form_access","=",true]],
"fields": ["model","website_form_label","website_form_key","website_form_default_field_id"] }
// → crm.lead / mailing.contact / project.task / mail.mail (see live facts above)
// Which crm.lead fields may a public form submit? tool: search
{ "model": "ir.model.fields",
"domain": [["model","=","crm.lead"],["website_form_blacklisted","=",false]],
"fields": ["name","field_description","ttype"] }
// → the 9-field allow-list; anything not here is dropped-to-custom, not written.
// Where do website leads currently route? tool: get
{ "model": "website", "ids": [1],
"fields": ["name","crm_default_team_id","crm_default_user_id"] }
Also list the pages (website_pages, see [[everjust-website]]) and get the arch of the
page you'll host the form on, so you keep its Tailwind idiom.
2. Place a Contact-Us form on a page (crm.lead target, COW-safe)
Fetch the page's current arch, insert a <form> block that posts to /website/form/crm.lead
using the page's Tailwind classes, then write the complete template back with
website_edit_page (it copy-on-writes into a website-specific view — see [[everjust-website]]):
// tool: website_edit_page (arch must be the FULL template, not a fragment)
{ "url": "/contact",
"arch": "<t name=\"Contact\" t-call=\"website.layout\"> … existing page …
<form action=\"/website/form/crm.lead\" method=\"post\"
class=\"s_website_form mx-auto w-full max-w-xl space-y-4\" data-mark=\"*\">
<input type=\"hidden\" name=\"csrf_token\" t-att-value=\"request.csrf_token()\"/>
<input name=\"contact_name\" placeholder=\"Your name\" class=\"w-full rounded-md …\"/>
<input name=\"email_from\" type=\"email\" required=\"1\" class=\"w-full rounded-md …\"/>
<input name=\"partner_name\" placeholder=\"Company\" class=\"w-full rounded-md …\"/>
<textarea name=\"description\" placeholder=\"How can we help?\" class=\"w-full rounded-md …\"/>
<button type=\"submit\" class=\"rounded-md bg-stone-900 px-5 py-2.5 text-white\">Send</button>
</form>
… rest of page … </t>" }
- Use field
names from the allow-list (recipe 1): contact_name, email_from,
partner_name, phone, description, name (the opportunity title). email_from is
the sensible required field. Anything else (e.g. name="budget") still works but lands
in the lead's description as a "custom" line — intended, not a bug.
- Do not add
name="stage_id", expected_revenue, tag_ids, etc. — they're
blacklisted; a public form can't set them (silently dropped-to-custom). Set those later
in the pipeline via [[everjust-crm-sales]].
- Keep
class="s_website_form" on the <form> (and the standard field-group structure if
you want native validation styling) so Odoo's website JS binds AJAX submit + the JSON
reply. Match every other class to the page's existing Tailwind vocabulary.
- The safest arch is a copy of an existing working form on this or a sibling tenant —
clone its structure, swap the field
names, restyle with the page's classes.
3. Route website leads to a team / salesperson (and know the medium)
The routing defaults are fields on the website record, not raw config params:
// Set the default sales team + owner for all website-form leads. tool: update
{ "model": "website", "ids": [1],
"values": { "crm_default_team_id": <team_id>, "crm_default_user_id": <user_id> } }
// resolve ids first: find(model="crm.team", name="Sales") ; find(model="res.users", name="…")
crm.lead.website_form_input_filter applies these at submit time: team_id ← the
form's value or website.crm_default_team_id; user_id ← form value or
website.crm_default_user_id; and if there's no user but a team and rule-based
assignment is OFF, it falls back to the team leader (team.user_id).
- It ALSO decides lead vs. opportunity: if the chosen team has
use_leads=True the
record is a lead, else an opportunity. On connectdomain the Sales team is
use_opportunities only, so website submissions land straight in the pipeline as
opportunities — matching the tenant's leads-funnel-OFF config (see [[everjust-crm-sales]]).
medium=Website is stamped automatically (utm.medium._fetch_or_create_utm_medium('website'))
— you don't set it on the form. That's how you later filter "leads from the website"
in CRM reporting (medium_id.name = "Website"). To also attribute a campaign_id /
source_id, add hidden inputs — but those field names must be whitelisted first
(recipe 5; they're not in the default allow-list), so prefer setting campaign in CRM post-hoc.
- The
crm_default_user_id many2one is domain-restricted to internal users
(share=False); you can't route website leads to a portal/share user.
4. Enable a NEW model as a form target + set its catch-all field
To let a form post to a model that isn't yet a target (e.g. a custom model), flip the
gate on ir.model and choose the field that absorbs custom/unmapped inputs. ir.model
is a structural model, so update needs confirm:true (see [[everjust-agent-mcp]]):
// 1. resolve the ir.model row + the field you want as the catch-all
find(model="ir.model.fields", name="…") // → id of e.g. the model's "description" field
// 2. enable it as a form target (structural → confirm) tool: update
{ "model": "ir.model", "ids": [<ir_model_id>],
"values": { "website_form_access": true,
"website_form_label": "Request a demo",
"website_form_default_field_id": <description_field_id> },
"confirm": true }
website_form_default_field_id is the field the controller dumps custom fields +
metadata into (as text). Pick a long-text/html field (like description). If you
leave it empty (as mailing.contact does), custom data is instead posted as a chatter
log message on the new record — fine only if the model has chatter.
website_form_label is the friendly action name; website_form_key is a short server
key (auto-generated if omitted). Both are cosmetic to the POST contract but populate the
builder UI.
- After enabling the model, its fields are still ALL blacklisted by default (the whitelist
starts empty) — enabling the model doesn't auto-allow any field. Whitelist the ones the
form submits with
formbuilder_whitelist (recipe 5).
5. Allow (or block) a specific field on a form (use the method, not a raw write)
The controller only writes fields in _get_form_writable_fields, driven by
ir.model.fields.website_form_blacklisted — which defaults to True (it's a
whitelist, not a blacklist). Do NOT update website_form_blacklisted directly on a
standard field: the ORM only lets you write it on custom fields and a successful write
triggers a costly registry reload. The supported path is the model method
formbuilder_whitelist(model, fields) (raw SQL, no reload), called via the call
tool — it's a mutating method so needs confirm:true and the connected user must be in
website.group_website_designer:
// Whitelist fields so a public form MAY write them. tool: call
{ "model": "ir.model.fields",
"method": "formbuilder_whitelist",
"args": ["crm.lead", ["mobile", "function"]],
"confirm": true }
// → true (both fields now submittable; the method validates they exist on the model)
- To re-blacklist (lock a field back down), set
website_form_blacklisted=true — that
direction is safe to do on standard fields via update because it doesn't add a custom
field, but it still needs an admin role. Simpler: just remove the field's name="…"
input from the form (recipe 2), since a blacklisted input drops-to-custom anyway.
- Only whitelist a field you genuinely want anonymous visitors to control. NEVER whitelist
assignment/pricing/status fields you don't want spoofable from the public internet
(
stage_id, probability, expected_revenue, priority, user_id if you route by
hand) — records are created as SUPERUSER, so the whitelist is the ONLY guard (Pitfall 5).
- Whitelisting alone doesn't add the field to any page — add a matching
name="<field>"
input to the form (recipe 2) afterward.
6. Debug "field didn't save" / "model does not exist"
// Is the target model actually enabled?
count(model="ir.model", domain=[["model","=","crm.lead"],["website_form_access","=",true]])
// → 0 means the /website/form/crm.lead POST returns {"error":"…model does not exist"}
// Is the field the form posts actually writable (not blacklisted)?
search(model="ir.model.fields",
domain=[["model","=","crm.lead"],["name","=","<the_field>"]],
fields=["name","website_form_blacklisted"])
// → website_form_blacklisted:true ⇒ the value silently went into the lead's `description`
// (the default field), it was NOT written to <the_field>.
// Did the lead land where expected? (medium, team, owner)
search(model="crm.lead", domain=[["medium_id.name","=","Website"]],
fields=["name","email_from","team_id","user_id","description","create_date"],
order="create_date desc", limit=10)
Most "the value disappeared" reports are a blacklisted field name → the input became a
custom line in description. Fix by un-blacklisting the field (recipe 5) + confirming the
form uses the exact field name.
Pitfalls
A model is NOT a form target until ir.model.website_form_access=True. Posting to
/website/form/<model> for any un-enabled model returns
{"error":"The form's specified model does not exist"} — the model exists, it's just
not opted in. Flip the flag (recipe 4, confirm:true); don't chase a "missing model".
Only whitelisted (non-blacklisted) fields are written; everything else silently
becomes text. An input whose name matches a blacklisted field — or no field at all —
is appended to the model's website_form_default_field_id (for crm.lead: description).
Nothing errors. So name="expected_revenue" on a public form does NOT set the revenue;
it dumps "expected_revenue : 5000" into the lead's Notes. Check the blacklist (recipe 6).
crm_default_team_id/crm_default_user_id are FIELDS on website, not
ir.config_parameter. The param key website.crm_default_team_id reads False; the
real values live on the website record. Set them with update on website (recipe 3),
not on ir.config_parameter. (The metadata toggle IS a config param, but note its real
key is website_form_enable_metadata, not website.form_enable_metadata.)
medium=Website is stamped by the input filter, not the form — and the team decides
lead-vs-opportunity. You don't (and can't, by default) set medium_id from the form;
website_form_input_filter sets it every time. That same filter picks type from the
team's use_leads. On connectdomain (Sales = opportunities-only) website submissions
become opportunities. Don't add a name="type" input expecting to force it.
Records are created AS SUPERUSER, but the field WHITELIST is the security boundary.
insert_record creates with SUPERUSER_ID, so the public visitor's (nonexistent) ACLs
don't apply — the whitelist is the ONLY thing stopping a visitor from setting a field.
That's exactly why un-blacklisting a sensitive field (recipe 5) is dangerous: you're
handing the anonymous internet write access to it. Un-blacklist only innocuous inputs.
The pages are hand-written Tailwind QWeb, not snippets — but keep the framework form
contract. Don't try to drag an s_website_form snippet via the builder over MCP
([[everjust-website]] Pitfall 3). Hand-write the <form action="/website/form/<model>">
in the page arch with the page's Tailwind classes, keep class="s_website_form" and the
csrf_token input so the shipped website JS binds AJAX submit and reads the JSON reply.
A form that doesn't match that structure may submit but not show success/error feedback.
website_edit_page overwrites the WHOLE view arch and copy-on-writes. As always
([[everjust-website]]), get the page's current arch, splice the <form> in, and send
the complete template back — a fragment truncates the page. The edit forks a
website-specific view; the shipped module view is untouched.
Editing form config runs AS YOUR user and is audited. Flipping website_form_access
/ un-blacklisting fields / setting website.crm_default_* needs an admin role;
without it you get AccessError. ir.model writes are structural → confirm:true.
Everything is logged to everjust.mcp.log and attributable to your key
(see [[everjust-agent-mcp]], [[everjust-platform]]). Odoo 19: the user's groups field is
group_ids (not groups_id) — relevant if you check a user's website-designer role.
mail.mail and mailing.contact are form targets too — but for real work use the
right skill. A /website/form/mail.mail form ("Send an E-mail") files a mail.mail,
but everjust's real outbound is everjust.mail.* ([[everjust-mail-ops]]).
mailing.contact ("Subscribe to Newsletter") captures a subscriber, but list/consent
logic is [[everjust-website-newsletter]]. Use this skill only to WIRE the form; hand off
the downstream logic.
CAPTCHA and CSRF behave differently for anonymous vs. signed-in. CSRF is only
enforced when the session is authenticated (embedded/anon forms skip it by design), and
the route carries captcha='website_form' — if a captcha provider is configured, a
hand-built form missing the widget can be rejected. Test the form as a logged-OUT
visitor, which is who actually submits it.
See also
- [[everjust-website]] — the page/arch/COW/publish mechanics you edit the
<form> INTO.
This skill is the form; that skill is the page.
- [[everjust-crm-sales]] — working the leads a form creates (stages, assignment, won/lost);
the model map for
crm.lead.
- [[everjust-agent-mcp]] — opening the MCP session, tool signatures, confirm-gating.
- [[everjust-platform]] — tenancy, the
/-vs-/odoo debrand, per-tenant invariants.
- [[everjust-website-snippets]] — the on-brand
s_cd_* block library (a form is NOT one).
- [[everjust-website-newsletter]] — the
mailing.contact subscribe-form downstream.
1---2name: everjust-website-forms3description: Wire a public website form on an everjust.app (Odoo 19) tenant to a backend model — enable a model as a form target, place the form on a marketing page, and route submissions (Contact-Us → CRM lead, with medium=Website attribution + team/salesperson assignment). Use when the task is to add or fix a Contact-Us / lead-capture / newsletter / task-request form, make a NEW model accept form submissions, understand the POST /website/form/<model> contract and its field whitelist, or set where website leads land (default team / salesperson). Plumbing is stock Odoo website + website_crm (Odoo 19 has NO separate website_form module — the /website/form controller lives in the website addon); drive it via the everjust_agent_mcp WEBSITE tools (the <form> is QWeb in a page arch) plus generic ORM (ir.model.website_form_access, formbuilder_whitelist on ir.model.fields, website.crm_default_team_id). Cross-references [[everjust-website]], [[everjust-crm-sales]], [[everjust-agent-mcp]], [[everjust-platform]].4---56# EVERJUST Website Forms — Agent Skill78Wire a **public website form** to a backend model on a live everjust.app tenant, and9route where its submissions land. Two halves: (1) the **form target** — a model must be10opted in (`ir.model.website_form_access = True`) before the framework will accept a POST11to it; (2) the **placement** — an HTML `<form action="/website/form/<model>">` living in a12marketing page's QWeb `arch`. The default and most common target is **`crm.lead`**13("Contact Us" → a CRM lead), which the `website_crm` addon further routes with14`medium=Website` attribution and a default sales **team**/**salesperson**.1516This is **stock Odoo `website` (`website_form`) + `website_crm`** under the everjust17white-label brand — the everjust addons don't change the form pipeline; they change the18*page chrome* it lives in (the pages are hand-written Tailwind QWeb, not drag-drop19snippets — see [[everjust-website]]). You drive it through the **`everjust_agent_mcp`**20server: the **WEBSITE tools** (`website_edit_page` / `website_pages`) to put the `<form>`21on a page, plus the **generic ORM tools** (`search`/`get`/`update`/`describe_model`/`find`)22to flip the model flags and set the routing config. See [[everjust-agent-mcp]] for opening23a session and the tool signatures; read [[everjust-platform]] for the tenancy invariants.2425Note: on Odoo 19 there is **no standalone `website_form` module** — the form controller26ships inside the `website` addon (`website/controllers/form.py`); `website_crm` is what27adds the `crm.lead` routing. On the live `connectdomain` tenant `website_crm` **is**28installed (`website_form` as a separate module is not — it doesn't exist).2930## When to use this skill3132- **Add / fix a Contact-Us or lead-capture form** on a marketing page that creates a33 `crm.lead` on submit (the canonical case).34- **Enable a NEW model as a form target** — flip `website_form_access`, set the human35 label, and choose the field that catches free-text / unmapped inputs.36- **Control the field whitelist** — allow a field to be submittable (whitelist it via37 `ir.model.fields.formbuilder_whitelist`) or block one from being writable by a public form.38- **Route where website leads land** — set the default sales **team**39 (`website.crm_default_team_id`) and **salesperson** (`website.crm_default_user_id`),40 and understand the `medium=Website` attribution that gets stamped automatically.41- **Understand / debug the `POST /website/form/<model>` contract** — why a field silently42 dropped, why "The form's specified model does not exist", where custom fields go.4344## When NOT to use this skill4546- **Building the marketing page itself, nav, SEO, publish state, the COW arch mechanic** —47 that is [[everjust-website]]. This skill assumes you already know how to `website_edit_page`48 a page's arch; it only covers the `<form>` you put *inside* it.49- **Reading, triaging, or replying to the submitted leads** — that is [[everjust-crm-sales]]50 (pipeline stages, assignment, won/lost). A form CREATES a lead; working it is CRM.51- **Sending email / the `mail.mail` "Send an E-mail" form target as real outbound mail** —52 `mail.mail` is a valid form target (label "Send an E-mail", default field `body_html`),53 but the everjust NATIVE outbound stack is `everjust.mail.*`; see [[everjust-mail-ops]].54 A website "email me" form still funnels through this same `/website/form/mail.mail`55 path, but for real mailbox send/receive use mail-ops.56- **Newsletter signup** as a subscription flow — the target `mailing.contact`57 ("Subscribe to Newsletter") is here, but the list/segment/consent logic is58 [[everjust-website-newsletter]] / mass_mailing.59- **On-brand drag-drop blocks** — the `s_cd_*` snippet library is60 [[everjust-website-snippets]]; a website form is NOT one of those. You hand-write the61 `<form>` markup in the page arch, matching the page's Tailwind vocabulary.6263---6465## Architecture — how a form submission flows (grounded on the live `connectdomain` tenant)6667```68public visitor submits <form action="/website/form/crm.lead" method="post"> (Tailwind QWeb on a page)69 │ POST (auth="public", csrf=False, captcha='website_form')70 ▼71 WebsiteForm.website_form(model_name) [odoo/addons/website/controllers/form.py]72 │ 1. ir.model.search([model=model_name, website_form_access=True]) ← GATE73 │ none → {"error":"The form's specified model does not exist"}74 │ 2. extract_data(): keep only _get_form_writable_fields() (the WHITELIST);75 │ unknown/blacklisted inputs become "custom" text; optional metadata76 │ 3. website_form_input_filter(request, values) ← per-model hook77 │ (crm.lead: stamps medium_id=Website, team_id/user_id, sets lead vs opp)78 │ 4. insert_record(): create AS SUPERUSER; custom+meta text lands in79 │ website_form_default_field_id (crm.lead → description)80 ▼81 new crm.lead → worked in the pipeline via [[everjust-crm-sales]]82```8384### Key models & real fields (live-verified)8586| Model | Role | Fields that matter here (real) |87|---|---|---|88| `ir.model` | **The form-target registry.** A model is a legal form target ONLY if its row here has `website_form_access=True`. | `model`, `website_form_access` (bool — the gate), `website_form_label` (the human "Action" name shown in the builder), `website_form_key` (a short server-action key), `website_form_default_field_id`→`ir.model.fields` (the field that catches custom/unmapped inputs + metadata) |89| `ir.model.fields` | **Per-field whitelist.** Which of a target model's fields a PUBLIC form may write. | `model`, `name`, `website_form_blacklisted` (bool — **defaults to `True`**, so it's a whitelist: a field is submittable ONLY when explicitly set `False`). Do NOT raw-write this on a standard field — flip it with the `formbuilder_whitelist(model, fields)` method (raw SQL, no registry reload; needs `website.group_website_designer`). The effective allow-list is computed by `ir.model._get_form_writable_fields`. |90| `crm.lead` | **The default target** — a website lead/opportunity. | writable-by-form set (live): `name`, `contact_name`, `partner_name` (Company Name), `email_from`, `phone`, `description`, `team_id`, `user_id`, `lead_properties`. Plus `medium_id`/`campaign_id`/`source_id` set by the input filter, not the form. See [[everjust-crm-sales]] for the full model. |91| `website` | **Holds the CRM routing defaults** for forms (these are FIELDS on `website`, added by `website_crm`). | `crm_default_team_id`→`crm.team` (Default Sales Team; live = **"Sales"** id 1), `crm_default_user_id`→`res.users` (Default Salesperson; live = **empty/unassigned**). |92| `utm.medium` | Attribution dimension. The website form stamps the **"Website"** medium (id 1 here). | `name` (`"Website"`) — resolved via `utm.medium._fetch_or_create_utm_medium('website')`. |9394**Live-tenant facts (connectdomain), verified — resolve at runtime, don't hardcode ids:**95- Form-target models with `website_form_access=True`: `crm.lead` (label "Create an96 Opportunity", key `create_lead`, default field **`description`**), `mailing.contact`97 ("Subscribe to Newsletter", key `create_mailing_contact`, **no** default field),98 `project.task` ("Create a Task", key `create_task`, default `description`), `mail.mail`99 ("Send an E-mail", key `send_mail`, default `body_html`).100- `crm.lead` public-form-writable fields (the 9 non-blacklisted): `name`, `contact_name`,101 `partner_name`, `email_from`, `phone`, `description`, `team_id`, `user_id`,102 `lead_properties`. Everything else (stage, revenue, probability, tags…) is blacklisted —103 a public form CANNOT set them.104- `website.crm_default_team_id` = **Sales** (id 1); `website.crm_default_user_id` =105 **False** (no default owner → lead is unassigned unless rule-based assignment picks it up).106- `medium=Website` (utm.medium id 1) is stamped automatically by107 `crm.lead.website_form_input_filter` on every form-created lead.108109### The `POST /website/form/<model>` contract (what the `<form>` must contain)110111The route is `@http.route('/website/form/<string:model_name>', type='http',112auth="public", methods=['POST'], website=True, csrf=False, captcha='website_form')`.113Your `<form>` markup must therefore:114115- `action="/website/form/<model>"` and `method="post"` — `<model>` is the dotted model116 name, e.g. `crm.lead`. It **must** be a `website_form_access=True` model or you get117 `{"error":"The form's specified model does not exist"}`.118- Give each field an `name="<odoo_field_or_custom>"`. A `name` that matches an authorized119 (non-blacklisted) field of the model is written to the record; **any other `name`120 becomes a "custom" line** appended (as text) to the model's `website_form_default_field_id`121 (for `crm.lead` that's the `description` — this is why stray inputs show up in the lead's122 Notes, not lost).123- Special reserved inputs the controller consumes: `csrf_token` (partial CSRF — only124 enforced for *authenticated* sessions; anonymous submits skip it), `website_form_signature`125 (an HMAC used only by the `mail.mail` email_cc path), `context`. Don't repurpose these126 names for your own fields.127- Odoo's website JS submits it via AJAX and expects a JSON reply: `{"id": <new_id>}` on128 success, `{"error": "..."}` or `{"error_fields": [...]}` on failure. If you hand-roll129 markup, mimic the standard `s_website_form` structure (a `.s_website_form` form) so the130 shipped JS binds to it; the simplest safe path is to copy the arch of an existing working131 Contact-Us form.132- **CAPTCHA**: the route is `captcha='website_form'`. If the tenant has a captcha provider133 configured, the form must render the widget; without a provider it's a no-op. Don't134 assume a bare hand-built form passes captcha if one is enabled.135136Because everjust marketing pages are **hand-written Tailwind QWeb** (not `s_*` snippets —137[[everjust-website]]), you place the form by editing the page arch and giving the form138elements the **same Tailwind classes** as the surrounding page. Keep the framework139form contract (`action`, field `name`s, the AJAX/JSON reply) intact.140141---142143## Recipes144145Route each through the `everjust_agent_mcp` MCP against the tenant (see [[everjust-agent-mcp]]).146Website-page edits need an **admin / website-designer** role; the model-flag flips147(`ir.model` / `ir.model.fields` / `website`) need admin too. Always `describe_model` and148read `your_access` before a write.149150### 1. Survey what's already wired (do this first)151152```jsonc153// Which models can be form targets on this tenant? tool: search154{ "model": "ir.model",155 "domain": [["website_form_access","=",true]],156 "fields": ["model","website_form_label","website_form_key","website_form_default_field_id"] }157// → crm.lead / mailing.contact / project.task / mail.mail (see live facts above)158159// Which crm.lead fields may a public form submit? tool: search160{ "model": "ir.model.fields",161 "domain": [["model","=","crm.lead"],["website_form_blacklisted","=",false]],162 "fields": ["name","field_description","ttype"] }163// → the 9-field allow-list; anything not here is dropped-to-custom, not written.164165// Where do website leads currently route? tool: get166{ "model": "website", "ids": [1],167 "fields": ["name","crm_default_team_id","crm_default_user_id"] }168```169170Also list the pages (`website_pages`, see [[everjust-website]]) and `get` the arch of the171page you'll host the form on, so you keep its Tailwind idiom.172173### 2. Place a Contact-Us form on a page (crm.lead target, COW-safe)174175Fetch the page's current `arch`, insert a `<form>` block that posts to `/website/form/crm.lead`176using the page's Tailwind classes, then write the **complete** template back with177`website_edit_page` (it copy-on-writes into a website-specific view — see [[everjust-website]]):178179```jsonc180// tool: website_edit_page (arch must be the FULL template, not a fragment)181{ "url": "/contact",182 "arch": "<t name=\"Contact\" t-call=\"website.layout\"> … existing page …183 <form action=\"/website/form/crm.lead\" method=\"post\"184 class=\"s_website_form mx-auto w-full max-w-xl space-y-4\" data-mark=\"*\">185 <input type=\"hidden\" name=\"csrf_token\" t-att-value=\"request.csrf_token()\"/>186 <input name=\"contact_name\" placeholder=\"Your name\" class=\"w-full rounded-md …\"/>187 <input name=\"email_from\" type=\"email\" required=\"1\" class=\"w-full rounded-md …\"/>188 <input name=\"partner_name\" placeholder=\"Company\" class=\"w-full rounded-md …\"/>189 <textarea name=\"description\" placeholder=\"How can we help?\" class=\"w-full rounded-md …\"/>190 <button type=\"submit\" class=\"rounded-md bg-stone-900 px-5 py-2.5 text-white\">Send</button>191 </form>192 … rest of page … </t>" }193```194195- Use field **`name`s from the allow-list** (recipe 1): `contact_name`, `email_from`,196 `partner_name`, `phone`, `description`, `name` (the opportunity title). `email_from` is197 the sensible required field. Anything else (e.g. `name="budget"`) still works but lands198 in the lead's **`description`** as a "custom" line — intended, not a bug.199- Do **not** add `name="stage_id"`, `expected_revenue`, `tag_ids`, etc. — they're200 blacklisted; a public form can't set them (silently dropped-to-custom). Set those later201 in the pipeline via [[everjust-crm-sales]].202- Keep `class="s_website_form"` on the `<form>` (and the standard field-group structure if203 you want native validation styling) so Odoo's website JS binds AJAX submit + the JSON204 reply. Match every other class to the page's existing Tailwind vocabulary.205- The safest arch is a **copy of an existing working form** on this or a sibling tenant —206 clone its structure, swap the field `name`s, restyle with the page's classes.207208### 3. Route website leads to a team / salesperson (and know the medium)209210The routing defaults are **fields on the `website` record**, not raw config params:211212```jsonc213// Set the default sales team + owner for all website-form leads. tool: update214{ "model": "website", "ids": [1],215 "values": { "crm_default_team_id": <team_id>, "crm_default_user_id": <user_id> } }216// resolve ids first: find(model="crm.team", name="Sales") ; find(model="res.users", name="…")217```218219- `crm.lead.website_form_input_filter` applies these at submit time: `team_id` ← the220 form's value **or** `website.crm_default_team_id`; `user_id` ← form value **or**221 `website.crm_default_user_id`; and if there's no user but a team and rule-based222 assignment is OFF, it falls back to the **team leader** (`team.user_id`).223- It ALSO decides **lead vs. opportunity**: if the chosen team has `use_leads=True` the224 record is a `lead`, else an `opportunity`. On connectdomain the Sales team is225 `use_opportunities` only, so website submissions land straight in the pipeline as226 **opportunities** — matching the tenant's leads-funnel-OFF config (see [[everjust-crm-sales]]).227- **`medium=Website`** is stamped automatically (`utm.medium._fetch_or_create_utm_medium('website')`)228 — you don't set it on the form. That's how you later filter "leads from the website"229 in CRM reporting (`medium_id.name = "Website"`). To also attribute a `campaign_id` /230 `source_id`, add hidden inputs — but those field `name`s must be whitelisted first231 (recipe 5; they're not in the default allow-list), so prefer setting campaign in CRM post-hoc.232- The `crm_default_user_id` many2one is domain-restricted to **internal** users233 (`share=False`); you can't route website leads to a portal/share user.234235### 4. Enable a NEW model as a form target + set its catch-all field236237To let a form post to a model that isn't yet a target (e.g. a custom model), flip the238gate on `ir.model` and choose the field that absorbs custom/unmapped inputs. **`ir.model`239is a structural model**, so `update` needs **`confirm:true`** (see [[everjust-agent-mcp]]):240241```jsonc242// 1. resolve the ir.model row + the field you want as the catch-all243find(model="ir.model.fields", name="…") // → id of e.g. the model's "description" field244// 2. enable it as a form target (structural → confirm) tool: update245{ "model": "ir.model", "ids": [<ir_model_id>],246 "values": { "website_form_access": true,247 "website_form_label": "Request a demo",248 "website_form_default_field_id": <description_field_id> },249 "confirm": true }250```251252- `website_form_default_field_id` is the field the controller dumps **custom fields +253 metadata** into (as text). Pick a long-text/`html` field (like `description`). If you254 leave it empty (as `mailing.contact` does), custom data is instead posted as a **chatter255 log message** on the new record — fine only if the model has chatter.256- `website_form_label` is the friendly action name; `website_form_key` is a short server257 key (auto-generated if omitted). Both are cosmetic to the POST contract but populate the258 builder UI.259- After enabling the model, its fields are still ALL blacklisted by default (the whitelist260 starts empty) — enabling the *model* doesn't auto-allow any field. Whitelist the ones the261 form submits with `formbuilder_whitelist` (recipe 5).262263### 5. Allow (or block) a specific field on a form (use the method, not a raw write)264265The controller only writes fields in `_get_form_writable_fields`, driven by266`ir.model.fields.website_form_blacklisted` — which **defaults to `True`** (it's a267whitelist, not a blacklist). **Do NOT `update` `website_form_blacklisted` directly on a268standard field**: the ORM only lets you write it on *custom* fields and a successful write269triggers a costly registry reload. The supported path is the model method270**`formbuilder_whitelist(model, fields)`** (raw SQL, no reload), called via the `call`271tool — it's a mutating method so needs **`confirm:true`** and the connected user must be in272`website.group_website_designer`:273274```jsonc275// Whitelist fields so a public form MAY write them. tool: call276{ "model": "ir.model.fields",277 "method": "formbuilder_whitelist",278 "args": ["crm.lead", ["mobile", "function"]],279 "confirm": true }280// → true (both fields now submittable; the method validates they exist on the model)281```282283- To **re-blacklist** (lock a field back down), set `website_form_blacklisted=true` — that284 direction is safe to do on standard fields via `update` because it doesn't add a custom285 field, but it still needs an admin role. Simpler: just remove the field's `name="…"`286 input from the form (recipe 2), since a blacklisted input drops-to-custom anyway.287- Only whitelist a field you genuinely want anonymous visitors to control. NEVER whitelist288 assignment/pricing/status fields you don't want spoofable from the public internet289 (`stage_id`, `probability`, `expected_revenue`, `priority`, `user_id` if you route by290 hand) — records are created as SUPERUSER, so the whitelist is the ONLY guard (Pitfall 5).291- Whitelisting alone doesn't add the field to any page — add a matching `name="<field>"`292 input to the form (recipe 2) afterward.293294### 6. Debug "field didn't save" / "model does not exist"295296```jsonc297// Is the target model actually enabled?298count(model="ir.model", domain=[["model","=","crm.lead"],["website_form_access","=",true]])299// → 0 means the /website/form/crm.lead POST returns {"error":"…model does not exist"}300301// Is the field the form posts actually writable (not blacklisted)?302search(model="ir.model.fields",303 domain=[["model","=","crm.lead"],["name","=","<the_field>"]],304 fields=["name","website_form_blacklisted"])305// → website_form_blacklisted:true ⇒ the value silently went into the lead's `description`306// (the default field), it was NOT written to <the_field>.307308// Did the lead land where expected? (medium, team, owner)309search(model="crm.lead", domain=[["medium_id.name","=","Website"]],310 fields=["name","email_from","team_id","user_id","description","create_date"],311 order="create_date desc", limit=10)312```313314Most "the value disappeared" reports are a **blacklisted field name** → the input became a315custom line in `description`. Fix by un-blacklisting the field (recipe 5) + confirming the316form uses the exact field `name`.317318---319320## Pitfalls3213221. **A model is NOT a form target until `ir.model.website_form_access=True`.** Posting to323 `/website/form/<model>` for any un-enabled model returns324 `{"error":"The form's specified model does not exist"}` — the model exists, it's just325 not opted in. Flip the flag (recipe 4, `confirm:true`); don't chase a "missing model".3263272. **Only whitelisted (non-blacklisted) fields are written; everything else silently328 becomes text.** An input whose `name` matches a blacklisted field — or no field at all —329 is appended to the model's `website_form_default_field_id` (for `crm.lead`: `description`).330 Nothing errors. So `name="expected_revenue"` on a public form does NOT set the revenue;331 it dumps "expected_revenue : 5000" into the lead's Notes. Check the blacklist (recipe 6).3323333. **`crm_default_team_id`/`crm_default_user_id` are FIELDS on `website`, not334 `ir.config_parameter`.** The param key `website.crm_default_team_id` reads False; the335 real values live on the `website` record. Set them with `update` on `website` (recipe 3),336 not on `ir.config_parameter`. (The metadata toggle IS a config param, but note its real337 key is **`website_form_enable_metadata`**, not `website.form_enable_metadata`.)3383394. **`medium=Website` is stamped by the input filter, not the form — and the team decides340 lead-vs-opportunity.** You don't (and can't, by default) set `medium_id` from the form;341 `website_form_input_filter` sets it every time. That same filter picks `type` from the342 team's `use_leads`. On connectdomain (Sales = opportunities-only) website submissions343 become **opportunities**. Don't add a `name="type"` input expecting to force it.3443455. **Records are created AS SUPERUSER, but the field WHITELIST is the security boundary.**346 `insert_record` creates with `SUPERUSER_ID`, so the public visitor's (nonexistent) ACLs347 don't apply — the whitelist is the ONLY thing stopping a visitor from setting a field.348 That's exactly why un-blacklisting a sensitive field (recipe 5) is dangerous: you're349 handing the anonymous internet write access to it. Un-blacklist only innocuous inputs.3503516. **The pages are hand-written Tailwind QWeb, not snippets — but keep the framework form352 contract.** Don't try to drag an `s_website_form` snippet via the builder over MCP353 ([[everjust-website]] Pitfall 3). Hand-write the `<form action="/website/form/<model>">`354 in the page arch with the page's Tailwind classes, keep `class="s_website_form"` and the355 `csrf_token` input so the shipped website JS binds AJAX submit and reads the JSON reply.356 A form that doesn't match that structure may submit but not show success/error feedback.3573587. **`website_edit_page` overwrites the WHOLE view arch and copy-on-writes.** As always359 ([[everjust-website]]), `get` the page's current arch, splice the `<form>` in, and send360 the **complete** template back — a fragment truncates the page. The edit forks a361 website-specific view; the shipped module view is untouched.3623638. **Editing form config runs AS YOUR user and is audited.** Flipping `website_form_access`364 / un-blacklisting fields / setting `website.crm_default_*` needs an **admin** role;365 without it you get `AccessError`. `ir.model` writes are structural → `confirm:true`.366 Everything is logged to `everjust.mcp.log` and attributable to your key367 (see [[everjust-agent-mcp]], [[everjust-platform]]). Odoo 19: the user's groups field is368 `group_ids` (not `groups_id`) — relevant if you check a user's website-designer role.3693709. **`mail.mail` and `mailing.contact` are form targets too — but for real work use the371 right skill.** A `/website/form/mail.mail` form ("Send an E-mail") files a `mail.mail`,372 but everjust's real outbound is `everjust.mail.*` ([[everjust-mail-ops]]).373 `mailing.contact` ("Subscribe to Newsletter") captures a subscriber, but list/consent374 logic is [[everjust-website-newsletter]]. Use this skill only to WIRE the form; hand off375 the downstream logic.37637710. **CAPTCHA and CSRF behave differently for anonymous vs. signed-in.** CSRF is only378 enforced when the session is authenticated (embedded/anon forms skip it by design), and379 the route carries `captcha='website_form'` — if a captcha provider is configured, a380 hand-built form missing the widget can be rejected. Test the form as a logged-OUT381 visitor, which is who actually submits it.382383## See also384385- [[everjust-website]] — the page/arch/COW/publish mechanics you edit the `<form>` INTO.386 This skill is the form; that skill is the page.387- [[everjust-crm-sales]] — working the leads a form creates (stages, assignment, won/lost);388 the model map for `crm.lead`.389- [[everjust-agent-mcp]] — opening the MCP session, tool signatures, confirm-gating.390- [[everjust-platform]] — tenancy, the `/`-vs-`/odoo` debrand, per-tenant invariants.391- [[everjust-website-snippets]] — the on-brand `s_cd_*` block library (a form is NOT one).392- [[everjust-website-newsletter]] — the `mailing.contact` subscribe-form downstream.