Skip to content

Build a multi-user SaaS portal

Build a portal that three kinds of visitor use: an anonymous visitor who sees a public home page, a signed-in user who sees a dashboard of only their own records, and an administrator who sees an elevated view. This is the shape most SaaS applications share, and it is composed from platform primitives rather than switched on. Each step reuses a recipe for the mechanism and adds only the connective decisions the recipe does not cover.

Decide the boundary before you build. Name the resource users own, who may read and write it, and which pages are public. The public home page is unauthenticated, the user dashboard requires a signed-in user and shows only granted records, and the admin dashboard is an application-role decision layered on the same ownership pieces. Follow the steps in order; the collector preserves their explicit order as positions.

1. Model the resource and its ownership grant

Section titled “1. Model the resource and its ownership grant”

Define the resource users will own and a grant model that records which user may see which record. Plan the list fields and sort for the dashboard while you are here, and keep the grant model private.

Start a CRUD app with one model for the resource users will create and manage. In MeroFoundry, application data is stored dynamically: models describe the resource and its fields, while records carry the resource values. Keep the first model small—only the fields needed for the create form and list—so the page and form have a clear, shared contract.

Place the model in the intended application and workspace before creating records. Applications, models, fields, and records are workspace-scoped by tenant_id and workspace_id; that scope is part of the data boundary, not a display-only label.

For the list view, use a data block with the model id. The documented record-list example accepts model_id, page_size, sort, and order, and shows selected record fields through content.columns. Begin with an explicit sort such as created_at descending so the newest created record appears predictably.

Author the landing page every anonymous visitor sees. Leave its authentication gate off, keep it free of any protected record binding, and give it a clear path to sign in or sign up. Treat it as marketing plus a door, not as a data surface.

Make each interaction state visible in the page itself: label inputs, use a submit control for the write, and provide success feedback with on_success_toast. These are supported form-block configuration points, so the feedback does not depend on an undocumented custom flow.

Keep the create form and its list close together when the user needs to see the result. Form submissions update the client cache, and record-list and data-table blocks re-render when records change, allowing the interface to show the completed action without navigation.

Use local state deliberately. Expression comparisons treat both sides of == and != as dotted paths rather than literals, so seed a comparison value into local state when a block needs to compare against a fixed value.

Avoid path navigation for minor in-page state changes: ? and # links are shallowly routed, while a same-application path-changing href uses client-side navigation. Reserve redirects for a genuine change of page or workflow.

Add authentication so a visitor becomes a known application user. Verify that a real user can sign in and that their identity, not the builder’s, is the actor the runtime sees. Under the default registration_email_verification: required, registering issues no session: the user confirms the mailed link, which signs nobody in, and then signs in. Authentication establishes who the user is; it does not by itself decide which records they may read. Then follow Secure authentication for your app (secure-authentication) to choose the verification mode, password length and Turnstile, and to wire password reset, change-password and sign-out-everywhere.

Authenticate the caller before evaluating permissions. Keep identity verification separate from authorization, use narrowly scoped credentials, and fail closed when the principal or required scope is missing.

Signing in establishes who the consumer is; it does not by itself open a private model. A model whose anonymous_access.read is false grants a signed-in consumer of the same application only what its settings.consumer_reads says:

  • any_authenticated: every eligible row, even when consumer_owner_query is also set.
  • owner: only the rows the model’s consumer_owner_query returns for the consumer; owner without that query is rejected at save time (consumer_reads_owner_requires_owner_query).
  • absent or off: unchanged legacy behavior. A configured consumer_owner_query still scopes the consumer to its rows; without one the consumer reads no private rows.

The same decision applies on reference validation, edit-form loading, model-qualified record get and list, public action targets, page bindings, and record pickers. A consumer signed into a different application is treated as anonymous, and anonymous callers follow only anonymous_access.read, so any_authenticated never opens a model to signed-out visitors and never restricts a model that is already publicly readable. Reading grants nothing else: consumer_writes alone decides consumer create, update, and delete, and a public record action still needs the model’s public_record_actions permission. Model settings use replacement semantics, so send the complete merged settings object. See row-level-security, “Set the consumer read policy”, for the owner-query contract.

Self-registration proves email ownership by default (identity_settings.registration_email_verification is required). Handle all three modes and the duplicate case in every registration form:

  • required: the registration POST answers 202 with registered: true and emailVerification: "pending" and no session. Show “check your email”; do not redirect to a signed-in page. The user follows the mailed link, lands on verification_redirect_path (default /) with email_verification=verified, and then signs in.
  • optional: the usual session response with emailVerification: "sent"; the user is signed in and actor.email_verified stays false until the link is followed.
  • off: the usual session response; the identity starts verified.
  • An email that already has an account gets the required answer under required and optional, and a value-free 409 under off.

