App Manifest Reference
What It Is
jetstack.app.json at the root of a Custom App bundle. With "manifestVersion": 2 it is the app's declarative contract: what the app owns and what it needs. The platform validates it in full when a release is uploaded, and a deploy reconciles it with the tenant (see Deploying And Rollbacks).
A manifest without manifestVersion keeps the original contract — only routes, read on every request — and behaves exactly as before.
Editors can validate and autocomplete the manifest with its JSON Schema, served by every tenant at /schemas/jetstack.app.v2.schema.json. jetstack init adds a local copy and points $schema at it.
Example
{
"manifestVersion": 2,
"name": "Case Portal",
"version": "1.4.0",
"compat": { "platformApi": ">=1" },
"access": { "visibility": "roles", "roles": ["case_writer", "case_reader"] },
"routes": {
"GET /cases": { "handler": "server/cases/list.php", "auth": "user" },
"GET /cases/:id": { "handler": "server/cases/get.php", "auth": "role:case_reader,case_writer" },
"POST /intake": { "handler": "server/intake.php", "auth": "public" },
"POST /jobs/digest": { "handler": "server/jobs/digest.php", "auth": "internal", "timeoutMs": 300000 }
},
"schema": {
"types": [
{ "system_name": "case", "name": "Case", "properties": [
{ "system_name": "title", "name": "Title" },
{ "system_name": "state", "name": "State", "data_type": "case_state" }
]},
{ "system_name": "case_state", "name": "Case state", "role": "enumeration", "items": ["Open", { "code": "closed", "title": "Closed" }] }
]
},
"roles": [{ "system_name": "case_writer", "name": "Case writer" }],
"permissions": [{ "role": "case_writer", "type": "case", "actions": ["list", "show", "create", "edit"] }],
"capabilities": ["records.write:case", "records.read:User"],
"schedules": [{ "route": "POST /jobs/digest", "every": "+1 day", "at": "07:00" }],
"secrets": ["RESEND_API_KEY"],
"env": ["DOCGEN_URL"],
"limits": { "timeoutMs": 30000 }
}
Unknown top-level keys are rejected, so a typo never goes unnoticed. Use an x- prefix ("x-team": "backoffice") for your own metadata.
Keys
manifestVersion
Required. 2 selects this contract.
name, description, version
Optional text. version is your release label (for example a semver "1.4.0"); it is recorded with the release and shown in its history.
compat
{ "platformApi": ">=2" } — the minimum app-platform API level the app needs. A release requiring a newer level than the tenant provides is rejected at upload. Level 2 adds navigation, events and mcp. Level 3 adds property rules in permissions and systemPermissions; a manifest that uses them must require ">=3", so an older platform refuses the app instead of deploying its grants without their limits. Level 4 adds migrations, which likewise need ">=4". Level 5 adds viewport and homepage, which need ">=5". Level 6 adds cors and perIpPerMinute in limits, which need ">=6". jetstack types and the schema.manifest endpoint report the tenant's level.
runtime
Where the app's server-side routes run:
"php"(the default): handlers run in the platform's sandbox.{ "type": "external", "upstream": "env:<VARIABLE>", "timeoutMs"?, "maxBodyBytes"? }: routes without ahandlerare forwarded to the app's own HTTP service, with a signed assertion of the caller. See External Runtime.upstream: the service's address, as an environment variable that is also listed inenv.timeoutMs: how long a forwarded request may take, 1000–120000 (default 30000).maxBodyBytes: the largest request body forwarded, at most 52428800 (default 10 MB).
{ "type": "managed", "source"?, "base"?, "docroot"?, "memoryMb"?, "cpus"?, "health"?, "timeoutMs"?, "maxBodyBytes"? }: the platform builds the bundle'ssourcefolder (defaultservice) into a PHP 8.4 container and forwards the routes without ahandlerto it. See Managed Runtime for each key.
A runtime other than php needs a host that offers it. The deploy plan reports missing.runtime where it is not offered, and the deploy is refused.
access
Who may open the app and call its routes:
{ "visibility": "public" }— anyone.{ "visibility": "auth" }— any signed-in user.{ "visibility": "roles", "roles": ["…"] }— users holding one of the roles (bysystem_name); superusers and tenant administrators always pass.
Omit it to keep managing access on the app's Access settings. When present, each deploy applies it.
routes
Server-side routes, served at /app/<slug>/api/<path>. Keys are "<METHOD> /<path>" with methods GET, POST, PUT, PATCH or DELETE. Path segments are literal, or a parameter such as :id; the handler receives parameters in $params. When two routes could match, literal segments win over parameters.
A value is either the handler path or an object:
handler— a.phpfile in the bundle, outsidepublic/(normally inserver/). With an external or managed runtime, leave it out (or write{}) to forward the route to the app's service.auth—public(default),user(signed in),internal(runs only from the scheduler;404over HTTP) orrole:<system_name>[,<system_name>…](signed in and holding one of the roles).csrf—trueby default: a state-changing call made with the user's session must carry the session's CSRF token, which the platform's runtime attaches for you. A call without it is refused with403, or runs as an anonymous caller on apublicroute.falseturns the check off, so any site can make the route act as the signed-in user; keep it for routes whose effects are harmless.timeoutMs— the handler's CPU-time limit (1000–600000). Defaults:limits.timeoutMs, else 30 seconds for HTTP routes and 5 minutes for scheduled ones. For a route forwarded to the app's service: how long to wait for it, at most 120000 (defaultruntime.timeoutMs).stream—trueon a GET route forwarded to the app's service: the web server streams the answer instead of buffering it, for large downloads. Hosts without streaming serve the route as usual.
schema
{ "types": [ … ] } — the data types the app owns. Each type needs a system_name and a name. Types may also carry plural, role (entity, enumeration or checklist) and any option the Types builder offers. properties lists the type's properties, each with a system_name and name, plus data_type: a built-in property type (pText, pLongText, pInt, pDecimal, pMoney, pDate, pEmail, …; see Property Types) or another type's system_name for a relation.
items— for an enumeration, its items: a title, or{ "code", "title" }. Deploys add missing items; they never delete items.renamedFrom— on a property, its previoussystem_name. The deploy renames the property and keeps its data, instead of adding a new one.
roles
[{ "system_name", "name" }] — roles the app owns. Users are assigned to them as usual, in Users.
permissions
[{ "role", "type", "actions": [ … ], "query"?, "properties"? }] — grants on the app's own types. actions are list, show, create, edit, delete, freeCreate and freeEdit. The role may be one the app declares or any existing role. Grants the app declared earlier and no longer declares are switched off.
query limits the grant to the records the query selects (row-level permissions). It names one of the manifest's queries, or a query of the tenant by system name. That query must filter the grant's type. Without query, the grant covers every record of the type.
"permissions": [
{ "role": "case_reader", "type": "case", "actions": ["list", "show"], "query": "cases_i_may_see" },
{ "role": "case_writer", "type": "case", "actions": ["create"] },
{ "role": "case_writer", "type": "case", "actions": ["edit"], "query": "cases_i_own" }
]
The platform applies the rule wherever records are read or written: lists and records in the tenant's screens, the REST API, MCP, the Internal API, relations and exports. A record outside the rule is not found, and a change to it is refused. For create, a new record must match the rule, or it is not saved.
A deploy is refused when a grant names a query that neither the manifest nor the tenant has, or a tenant query that filters another type.
properties (platform API 3) says what the role may do with single properties of the type, in place of what the type grant gives it. A type grant lets the role see every property (list, show) and write it (create, edit); a property named here keeps only the actions listed for it:
| Rule | Means |
|---|---|
"notes": [] | the role does not see the property at all |
"fee": ["show"] | the role sees it but cannot write it |
"status": ["show", "edit"] | the role sees it and can change it, but cannot set it on a new record |
Properties the grant does not name follow the type grant. Each role and property has one rule across all of the manifest's grants. A property the app stops naming follows its type grant again at the next deploy.
Worked example: case workers. A case worker sees and edits only the cases they own, never sees the internal notes, sees the fee without changing it, and may use the REST API:
{
"manifestVersion": 2,
"compat": { "platformApi": ">=3" },
"roles": [{ "system_name": "case_worker", "name": "Case worker" }],
"schema": { "types": [
{ "system_name": "case", "name": "Case", "properties": [
{ "system_name": "title", "name": "Title" },
{ "system_name": "fee", "name": "Fee", "data_type": "pMoney" },
{ "system_name": "notes", "name": "Internal notes" }
]}
]},
"queries": [
{ "system_name": "cases_i_own", "name": "Cases I own", "type": "case",
"filters": [ { "property": "owner", "type": "is", "param": "{$currentUser.id}" } ] }
],
"permissions": [
{ "role": "case_worker", "type": "case", "actions": ["list", "show", "edit"], "query": "cases_i_own",
"properties": { "notes": [], "fee": ["show"] } }
],
"systemPermissions": [ { "role": "case_worker", "permissions": ["useApi"] } ]
}
A user without an id — nobody signed in — matches no case: the rule never widens. jetstack perms explain user:<login> case edit --record <id> shows what one user ends up with (see Roles And Permissions).
systemPermissions
[{ "role", "permissions": [ … ] }] (platform API 3) — system permissions for roles the app declares in roles. An app may grant what its users need to work with it:
| Permission | Lets the role |
|---|---|
useApi | use the REST API |
useMcp | use the MCP server |
homepage | open their own dashboard |
navigation | use the navigation |
notifications | see their own notifications |
search | use the in-app full-text search |
profile | see their own profile |
useAiChat | use the AI chat |
system.export.xlsxPage, system.export.xlsxAll | export lists to XLSX (one page, or all) |
Managing users, secrets, versions, connectors or the configuration is not on the list: the tenant grants those. A system permission the app no longer declares is switched off at the next deploy.
"systemPermissions": [ { "role": "case_worker", "permissions": ["useApi", "search"] } ]
queries
[{ "system_name", "name", "type", "filters": [ … ], "description"? }] — queries the app owns, for its grants' query. A deploy creates or updates each one as a Permission query of the tenant, owned by the app, with its filters fixed. A query the app no longer declares is removed; grants the tenant gave with it are removed too, so they never widen to every record.
type— one of the types the app declares inschema.types.filters— each filter must match. A filter is{ "property", "type", "param"?, "or"? }:property: a property of the type by system name, or a path through relations such ascase.owner. Every type also hasowner, the user who created the record.type:equal,not_equal,is,is_not,in,not_in,contains,not_contains,less_than,greater_than,between,null,not_nulloris_exclusively.param: the value to compare with. All types butnullandnot_nullneed one.or: alternatives, filters of the same shape; the filter also matches when one of them does.
A value can be the signed-in user, worked out each time the rule is checked:
| Value | Means |
|---|---|
{$currentUser.id} | the signed-in user |
{$currentUser.<property>} | a value of the signed-in user's record, such as {$currentUser.department} |
When the signed-in user has no such value, or nobody is signed in, that filter (or that alternative) matches no record. A rule never widens because a value is missing.
"queries": [
{
"system_name": "cases_i_may_see",
"name": "Cases I own or read",
"type": "case",
"filters": [
{ "property": "owner", "type": "is", "param": "{$currentUser.id}",
"or": [ { "property": "readers", "type": "is", "param": "{$currentUser.id}" } ] }
]
},
{
"system_name": "cases_i_own",
"name": "Cases I own",
"type": "case",
"filters": [ { "property": "owner", "type": "is", "param": "{$currentUser.id}" } ]
}
]
The queries show in the tenant like any other: in the query builder, and next to their grants in the permissions matrix. Edits made there are replaced at the app's next deploy.
capabilities
What the app's service principal may do:
records.read:<type>,records.write:<type>,records.delete:<type>— for any type, including ones built in the builders (such asUser).db:raw— allows$jetstack->db->query(). Raw SQL bypasses permissions entirely, so apps must declare it.
The principal acts when a handler calls $jetstack->asApp() and when a declared schedule runs. In a draft on a development environment, the app's draft identity acts instead, with the capabilities the draft declares (see Building Apps With Code).
schedules
[{ "route", "every", "at"?, "name"? }] — recurring runs of an internal route without path parameters:
every— a forward interval such as"+15 minutes","+1 hour"or"+1 day".at— optional"HH:MM"time of the first run, in the tenant's time zone.
They run as the app's service principal. Deploys create, re-time and remove them to match the manifest.
navigation
[{ "key", "title", "path"?, "icon"?, "roles"?, "order"?, "parent"? }] — menu items of the tenant that open the app. Each becomes a module named app_<slug>__<key> that shows the app at path:
"navigation": [
{ "key": "cases", "title": { "en": "Cases", "cs": "Případy" }, "path": "/cases", "icon": "bi-briefcase" },
{ "key": "audit", "title": "Audit", "path": "/audit", "parent": "cases", "roles": ["case_master"] }
]
key— the entry's identity (lowercase letters, digits, underscores). Changing it replaces the menu item.title— text, or texts by language; the tenant's default language is the base, the others become its translations.path— where in the app the item opens (default/). A link to the module with?record=<id or uuid>passes the record on, for example/app/case-portal/cases?record=42; the App SDK'sembed().recordreads it. A link to the module with?path=/cases/12opens that page of the app instead, such as a case from a notification;$api->utils->appLink()builds these links.icon— a Bootstrap icon such asbi-briefcase.roles— who sees the item. Without it, the roles with access to the app (access.roles), or every signed-in user when the app is open to all of them. The plan warns about roles that see the item but cannot open the app.order— position among its siblings;parent— the key of another entry to nest under.
Deploys create, update and remove the items to match — removing one loses no data, so it needs no --allow-destructive. Their layout can be changed in the Modules screen; a deploy leaves it alone. A tenant can keep apps out of its menu with customAppsNavigation.
viewport
How the app is drawn: "embedded" (the default) inside the platform's layout — its top bar and navigation around it — or "full", taking the whole browser window.
"viewport": "full"
With "full":
- The app draws everything itself: the platform shows no top bar (search, notifications, jobs, user menu) and no navigation.
- Menu items that open the app (its
navigation, or modules made by hand) open it in the whole window, at its own address. - Superusers and tenant administrators keep the platform's functions in the floating system tray. Everyone else gets only what the app draws: give them what they need with the platform services and
links(). - The bootstrap's
viewport(and the SDK'sviewport()) tells the page which it is.
Needs "compat": { "platformApi": ">=5" }. A tenant can keep every app embedded with customAppsFullViewport. See Full-Window Apps.
homepage
Makes the app the tenant's home page: / opens it for people who have no start page of their own.
"homepage": true
trueopens the app's start; a path, such as"/today", opens that page of it.- A start page set for the person or their group wins over the app.
- One app at a time holds the home page: a deploy or activation that would give it to a second app is refused and names the app that holds it. To move it, deploy the holder without the key first.
- A full-window app is opened at its own address; an embedded app is shown inside the platform's layout.
Needs "compat": { "platformApi": ">=5" }. A tenant can switch it off with customAppsHomepage. See The Home Page.
events
[{ "on", "route", "when"?, "runAs"?, "include"?, "name"? }] — record events delivered to an internal route of the app: after a change (<type>.created, .updated, .deleted, retried until delivered) or before it (<type>.creating, .updating, .deleting, able to refuse it). See App Events for the options and what the handler receives.
mcp
{ "tools": [{ "name", "description", "route", "inputSchema", "title"?, "outputSchema"?, "annotations"? }] } — routes offered as tools to MCP clients and the tenant's AI agents. A tool runs its route as the caller. See App MCP Tools.
extensions
What the app adds to the tenant's own screens. See App Extensions for how each kind is used.
widgets:[{ "key", "title", "script", "element", "propsSchema"?, "description"? }]— elements for canvases.propsSchemais an object schema for the settings chosen in the canvas builder.renderers:[{ "key", "title", "script", "element", "dataTypes"?, "description"? }]— elements that show a property's value.dataTypeslists the property types it can show; without it, any.actions:[{ "key", "title", "type", "route" | "script" + "element", "confirm"?, "icon"? }]— record actions on the typetype:routenames aPOSTroute that is notinternal; it runs as the person who clicks.scriptwithelementopens the element for the record instead.
automationActions:[{ "key", "title", "route", "inputSchema"?, "outputSchema"?, "runAs"?, "description"? }]— automation steps:routenames aPOSTroute without path parameters.runAsisapp(the service principal, default) oractor.
Common rules:
keyis lowercase letters, digits and underscores (at most 40) and stays stable: tenants storeapp:<slug>:<key>.scriptis a JavaScript module underpublic/;elementis the custom element's tag name, with a hyphen.- At most 30 entries per kind.
A deploy grants each record action's permission, app_<slug>__<key>, to the roles its route admits.
migrations
[{ "id", "file", "mode", "description", "timeoutMs"?, "triggers"? }] — the app's data migrations, in the order they run. Each runs once in each environment, as the app, when a release that declares it is deployed there:
id— stable for good: letters, digits,.,_and-, such as"2026-10-12-split-client-names". Where it has run, it never runs again.file— the migration's.phpfile in the bundle, outsidepublic/(by convention inmigrations/).mode—"blocking": during the deploy, before the release goes live;"background": after it, in batches from the scheduler. There is no default.description— what it does; the plan shows it.timeoutMs— how long one batch may run (1000–600000; default 60000).triggers—truelets its writes fire app events, automations and code hooks; by default they stay quiet.
Needs "compat": { "platformApi": ">=4" }. jetstack migration new writes the file and this entry.
secrets
Names of Secret Store entries the app needs. The values live on each tenant and are never part of a release; a deploy is refused while one is missing.
env
Names of environment variables (Customization → Environment Variables) the app needs; checked the same way as secrets.
cors
The sites whose pages may call the app's public routes, for a website or front end on another domain:
"cors": { "origins": ["https://www.acme.com", "http://localhost:5173"] }
- What may be listed. 1 to 20 origins:
https://with a domain and an optional port, orhttp://localhostandhttp://127.0.0.1with a port while developing.["*"]allows any site. - What it opens. Only routes with
"auth": "public". - How the calls run. Without the visitor's session: a call from such a site runs anonymously, needs no CSRF token, and only that site may read the answer. The answer never allows credentials, so a signed-in person's data never reaches the other site.
- Visitors are limited. Each visitor may make 120 calls a minute unless
limitssays otherwise.
Needs "compat": { "platformApi": ">=6" }. See External Front Ends.
preview
What the sandboxed preview of a draft may load from other sites. A preview's page loads everything from the tenant only, so code an AI agent wrote can neither send data elsewhere nor load code from elsewhere. preview.allow adds sites, by kind:
"preview": { "allow": { "img": ["https://images.example.com"], "connect": ["https://api.example.com"], "frame": ["https://www.youtube-nocookie.com"] } }
- Kinds.
connect(calls such asfetch),img,font,script,style,frameandmedia. - What may be listed. Up to 20 origins per kind:
https://with a domain, a subdomain wildcard such ashttps://*.tile.openstreetmap.org, orhttp://localhostandhttp://127.0.0.1with a port while developing. - Where it applies. Only to previews of drafts and unpublished releases. The live app is not affected.
- What the preview blocks shows among the Studio's problems, with the kind and the site.
limits
{ "timeoutMs": 30000, "maxUploadBytes": 10485760, "perIpPerMinute": 60 }:
timeoutMs— the default CPU-time limit for the app's routes.maxUploadBytes— the largest file a route accepts; a larger one is refused with413before the handler runs. Without it, the server's own upload limit applies.perIpPerMinute— the calls one visitor may make per minute to the app's public routes without signing in.- A visitor is an IP address. An IPv6 address counts by its
/64network, which a household or a phone shares. - A call over the limit gets
429withRetry-After. It is counted before the tenant's own API quota, so one visitor cannot use that up. 0means no limit.- The default is 120 for calls from a site in
cors, and no limit otherwise. - Needs
"compat": { "platformApi": ">=6" }.
- A visitor is an IP address. An IPv6 address counts by its