Summary#
You do not write an API for your own app.
A page reads data by naming the entity. A button calls a server function by name. The engine carries the flow across the client/server boundary for you — durably — and your security rules are already inside the query when it runs.
[Role] enum AppRole { Authenticator, Member }
[Principal]
entity User {
[Required, MaxLength(255)] string Email;
[MaxLength(200)] string PasswordHash;
security {
allow read where Id == user.Id; // the rule lives HERE — not in an endpoint
deny read PasswordHash when !IsAuthenticator; // …and the credential is masked from everyone but sign-in
}
}
policy IsAuthenticator => RoleGrant.Any(g => g.User == user && g.Role == AppRole.Authenticator);
entity RoleGrant {
[Required] User User;
[Required] AppRole Role = AppRole.Member;
security { allow read where User == user; }
}
entity Note {
[Required, MaxLength(200)] string Title;
security {
allow read, update, delete where CreatedBy == user.Id; // …and it is compiled INTO every query
allow create when IsAuthenticated;
}
}
// An ordinary server function. No route, no controller, no request/response type.
Note Publish(string title) {
return new Note { Title = title }; // committed when the function returns. Note what is ABSENT:
} // nobody sets an owner — `CreatedBy` is stamped for you.
[Page("/notes")]
[Render(CSR)]
component Notes() {
var notes = Note.ToList(); // a SERVER read. No fetch, no endpoint, no DTO.
string draft = ""; // a client value — inferred from the initializer, not declared
action Add() { Publish(draft); } // calls the server BY NAME. The engine hands off.
render {
Input(value: draft, placeholder: "Title");
Button("Publish", onPress: Add);
foreach (var n in notes) { Text(n.Title); }
}
}That is the entire feature. There is no other file. No NotesController, no NoteDto, no notesApi.ts, no
useNotes() hook, no repository, no migration, no [Authorize] on an endpoint — and Note.ToList() returns your
notes rather than everyone's, because the security { } block is part of the query rather than a check someone
remembered to write.
Description#
What you are NOT building#
If you have built a SaaS app before, you are carrying a mental checklist. Most of it does not apply here, and the time you would spend on it is the time you should spend on the actual problem:
| What you would normally build | What replaces it |
|---|---|
| REST controllers, routes, an API surface for your own UI | Nothing. The UI calls functions by name. |
| DTOs, view models, mappers | Nothing. The entity is the wire type. |
| A serialization layer | Nothing. One shared codec, for every app. |
fetch/axios, URLs, CORS config | Nothing. There is no URL to call. |
| A client store, reducers, cache invalidation | Nothing. Reads land in a shared store; a live read refetches on a change signal. |
| Change tracking — dirty flags, diffing, a save pipeline | Nothing. The platform tracks what you created and changed. You only say when to persist (UnitOfWork.Commit()), and on the server not even that. |
An ORM, a repository, a DbContext | Nothing. You query the entity type directly (Querying data). |
| Migrations, DDL | osy compile. Additive change (a new entity, a new field) just applies. See the caveat below. |
| Auth middleware, per-endpoint guards | security { } on the entity (The security model) + [Authorize] on the page. Written once, enforced everywhere. |
| Session/JWT plumbing | Security.IssueJwt + Session.SignIn (auth bootstrap (login, before anyone is signed in)). |
async / await / Task<T> | Nothing. There is no async in Osy# (async / await — why Osy# has neither). |
| Background jobs, queues, retry logic | A workflow — durable states that survive a restart. |
The platform's own Admin — a complete app with authentication, grids, tabs and forms — declares no API, no DTO, no fetch layer, no store and no migration. Its entire authentication surface is one file of about forty lines, most of which are comments.
It is one model — but it is not one machine#
This is where an honest guide has to stop selling. One language, one call syntax, and a boundary the engine crosses for you — but three things stay yours to reason about. Get these three right and you can forget the rest.
1. Where a statement runs — and, just as importantly, where it does not. Two things reach the server from a UI action, and only two:
- A query — naming an entity (
Note.ToList(),Note.Single(…)). That is the fetch. - A call to a top-level function —
Publish(draft). That is a hand-off, and therefore a round trip.
Everything else is local. Once rows are in hand they live in the client's store, so reading their properties —
n.Title, n.Owner, a field on a row you are editing — costs nothing. It is not a request; it is memory. So do
not contort a component to "avoid extra reads" of data it already has: there are no extra reads. Pure computation,
control flow and looping over rows you fetched all run where the cursor already is.
What is worth noticing is a loop that calls a function ten times — that is ten hand-offs. The syntax hides the round trip; the latency does not.
2. When data persists. You never write change tracking. Osy# tracks it all — which rows you created, which fields you touched, what the new values are — with no dirty flags, no diffing, no "is this modified" bookkeeping, and no save pipeline of your own. The only thing you ever say is when it should persist, and even that is only on one side:
- In a server function,
new Note { … }is persisted when the function returns. There is nothing to say — noSave(), noUnitOfWork.Commit()(function). - In a UI action, the edit is staged optimistically — it appears on screen at once, and persists when a
UnitOfWork.Commit()runs, which belongs in a Save action.
So the same statement means "write it" on the server and "stage it, and show it immediately" on the client. That is not an inconsistency to memorise — it is the whole reason a form can be typed into, previewed and abandoned without a round trip per keystroke. The tracking is done for you either way. You are only ever choosing the moment.
3. How a page is delivered. [Render(CSR)] is the default and is always correct. [Render(SSR)] pre-renders for a
faster first paint — but only for [AllowAnonymous] pages, so a signed-in page is delivered client-side either
way. Choosing it never changes what a page does.
The flow is durable, which is why the boundary can be invisible#
When an action hits a server call, it does not fire a request and wait. It suspends — its whole continuation is persisted — the server runs the function, and the action resumes mid-statement. That is why the boundary needs no syntax: the engine can stop and restart your flow anywhere, so it does not need you to mark where the seams are.
The consequence worth knowing: a suspended flow survives a client reload and a server restart. The thing that would be a distributed-systems problem in the architecture you were about to build is a property of the engine here.
When you DO write an API#
There is a REST surface — app.Apis — and an MCP surface — app.McpServer. Both exist for exactly one purpose:
publishing your app to someone else. A third party's integration. An agent. A partner's webhook.
Never for your own UI to talk to your own server. And note what they are: you expose an existing function; you do not write an endpoint. The function stays transport-agnostic — it does not know or care that it is reachable over HTTP.
Calling someone else's API is the other direction entirely, and has its own surface (Http.*).
What is left to build?#
What is left, once the scaffolding is gone, is the part that was always the point:
- The model — the entities, and what is true about them (entity, invariant).
- The rules — who may see and change what (The security model). One block per entity, enforced in every query, on every path, forever.
- The logic — ordinary functions.
- The screens — components that name the data they need.
An agent given a business problem should start at the entities and the security rules, and will find that most of what it expected to build does not exist. That is the intended experience.
Two caveats: schema evolution, and the UI surface#
- Schema evolution is not unconditional. Additive change applies on
osy compile. A change that would remove or reshape existing data (a drop, a rename, a type change) is held until you authorise it with a generated migration — which is the platform refusing to destroy data behind your back, not a gap. - The UI surface is still moving. The
ui/pages are markedpreviewfor that reason. The execution model on this page — the hand-off, the security-in-the-query, the absence of an API — is settled; the exact spelling of a component member is the part still being sharpened.
See also#
- app.osy — the
app { }block: the one file that says what your app is - project layout — where the
.osyfiles live - The security model — the rules that replace your auth middleware, and why they are in the query
- Querying data — how you read data, and what it costs
- function — a server function (and why it has no
Save()) - async / await — why Osy# has neither — why there is no
async, and where the oneawaitlives - publishing a REST API (app.Apis) —
app.Apis: publishing your functions to someone else