Page-builder form blocks on the identity model already present these outcomes. Turning on required or optional needs an outbound sender; without one the settings save is refused.

Email normalisation and uniqueness, enumeration-safe answers, rate limits, the per-account login backoff, refresh rotation and revocation on password change are automatic. Choose the verification mode, password_min_length and registration Turnstile, and wire forgot-password, reset-password, change-password and sign-out-everywhere as described in Secure authentication for your app (secure-authentication). Never build reset or verification tokens from rules and records, and never list a password field in a public record action.

Gate the dashboard page on a signed-in user and bind its list to the saved ownership query so each user sees only the records granted to them. This is the row-scope boundary: the server binds the current user on query execution, the creator-grant rule writes the grant on create, and closing the generic record routes removes the side doors. Apply the recipe to the resource from step 1.

Consumer reads follow one declarative model-level policy, consumer_reads. It decides what a signed-in consumer of the application may read on a private model, on every consumer read path: reference validation, edit-form loading, model-qualified record get and list, public action targets, page bindings, and record pickers. Its row scope comes from the model’s consumer_owner_query, a saved ownership query you author; there is no generic owner field or per-record ACL table. Consumer update/delete ownership is the separate consumer_writes policy. The application still composes the grant itself from generic pieces: workspace scoping, page authentication, a grant model of your own, rules that write grants, and the ownership query. This recipe covers those pieces and is explicit about where each one stops.

Treat tenant, workspace, and application ownership as the first record-access boundary. The platform documents applications, models, fields, and records as workspace-scoped by both tenant_id and workspace_id; create the CRUD resource in the intended application rather than treating a client-side filter as isolation.

Require authentication on a page that should only render to signed-in public users by setting Page.settings.requires_auth = true. The runtime requires both a valid public JWT and an active persisted session for protected access, and invalid, expired, revoked, cross-application, or mismatched sessions fail closed.

Authenticate a caller before evaluating its permissions, and keep identity verification separate from authorization. A valid public app session establishes the principal for the page-render surface; it does not grant generic record access. When no valid principal or active session is present, deny the protected request rather than continuing with an anonymous or stale identity.

Do not mistake page authentication for generic records permission. The documented public consumer JWT does not authorize generic record list, get, update, delete, or reorder endpoints; public pages must use the render/runtime data contract or another explicitly allowlisted public surface.

For machine or authoring access, grant only the API-key scope family the operation needs. The API reference defines separate pages:* scopes for page authoring, while the interactive-page guide distinguishes data access from authoring access and names records:* and model/application reads for the data tier. A request whose key lacks the required permission is denied with 403 api_key_permission_denied; do not treat a missing scope as a fallback to broader access.

To add every new identity record to a default group_membership, create an active rule on the application’s identity model with trigger record_create and phase post_save. Apply this literal action JSON, replacing only the two UUID placeholders:

{
"type": "create_record",
"target_model_id": "<group_membership-model-id>",
"trigger_rules": false,
"data": {
"member": {"var": "record_id"},
"role": "<role_group-id>",
"source": "signup"
}
}

<role_group-id> is the UUID of the default role_group record in that application’s data, not a model UUID and not a role name. Re-bind it in every environment: rebuilding or re-importing an application creates new record IDs, and a copied rule can otherwise write memberships that point to a dead role-group reference.

This is deliberately a rule on identity-record creation, not a signup-only hook. It fires for every identity record create, including an administrator or API-key authored create. The member value uses record_id, which is the new app_user record ID; it is not the application creator or the acting administrator. Also treat this membership write as required: if the post-save create_record action fails and the rule is configured to abort on failure, signup fails and the originating identity record is not retained.

This is the worked pattern for the common case: ordinary users share one application and each should see only the records granted to them. It was verified on the hosted runtime on 2026-09-05 against a two-user application (Contact plus Contact Permission). The blocks below use the model names resource, resource_perm, and app_user; if you substitute your own, substitute them consistently in every block. Replace each <...> placeholder with the generated id it names and {application_id} with your application’s id.

The technique has three cooperating parts. Each read or write path uses its own part, because there is no single row-scope switch.

Operation Path that enforces it Where the identity decision comes from
Create a record Authenticated public form, then record create, then a creator-grant rule The validated form session supplies actor.id; the rule writes the grant
List records Published page, then a saved ownership query joining resource to grant The server binds current_user_id when the query executes
Direct record access Closed on the resource model and on the grant model records_api: "off" removes the generic list, get, update, and delete routes

