This is the constraint that shapes the architecture of everything you build in a page, and it is not stated anywhere you would look for it.
Reads are general. Writes are not.
The runtime's repository namespace reaches any datatype:
await appmint.repository.find('sf_product', { where: [['data.status','eq','active']] });
await appmint.repository.findOne('post', id);
await appmint.repository.search('sf_product', 'chair');
await appmint.repository.aggregate('order', pipeline);
await appmint.repository.categories('sf_product');There is no matching create, update or delete. That is deliberate, and the runtime says so in its own manifest:
READ-ONLY generic data access. Customer cannot create/update/delete here — use a domain namespace instead.
The reasoning is sound. The runtime runs in a browser, where the visitor chooses the request. A general write endpoint exposed there would let anyone with devtools write any record in the org.
Where writes do exist
Only inside namespaces that model a specific business action, each of which validates what it is given:
| Namespace | Writes |
|---|---|
cart | add, remove, update, clear, recalc |
storefront | checkout |
customer | sign-in, register, profile updates |
reservation | create, modify, cancel |
ticket | create |
order | customer-scoped actions |
state | in-memory only — see below |
form is not on that list. It offers serialize, validate and reset — there is no form.submit. That surprises people, because a form feels like the obvious way to write a record.
If what you need to persist is a booking, a cart, a ticket, an order or a customer, a namespace already does it — use that and stop reading. This page is about everything else.
The three routes out
When your application needs to persist something the domain namespaces do not model — a quote request, a configurator result, a survey, a waitlist entry — you have three options. Picking correctly is most of the skill.
Route 1 — A CRM form
Define a crm_form record with a schema and public permissions, then POST to it. It accepts an arbitrary field set and stores a form_submission.
fetch('https://appengine.appmint.io/crm/form/submit/quote-request', {
method: 'POST',
headers: { 'Content-Type': 'application/json', orgid: 'your-org' },
body: JSON.stringify(values),
});Cost: submissions land in one generic collection rather than a datatype of your own, so querying and reporting on them is clumsier. Requires: Guest in both permission.create and permission.read — granting only create fails with a message about public access that points at the wrong permission.
Use it when: the thing is genuinely a submission — someone told you something and a human or automation will pick it up. This is the right answer more often than people expect.
Full setup: Collect form submissions.
Route 2 — Call the API from your page code
The runtime exposes a raw transport that carries the same auth, orgid and error mapping as the named methods:
await appmint.api.post('crm/some-endpoint', payload);Cost: you are bound to an endpoint path rather than a named method, so a path change breaks you silently. And you are limited to endpoints that accept the credentials a browser page carries — which does not include the generic repository write endpoints.
Use it when: a domain endpoint exists for what you want but the runtime has no wrapper for it yet.
This is not a way to reach repository/create. The restriction is enforced server-side by what the page's credentials are allowed to do, not by the absence of a client method. Reaching for api.post('repository/create', …) gets you a refusal, and trying to fix that by putting an operator token in the page turns a constraint into a breach — that token can read and write everything in the organization, and a page is public by definition.
Route 3 — Your own server
Your server holds the credential, decides what the visitor was entitled to ask for, and makes the call.
Cost: you now have a server to deploy and maintain. It is no longer a page-only application.
Use it when: the write must not be initiated by a visitor at all, involves money beyond the cart, or touches records belonging to someone else.
Pattern: The server proxy.
Choosing
| What you are persisting | Route |
|---|---|
| A booking, cart, order, ticket, customer | The domain namespace — already done |
| A lead, enquiry, quote request, application | A CRM form |
| Something a domain endpoint covers but the runtime has not wrapped | appmint.api.* |
| Anything the visitor must not be able to forge | Your own server |
| Ephemeral UI state — steps, selections, filters | state, and nothing else |
What this means in practice
Most page applications turn out to be read-heavy with one write at the end. You read a catalogue, a set of definitions or a list of records; you let someone configure or choose; and you commit once through a domain namespace.
Print Oxygen's design studio is exactly this shape. A hundred and thirteen kilobytes of editing, previewing and pricing — and the entire persistence layer is one line:
window.appmint.cart.add(item).then(go).catch(go);That is not a limitation it works around. It is the architecture the constraint produces, and it is a good one: everything before the commit is disposable, so there is no partial state to reconcile, no draft records to clean up, and nothing to migrate when the design changes.
If your design needs to write repeatedly as the user works — autosave, draft records, per-step persistence — that is a strong signal you have crossed out of page-application territory. Read When a page is not enough before building it anyway.
Next: State and rendering — how to hold everything before that one write.