Meridian handbook
Destination platform (engine-dmo)

Portal screens and actions

How to give one site its own portal path, routes and hidden screens, declare portal screens with scoped data and portal actions that write documents or custom fields, restyle the reference portal's templates, and build a portal of your own on the member API.

engine-dmo

This feature is part of the DMO bundle (engine-dmo).

The engine’s reference portal covers the work every partner does. A site can go further without writing Go: it can move and trim its portal, add screens (pages with data the engine reads for the partner’s own organisation) and actions (forms that write a document of the organisation or its custom fields), and restyle every template. Or it can switch the screens off and build its own portal on the member API.

All of this lives in one file per site, config/sites/<site>/portal.yaml, beside templates under the site’s template chain.

A screen and an action

config/sites/main/portal.yaml
path: /partners
hide: [events]
screens:
  - key: overview
    path: /organisations/{org}/overview
    template: overview.html
    title: { en-GB: Overview }
    nav: { group: business, order: 10 }
    can: listing.update
    data:
      listings: { documents: { type: listing, states: [draft, published], sort: -updatedAt, limit: 5 } }
      figures: { report: { range: last_month } }
      membership: { membership: {} }
      waiting: { pending: { limit: 5 } }
      nearby: { published: { type: listing, near: $org.listing, within: 2km, sort: distance, limit: 6 } }
actions:
  - key: preferences.update
    label: { en-GB: Update your preferences }
    can: organisation.update
    target:
      organisation: { fields: [custom.preferredContactTime, custom.coachParking] }
    rate_limit: 20/hour/person
    respond: { redirect: { screen: overview } }
templates/main/portal/overview.html
{% extends "_layout.html" %}
{% block title %}{{ screen.title }}{% endblock %}
{% block main %}
<h1>{{ screen.title }}</h1>
<ul>
  {% for l in data.listings.items %}<li>{% if l.href %}<a href="{{ l.href|url }}">{{ l.title }}</a>{% else %}{{ l.title }}{% endif %}</li>{% endfor %}
</ul>
{% set f = portal_form('preferences.update') %}
<form {{ f.attrs }}>
  {{ f.hidden }}
  {% for field in f.fields %}{{ field_widget(field, f.errors) }}{% endfor %}
  <p><button class="p-button" type="submit">{{ f.label }}</button></p>
</form>
{% endblock %}

The action writes custom fields declared in config/crm/organisation.yaml (see CRM). The template extends _layout.html, the portal’s layout, which engine-dmo portal scaffold --site main copies into templates/main/portal/ (see Restyling the portal).

Then:

engine-dmo check --fix --project .
engine-dmo portal check --project .

check --fix writes the screen’s scr_ id and the action’s act_ id. engine-dmo dev portal screen add and engine-dmo dev portal action add write the same entries from flags, with --scaffold writing a starting template for a screen.

Per-site portals

config/sites/<site>/portal.yaml is laid over the install’s config/portal/portal.yaml. Anything it leaves out keeps the install’s value.

KeyMeaning
modetemplated, api, both or off, for this site
pathWhere the portal answers on this site’s host, such as /partners. Defaults to server.portal_path.
template_dirThe directory under each of the site’s template roots that holds its portal templates
routes.<screen>A reference screen’s path under the portal path, as in the install file
hideReference screens this site does not serve: any of contact_details, contact_team, enquiries, events, hours, listing_edit, membership, people, photos, promotions, report
screensThe site’s own screens, and replacements of reference screens’ templates
actionsThe site’s own actions

The report rules and invitation timings stay install-wide. A link the engine builds to a hidden screen (in the portal, in mail, in the admin) is never made.

Screens

A screen is a page under the portal path, rendered from a template under the site’s portal template directory, with declared data the engine reads for the person’s active organisation. Templates never query; the data is a closed set of sources, each scoped in the database to the organisation, the person’s role and the site’s network.

KeyRequiredMeaning
keyyesLowercase letters, digits and underscores. Unique on the site.
idwritten by check --fixThe stable id (scr_)
pathyes, for a new screenThe path under the portal path, with {org}, {id} or no placeholder. Must not match the same addresses as another screen.
templateyesAn .html file under the site’s portal template directory, found along the site’s template chain
titlenoThe title per locale, which the navigation shows; the key humanised when absent
navno{ group: <lowercase word>, order: <number> } puts the screen in the portal navigation. group defaults to business. A screen with {id} cannot be in the navigation.
cannoThe partner permission it needs: a built-in permission, another declared action.<key>, or screen.<key>. Defaults to screen.<key>, which a role must grant (the built-in manager holds screen.*).
tiersnoThe tiers whose organisations may open it, as a tier list. Needs {org} or {id} in the path.
datanoNamed data sources, read in the order declared

A screen keyed as a reference screen (organisation, report, people and so on) replaces that screen’s template and nothing else: it takes only key and template, keeps the reference screen’s path, permission and context, and cannot replace a hidden screen.

Data sources