“Forms to saved queries” is the whole composition: the form writes, and the page’s data binding reads through the saved query. There is no form.saved_query_id setting in the working example. An authenticated page, or a hidden field holding a user id, is not a boundary by itself.

app_user below is the application’s identity model (see authentication-basics). Create the protected model resource with its business fields; the saved query in step 3 selects title:

Field Type Meaning
title required text the record’s display name

Create the grant model resource_perm with one row per granted (record, user) pair:

Field Type Meaning
resource_id required reference to resource the protected record
user_id required reference to app_user the grantee
perm_bits required integer stored permission mask; a full-access creator receives 15
granted_by required reference to app_user the granting actor
granted_at required timestamp time of the grant
source required text provenance, such as creator

A reference field stores the referenced record’s id; set its settings.target_model_id to the referenced model. Publish both models, then merge these keys into the grant model’s settings and republish it. Merge only anonymous_access into the resource model’s settings: disable anonymous read and create on both models, because the authenticated form is the intended write path.

{
"anonymous_access": {"read": false, "create": false},
"unique_constraints": [{"name": "resource_user", "fields": ["resource_id", "user_id"]}]
}
Add a composite constraint to a rule-written grant safely
Section titled “Add a composite constraint to a rule-written grant safely”

For a grant model that a rule writes, add the (resource_id, user_id) constraint in this order:

  1. Dedupe the existing grant rows first. Publishing builds the index transactionally (not CONCURRENTLY), so duplicate rows make the index build fail.
  2. Publish the referenced fields before declaring the constraint. A constraint that names an unpublished field is rejected with unique_constraint_field_not_published.
  3. Read the model’s complete settings object, merge in unique_constraints, and write the complete merged object with update_model. Settings have replacement semantics, so sending only the constraint can otherwise discard records_api and anonymous_access.
  4. Republish the model to materialize the physical composite index.

Apply the same sequence to group_membership for (member, role) once its signup rule writes one membership row per signup.

A reference field on its own does not register a query relationship. Create it before you execute the join, or the query fails with Unknown relationship.

POST /api/v1/public/applications/{application_id}/relationships
{
"name": "permissions",
"type": "one_to_many",
"sourceModelId": "<resource-model-id>",
"targetModelId": "<grant-model-id>",
"targetFieldId": "<schema field id of resource_perm.resource_id>"
}

targetFieldId is the grant model’s resource_id schema field id, not a resource record id. The compiler uses this declaration to generate the resource_perm.resource_id = resource record id equality. Do not add an id FieldRef to reproduce that equality: id is the record’s metadata UUID, not a published business field, so a public saved query that references it anywhere is rejected at execution with 422 Unknown public field: 'id'.

POST /api/v1/public/applications/{application_id}/saved-queries
{
"name": "Visible records",
"isPublic": true,
"ast": {
"select": [
{"modelAlias": "resource", "fieldName": "title"}
],
"fromModel": "resource",
"fromAlias": "resource",
"joins": [{
"type": "inner",
"modelName": "resource_perm",
"alias": "perm",
"relationshipName": "permissions",
"onPredicate": {"terms": [{
"left": {"modelAlias": "perm", "fieldName": "user_id"},
"right": {"parameter": "current_user_id"}
}]}
}]
}
}

Use the reserved operand {"parameter": "current_user_id"} in the JOIN predicate. Do not replace it with a client parameter, a hidden form field, a template string, or a literal. When the query executes, the server substitutes the caller’s identity for that operand: the signed-in consumer’s identity (app_user) record id. The inner join therefore keeps only resources with a grant row whose user_id is the caller, so the row restriction lives in the ON predicate even though there is no top-level WHERE. Keep that inner join: the public query guard accepts other join shapes too, so it does not enforce ownership for you. Every model and field the query references must be published, and aggregates are rejected; docs/api.md, “Server-bound current_user_id and consumer ownership joins”, lists each restriction and the response it returns.

4. Write the creator grant with a server-owned rule
Section titled “4. Write the creator grant with a server-owned rule”

Create an active record_create, post_save rule scoped to the resource model. Its action creates the grant from the session actor, and on_failure: "abort" makes the grant required:

POST /api/v1/public/applications/{application_id}/rules
{
"name": "Grant the creator",
"modelId": "<resource-model-id>",
"trigger": "record_create",
"phase": "post_save",
"isActive": true,
"condition": {"and": []},
"settings": {"on_failure": "abort"},
"actions": [{
"type": "create_record",
"target_model_id": "<grant-model-id>",
"trigger_rules": false,
"data": {
"resource_id": {"var": "record_id"},
"user_id": {"var": "actor.id"},
"perm_bits": 15,
"granted_by": {"var": "actor.id"},
"granted_at": {"var": "time.utc_instant"},
"source": "creator"
}
}]
}

