App Extensions
What It Is
A Custom App can add to the tenant's own screens as well as serve its own pages. Its manifest declares the additions under extensions. Each one is a custom element (a web component defined by one of the app's scripts) or one of the app's routes:
| Kind | Where it appears | What it does |
|---|---|---|
| Widgets | canvases, as the App widget item | shows the app's element, with settings chosen in the canvas builder |
| Renderers | property values in tables, cards and record details | shows a property's value through the app's element |
| Record actions | a record's context menu and top buttons, and MCP | runs the app's route for the record, or opens the app's element for it |
| Automation actions | the automation builder, as the App action step | runs the app's route with the step's inputs and passes its answer on |
The tenant's canvases, properties and automations store only a reference, app:<slug>:<key>. Every use looks the reference up in the app's live release. So a new release takes effect at once, and a deploy never rewrites the tenant's configuration.
Why It Matters
Use extensions when an app's features belong where people already work, rather than on the app's own pages:
- a case summary on the dashboard canvas, a status badge in every table of cases, "Generate contract" on the case record
- an automation step that runs the app's logic, such as opening a ticket or scoring a lead
The tenant stays in charge. Implementers decide where a widget goes and which properties use a renderer, and administrators decide which roles get an action. The app only offers them.
Prefer the tenant's own tools for one-off presentation, such as a canvas's Custom HTML or a view's formatting. An extension pays off when the same behavior serves many screens, or needs the app's code and data.
Mental Model
- Same-origin web components, trusted like the tenant's own code. An extension's script is a file of the app's live release. It is served from the app's address (
/app/<slug>/…) under the app's access rules, so it loads only for people who may open the app. It runs in the page with the viewer's session: it can do what the viewer can, and nothing more. - Data travels as attributes. The platform writes the element with its data in
data-*attributes. The element calls the app's routes for anything else. - References, resolved on every use. When the app is away, each surface degrades on its own terms (see When An App Is Away). The reference is kept, so the extension comes back with the app.
Declaring Extensions
"extensions": {
"widgets": [{ "key": "ticket_counter", "title": "Open tickets", "script": "public/ext/ticket-counter.js",
"element": "ex-ticket-counter", "propsSchema": { "type": "object", "properties": { "heading": { "type": "string" } } } }],
"renderers": [{ "key": "status_pill", "title": "Status pill", "script": "public/ext/status-pill.js",
"element": "ex-status-pill", "dataTypes": ["pText"] }],
"actions": [{ "key": "close", "title": "Close ticket", "type": "ex_ticket", "route": "POST /actions/close",
"confirm": "Close this ticket?", "icon": "bi-check2-circle" }],
"automationActions": [{ "key": "open_ticket", "title": "Open a ticket", "route": "POST /automation/open",
"inputSchema": { "type": "object", "properties": { "title": { "type": "string", "minLength": 1 } }, "required": ["title"] } }]
}
Rules shared by every kind:
keyidentifies the extension within its kind (lowercase letters, digits and underscores, at most 40). Stored references use it, so keep it stable across releases.titleis the name people see in the builders;descriptionis optional help text.scriptis a JavaScript module underpublic/that defineselement, a tag name with a hyphen. Two scripts must not define the same element.- An app declares at most 30 entries of each kind.
The App Manifest Reference lists every key. Schemas (propsSchema, inputSchema, outputSchema) use the same JSON Schema subset as App MCP Tools.
What Every Element Receives
| Attribute | Meaning |
|---|---|
data-app | the app's slug |
data-extension | the extension's key |
data-api-base | where the app's routes are, /app/<slug>/api |
data-csrf-token | the session's token: send it as the X-CSRF-Token header when calling a route that changes things |
Each kind adds its own data, described in its section below. Values that are not text are written as JSON.
Define the element only once, because a page can load the script more than once:
class TicketCounter extends HTMLElement {
async connectedCallback() {
const props = JSON.parse(this.dataset.props || '{}');
const answer = await fetch(`${this.dataset.apiBase}/tickets/summary`, { credentials: 'same-origin' });
this.textContent = `${props.heading ?? 'Tickets'}: ${JSON.stringify((await answer.json()).byStatus)}`;
}
}
customElements.get('ex-ticket-counter') || customElements.define('ex-ticket-counter', TicketCounter);
A route called this way runs as the viewer, under the route's own auth, exactly as when the app's pages call it.
Widgets
A widget is the app's element placed on a canvas.
How to use it:
- In the canvas builder, add App widget (group Apps). The item is offered while at least one app provides widgets.
- In the item's settings, choose the Widget. The list shows the widgets of apps the current user may open.
- Write its Settings (JSON): a JSON object that must fit the widget's
propsSchema. The settings help lists the keys the widget takes.
The element receives the settings as data-props (JSON).
Settings are written as plain JSON, so numbers stay numbers. String values can use {=…} and {$…} like other items' text, for example {"caseId": "{=parentObject.id}"} inside an Object wrapper, or {"viewerId": "{=currentUser.id}"}. They are filled in after the JSON is read, so a filled-in value never changes the settings' shape.
Instead of the widget, the canvas shows a placeholder when:
- no widget is chosen yet
- the widget's app is not available to the viewer (see When An App Is Away)
- the settings are not a JSON object, or do not fit the widget's
propsSchema(the placeholder names the problem)
Renderers
A renderer shows a property's value through the app's element wherever views display it: table and card viewers, the record detail overview, and the canvas Property value item. Forms, exports, the REST API, MCP and PDFs keep the plain value.
How to use it:
- set the property's
renderersetting to the renderer's reference, such asapp:tickets:status_pill dataTypesin the manifest lists the property types the renderer can show, such as["pText"]; without it, the renderer takes any type
The element receives:
data-value— the value (text as it is; numbers, booleans and an empty value as JSON)data-property— the property's system namedata-data-type— the property type- the platform's own rendering of the value, as the element's content
The platform's rendering inside the element is also its fallback. When the renderer is unavailable, or the property's type is not among its dataTypes, views show the plain value.
Record Actions
A record action adds an item to the records of one type (type, the type's system name). It shows in the record's context menu and top buttons. It either runs a route or opens an element:
route— aPOSTroute of the app that is notinternal. It runs as the person who clicks, with the body{ "record": { "id", "uuid", "type" } }.- The answer's
messageis shown to the person. - When the route fails, its
messageis shown as a warning. Without one, the person sees "
- The answer's
script+element— opens the element for the record in a dialog on the tenant's screen, from the context menu and the record's action menu alike. The dialog shows a page of the app,/app/<slug>/__jetstack/action/<key>?record=<id>, which grows with its content:- the element gets
data-record-idanddata-type; - the page has the app's runtime, so the App SDK works there;
- it has the tenant's stylesheet and the light or dark mode of the screen around it, so Bootstrap classes look as they do in the tenant.
- the element gets
confirm is a question, as plain text, asked before the action runs; icon is a Bootstrap icon such as bi-file-earmark.
Who gets the action:
- The action has a permission of its own on its type,
app_<slug>__<key>, which the permissions matrix shows next to the type's other actions. - A deploy grants it to the roles the route admits: the roles of a
role:…route, or every signed-in user (user) for auserorpublicroute. An element action is granted to the roles that may open the app. - Administrators can change the grants in the matrix. Deleting the app removes them.
- On a record, the action also needs the person to be allowed to open the app. The route's own
authis checked again when it runs.
Over MCP, route actions are listed with their type and run with record_action. A failed run is reported as an error carrying the app's message. Element actions are for people only.
Automation Actions
An automation action is a step that runs one of the app's routes. The route must be POST and have no path parameters. It is usually internal, since automations call it rather than people.
How to use it:
- In the automation builder, add App action (group Apps) and choose the Action. Its help lists the inputs it takes.
- Write the Inputs as an object or array literal, or as a JSON string. They are checked against the action's
inputSchema. Number literals, which arrive from automation code as text, are read as numbers where the schema expects a number.
The route receives the inputs as its JSON body. It runs as the app's service principal, or with "runAs": "actor" as whoever the automation runs as.
| Endpoint | Provided parameters |
|---|---|
| On success | result (the route's JSON answer), status (its HTTP status) |
| On error | code, message (the app's message, or the reason the step could not run) |
| Completed | — |
A failed step is also recorded among the run's errors. outputSchema documents what result holds, for the people building automations; it is not enforced.
When An App Is Away
An app is away when it is deactivated or deleted, has no live release, or customAppsExtensions is off. For widgets, renderers and record actions, it is also away for a person who may not open it.
| Surface | What happens |
|---|---|
| Widget | a placeholder takes its place |
| Renderer | the plain value |
| Record action | not offered; its element page answers 404 |
| Automation action | the step takes On error with code 404 |
Nothing is removed from canvases, properties or automations. When the app is back, so are its extensions.
A tenant's exports and tenant-as-code trees keep the references as written (app:<slug>:<key>), so they move between environments with the configuration. The app itself is deployed to each environment.
Security
- Scripts come only from the app's live release, whose deploys are audited. They are served from the app's own address, under its access rules.
- An extension's element runs with the viewer's session, like the tenant's own Custom HTML. Review extension scripts as you review the tenant's code.
- The platform escapes every attribute it writes, and writes settings and values as JSON. Elements should likewise treat data as text, never as markup.
- A record action runs its route as the person who clicks it, and the route's
authstill applies. An action's permission cannot widen what the route allows. - To switch extensions off, turn off
customAppsExtensions(for all apps) or deactivate the app (for one).
Tenant Settings
This key is not on the Application settings screen; set it in the tenant configuration.
customAppsExtensions
What it controls:
- whether apps'
extensionsappear on the tenant's screens: widgets, renderers, record actions and automation actions
Default:
true
How to configure it:
- set it to
falseto take every app's extensions off the tenant's screens at once, for example while reviewing an app's scripts
What it affects:
- while it is off, every app counts as away for its extensions (see When An App Is Away), and the canvas builder no longer offers App widget
- the apps' own pages, routes, events and MCP tools are not affected, and the stored references are kept
Example
packages/examples/extensions is a small tickets app with one extension of each kind:
- a widget counting open tickets
- a status renderer
- a Close ticket record action and a Ticket timeline element action
- an Open a ticket automation action
Deploy it with jetstack deploy. Then set the ticket type's status property to use app:tickets:status_pill, and add App widget to a canvas.