Each entry under data: is <name>: { <source>: { <parameters> } }, and the template reads it as data.<name>. A list source gives { denied, items, count }; when the person’s role lacks a source’s extra permission, denied is true and items is empty.

SourceParametersGivesExtra permission
documentstype (required), states (draft, published or both; default both), sort (updatedAt, createdAt or title, - for descending), limit (default 50, at most 100)The organisation’s documents of the type. Each item: id, type, title, state, live, pending, site, created, updated, fields, href
documenttype (required)The document the path’s {id} names, which must be the organisation’s. Needs {id} in the path.
publishedtype (required), near ($org.listing), within (a distance such as 5km or 800m, needs near), from (today, for events with an occurrence from today), sort (name, recent or distance; distance needs near), limit (default 10, at most 100)Published documents of the type on the site. Each item: id, type, title, summary, uri, url, tags. With near, around the organisation’s published listing.
reportrange (last_12_months or last_month)The organisation’s member reportreport.view
enquiriessince (a period such as 30d), status (open, new, read, replied, archived, all; default open), limit (default 50, at most 200)Its enquiriesenquiries.view
pendinglimit (default 50, at most 200)Its changes waiting for staff. Each item: id, document, type, title, status, submitted, fields, yours, hrefpending.view
peoplenone ({})Its people: name, partner_role, is_primary, you
membershipnoneIts memberships by site: site, site_name, current, tier, status, joined_onmembership.view
organisationnoneThe organisation: id, name, legal_name, email, website, phone, address, social, custom
promotionslimit (default 50, at most 200)Its promotion requestspromotion.request
first_sessionnoneThe first-session checklist: steps (each label, href, done), remaining, total, complete

Every source except published without near reads the active organisation, so the screen’s path needs {org} or {id}; check says so at the line.

The screen template’s context

NameWhat it is
screenkey, id, title, path
data.<name>Each declared source
organisationThe active organisation, with its links to the reference screens and what the person may do (organisation.can.listing_update and so on)
memberThe signed-in person: id, name, displayName, email, organisations, activeOrganisation
personThe signed-in person’s names and email
portalpath, login, logout, account, assets, nav (the site’s own screens in the navigation), and portal.route(screen, id), the path of any screen
portal_form(key)A declared action’s form; see below
field_widget(field, errors)One accessible control for a field of a form
component(name, ...)One of the shipped components: asset, error_summary, field, link, opening_hours, place, portable_text, recurrence, status
site, locale, csrf, request, t()As on the public site, plus the public site’s globals

t() reads the site’s string catalogue first and then the portal’s own strings; portal check warns about a key in neither.

Actions

An action is a form the portal posts to {portal path}/actions/<key> (with ?id= for a document it edits, ?org= otherwise). It writes declared fields of one target, and it is refused unless the person’s role grants its permission and the organisation’s tier is one the action allows; both are decided in the database.

KeyRequiredMeaning
keyyes<subject>.<verb>, lowercase, one dot. The verb is one of create, update, remove, add, submit, request, invite. A built-in action’s key (profile.update, organisation.update, listing.update, photos.add, photos.remove, hours.update, people.invite, people.role, event.submit, promotion.request) cannot be reused.
idwritten by check --fixThe stable id (act_)
labelnoIts name per locale where a screen lists it; the key when absent
cannoThe partner permission it needs. Defaults to action.<key>.
tiersnoThe tiers whose organisations may use it; every tier and none when absent
targetyesOne of document, organisation, person; see below
fieldsfor a document targetThe document fields it writes
approvalnoDocument targets only. tier (the default): the tier’s member_editable rule decides whether the change waits. required: every change waits for staff.
rate_limitno<count>/<window>/<key>, keyed person, organisation, session or ip, such as 20/hour/person
respondno{ redirect: { screen: <screen> } }, a reference screen or one of the site’s own, answered with 303 after a good post. Absent, the post returns to the screen the form was on.

Targets:

TargetWrites
document: { type: offer, create: true }A new document of the type, owned by the active organisation
document: { type: offer, id: $id }The document named by ?id=, which must be the organisation’s
organisation: { fields: [custom.<name>, ...] }The active organisation’s custom fields, at once
person: { fields: [custom.<name>, ...] }The signed-in person’s custom fields, at once

A document action’s fields are also held to the tier’s member_editable rule: a field no tier the action allows may edit is warned about at load, and a save keeps only what the tier’s rule allows.

A document action from the reference example, which lets silver and gold businesses add offers:

actions:
  - key: offer.create
    label: { en-GB: Add an offer }
    can: listing.update
    tiers: { min: silver }
    target:
      document: { type: offer, create: true }
    fields: [title, summary, validUntil, bookingEmail, heroImage]
    approval: tier
    rate_limit: 20/hour/person
    respond: { redirect: { screen: offers } }

Rendering an action’s form

portal_form('<key>') (or portal_form('<key>', id) for a document it edits) returns the form for the active organisation. It is a template error when the site has no such action or the person may not use it, so a screen never shows a form that would be refused; wrap it in a check of the person’s permission when a screen is open to people who cannot use the action.