The rule reads the authenticated actor, never data.user_id. Submit through a model-bound public form whose settings are {"access": "authenticated"}; a direct model-bound page form can attribute creation to the application owner, which is why it must not be the creation surface.

5. Close generic access and bind the page to the query
Section titled “5. Close generic access and bind the page to the query”

Read each model’s settings, merge in records_api: "off", write the complete merged settings back, and republish both models. The switch moves a model to draft, so query execution and form writes fail until you republish. Merge, do not replace: sending only {"records_api": "off"} to a full-settings interface can drop the uniqueness constraint and the anonymous-access policy.

Set the list page’s requires_auth: true and bind its list to the saved query rather than a direct model source:

{
"id": "visible-records",
"type": "container",
"binding": {
"item_var": "record",
"repeat_over": {
"source": "saved_query",
"saved_query_id": "<saved-query-id>"
}
},
"blocks": [
{"id": "visible-record-title", "type": "text", "content": {"text": "{{ record.title }}"}}
]
}

The page binding never names the user. The authenticated render resolves this container repeat on the server, the same way it resolves a record-list, data-table, or stat block whose source is {"source": "saved_query", ...}: the rows arrive in the block’s runtime.records, and the server binds current_user_id from the validated session exactly as in step 7 (see “Where each piece stops”). The browser does not execute the query itself. The render never forwards page or URL parameters as current_user_id: a URL such as ?current_user_id=<another user's id> does not change the rows.

Every block that exposes protected data must read through this constrained path. records_api: "off" does not retrofit a row filter onto a direct model binding elsewhere in the application.

settings.consumer_writes gates consumer update and delete, and off also refuses consumer create. Any other value leaves create governed by records_api and the existing scope checks; ownership only applies to update and delete. Its values are:

  • off: consumer create, update, and delete answer 404 (Record not found).
  • any_authenticated: any authenticated consumer in the application’s tenant, workspace, and application scope may update or delete. Existing models were migrated to this value so operators can tighten access deliberately.
  • owner: only the owner may update or delete.

A model whose settings do not contain consumer_writes behaves as off: consumer create, update, and delete answer 404 until the setting names a policy.

The policy applies only while the model’s records_api is on. This recipe’s step 5 sets records_api: "off", which already answers consumer create, update, and delete on both models with 404, so consumer_writes decides nothing until you enable records_api on a model whose records consumers may edit.

Use owner with settings.consumer_owner_query for new protected models; it is the recommended setting. consumer_owner_query is the UUID of a builder-authored saved query that references current_user_id somewhere (a JOIN parameter, as in this recipe’s ownership join, or a WHERE placeholder ":current_user_id") and projects the target record id as id, for example {"modelAlias": "resource", "fieldName": "id"}. The update or delete is permitted only when a returned row’s id equals the target record’s id, so the step 3 query, which projects only title, is not itself a valid owner query. The runtime supplies current_user_id from the authenticated consumer’s record ID and passes no other parameters, so the client cannot choose the owner. The query runs with builder shape: any number of joins, any join type, compound ON predicates, CTEs and subqueries are fine, so a “walk-up” owner query (the entity, its parent, the parent’s grant table with scope = "subtree") is a valid owner query. If no ownership query is configured, owner instead permits only records where created_by == actor.record_id.

The ownership query must reference current_user_id (a query that never binds the actor would grant every consumer the same rows, so it is rejected before execution) and belong to the target record’s tenant, workspace, and application. A missing query, a query without current_user_id, an out-of-scope query, or an execution failure denies the update or delete. Model settings use replacement semantics: an update_model settings update that omits consumer_writes resolves to off, so an omitted key fails closed. Invalid values return invalid_consumer_writes; an invalid non-UUID consumer_owner_query returns invalid_consumer_owner_query.

Before changing migrated models, list the ones that still allow any authenticated consumer to write and review each model’s intended ownership relationship:

SELECT id, application_id, name, settings
FROM models
WHERE settings ->> 'consumer_writes' = 'any_authenticated'
ORDER BY application_id, name;

