Building Apps With Code
What It Is
Jetstack can be built on in two ways that share one platform underneath:
- Configuration — types, queries, views, canvases, automations and modules, assembled in the builders.
- Code — a Custom App you write in your own editor and toolchain, keep in git, and ship with the
jetstackcommand-line tool. A manifest v2 app declares the data types, roles, permissions and schedules it needs, and every deploy creates or updates them in the tenant.
The same code can also be written in the browser, in the Studio, with an AI agent beside it. The Studio and the CLI work on the same draft (jetstack draft pull and push): start in either and continue in the other.
Both run on the same kernel: users and sign-in, the data model and its records, roles and permissions, automations, the scheduler, files, secrets, e-mail, the REST API and MCP. An app built with code does not reimplement any of that. It uses it.
Why It Matters
Some solutions fit the builders well. Others need a bespoke interface, a particular workflow or an unusual data flow, and bending the builders to them produces something hard to maintain. Code-built apps remove that trade-off without giving up what the platform already provides:
- Freedom where it matters. The interface and the server-side logic are yours: any front-end framework, any structure, reviewed and tested like other software.
- The platform where it counts. Sign-in, permissions, audit, records and integrations stay the platform's, so every app gets them the same way and administrators manage them in one place.
- A professional release process. Releases are immutable and identified by their content. Deploys show a plan first and are recorded in a history. Environments are promoted without rebuilding, and a rollback is one command.
- Configuration and code interoperate. A type an app declares is an ordinary type. Builders can put views, automations and permissions on it, and the app can use queries and e-mail templates made in the builders.
Mental Model
flowchart LR
Repo[Git repository] -->|jetstack deploy| Release[Immutable release]
Release --> Plan{Plan}
Plan -->|apply| Config[Types · roles · grants · schedules]
Plan -->|activate| Live[Live app /app/<slug>/]
Live --> Kernel[(Platform kernel)]
Config --> Kernel
- App — a named, slugged unit of ownership (
/app/<slug>/). - Release — an uploaded bundle: front-end, server-side handlers and manifest. It never changes after upload, and it is identified by a digest of its content, so uploading the same build twice yields the same release.
- Manifest v2 —
jetstack.app.jsonwith"manifestVersion": 2. It declares what the app owns (routes, types, roles, grants, schedules) and what it needs (capabilities, secrets, environment values). See the App Manifest Reference. - Deployment — a release applied to a tenant: plan, configuration changes, activation, all recorded in the app's deployment history.
- Environment — a tenant playing a role for an app, such as
dev,stagingorprod. Promotion deploys the same release to the next environment. - Service principal — a non-interactive user the platform creates for each v2 app. Its permissions are exactly the app's declared
capabilities. Scheduled jobs run as it, and handlers can act as it on purpose.
How To Build An App
1. Scaffold
jetstack init case-portal
This creates a working v2 app: jetstack.app.json, a page in public/, three handlers in server/, jetstack.config.json with placeholder environments, and AGENTS.md, a short guide that keeps AI coding assistants building on the platform instead of reinventing sign-in or storage. Put your own front-end build in place of public/ (set build.command and build.output in jetstack.config.json).
The starter's note type is open to every signed-in user: its permissions grant the built-in user role. Replace that grant with your own roles before real data goes in.
2. Declare the data model
Describe the app's types in the manifest instead of creating them by hand:
"schema": {
"types": [
{ "system_name": "case", "name": "Case", "properties": [
{ "system_name": "title", "name": "Title" },
{ "system_name": "client", "name": "Client", "data_type": "client" }
]}
]
}
Deploys create what is missing and update what exists. Records are then read and written with the Internal API ($jetstack->resources), with permissions and automations applied exactly as for records entered in forms.
After a deploy, jetstack types writes the model as TypeScript for the front-end and PHP stubs for handlers, so the editor knows every field and catches a misspelled one before the tenant does.
3. Declare access
accesscontrols who may open the app at all.- A route's
authispublic,user(signed in),internal(scheduler only) orrole:<system_name>. rolesandpermissionsdeclare the app's own roles and their grants on the app's types.- A grant can cover only some records: its
querynames one of the manifest'squeries, such as "cases whose owner is the signed-in user". The platform applies that rule in every screen, the REST API, MCP and the Internal API, so no handler has to filter for it. See App Manifest Reference.
4. Decide what the app itself may do
Handlers act as the caller by default: a signed-in user sees and changes only what that user may. For work the caller is not allowed to do, such as a public form that files a record, a handler acts as the app:
$me = $jetstack->users->current(); // who calls: id, name, email, roleNames — read it before switching
$case = $jetstack->asApp(function () use ($jetstack, $body, $me) {
$case = $jetstack->resources->get('case', ['title' => $body['title'], 'case_owner' => $me['id']]);
$case->save();
return ['id' => (int) $case->id, 'title' => (string) $case->exportRawData('title', true)];
});
Inside asApp(), permissions are the app's declared capabilities (for example "records.write:case"), and every use is recorded in the event log. Raw SQL through $jetstack->db->query() needs the db:raw capability, because it bypasses permissions.
The app's service principal is made by its first deploy. Before that, and whenever a draft declares other capabilities than live, a draft is tried this way:
- On a development environment, a draft acts inside
asApp()as the app's draft identity: a user of its own (app-<slug>.draft@apps.invalid, roleapp_<slug>__draft) whose grants are the capabilities the draft declares. This holds wherever a draft runs: the Studio's preview,jetstack devand the builder'sapp_route_call. The live app keeps its own principal and grants. - On production, a draft acts as the live principal, so
asApp()fails until the app's first deploy.
Two habits keep handlers right:
- Read inside
asApp()and return plain values. A record's relation fields are read when first used, with the permissions of that moment; afterasApp()returns, that is the caller again. - Read values raw for data.
$record->fieldgives text HTML-escaped, ready to print into a page. What a route answers as JSON, or passes on to a document or another service, should come from$record->exportRawData('field', true), or it is escaped twice.
The case portal example (packages/examples/case-portal) is built this way.
5. Decide how it is shown
Decide early how the app is shown. Inside the platform's layout is the default; "viewport": "full" gives it the whole window, where the app draws its own navigation and user menu (links() in the SDK), and "homepage": true makes it the tenant's home page. A full-window app offers people search, notifications and jobs with the platform services, and tells the AI assistant what is on screen.
While you develop with jetstack dev, administrators keep the system tray, and apps your app links to or embeds load from the tenant.
6. Ship
jetstack login --env dev # sign in through the browser, as you
jetstack deploy --env dev
deploy builds, uploads, shows the plan and deploys. In CI, use a deploy token instead (JETSTACK_TOKEN_DEV); jetstack dev always needs the browser sign-in, because it acts as you. See Deploying And Rollbacks for what a deploy does and what a rollback restores, and The jetstack CLI for every command.
Example
A client-intake portal:
- Configuration: a
Clienttype, an automation that assigns new cases, a view the case team uses every day. - Code: the public intake form and its validation, a document-upload flow, and a dashboard with charts. These are a v2 app that declares the
Casetype it owns, acase_readerrole, and a schedule that sends a daily digest. - Result: the case team works in the familiar views, the automation fires for cases filed through the portal, and the portal ships through
dev → prodwith a plan, a history and one-command rollback.
Best Practices
- Keep one app per business capability. Let it own its types; reference shared types (such as
Useror aClientbuilt in the builders) through relations and capabilities. - Give the service principal the smallest set of capabilities that works, and avoid
db:rawunless a query truly needs SQL. - Evolve the schema additively: add new properties, deploy code that uses them, and remove old ones in a later deploy with
--allow-destructive. - Keep secrets and environment-specific values out of the bundle. Declare their names in the manifest and set the values on each tenant.
- Deploy to a non-production environment first, then
promotethe same release. Never rebuild for production.