PropertyMeaning
attrsThe form element’s attributes: method, action, and the multipart encoding when a field uploads
hiddenThe hidden inputs (CSRF token, return address)
fieldsThe form description’s fields, each rendered with field_widget(field, f.errors)
errorsField errors after a failed post, with _summary and _count for an error summary (component('error_summary', f))
label, key, id, actionThe action’s label, key, id and post address
approvalrequired when changes will wait for staff

The reference organisation screen lists every action the person’s role and tier allow that needs no document (actions in its context).

Restyling the portal

The engine renders its own templates until a file of the same name exists in the site’s portal template directory, templates/<root>/<template_dir>/ along the site’s template chain. Copy the reference portal there to start:

engine-dmo portal scaffold --site main

This writes about forty files (screens, the _layout.html they extend, the claim flow and components/) into the site’s first template root, such as templates/main/portal/. Existing files are left alone unless you pass --force. Always pass --site. Without it the files go to templates/portal/, which is on no site’s template chain unless one of your roots is named portal, so none of them is ever rendered; check then warns that nothing in the folder renders. If that happened, delete templates/portal/ and run the command again with --site. To share one restyled portal between sites, pass --out templates/_shared/portal.

Then edit what you need and delete what you do not: a template you delete falls back to the engine’s.

CommandWhat it does
engine-dmo portal scaffold [--site <site>] [--out <dir>] [--force]Copy the reference portal’s templates into the project
engine-dmo portal checkPrint each site’s mode, path, template directory and screen routes, and check the portal templates against the portal context
engine-dmo portal statusWhat each site’s portal is serving and which templates the project overrides (needs the database)

The portal’s strings are the engine’s own catalogue; a project overrides one by adding the same key to config/strings/<locale>.yaml.

The member API

The member API serves the same data and the same rules as the portal as JSON, for a portal built with another toolchain. It is on when server.api.member.enabled is true (the default) and the site’s portal mode is api or both.

Base path/api/member/v1 on the site’s host
AuthenticationThe member session cookie (__Host-mrd_member) set by the portal’s sign-in screens, same origin only
Unsafe requestsCarry the CSRF token from GET /me in X-CSRF-Token
ErrorsRFC 9457 problem details; a 422 carries field_errors with structured paths
Rate limitendpoints.rate_limit.member per session on every write, and four times that per address
ContractGET /api/member/v1/openapi.json, or engine-dmo api member openapi
OperationMethod and pathPermission
getMeGET /meThe person, their organisations and the CSRF token
getOrganisation, patchOrganisationGET, PATCH /organisations/{id}organisation.update for details, hours.update for opening hours
listListingsGET /organisations/{id}/listingsThe organisation’s documents
getListing, patchListingGET, PATCH /listings/{id}listing.update; fields the tier does not permit are discarded and recorded
getListingFormGET /listings/{id}/formThe form description for this listing, person and tier
getListingStatusGET /listings/{id}/statusWhat is live, pending or returned, and the expected turnaround
listListingPhotos, arrangeListingPhotosGET, POST /listings/{id}/photosPhotos
listOrganisationAssetsGET /organisations/{id}/assetsThe organisation’s own uploads
uploadAsset, patchAssetPOST /assets, PATCH /assets/{id}Uploads (multipart), alt text and caption
getUploadTermsGET /upload-termsThe terms a member accepts with an upload
listPeople, invitePerson, setPersonRole, removePerson/organisations/{id}/people, /people/{link}people.invite, people.manage
listInvitationsGET /organisations/{id}/invitationsOpen invitations
getMembershipGET /organisations/{id}/membershipMemberships by site
getReportGET /organisations/{id}/reportThe member report: the last twelve months by default, range=last_month or month=2026-08
getActivityGET /organisations/{id}/activityMember-visible timeline entries
contactTeamPOST /organisations/{id}/contactA message to the team, recorded on the timeline
listEventsGET /eventsThe organisations’ event documents
engine-dmo api member routes          # every operation and whether this project serves it
engine-dmo api member openapi         # the OpenAPI 3.1 document
engine-dmo api member form --listing <id> --person <id>   # the form a given member would receive

Declared screens and actions are portal features; the member API does not expose them, and event submission has no API operation yet.

Troubleshooting

Message from checkFix
the <source> source reads the active organisation's data, and this screen's path has no {org} or {id}Add {org} to the path, such as /organisations/{org}/<key>.
"<name>" is not a screen data sourceUse one of the sources in the table.
screen <key> replaces the reference screen's template and takes no pathA screen keyed as a reference screen takes template only; give a new screen its own key.
unknown partner permission "<name>"Use a built-in permission, an action.<key> or a screen.<key>.
action key "<key>" is <subject>.<verb>: lowercase, one dotRename it, with one of the allowed verbs.
an action with input and steps ... is not built yetGive the action a target; a form that only stores a message is a declared endpoint with access: member.
tier "<tier>" is not sold on site <site> (warning)List the tier in the site’s memberships.tiers, or drop it from the rule.