settings.consumer_reads decides which rows of a private model (anonymous_access.read: false) a signed-in consumer of the same application may read. Every consumer read path consumes the same decision: submitted reference validation on create, update, and public actions; edit-form loading (GET /api/v1/public/apps/{application_id}/records/{record_id}); model-qualified get and list (/api/v1/public/apps/{application_id}/models/{model_id}/records); public action targets; ordinary page bindings (record-list, repeats, record_detail, drag-and-drop lists); and record pickers. Its values are:

  • any_authenticated: every eligible row, even when consumer_owner_query is also configured (the retained query then decides nothing for reads; consumer_writes: "owner" may still use it).
  • owner: only the rows consumer_owner_query returns for the consumer. Saving owner without consumer_owner_query is rejected with consumer_reads_owner_requires_owner_query.
  • off: identical to an absent key, which keeps the earlier behavior: a configured consumer_owner_query scopes the consumer to its rows (grants and exclusions alike), and a private model without one grants consumers no rows.

Any other value is rejected with invalid_consumer_reads, and a non-UUID owner query still returns invalid_consumer_owner_query. The owner query follows the step 6 contract: it binds current_user_id, projects the target record id as id, belongs to the model’s application, and fails closed on any doubt. For example, merged into the resource model’s complete settings:

{
"records_api": "on",
"anonymous_access": {"read": false, "create": false},
"consumer_reads": "owner",
"consumer_owner_query": "<owner-query-id>",
"consumer_writes": "owner"
}

The grant never widens anything else. Application, tenant, and workspace scope, records_api: "off", and published-schema and published-field governance apply first; a consumer signed into another application is treated as anonymous. Owner-scoped list rows are filtered in the query itself, before pagination and total, so excluded rows are never counted. Anonymous callers are governed only by anonymous_access.read: with read: false they are denied on every path even under any_authenticated, and with read: true the public rows stay public whatever consumer_reads says. A page stat block over an owner-scoped model resolves no value, because an aggregate cannot be narrowed to the granted rows.

consumer_reads grants no writes and no actions. consumer_writes alone authorizes consumer create, update, and delete; a public record action needs both a readable target and the model’s public_record_actions ACT permission, so a readable target does not permit an undeclared action and ACT never executes against a row the read policy hides (404). A public profile action on the identity model therefore needs a read grant for the consumer’s own identity record, such as consumer_reads: "owner" with an owner query that selects it. Settings use replacement semantics, so an update_model settings update that omits consumer_reads or consumer_owner_query removes it.

Execute the saved query in each user’s own consumer session at POST /api/v1/applications/{application_id}/queries with body {"savedQueryId": "<saved-query-id>"}. Do not send current_user_id; the server binds it. Each user receives only the records they created, and a signed-in user with no grants receives zero rows. A client that supplies params.current_user_id is rejected with 422 whose validation message is current_user_id is server-bound and cannot be supplied in params, and an anonymous caller receives no rows: 400 Missing query parameter: current_user_id. With the switch off, a direct record list or get on either model through /api/v1/public/apps/{application_id}/models/{model_id}/records returns 404 naming settings.records_api. After a form submission, read the grant back and confirm user_id and granted_by are the submitting user’s identity record id. Verify in a real browser with two separate sessions, not only through the API.

The pattern above is a real, server-enforced boundary for the paths it covers. These are its edges, stated once:

  • A bound filter is presentation, not a boundary. A record-list, data-table, or repeat bound to a model with a filter such as {{current_user.id}} shows the viewer the right rows, but nothing stops a different read path from returning other rows. The server-bound saved query in step 3 is the boundary; the bound filter is not.
  • It checks that a grant exists, not what it permits. The join qualifies any matching grant row, including a zero-bit row, because it does not inspect perm_bits. Keep grants limited to full-access creator rows until bit-specific enforcement is built and tested.
  • It is verified for create and list. Search, detail, edit, delete, and self-service sharing each need their own composition and their own acceptance tests. For search and detail, preserve the server-bound ownership join and let a caller-supplied selector narrow the result, never replace the actor restriction. For update and delete, enable records_api on the model and use consumer_writes: "owner" with a consumer_owner_query that binds the actor and projects the target’s id (step 6).
  • The record and its grant are two writes. Configure required grant rules to abort on failure; the create path compensates by removing the originating record when an aborting post-save action fails. Test this behavior whenever you add further rule actions.
  • The read policy scopes rows; it does not create grants. consumer_reads (see “Set the consumer read policy”) applies one decision on every consumer read path, but which rows a consumer owns is whatever the consumer_owner_query you author returns. The platform ships the composable pieces above, the per-model records_api switch (ADR docs/adr/2026-09-03-per-model-records-api-switch.md), consumer_reads for consumer reads, and consumer_writes for consumer update/delete; the application composes its grants from them.
  • Who executes the saved query decides its shape and visibility, not who reads the result. Step 7’s POST /api/v1/applications/{application_id}/queries call and any page-render binding execute with public_saved_query=True: every model and field the query references anywhere must be public, and a client can never supply current_user_id. Identity on a render comes only from the authenticated-render exception (ADR docs/adr/2026-09-21-saved-query-full-expressiveness.md, ruling 8, amended 2026-09-27): when the published page’s session validation established a consumer identity, the render executor binds current_user_id server-side from that validated identity record id — so a record-list, data-table, or stat block bound to this recipe’s ownership query resolves in the render itself, with zero follow-up query requests, and each consumer receives only their granted rows. The step 5 container repeat is not resolved in the render; the browser executes it under the same session (step 5). URL or binding parameters cannot supply or override the id; cross-application, revoked, expired, or stale-version sessions fail closed. Anonymous renders are unchanged: no identity is bound, the anonymous visibility walk applies, and an identity-scoped query stays unresolved rather than executing for the wrong (or no) caller. This is equivalence, not new access: the render executes exactly what the same consumer could execute on the public query route, under the same public-field visibility, shape, and query-budget rules. A rule or service executing the same ownership query on the consumer’s behalf (see mcp-consumer-tools, require_query) runs with public_saved_query=False instead: builder shape and builder field visibility apply, and current_user_id is server-bound to the event actor. The practical consequence: a page reads public-field data by binding a public saved query directly, and a signed-in consumer’s identity-scoped public rows resolve through that same binding during an authenticated render; private fields remain reachable only through a rule or service executing a builder query on the consumer’s behalf, never by loosening the public path. See docs/adr/2026-09-21-saved-query-full-expressiveness.md, ruling 8.

