MCP Builder
What It Is
The MCP builder is a second MCP server of each tenant, at /api/mcp/builder. Where the regular MCP server (/api/mcp) works with records, the builder works with the tenant's configuration: roles and permissions, users and API keys, secrets and environment values — the things you otherwise set up in the Builder screens.
It is meant for an AI assistant you task with implementation work, such as "give the ERP integration read-only API access to invoices and customers".
Why It Matters
Implementation requests often need configuration, not data. Without the builder, an assistant can change records and the data model but not, for example, create a role, grant it permissions, create an integration user and issue its API key. With the builder it can do all of that, the same way the Builder screens do it, under the same rules:
- it acts as the signed-in user, with that user's permissions;
- it goes through the platform's own managers, so caches, hooks and audit behave as for a change made in the UI;
- it never sees or writes numeric ids or internal formats — only system names and documents.
Mental Model
The builder exposes configuration as kinds — role, access, user, form, automation, view, canvas, module and more (see Kinds) — each read as a document and changed by ops (role.upsert, access.grant, …).
flowchart LR
A[builder_describe] --> B[builder_list / builder_get]
B --> C[builder_apply mode=check]
C -->|effects, diffs, expect| D{Looks right?}
D -- yes --> E[builder_apply mode=apply + expect]
D -- no --> C
- builder_describe — the index (kinds, ops, catalogs, playbooks, this tenant's environment); with a kind, its ops, argument shapes, examples and pitfalls.
- builder_list / builder_get — what exists, by system name.
builder_getreturns exactly the shape an upsert accepts. - builder_apply — always twice:
mode: "check"validates every change and shows its effect (create,update,delete,noop) and diff. Nothing is written. It returnsexpect: a fingerprint of every existing item the changes touch.mode: "apply"with the same changes and thatexpectmakes them. If someone changed one of those items in the meantime (for example in the Builder screens), nothing is written and the assistant must read and check again.
- access_explain — whether a role or a user may do something, and why.
- docs_search / docs_read — this documentation.
One apply may contain up to 50 changes of several kinds. The builder orders them so that what a change refers to exists first (roles before their grants, users before their API keys). Changes are applied one by one: if one fails, the earlier ones stay applied, and the result lists what failed, what was not attempted, and inverse changes that undo what was applied.
Every applied call writes one line to the Event Log (category Configuration Changed): who, which client, which changes. Values of secrets and API keys are never logged. A call that changed configuration also appears in the change history, as a change from mcp; the call's note is its message.
Errors That Point To The Fix
When a change names something that does not exist — a type, a property, an action, a role — the error lists the closest names that do exist (didYouMean), says where in the request it is (changes[2].types[0].actions[1]) and, where it helps, which documentation chapter explains it. An assistant should never guess a name: it reads it with builder_list, builder_get or a catalog (builder_describe(catalog: "system_permissions"), builder_describe(catalog: "type_actions", q: "<type>")).
Who Can Use The Builder
The builder is part of every tenant, at https://<tenant>/api/mcp/builder. Who may use it is a system permission:
- Give the people (or service accounts) who may use it the system permission Use the MCP builder (
useMcpBuilder) on one of their roles, in addition to Use MCP server (useMcp). Tenant Admins and Superusers have both. - Connect the assistant to
https://<tenant>/api/mcp/builder.
The tenant's own AI agents of the builder kind use it too, under the same permissions of the agent's user.
Connecting An Assistant
Clients that support MCP authorization (Claude, Claude Code and others) discover the builder's own authorization metadata from its URL. During sign-in they ask for the builder as the resource; the consent screen then says that the application will be able to change the tenant's configuration.
A token is bound to the server it was authorized for. The builder's token carries the scope mcp:builder: it works on /api/mcp/builder and nowhere else, and the builder accepts no other API key — so no existing integration key gains builder access. A person's CLI sign-in (jsc_…) works on both servers.
Kinds
| Kind | What it is | Ops | Notes |
|---|---|---|---|
role | Roles users hold | upsert, delete | Built-in roles take only an MFA change. Deleting a role deletes its permissions. |
access | What a role may do: system permissions, actions on types, fields | grant, revoke, scheme, clear, set | Fields are deny-by-default; a type grant also grants the fields it needs. |
permission_query | Rules that limit a grant to some records | upsert, delete | Filters in the same format as app manifests; refused while grants use it. |
user | Users and their roles | upsert, roles | The builder never sets passwords: a user it creates is an integration user. |
api_key | API keys of users | issue, revoke | The key appears once, in the apply output. |
secret | Secret Store values | set, delete | Written, never read back. |
env | Environment values | set, delete | Not for secrets. |
property | A type's fields | add, update, remove | add can place the field on the form in the same step. Removing a field with values needs force. |
form | A type's form layout and variants | place, unplace, group, remove_group, set | Fields by system name; each field in one place. |
actions | The actions (buttons) of a type's records | upsert, remove, set | Run an automation, open a canvas or public page, follow a link, or show information. |
top_nav | The tabs of a record detail | set, reset | Canvases, views of related records and the built-in tabs; exactly one default. |
automation | Automations: trigger and steps | upsert, publish, discard_draft, delete | A change is a draft until published; only published automations run and are promoted. |
schedule | An automation's scheduled runs | upsert, delete, skip_next, run_now | One per automation, named by it. Skipping and running now are operational (allowed on a locked production). |
filter_list | Named lists of values that query filters match ("is in list") | upsert, delete | Values given replace the list's values. |
tag | Shared tags that label records | upsert, delete | Operational: each tenant keeps its own tags; they are not promoted. |
query | Queries that feed views and the options of relation fields | upsert, delete | Filters, columns, sorting, groupings by field name. Custom SQL queries are read only. |
view | Lists of a type's records | upsert, delete | Query, viewer, layout (zones, custom buttons, quick filters), adding defaults, actions. |
canvas | Pages, snippets, widgets and field renderings | upsert, delete | Written as one Custom HTML item (Latte), compiled before it is stored. |
module | Entries of the left navigation | upsert, arrange, delete | A page (view, canvas or custom app), an app screen, an external link or a heading. Deleting one moves its children up. |
permalink | Short public links | upsert, delete | By a stable name; each environment claims its own address (slug). |
route | Addresses on the tenant's own domain | upsert, delete | By the route mask; a mask the app's own addresses would take is refused. |
app | Custom apps at /app/<slug>/ | upsert, deploy, activate, delete | A deploy makes a release from files and deploys it as jetstack deploy does; activate (a rollback) is allowed on a locked production. |
Access Documents
A role's access reads as one document:
{
"role": "erp_reader",
"system": {"useApi": "allow"},
"types": {"invoice": {"list": "allow", "show": {"query": "invoices_of_my_company"}}},
"fields": {"invoice": [
{"properties": ["amount", "due", "number"], "show": "allow"},
{"properties": ["internal_note"], "show": "deny"}
]}
}
A cell is "allow", "deny" or {"query": "<permission query>"}; * means every action, type or property.
access.granton a type also grants the fields the actions need — show for list and show, create and edit for writes — because a role without field grants sees records with no fields. Hide single fields afterwards withaccess.revokeon<type>.<field>.access.schemeapplies the presets of the Permissions screen (read,read_meta,contribute,manage,full) to a role on some types, replacing its grants there.access.setsets exact cells;nullremoves a rule. The inverse of every access change is anaccess.set.
Forms, Actions And Tabs
A type's user interface is three documents, all by system names.
Form layout (form) — groups of fields, the sections before and after them, and the form variants:
{
"type": "invoice",
"variants": {"add": "formBasic", "edit": "formBasic", "show": "grouped"},
"groups": [
{"id": "_basics", "title": "Basics", "width": 50, "items": ["number", "customer"]},
{"id": "_amounts", "title": "Amounts", "width": 50, "items": ["amount", "paid_on", "owner"]}
],
"before": {"fields": [], "description": null, "showIf": null},
"after": {"fields": ["internal_note"], "description": null, "showIf": null},
"unplaced": []
}
A field in no group (unplaced) shows under Others in forms, and not at all in a grouped or tabbed record detail. That is why a new field should be placed when it is added:
[
{"op": "property.add", "type": "invoice",
"property": {"system_name": "paid_on", "property_name": "Paid on", "property_type": "pDate"},
"placement": {"group": "Amounts", "after": "amount"}},
{"op": "form.group", "type": "invoice", "group": {"title": "Payment", "width": 50}},
{"op": "form.place", "type": "invoice", "fields": ["due"], "group": "Payment"}
]
Changes to the same form in one call build on each other, and the check refuses a field placed twice, a group title used twice (forms would merge the groups) or a field that does not exist.
Actions (actions) — the custom actions with what they run, the built-in actions (moved, renamed, shown elsewhere) and dividers, in order:
{"op": "actions.upsert", "type": "invoice",
"action": {"id": "approve", "title": "Approve", "icon": "bi bi-check", "show": ["contextmenu", "topbuttons"],
"run": {"automation": "approve_invoice"}, "confirm": "Approve this invoice?",
"showIf": "this->amount > 0", "mcpDescription": "Approves the invoice"}}
An action id is letters, digits and dots; a short id like approve becomes tenant.action.approve. Actions are governed by permissions: the check says which access.grant gives roles the new action. AI agents can run only actions that run an automation.
Tabs (top_nav) — the tabs of a record detail: the built-in tabs (system.overview, system.log, system.lookup), canvases and views of related records. Exactly one tab is the default. A tab's password is write-only: true keeps the one set.
Automations
An automation reads as one document: its trigger and its steps.
{
"name": "welcome_customer",
"title": "Welcome a new customer",
"trigger": {"source": "record", "type": "customer", "event": "onAfterCreate"},
"steps": [
{"key": "has_email", "node": "Condition", "in": {"condition": "trigger(\"object\").email != null"},
"on": {"if_true": [
{"key": "mail", "node": "SendEmail", "in": {
"to": "trigger(\"object\").email",
"subject": {"text": "Welcome"},
"template": {"ref": "email_template:welcome"}}}
]}}
]
}
- Steps. A step is a node (
builder_describe(catalog: "automation_nodes")lists them with their inputs and endpoints), its inputs (in) and the steps hanging from its endpoints (on). A new step has akey— your own name for it; an existing one keeps itsid(read the automation withbuilder_getfirst). A list may end with{"goto": <id or key>}. - Inputs are expressions, as in the automation builder:
trigger("object").email,var("rate") * 2,"Total: " + trigger("object").amount(texts are joined with+). For a plain value use{"text": "…"},{"value": <JSON>},{"ref": "<kind>:<name>"}(a type, role, query, automation, canvas, view or email template) or{"code": "…"}for PHP and JavaScript inputs.builder_describe(catalog: "functions")lists the functions. - Checked before anything is written: nodes, inputs and endpoints exist; functions exist and get the right number of arguments; references resolve; PHP passes the sandbox. A bare word in an expression reads as that word, and a typo in a function makes the input false at run time — the check stops both.
- Drafts. A change is saved as a draft — what the automation builder shows — unless
publish: true;automation.publishpublishes it later. Only the published version runs and is promoted. - Testing. On development,
automation_test_run(automation, record?)runs the draft (or the published version) once and returns every step: the inputs as resolved, the endpoints taken, the errors. What it does is real there: records it changes, calls it makes — unless development keeps to allowed hosts.
Schedules
A schedule runs an automation (trigger {"source": "scheduled"}) at a first time and then at an interval. Each automation has at most one, named by the automation:
{"op": "schedule.upsert", "schedule": {
"automation": "mark_overdue", "args": {"days": 14},
"firstRun": "2026-10-13T02:00:00+02:00", "every": "+1 day", "log": true}}
The automation reads trigger("args"). A schedule is configuration (schedules/<automation>.json in the tenant tree) and travels with it; each environment schedules it from its next occurrence, so a promotion never catches up runs missed in between. schedule.skip_next skips one run, schedule.run_now runs the automation once now — both are operational and allowed on a locked production. Times are stored in UTC, so a daily run keeps its UTC time across daylight-saving changes. Tasks made on the Scheduler screen are not schedules: they are not listed here and do not travel.
Queries
A query selects records of one type: its filters, the columns a view shows, sorting, grouping and the page length. It feeds a view (the view's default query, or one of the other queries its users may switch to) or limits what a relation field offers (purpose: "property").
{"op": "query.upsert", "query": {
"name": "open_invoices", "title": "Open invoices", "type": "invoice", "public": true,
"filters": [{"property": "paid_on", "type": "null", "fixed": true},
{"property": "customer.city", "type": "is", "param": "Praha"}],
"columns": ["number", "customer", "amount", "due"],
"sorting": [{"property": "due", "direction": "ASC"}],
"groupings": [], "pageLength": 25}}
- Filters work as in permission queries: a
propertymay follow relations (customer.city),typeis the operator (builder_describe(kind: "query")and the check name the operators of each field).fixed: truekeeps a filter the view's user cannot remove. public: trueis needed for a query to appear in a view's query picker; a view's default query needs no flag.- Keys left out stay; a list given replaces the stored one. What the document does not show (hidden columns, calculations, tag filters) is kept and listed under
otherSettings. - Filters narrow what a view shows; they are not access control. Limit what a role may see with permission queries.
Views
A view lists a type's records. Its document says which query feeds it, how it shows them and what can be done from it:
{"op": "view.upsert", "view": {
"name": "open_invoices", "title": "Open invoices", "type": "invoice", "viewer": "table",
"query": "open_invoices", "otherQueries": ["invoices_by_customer"],
"adding": {"default": {"amount": 0}, "hidden": ["internal_note"]},
"columnNames": {"amount": "Total"},
"layout": {"table": {
"viewZoneCustomButtons": {"header_right": [
{"id": "remind", "text": "Send reminders", "automation": "send_reminders", "icon": "bi bi-envelope"}]},
"viewZoneQuickFilters": {"before_data": [
{"id": "big", "label": "Over 10 000", "renderType": "toggle",
"property": "amount", "filterType": "greater_than", "filterParams": {"greater_than": 10000}}]}}},
"actions": [{"export": "xlsxPage"}, {"divider": true},
{"id": "remind", "title": "Send reminders", "automation": "send_reminders"}]}}
- Viewer —
table,table_sortable,list,cards,calendarorkanban. A kanban view groups by its query's first grouping; a calendar needs a start date (calendar.start, or a field with a start-date role). - Layout — per viewer, as the View Layout Builder stores it: which components sit in which zone, custom buttons and quick filters (
builder_describe(catalog: "view_components")). Every custom button and quick filter has a stableid. Whatever the Layout Builder would silently drop — an unknown component, a button without a target, ajavascript:link, a quick filter that filters nothing — is refused with the reason; a value it would change (an invalid colour) is reported. - Actions —
nullfor the default exports, or the whole list: exports, dividers and custom actions that run an automation (it gets the listed records, or the selection, astrigger("dataQuery")) or open a canvas. A custom action shows only to roles granted its id as a system permission:access.grant {role, target: "system", actions: ["tenant.action.<id>"]}. show,calendar,kanban,addingandlayoutmerge one level: give only what changes;nullremoves a key, andlayout.<viewer>: nullresets that viewer's layout.
A query, automation or canvas created in the same call must be listed before the view that uses it.
Canvases
A canvas is a page (role: "main": a module page, a record tab, a view action), a snippet, a dashboard widget or a field's rendering. The builder writes a canvas as one Custom HTML item — HTML with Latte, the same as the item of that name in the canvas builder:
{"op": "canvas.upsert", "canvas": {
"name": "welcome", "title": "Welcome", "role": "main",
"html": "<h1>Hello {$currentUser.full_name}</h1>\n<p n:if=\"$presenter->getUser()->isLoggedIn()\">Signed in.</p>",
"wrapper": {"tag": "section", "class": "p-3"},
"css": "h1 { color: #0a58ca; }"}}
- The markup is compiled the way the canvas renders it (in the template sandbox) before anything is stored; a syntax error is refused with its line.
- In a record tab or a field's rendering, the record is
{$canvas->getWrapperObject()->field}. - A canvas built from other items in the canvas builder keeps them:
builder_getlists them asitems, and only its other fields (title, CSS, scripts, …) change here. - A change is refused while someone has unsaved changes in the canvas builder (
draft). What the builder writes is published at once.
Show a canvas with a module (link: {kind: "page", canvas: "<name>"}), a record tab (top_nav) or a view action.
Modules
A module is one entry of the left navigation. Its link says what it opens:
link.kind | Opens | Keys |
|---|---|---|
page | A page of the app showing a custom app, a canvas or a view (in that precedence) | app, canvas, view |
app | A screen of the app | target: "Presenter:action", e.g. "Users:default" |
external | An outside address | url (http or https) |
heading | Nothing; it groups the modules under it | — |
[{"op": "module.upsert", "module": {"name": "support", "title": "Support", "icon": "bi bi-life-preserver",
"link": {"kind": "heading"}, "roles": ["support"]}},
{"op": "module.upsert", "module": {"name": "tickets", "title": "Tickets", "parent": "support",
"link": {"kind": "page", "view": "open_tickets"}, "roles": ["support"]}},
{"op": "module.arrange", "parent": "support", "modules": ["tickets", "customers"]}]
A new module, or one moved to another parent without an order, goes last among its siblings. module.arrange puts the modules listed first, in that order; other modules under the same parent follow in their current order. A role sees a module only when roles lists it; the view or canvas on the page still checks that role's own permissions. A module's hand-written layout (customLayout in the document) is edited on the Modules screen, not here.
Permalinks And Routes
Both give a public page — a public module, a record shown through a canvas, a webhook — an address to hand out:
- A permalink is a short address that redirects to the page (a permalink may also redirect to any web address): on the platform's link host,
https://<link host>/<slug>, or on an isolated environment its ownhttps://<tenant>/l/<slug>. The slug is claimed when the link is made, so the same link has a different slug on development and on production. The link's name stays the same: a promotion creates or updates production's link by name, and view buttons that open the link are pointed at production's slug. - A route is an address on the tenant's own domain,
https://<tenant>/<mask>, named by its mask (apply_form,offer.<id>). The app's own addresses come first: a mask of one or two plain words (apply,form/<id>) is taken by the app, so the check refuses it and suggests an underscore, a dot or three parts.
[{"op": "module.upsert", "module": {"name": "apply", "public": true}},
{"op": "permalink.upsert", "permalink": {"name": "apply_link", "title": "Application form", "target": {"module": "apply"}}},
{"op": "route.upsert", "route": {"mask": "apply_form", "title": "Application form", "target": {"module": "apply"}}}]
builder_get(kind: "permalink") shows the link's address on this environment (url): give out production's. A link made on the Links screen has no name and stays on its environment; name it with {name, slug: <its slug>} to make it travel. A view button opens a link by name: {targetType: "permalink", permalink: "apply_link"}.
Custom Apps
A custom app is a front-end bundle served at /app/<slug>/, with optional PHP handlers its front-end calls. The builder creates the app and deploys its files:
[{"op": "app.upsert", "app": {"slug": "price-calculator", "name": "Price calculator"}},
{"op": "app.deploy", "slug": "price-calculator", "label": "1.0",
"manifest": {"manifestVersion": 2, "name": "Price calculator",
"access": {"visibility": "roles", "roles": ["sales"]},
"routes": {"POST /quote": {"handler": "server/quote.php", "auth": "user"}}},
"files": {"public/index.html": "<!doctype html>…<script src=\"app.js\"></script>…",
"public/app.js": "fetch(window.__JETSTACK__.apiBase + '/quote', {method: 'POST'})…",
"server/quote.php": "<?php $jetstack->sendJson(['price' => 42]);"}},
{"op": "module.upsert", "module": {"name": "calculator", "title": "Calculator",
"link": {"kind": "page", "app": "price-calculator"}, "roles": ["sales"]}}]
- A deploy makes a release from the files and deploys it as
jetstack deploydoes: the deployment is recorded, what a v2 manifest declares (types, roles, grants, schedules) is applied, and its data migrations run. Files are text, or{"base64": "…"}for binary files. Before anything is stored, the check validates the manifest as an upload is validated. Deploying the files the app already serves changes nothing;activate: falseonly uploads the release. - On production, data migrations need
confirmMigrations: true(or wait for another person's approval, as production requires), and removing what the release no longer declares needsallowDestructive: true. - Access is
"public","signed_in"(the default for an app the builder creates) or{"roles": [...]}onapp.upsert; a v2 manifest'saccessdecides once the release is deployed. App code runs with the signed-in user's session, so keep it as narrow as the app needs. - Releases stay:
app.activate {slug, version}rolls back to an earlier one (a rollback never runs migrations). It is allowed on a locked production. builder_get(kind: "app", ref: "<slug>")shows the releases, the served manifest and its files;ref: "<slug>/<path>"reads one file.- Whole window and home page come from the manifest:
"viewport": "full"and"homepage": true(with"compat": {"platformApi": ">=5"}).builder_getshows both as read-onlyviewportandhomepage. A deploy or activation that would make a second app the home page is refused (homepage_taken) and names the app that holds it. See Full-Window Apps and The Home Page.
Deploying needs the system permission deployApps. A promotion carries each app's live release to production.
Working On An App's Draft
Every app has one draft that the Studio, the app's page, the CLI and assistants share. Rather than sending a whole app with app.deploy, an assistant changes the draft file by file, as a developer does in an editor:
| Tool | What it does |
|---|---|
app_files | The draft's files with their size and sha256, which changed since the release the draft started from, and where the draft stands, with its branches |
app_file_read | One file as numbered lines with its sha256, at most 400 lines a call (offset, limit) |
app_file_write | Write a whole file: a new one, or one you read (then expect_sha256 is required) |
app_file_edit | Exact replacements: each old_string must occur once (or replace_all), all or nothing |
app_file_delete, app_file_move | Remove or rename a file |
app_check | Everything wrong with the draft at once: the manifest, every handler in the PHP sandbox, routes without a handler, what deploying it would change, its recent errors. For an app built in the Studio also its build, type and browser problems, which the person's open Studio reports; wait_seconds (up to 20) waits for it to build your latest change. While nobody has the app open, the platform builds the sources itself for their build problems |
app_logs | The draft's log (or version: "live"), newest last |
app_route_call | Run a route of the draft as a browser would call it: its status, JSON, effects, duration and error id |
app_model_apply | On development: put the draft's data model on this environment before the deploy (see The draft's data model); mode: "check" says what it would change |
app_branch_create | Start a branch of the draft (name, title), holding its files |
app_branch_diff | What a branch changed since it started, with diff hunks, and which files the draft changed meanwhile |
app_branch_merge | Merge a branch into the draft (into: "branch": the draft's changes into the branch). mode: "check" lists what merges and what conflicts; "apply" writes it, with resolutions and the check's revisions as expect |
app_branch_discard | Throw a branch away |
-
Branches. Every tool above takes
branchto work on a branch of the draft instead: its files, checks, log and routes. An assistant can build larger work on a branch for the person to review, and merges it only when asked:- a conflict comes with the file between
<<<<<<<,=======and>>>>>>>markers; - a resolution names
"draft","branch", or{"content": …}with the merged file; - with a conflict left unresolved, nothing is written.
The draft's data model (
app_model_apply) and publishing are the draft's, not a branch's. On a production, branch changes are draft changes: they run without asking. - a conflict comes with the file between
-
Every change names the version of the file it replaces (
expect_sha256). A file that someone changed meanwhile (in the Studio, the person, another assistant) is refused withchangedand its current sha256, never overwritten. Read it again and repeat the change. -
Paths. The tools write only the places an app keeps files (the manifest,
src/,server/,public/,static/,migrations/,tests/and a few root files). Writes are text, up to 256 KB. -
Studio apps. In an app built in the Studio (with
studio.json),public/is build output: write sources insrc/. -
Route calls run as a browser's call would: the app's access and the route's
authand roles apply, andinternalroutes are not called. Who they run as:as: "anonymous"(the default);"test:<login>", one of the tenant's test users;"person", on development only: as the person the assistant works for, acting on real data.
-
Publishing.
app.deploywithfrom: "draft"publishes the draft and deploys the release; the next change starts the draft again from it. An app built in the Studio is published with the production build of the draft's current sources that the person's open Studio keeps ready. Without one, the platform builds it then. Sources that do not build are refused (build_failed, with the first error); where the platform does not build, the deploy is refused (studio_build) until the Studio has built them. An app built on a computer waits instead for the build the person sends withjetstack draft build.app.deploywith files leaves the draft's changes out (it warns) and refuses an app built in the Studio. -
Rights. The tools need
deployApps. On a locked production they only read.
The playbook studio (builder_describe(topic: "studio")) walks an assistant through working on a draft.
Inspecting What Happened
inspect reads, never writes:
automation_runs: the recent runs of an automation, and withfilter.runevery step of one;event_log;scheduler_log;kv(the key-value store);local_files;app_logs: an app's log (filter.app, withversionliveordraft,level,route,q,since).
Runs are kept when the setting logAutomationRuns is on; test runs always.
Environments
A tenant is a development or a production environment, paired with the other: isolated environments know it from their setup, other tenants from the settings environmentRole and peerEnvironmentUrl. Production's change policy decides what the builder may change there:
| Development | Production, open | Production, reviewed | Production, locked | |
|---|---|---|---|---|
| Configuration (roles, access, forms, automations, views, …) | changed | changed | changed, and waits for review in the history | refused — change it on development and promote |
| Operational (users, API keys, secret and environment values, tags, skipping a scheduled run, rolling an app back) | allowed | allowed | allowed | allowed |
Users, API keys and secret and environment values are not configuration: they are not promoted and are managed on each environment.
Promoting From Development
On development the builder has one more tool, promotion. It records the changes made there in the change history now (a promotion takes the newest entry), with your note as the message, and returns what the person who promotes needs:
- History → Promote on development: it signs in to production, shows production's plan, and promotes. What development no longer has stays on production unless it is ticked.
- or
jetstack promote --to prodfrom a workspace, which promotes what git holds (--remove <selector>for removals).
The playbook promote_changes walks an assistant through it, including what to tell the person who promotes. The assistant never promotes itself.
Example: Read-Only API Access For An Integration
The playbook integration_access (builder_describe(topic: "integration_access"), or the MCP prompt of the same name) walks an assistant through it. One check, then one apply:
[
{"op": "role.upsert", "role": {"name": "erp_api", "title": "ERP integration (API)"}},
{"op": "access.scheme", "role": "erp_api", "types": ["invoice", "customer"], "scheme": "read"},
{"op": "access.revoke", "role": "erp_api", "target": "invoice.internal_note", "actions": ["show"]},
{"op": "access.grant", "role": "erp_api", "target": "system", "actions": ["useApi"]},
{"op": "user.upsert", "user": {"login": "erp-api@acme.example", "firstName": "ERP", "lastName": "API", "roles": ["erp_api"]}},
{"op": "api_key.issue", "user": "erp-api@acme.example", "description": "ERP", "validTo": "2027-06-30T23:59:59+02:00"}
]
Then access_explain(subject: "user:erp-api@acme.example", target: "invoice.internal_note", action: "show") confirms the field is hidden.
Best Practices
- Keep the check and the apply in the same conversation, and read the check's warnings: they say what is easy to get wrong (a role that requires MFA cannot use API keys; a scheme replaces grants).
- Give integrations their own role and their own user — never a person's account — so they can be audited and revoked on their own.
- Prefer
access.schemeoraccess.grantper type over grants on every type (*), which also cover types created later. - Build and test on development, then promote; set production's change policy to Locked or Reviewed so nothing reaches it unseen.