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:

KindWhere it appearsWhat it does
Widgetscanvases, as the App widget itemshows the app's element, with settings chosen in the canvas builder
Renderersproperty values in tables, cards and record detailsshows a property's value through the app's element
Record actionsa record's context menu and top buttons, and MCPruns the app's route for the record, or opens the app's element for it
Automation actionsthe automation builder, as the App action stepruns 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:

  • key identifies the extension within its kind (lowercase letters, digits and underscores, at most 40). Stored references use it, so keep it stable across releases.
  • title is the name people see in the builders; description is optional help text.
  • script is a JavaScript module under public/ that defines element, 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

AttributeMeaning
data-appthe app's slug
data-extensionthe extension's key
data-api-basewhere the app's routes are, /app/<slug>/api
data-csrf-tokenthe 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:

  1. In the canvas builder, add App widget (group Apps). The item is offered while at least one app provides widgets.
  2. In the item's settings, choose the Widget. The list shows the widgets of apps the current user may open.
  3. 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 renderer setting to the renderer's reference, such as app:tickets:status_pill
  • dataTypes in 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 name
  • data-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 — a POST route of the app that is not internal. It runs as the person who clicks, with the body { "record": { "id", "uuid", "type" } }.
    • The answer's message is shown to the person.
    • When the route fails, its message is shown as a warning. Without one, the person sees "
  • 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-id and data-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.

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 a user or public route. 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 auth is 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:

  1. In the automation builder, add App action (group Apps) and choose the Action. Its help lists the inputs it takes.
  2. 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.

EndpointProvided parameters
On successresult (the route's JSON answer), status (its HTTP status)
On errorcode, 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.

SurfaceWhat happens
Widgeta placeholder takes its place
Rendererthe plain value
Record actionnot offered; its element page answers 404
Automation actionthe 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 auth still 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' extensions appear on the tenant's screens: widgets, renderers, record actions and automation actions

Default:

  • true

How to configure it:

  • set it to false to 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.