Give the dashboard an authenticated form so a user can add and update their own records, and show the result in their list after a successful write. The creator-grant rule from the previous step is what makes a newly created record visible to its creator and no one else.

Use a form block for the create operation and set source.target_mode to "create" (the documented default) with the target model_id. Add a clear on_success_toast so the user receives an immediate confirmation after the write.

Inside the form, bind each input to its model field with binding.field and save_mode: "submit", then include a button whose display type is submit. The documented example uses this shape to create a record from the submitted values.

Keep create and edit intent explicit: the form source uses target_mode: "edit" for PATCH behavior and needs the target record_id; use target_mode: "delete" only for an intentional delete control.

After a successful form write, the client cache is updated and data blocks re-render without a page reload unless on_success_redirect is configured. Pair the form with a model-backed list on the same page to make the create result visible in the normal CRUD flow.

An administrator is an application role, not a platform superuser, so build the admin view from the same pieces with a wider grant. Choose how an admin gains reach: an admin-scoped saved query that omits the per-user ownership join, or an explicit admin grant the role receives. Gate the admin page on the admin role and keep the generic record routes closed for everyone; an unauthenticated or broadly readable admin surface undoes the boundary the user dashboard depends on.

Use the same shell on every page so navigation, identity, and the way visitors reach the footer remain stable. The shell has a header composition followed by page content and a footer composition. Give the outer containers the site-header and site-footer classes: these are useful semantic hooks for a Style’s layout CSS, while the blocks themselves remain canonical base blocks.

The header is a full-width container with a brand heading and a canonical navigation-menu. Keep the primary links short and predictable; use shallow links for pages in the same site and reserve external URLs for destinations that genuinely leave the site.

Annotated copyable composition:

{
"blocks": [
{
"id": "site-header",
"type": "container",
"display": {"layout": "flex", "direction": "row", "gap": 24, "full_width": true},
"style": {"class": "site-header items-center justify-between border-b px-6 py-4"},
"blocks": [
{"id": "site-brand", "type": "heading", "content": {"text": "Acme", "level": "h2"}},
{"id": "site-navigation", "type": "navigation-menu", "content": {"items": [
{"label": "Home", "href": "/"}, {"label": "About", "href": "/about"}, {"label": "Contact", "href": "/contact"}
]}},
{"id": "site-cta", "type": "button", "content": {"label": "Get started", "href": "/start"}, "display": {"variant": "default"}}
]
}
]
}

Put the footer after page-specific content. A short text block and a second navigation menu are enough for a small site; add only links visitors need for trust, support, or legal requirements.

Annotated copyable composition:

{
"blocks": [
{
"id": "site-footer",
"type": "container",
"display": {"layout": "flex", "direction": "column", "gap": 12, "full_width": true},
"style": {"class": "site-footer border-t px-6 py-10"},
"blocks": [
{"id": "footer-copy", "type": "text", "content": {"text": "© 2026 Acme. Built with care."}},
{"id": "footer-navigation", "type": "navigation-menu", "content": {"items": [
{"label": "Privacy", "href": "/privacy"}, {"label": "Terms", "href": "/terms"}
]}}
]
}
]
}

Theme the whole site through shadcn CSS custom properties in a Style’s custom_css. Define tokens such as --primary, --card, --radius, and their related foreground, surface, border, input, ring, and background variables with oklch(...) values. This makes every page using the Style consistent. Do not target component classes and do not invent a data-block-type attribute: the renderer does not emit that attribute, so either approach matches nothing and fails silently. Use style.class on the shell and sections for layout and spacing only; token declarations are the theme boundary.

Assemble a simple landing page in this order: shared shell -> layout-hero -> layout-feature-grid -> layout-contact-form -> shared footer. Start with the header composition, then append each referenced recipe’s top-level blocks in that order, and finish with the footer. Fetch a recipe by slug from /llmrecipes/<slug>.txt and adapt its IDs, copy, and tenant-specific references before publishing. The page-level skeleton is intentionally only an ordering guide:

{"blocks": ["shared header", "layout-hero", "layout-feature-grid", "layout-contact-form", "shared footer"]}

Keep one consistent section rhythm, normally using comparable vertical padding on each referenced section and enough gap for the content to scan as separate bands. Drop the feature grid when the offer needs one focused explanation, or drop the contact form when the page’s only conversion is a link. Reorder only when the visitor’s decision path demands it—for example, put the contact form before the feature grid for a service request. Do not copy the referenced recipes’ JSON trees into this page; their slugs are the source of truth and prevent the assembled guidance from drifting.

7. Expose each user’s data to their own agent over MCP

Section titled “7. Expose each user’s data to their own agent over MCP”

Let a signed-in user point their own AI agent at the portal and get back only their rows. Serve an MCP endpoint for the application, publish a tool over the same ownership saved query the dashboard uses, and bind it with a tool_called rule. The row scope is not re-derived for MCP: the runtime binds the calling user as current_user_id on the same server-bound join, so the tool is scoped to whoever authenticated the call and to no one else. This is the machine-facing twin of the user dashboard, and it inherits that boundary rather than adding a new one.

Give a signed-in consumer’s own AI agent a tool that returns only that consumer’s rows. The row scope is the same server-bound ownership boundary the dashboard already uses; MCP does not add a second authorization path. This recipe binds an existing catalog tool to the existing ownership saved query so tools/call is scoped to the calling consumer.

Preconditions: an ownership saved query with a current_user_id-bound inner join (row-level-security), a catalog tool for it (mcp-tool-catalog), and a served, enabled endpoint (mcp-endpoint). The consumer query guard itself permits any join shape and ON predicate as long as every referenced model and field is public, and identity is available only through the server-bound current_user_id parameter — this recipe’s row scope depends on the inner ownership join specifically, so keep it.

This recipe’s require_query action executes the ownership query with public_saved_query=False (rule_saved_query_executor.py), not the public HTTP route’s public_saved_query=True. Under this execution context the query is checked against builder shape and builder field visibility rather than the public-field walk, and current_user_id is server-bound to the event actor — the consumer the rule is acting on behalf of, not a caller-suppliable value. The ownership join compiles under this flag exactly as it does under the public flag, so the same saved query serves both this recipe and a page-render binding. See docs/adr/2026-09-21-saved-query-full-expressiveness.md, ruling 8.

Create two application-scoped rules (model_id null) triggered by tool_called, each scoped to the one tool by name so unrelated tools do not fire them:

{"==": [{"var": "event.tool_name"}, "list_my_contacts"]}

Query rule, trigger tool_called, phase post_save. It runs the ownership saved query and stops the tool if the caller has no rows:

{
"type": "require_query",
"params": {
"saved_query_id": "<ownership-saved-query-id>",
"message": "No visible contacts for this consumer",
"template": {}
}
}

Leave template empty. current_user_id is server-bound and cannot be supplied; naming it in the template is rejected. The runtime binds it from the calling consumer’s identity, exactly as the dashboard’s query execution does.

Response rule, trigger tool_called, phase response. It shapes the tool’s result body from the rows the query returned:

{
"type": "patch_response",
"patches": [
{"op": "replace", "path": "/body", "value": {"contacts": {"var": "query_rows"}}}
]
}

query_rows is the output of the query rule. The two rules cooperate: the post_save rule produces the rows, the response rule renders them into the tool result.

The calling consumer authenticates the MCP request with the consumer OAuth bearer their MCP client obtained through the application host’s consent page: Authorization: Bearer <consumer OAuth access token>. The client adds https://<app host>/mcp as a remote connector, the consumer signs in as an application user in the browser tab it opens, reviews the client and scopes on https://<app host>/oauth/authorize, and approves; the client exchanges the code at https://<app host>/token. The grant’s subject is the consumer’s app_user record, and the runtime binds it as current_user_id for the ownership join. The consumer never supplies an identity, and a tool argument can never set it.

The bearer is issued for the endpoint’s one audience, the inbound URL of the service in mcp_settings.endpoint_service_id, so it is accepted on both the /mcp route and the inbound route. A consumer session token sent as a bearer, the consumer session cookie and the endpoint service’s API key are not MCP credentials: each gets the 401 that starts the client’s sign-in (mcp-endpoint, section 4). See the customer guide Connect an agent to your application.

Two guarantees follow, both fail closed:

  • Unauthenticated call. No consumer bearer, so the call is refused with 401 before any tool runs; with anonymous access opted in (mcp-endpoint, section 5), an anonymous call of a non-anonymous tool gets the same 401, and an anonymous tool runs as the service actor, so the ownership join binds nothing and the tool returns its miss message rather than another consumer’s rows.
  • Cross-application bearer. A bearer issued for a different application has a different audience and is refused; it never scopes this application’s rows.

Serve the tool and connect three MCP clients, one per consumer. For each, run the browser consent journey on the application host as that consumer: open https://<app host>/oauth/authorize with the client’s registered redirect_uri and an S256 code_challenge, sign in on the application’s sign-in page when it redirects with returnTo, approve on the consent page, and exchange the returned code with its code_verifier at https://<app host>/token. That yields three consumer bearers, each bound to its own app_user record.

Call the tool with each bearer. Each receives only their granted rows, for example four, one, and zero, and none sees another’s. Run initialize then tools/list then tools/call per bearer; do not reuse one consumer’s Mcp-Session-Id for another. A fourth call with no bearer gets 401 and no rows.

Reach beyond the database: notify a channel when a record changes, enrich a record with an LLM, add semantic search, email your users, or take payment. Each is an outbound integration built from the same pieces — a service config, an allow-listed destination host, and a rule or function that fires it. Start with the foundational call, then layer on the specific integrations your portal needs; the ones that must store a result (an LLM answer, a Stripe id) run through a function so the response lands back on the record.

Post to Slack, notify a customer’s webhook, or hit any HTTP API when a record changes. This is the base outbound pattern the other integration recipes build on: define the endpoint once as a service, allow-list its host, and fire it from a call_webservice rule action.

Create a service config for the endpoint. The base_url is the host (the request path is supplied per call); credentials go in auth_config and are encrypted at rest.

POST /api/v1/applications/{application_id}/services
{
"name": "Slack Notifier",
"service_type": "webhook_out",
"base_url": "https://hooks.slack.com",
"auth_type": "none",
"headers": {"Content-Type": "application/json"}
}

auth_type is one of none, bearer (auth_config: {"token": "..."} → Authorization: Bearer ...), or api_key (auth_config: {"header_name": "X-API-Key", "api_key": "..."}). The base URL must resolve to a public host: the platform blocks private, loopback, and link-local addresses, so an internal service must be reached through a publicly routable name or tunnel.

Outbound calls are denied unless the host is on the application’s allow-list. Set it per governance scope:

PUT /api/v1/applications/{application_id}/outbound-destination-governance
{
"service_invoke": {"allowed_hosts": ["hooks.slack.com"]},
"webhook_out": {"allowed_hosts": []}
}

An empty list allows nothing on that path once governance is set; entries are exact hosts or a single-label wildcard (*.example.com). A call to any other host fails with service_destination_not_allowed.

Add a rule on the triggering model (for example record_create, phase post_save) with a call_webservice action. The body is a template resolved against the event, so record fields, the actor, and any require_query rows are available:

{
"type": "call_webservice",
"params": {
"service_config_id": "<service-uuid>",
"method": "POST",
"path": "/services/T000/B000/XXXX",
"mode": "sync",
"body": {"text": {"concat": ["New contact added: ", {"var": "after.first_name"}, " ", {"var": "after.last_name"}]}}
}
}

path is the request path appended to base_url (the webhook-specific part for Slack). mode: "sync" calls the service inline and records the response on the rule’s side effects; omit it to enqueue the call as a background job. Inline url, auth, or headers are rejected: always reference a service_config_id.

The response is written to the outbound service log but is not fed back into later rule actions. To use an API’s response (store it on the record, branch on it), call the service from a function instead (see the LLM recipe), whose return value is merged into the record.

SMS follows the same shape: a form-encoded POST to the provider’s API. Twilio authenticates with HTTP Basic, which the service auth layer does not yet inject, so pass its credentials via a header until basic auth lands.